From c54e454c8ea373a7f07d3f8f0b00060336d9e266 Mon Sep 17 00:00:00 2001 From: Zygmunt Krynicki Date: Thu, 13 Jun 2024 08:13:42 +0200 Subject: [PATCH] Import snapd_2.63.orig.tar.gz [dgit import orig snapd_2.63.orig.tar.gz] --- .clang-format | 4 + .github/labeler.yml | 16 + .github/spread-problem-matcher.json | 14 + .github/workflows/cla-check.yaml | 16 + .github/workflows/labeler.yaml | 15 + .github/workflows/macos-quick.yaml | 34 + .github/workflows/naming.yml | 23 + .github/workflows/nightly.yaml | 94 + .github/workflows/riscv64-builds.yml | 19 + .github/workflows/test.yaml | 804 + .gitignore | 94 + .golangci.yml | 323 + .mailmap | 3 + .woke.yaml | 5 + CODE_OF_CONDUCT.md | 76 + CODING.md | 236 + CONTRIBUTING.md | 179 + COPYING | 674 + HACKING.md | 543 + NEWS.md | 245 + PULL_REQUEST_TEMPLATE.md | 2 + README.md | 72 + advisor/backend_bolt.go | 288 + advisor/backend_common.go | 37 + advisor/backend_test.go | 76 + advisor/cmdfinder.go | 110 + advisor/cmdfinder_test.go | 153 + advisor/export_test.go | 22 + advisor/finder.go | 36 + advisor/pkgfinder.go | 43 + advisor/pkgfinder_test.go | 40 + arch/arch.go | 133 + arch/arch_test.go | 64 + arch/archtest/archtest.go | 31 + arch/endian.go | 40 + arch/endian_test.go | 117 + arch/export_test.go | 30 + aspects/aspects.go | 1556 ++ aspects/aspects_test.go | 2470 +++ aspects/export_test.go | 30 + aspects/schema.go | 1243 ++ aspects/schema_test.go | 2341 +++ aspects/transaction.go | 166 + aspects/transaction_test.go | 373 + asserts/account.go | 115 + asserts/account_key.go | 415 + asserts/account_key_test.go | 1144 ++ asserts/account_test.go | 196 + asserts/aspect_bundle.go | 115 + asserts/aspect_bundle_test.go | 201 + asserts/asserts.go | 1389 ++ asserts/asserts_test.go | 1348 ++ asserts/assertstest/assertstest.go | 613 + asserts/assertstest/assertstest_test.go | 225 + asserts/batch.go | 251 + asserts/batch_test.go | 517 + asserts/constraint.go | 464 + asserts/constraint_test.go | 822 + asserts/crypto.go | 389 + asserts/database.go | 851 + asserts/database_test.go | 1700 ++ asserts/digest.go | 43 + asserts/digest_test.go | 65 + asserts/export_test.go | 369 + asserts/extkeypairmgr.go | 302 + asserts/extkeypairmgr_test.go | 331 + asserts/fetcher.go | 197 + asserts/fetcher_test.go | 375 + asserts/findwildcard.go | 187 + asserts/findwildcard_test.go | 281 + asserts/fsbackstore.go | 321 + asserts/fsbackstore_test.go | 921 + asserts/fsentryutils.go | 74 + asserts/fskeypairmgr.go | 106 + asserts/fskeypairmgr_test.go | 97 + asserts/gpgkeypairmgr.go | 413 + asserts/gpgkeypairmgr_test.go | 366 + asserts/header_checks.go | 324 + asserts/headers.go | 318 + asserts/headers_test.go | 396 + asserts/ifacedecls.go | 1144 ++ asserts/ifacedecls_test.go | 2204 +++ asserts/info/main.go | 37 + asserts/internal/grouping.go | 286 + asserts/internal/grouping_test.go | 713 + asserts/membackstore.go | 303 + asserts/membackstore_test.go | 640 + asserts/memkeypairmgr.go | 71 + asserts/memkeypairmgr_test.go | 94 + asserts/model.go | 1221 ++ asserts/model_test.go | 1733 ++ asserts/pool.go | 1022 ++ asserts/pool_test.go | 1744 ++ asserts/preseed.go | 226 + asserts/preseed_test.go | 195 + asserts/privkeys_for_test.go | 54 + asserts/repair.go | 218 + asserts/repair_test.go | 368 + asserts/serial_asserts.go | 251 + asserts/serial_asserts_test.go | 365 + asserts/signtool/keymgr.go | 101 + asserts/signtool/keymgr_test.go | 91 + asserts/signtool/sign.go | 131 + asserts/signtool/sign_test.go | 321 + asserts/snap_asserts.go | 1158 ++ asserts/snap_asserts_test.go | 2458 +++ asserts/snap_resource_asserts.go | 339 + asserts/snap_resource_asserts_test.go | 693 + asserts/snapasserts/export_test.go | 21 + asserts/snapasserts/snapasserts.go | 282 + asserts/snapasserts/snapasserts_test.go | 791 + asserts/snapasserts/validation_sets.go | 730 + asserts/snapasserts/validation_sets_test.go | 1512 ++ asserts/store_asserts.go | 163 + asserts/store_asserts_test.go | 236 + asserts/sysdb/generic.go | 196 + asserts/sysdb/staging.go | 183 + asserts/sysdb/sysdb.go | 56 + asserts/sysdb/sysdb_test.go | 215 + asserts/sysdb/testkeys.go | 30 + asserts/sysdb/trusted.go | 156 + asserts/system_user.go | 356 + asserts/system_user_test.go | 308 + asserts/systestkeys/trusted.go | 347 + asserts/validation_set.go | 275 + asserts/validation_set_test.go | 202 + boot/assets.go | 908 + boot/assets_test.go | 2805 +++ boot/boot.go | 580 + boot/boot_robustness_test.go | 324 + boot/boot_test.go | 5550 ++++++ boot/bootchain.go | 349 + boot/bootchain_test.go | 1234 ++ boot/booted_kernel_partition_linux.go | 56 + boot/booted_kernel_partition_test.go | 60 + boot/bootstate16.go | 200 + boot/bootstate20.go | 924 + boot/bootstate20_bloader_kernel_state.go | 331 + boot/boottest/bootenv.go | 183 + boot/boottest/device.go | 138 + boot/boottest/device_test.go | 140 + boot/boottest/model.go | 100 + boot/cmdline.go | 400 + boot/cmdline_test.go | 510 + boot/debug.go | 138 + boot/errors.go | 49 + boot/export_test.go | 287 + boot/flags.go | 376 + boot/flags_test.go | 568 + boot/initramfs.go | 194 + boot/initramfs20dirs.go | 143 + boot/initramfs_test.go | 884 + boot/kernel_os.go | 87 + boot/kernel_os_test.go | 804 + boot/makebootable.go | 653 + boot/makebootable_test.go | 2390 +++ boot/modeenv.go | 583 + boot/modeenv_test.go | 976 + boot/model.go | 160 + boot/model_test.go | 1233 ++ boot/reboot.go | 125 + boot/reboot_test.go | 191 + boot/seal.go | 1019 ++ boot/seal_test.go | 2740 +++ boot/systems.go | 554 + boot/systems_test.go | 2113 +++ bootloader/androidboot.go | 98 + bootloader/androidboot_test.go | 105 + bootloader/androidbootenv/androidbootenv.go | 90 + .../androidbootenv/androidbootenv_test.go | 69 + bootloader/asset.go | 113 + bootloader/asset_test.go | 136 + bootloader/assets/assets.go | 143 + bootloader/assets/assets_test.go | 156 + bootloader/assets/assetstesting.go | 45 + bootloader/assets/data/README.grub | 10 + bootloader/assets/data/grub-recovery.cfg | 83 + bootloader/assets/data/grub.cfg | 57 + bootloader/assets/export_test.go | 37 + bootloader/assets/genasset/export_test.go | 32 + bootloader/assets/genasset/main.go | 160 + bootloader/assets/genasset/main_test.go | 174 + bootloader/assets/generate.go | 23 + bootloader/assets/grub.go | 48 + bootloader/assets/grub_cfg_asset.go | 149 + bootloader/assets/grub_recovery_cfg_asset.go | 208 + bootloader/assets/grub_test.go | 167 + bootloader/bootloader.go | 470 + bootloader/bootloader_test.go | 366 + bootloader/bootloadertest/bootloadertest.go | 609 + bootloader/bootloadertest/utf16.go | 37 + bootloader/efi/efi.go | 183 + bootloader/efi/efi_test.go | 173 + bootloader/export_test.go | 328 + bootloader/grub.go | 692 + bootloader/grub_test.go | 1423 ++ bootloader/grubenv/grubenv.go | 116 + bootloader/grubenv/grubenv_test.go | 92 + bootloader/lk.go | 516 + bootloader/lk_test.go | 534 + bootloader/lkenv/export_test.go | 25 + bootloader/lkenv/lkenv.go | 662 + bootloader/lkenv/lkenv_test.go | 915 + bootloader/lkenv/lkenv_v1.go | 216 + bootloader/lkenv/lkenv_v2.go | 365 + bootloader/piboot.go | 475 + bootloader/piboot.md | 128 + bootloader/piboot_test.go | 695 + bootloader/uboot.go | 219 + bootloader/uboot_test.go | 317 + bootloader/ubootenv/env.go | 348 + bootloader/ubootenv/env_test.go | 424 + bootloader/ubootenv/export_test.go | 24 + bootloader/withbootassettesting.go | 115 + bootloader/withbootassettesting_test.go | 95 + build-aux/snap/local/apparmor/af_names.h | 240 + ...ace-dynamic_cast-with-is_type-method.patch | 791 + build-aux/snap/snapcraft.yaml | 201 + c-vendor/README | 2 + c-vendor/vendor.sh | 31 + check-commit-email.py | 66 + check-pr-title.py | 78 + client/aliases.go | 102 + client/aliases_test.go | 200 + client/apps.go | 402 + client/apps_test.go | 681 + client/aspects.go | 54 + client/aspects_test.go | 61 + client/asserts.go | 153 + client/asserts_test.go | 238 + client/buy.go | 63 + client/change.go | 164 + client/change_test.go | 234 + client/client.go | 803 + client/client_test.go | 731 + client/clientutil/modelinfo.go | 448 + client/clientutil/modelinfo_test.go | 502 + client/clientutil/service_scope.go | 99 + client/clientutil/service_scope_test.go | 82 + client/clientutil/snapinfo.go | 160 + client/clientutil/snapinfo_test.go | 328 + client/cohort.go | 50 + client/cohort_test.go | 76 + client/conf.go | 52 + client/conf_test.go | 105 + client/connections.go | 81 + client/connections_test.go | 270 + client/console_conf.go | 44 + client/console_conf_test.go | 63 + client/errors.go | 155 + client/export_test.go | 63 + client/icons.go | 72 + client/icons_test.go | 73 + client/interfaces.go | 149 + client/interfaces_test.go | 268 + client/login.go | 186 + client/login_test.go | 162 + client/model.go | 227 + client/model_test.go | 292 + client/notices.go | 64 + client/notices_test.go | 50 + client/packages.go | 296 + client/packages_test.go | 470 + client/quota.go | 164 + client/quota_test.go | 194 + client/snap_op.go | 502 + client/snap_op_test.go | 916 + client/snapctl.go | 98 + client/snapctl_test.go | 126 + client/snapshot.go | 282 + client/snapshot_test.go | 317 + client/systems.go | 269 + client/systems_test.go | 416 + client/users.go | 145 + client/users_test.go | 198 + client/validate.go | 135 + client/validate_test.go | 240 + client/warnings.go | 89 + client/warnings_test.go | 121 + cmd/.clangd | 2 + cmd/.indent.pro | 35 + cmd/Makefile.am | 568 + cmd/autogen.sh | 64 + cmd/configure.ac | 294 + cmd/decode-mount-opts/decode-mount-opts.c | 38 + .../apparmor-support.c | 164 + .../apparmor-support.h | 95 + cmd/libsnap-confine-private/bpf-support.c | 196 + cmd/libsnap-confine-private/bpf-support.h | 96 + cmd/libsnap-confine-private/bpf/bpf-insn.h | 311 + .../bpf/vendor/linux/bpf.h | 6152 +++++++ .../bpf/vendor/linux/bpf_common.h | 57 + .../cgroup-freezer-support.c | 126 + .../cgroup-freezer-support.h | 52 + .../cgroup-support-test.c | 369 + cmd/libsnap-confine-private/cgroup-support.c | 241 + cmd/libsnap-confine-private/cgroup-support.h | 65 + cmd/libsnap-confine-private/classic-test.c | 222 + cmd/libsnap-confine-private/classic.c | 91 + cmd/libsnap-confine-private/classic.h | 37 + .../cleanup-funcs-test.c | 202 + cmd/libsnap-confine-private/cleanup-funcs.c | 80 + cmd/libsnap-confine-private/cleanup-funcs.h | 100 + .../device-cgroup-support.c | 780 + .../device-cgroup-support.h | 72 + cmd/libsnap-confine-private/error-test.c | 292 + cmd/libsnap-confine-private/error.c | 165 + cmd/libsnap-confine-private/error.h | 208 + .../fault-injection-test.c | 63 + cmd/libsnap-confine-private/fault-injection.c | 78 + cmd/libsnap-confine-private/fault-injection.h | 67 + cmd/libsnap-confine-private/feature-test.c | 118 + cmd/libsnap-confine-private/feature.c | 73 + cmd/libsnap-confine-private/feature.h | 38 + cmd/libsnap-confine-private/infofile-test.c | 300 + cmd/libsnap-confine-private/infofile.c | 149 + cmd/libsnap-confine-private/infofile.h | 48 + cmd/libsnap-confine-private/locking-test.c | 164 + cmd/libsnap-confine-private/locking.c | 208 + cmd/libsnap-confine-private/locking.h | 107 + cmd/libsnap-confine-private/mount-opt-test.c | 346 + cmd/libsnap-confine-private/mount-opt.c | 338 + cmd/libsnap-confine-private/mount-opt.h | 89 + cmd/libsnap-confine-private/mountinfo-test.c | 310 + cmd/libsnap-confine-private/mountinfo.c | 352 + cmd/libsnap-confine-private/mountinfo.h | 134 + cmd/libsnap-confine-private/panic-test.c | 88 + cmd/libsnap-confine-private/panic.c | 67 + cmd/libsnap-confine-private/panic.h | 91 + cmd/libsnap-confine-private/privs-test.c | 67 + cmd/libsnap-confine-private/privs.c | 78 + cmd/libsnap-confine-private/privs.h | 38 + .../secure-getenv-test.c | 23 + cmd/libsnap-confine-private/secure-getenv.c | 31 + cmd/libsnap-confine-private/secure-getenv.h | 36 + cmd/libsnap-confine-private/snap-test.c | 627 + cmd/libsnap-confine-private/snap.c | 323 + cmd/libsnap-confine-private/snap.h | 136 + .../string-utils-test.c | 877 + cmd/libsnap-confine-private/string-utils.c | 269 + cmd/libsnap-confine-private/string-utils.h | 116 + cmd/libsnap-confine-private/test-utils-test.c | 69 + cmd/libsnap-confine-private/test-utils.c | 113 + cmd/libsnap-confine-private/test-utils.h | 32 + cmd/libsnap-confine-private/tool.c | 255 + cmd/libsnap-confine-private/tool.h | 52 + cmd/libsnap-confine-private/unit-tests-main.c | 22 + cmd/libsnap-confine-private/unit-tests.c | 29 + cmd/libsnap-confine-private/unit-tests.h | 29 + cmd/libsnap-confine-private/utils-test.c | 281 + cmd/libsnap-confine-private/utils.c | 304 + cmd/libsnap-confine-private/utils.h | 132 + cmd/snap-bootstrap/README.md | 77 + cmd/snap-bootstrap/cmd_initramfs_mounts.go | 2152 +++ .../cmd_initramfs_mounts_nosecboot.go | 55 + ..._initramfs_mounts_recover_degraded_test.go | 292 + .../cmd_initramfs_mounts_secboot.go | 34 + .../cmd_initramfs_mounts_test.go | 8405 +++++++++ .../cmd_recovery_chooser_trigger.go | 123 + .../cmd_recovery_chooser_trigger_test.go | 195 + cmd/snap-bootstrap/degraded-recover-mode.svg | 3 + cmd/snap-bootstrap/export_test.go | 235 + cmd/snap-bootstrap/initramfs_mounts_state.go | 161 + cmd/snap-bootstrap/initramfs_systemd_mount.go | 217 + .../initramfs_systemd_mount_test.go | 333 + cmd/snap-bootstrap/main.go | 80 + cmd/snap-bootstrap/main_test.go | 50 + cmd/snap-bootstrap/triggerwatch/evdev.go | 263 + .../triggerwatch/export_test.go | 72 + .../triggerwatch/triggerwatch.go | 158 + .../triggerwatch/triggerwatch_test.go | 235 + cmd/snap-confine/PORTING | 15 + cmd/snap-confine/README.mount_namespace | 92 + cmd/snap-confine/README.nvidia | 26 + cmd/snap-confine/README.syscalls | 436 + cmd/snap-confine/cookie-support-test.c | 103 + cmd/snap-confine/cookie-support.c | 75 + cmd/snap-confine/cookie-support.h | 34 + ...printk-based-debugging-to-pivot_root.patch | 132 + cmd/snap-confine/mount-support-nvidia.c | 638 + cmd/snap-confine/mount-support-nvidia.h | 48 + cmd/snap-confine/mount-support-test.c | 101 + cmd/snap-confine/mount-support.c | 1145 ++ cmd/snap-confine/mount-support.h | 85 + cmd/snap-confine/ns-support-test.c | 154 + cmd/snap-confine/ns-support.c | 996 ++ cmd/snap-confine/ns-support.h | 153 + cmd/snap-confine/seccomp-support-ext.c | 98 + cmd/snap-confine/seccomp-support-ext.h | 30 + cmd/snap-confine/seccomp-support-test.c | 193 + cmd/snap-confine/seccomp-support.c | 301 + cmd/snap-confine/seccomp-support.h | 50 + cmd/snap-confine/selinux-support.c | 96 + cmd/snap-confine/selinux-support.h | 27 + cmd/snap-confine/snap-confine-args-test.c | 432 + cmd/snap-confine/snap-confine-args.c | 256 + cmd/snap-confine/snap-confine-args.h | 124 + .../snap-confine-invocation-test.c | 162 + cmd/snap-confine/snap-confine-invocation.c | 207 + cmd/snap-confine/snap-confine-invocation.h | 89 + cmd/snap-confine/snap-confine.apparmor.in | 637 + cmd/snap-confine/snap-confine.c | 882 + cmd/snap-confine/snap-confine.rst | 185 + .../spread-tests/main/cgroup-used/task.yaml | 37 + .../main/core-is-preferred/task.yaml | 10 + .../spread-tests/main/debug-flags/task.yaml | 12 + .../main/hostfs-created-on-demand/task.yaml | 24 + .../main/media-visible-in-devmode/task.yaml | 15 + .../main/mount-ns-sharing/task.yaml | 20 + .../task.yaml | 26 + .../mount-profiles-bin-snap-source/task.yaml | 26 + .../main/mount-profiles-missing-dst/task.yaml | 19 + .../main/mount-profiles-missing-src/task.yaml | 19 + .../main/mount-profiles-mount-tmpfs/task.yaml | 20 + .../main/mount-profiles-ro-mount/task.yaml | 18 + .../main/mount-profiles-rw-mount/task.yaml | 24 + .../spread-tests/main/mount-usr-src/task.yaml | 17 + .../main/test-seccomp-compat/task.yaml | 16 + .../main/test-snap-runs/task.yaml | 15 + .../main/user-data-dir-created/task.yaml | 23 + .../user-xdg-runtime-dir-created/task.yaml | 21 + .../regression/lp-1599608/task.yaml | 69 + cmd/snap-confine/udev-support.c | 393 + cmd/snap-confine/udev-support.h | 32 + cmd/snap-confine/user-support.c | 73 + cmd/snap-confine/user-support.h | 25 + cmd/snap-device-helper/main.c | 38 + .../snap-device-helper-test.c | 482 + cmd/snap-device-helper/snap-device-helper.c | 216 + cmd/snap-device-helper/snap-device-helper.h | 31 + cmd/snap-discard-ns/snap-discard-ns.c | 228 + cmd/snap-discard-ns/snap-discard-ns.rst | 62 + cmd/snap-exec/export_test.go | 72 + cmd/snap-exec/main.go | 291 + cmd/snap-exec/main_test.go | 665 + cmd/snap-failure/cmd_snapd.go | 224 + cmd/snap-failure/cmd_snapd_test.go | 458 + cmd/snap-failure/export_test.go | 38 + cmd/snap-failure/main.go | 77 + cmd/snap-failure/main_test.go | 82 + cmd/snap-fde-keymgr/export_test.go | 70 + cmd/snap-fde-keymgr/main.go | 250 + cmd/snap-fde-keymgr/main_test.go | 452 + cmd/snap-gdb-shim/snap-gdb-shim.c | 51 + cmd/snap-gdb-shim/snap-gdbserver-shim.c | 61 + cmd/snap-mgmt/snap-mgmt-selinux.sh.in | 112 + cmd/snap-mgmt/snap-mgmt.sh.in | 235 + cmd/snap-preseed/export_test.go | 59 + cmd/snap-preseed/main.go | 146 + cmd/snap-preseed/preseed_classic_test.go | 165 + cmd/snap-preseed/preseed_uc20_test.go | 118 + cmd/snap-recovery-chooser/export_test.go | 65 + cmd/snap-recovery-chooser/main.go | 236 + cmd/snap-recovery-chooser/main_test.go | 570 + cmd/snap-repair/cmd_done_retry_skip.go | 82 + cmd/snap-repair/cmd_done_retry_skip_test.go | 81 + cmd/snap-repair/cmd_list.go | 74 + cmd/snap-repair/cmd_list_test.go | 47 + cmd/snap-repair/cmd_run.go | 123 + cmd/snap-repair/cmd_run_test.go | 121 + cmd/snap-repair/cmd_show.go | 91 + cmd/snap-repair/cmd_show_test.go | 145 + cmd/snap-repair/export_test.go | 148 + cmd/snap-repair/main.go | 94 + cmd/snap-repair/main_test.go | 104 + cmd/snap-repair/runner.go | 1156 ++ cmd/snap-repair/runner_test.go | 2408 +++ cmd/snap-repair/staging.go | 81 + cmd/snap-repair/testkeys.go | 34 + cmd/snap-repair/trace.go | 176 + cmd/snap-repair/trace_test.go | 65 + cmd/snap-repair/trusted.go | 89 + cmd/snap-seccomp-blacklist/.gitignore | 4 + cmd/snap-seccomp-blacklist/BE-bpf-script | 3 + cmd/snap-seccomp-blacklist/LE-bpf-script | 3 + cmd/snap-seccomp-blacklist/Makefile | 40 + .../snap-seccomp-blacklist.c | 226 + cmd/snap-seccomp/export_test.go | 80 + cmd/snap-seccomp/main.go | 1045 ++ cmd/snap-seccomp/main_nonriscv64.go | 37 + cmd/snap-seccomp/main_ppc64le.go | 31 + cmd/snap-seccomp/main_riscv64.go | 41 + cmd/snap-seccomp/main_test.go | 921 + cmd/snap-seccomp/old_seccomp.go | 30 + cmd/snap-seccomp/syscalls/syscalls.go | 509 + cmd/snap-seccomp/versioninfo.go | 81 + cmd/snap-seccomp/versioninfo_test.go | 74 + cmd/snap-update-ns/bootstrap.c | 511 + cmd/snap-update-ns/bootstrap.go | 134 + cmd/snap-update-ns/bootstrap.h | 35 + cmd/snap-update-ns/bootstrap_ppc64le.go | 31 + cmd/snap-update-ns/bootstrap_test.go | 159 + cmd/snap-update-ns/change.go | 819 + cmd/snap-update-ns/change_test.go | 3052 ++++ cmd/snap-update-ns/common.go | 125 + cmd/snap-update-ns/common_test.go | 175 + cmd/snap-update-ns/expand.go | 85 + cmd/snap-update-ns/expand_test.go | 103 + cmd/snap-update-ns/export_test.go | 299 + cmd/snap-update-ns/main.go | 101 + cmd/snap-update-ns/main_test.go | 440 + cmd/snap-update-ns/secure_bindmount.go | 97 + cmd/snap-update-ns/secure_bindmount_test.go | 200 + cmd/snap-update-ns/sorting.go | 110 + cmd/snap-update-ns/sorting_test.go | 163 + cmd/snap-update-ns/system.go | 103 + cmd/snap-update-ns/system_test.go | 161 + cmd/snap-update-ns/trespassing.go | 296 + cmd/snap-update-ns/trespassing_test.go | 478 + cmd/snap-update-ns/update.go | 102 + cmd/snap-update-ns/update_test.go | 439 + cmd/snap-update-ns/user.go | 160 + cmd/snap-update-ns/user_test.go | 251 + cmd/snap-update-ns/utils.go | 787 + cmd/snap-update-ns/utils_test.go | 1601 ++ cmd/snap/cmd_abort.go | 62 + cmd/snap/cmd_abort_test.go | 89 + cmd/snap/cmd_ack.go | 79 + cmd/snap/cmd_advise.go | 336 + cmd/snap/cmd_advise_test.go | 265 + cmd/snap/cmd_alias.go | 118 + cmd/snap/cmd_alias_test.go | 75 + cmd/snap/cmd_aliases.go | 143 + cmd/snap/cmd_aliases_test.go | 175 + cmd/snap/cmd_auto_import.go | 373 + cmd/snap/cmd_auto_import_test.go | 561 + cmd/snap/cmd_booted.go | 49 + cmd/snap/cmd_buy.go | 137 + cmd/snap/cmd_buy_test.go | 462 + cmd/snap/cmd_can_manage_refreshes.go | 53 + cmd/snap/cmd_changes.go | 210 + cmd/snap/cmd_changes_test.go | 252 + cmd/snap/cmd_confinement.go | 57 + cmd/snap/cmd_confinement_test.go | 39 + cmd/snap/cmd_connect.go | 93 + cmd/snap/cmd_connect_test.go | 329 + cmd/snap/cmd_connections.go | 214 + cmd/snap/cmd_connections_test.go | 853 + cmd/snap/cmd_connectivity_check.go | 63 + cmd/snap/cmd_connectivity_check_test.go | 84 + cmd/snap/cmd_create_cohort.go | 85 + cmd/snap/cmd_create_cohort_test.go | 87 + cmd/snap/cmd_create_key.go | 75 + cmd/snap/cmd_create_key_test.go | 34 + cmd/snap/cmd_create_user.go | 118 + cmd/snap/cmd_create_user_test.go | 152 + cmd/snap/cmd_debug.go | 34 + cmd/snap/cmd_debug_bootvars.go | 84 + cmd/snap/cmd_debug_bootvars_test.go | 157 + cmd/snap/cmd_debug_disks.go | 60 + cmd/snap/cmd_debug_gadget_disk_mapping.go | 60 + cmd/snap/cmd_debug_migrate.go | 80 + cmd/snap/cmd_debug_migrate_test.go | 160 + cmd/snap/cmd_debug_model.go | 54 + cmd/snap/cmd_debug_model_test.go | 56 + cmd/snap/cmd_debug_seeding.go | 163 + cmd/snap/cmd_debug_seeding_test.go | 493 + cmd/snap/cmd_debug_stacktraces.go | 48 + cmd/snap/cmd_debug_state.go | 577 + cmd/snap/cmd_debug_state_test.go | 552 + cmd/snap/cmd_debug_timings.go | 292 + cmd/snap/cmd_debug_timings_test.go | 348 + cmd/snap/cmd_debug_validate_seed.go | 56 + cmd/snap/cmd_debug_validate_seed_test.go | 45 + cmd/snap/cmd_delete_key.go | 75 + cmd/snap/cmd_delete_key_test.go | 95 + cmd/snap/cmd_disconnect.go | 105 + cmd/snap/cmd_disconnect_test.go | 270 + cmd/snap/cmd_download.go | 186 + cmd/snap/cmd_download_test.go | 130 + cmd/snap/cmd_ensure_state_soon.go | 46 + cmd/snap/cmd_ensure_state_soon_test.go | 55 + cmd/snap/cmd_export_key.go | 104 + cmd/snap/cmd_export_key_test.go | 84 + cmd/snap/cmd_find.go | 275 + cmd/snap/cmd_find_test.go | 700 + cmd/snap/cmd_first_boot.go | 49 + cmd/snap/cmd_get.go | 295 + cmd/snap/cmd_get_base_declaration.go | 70 + cmd/snap/cmd_get_base_declaration_test.go | 80 + cmd/snap/cmd_get_test.go | 460 + cmd/snap/cmd_handle_link.go | 91 + cmd/snap/cmd_help.go | 387 + cmd/snap/cmd_help_test.go | 224 + cmd/snap/cmd_info.go | 703 + cmd/snap/cmd_info_test.go | 1341 ++ cmd/snap/cmd_interface.go | 190 + cmd/snap/cmd_interface_test.go | 287 + cmd/snap/cmd_interfaces.go | 181 + cmd/snap/cmd_interfaces_test.go | 674 + cmd/snap/cmd_keys.go | 109 + cmd/snap/cmd_keys_test.go | 155 + cmd/snap/cmd_known.go | 147 + cmd/snap/cmd_known_test.go | 229 + cmd/snap/cmd_list.go | 142 + cmd/snap/cmd_list_test.go | 260 + cmd/snap/cmd_login.go | 135 + cmd/snap/cmd_login_test.go | 93 + cmd/snap/cmd_logout.go | 53 + cmd/snap/cmd_managed.go | 57 + cmd/snap/cmd_managed_test.go | 46 + cmd/snap/cmd_model.go | 175 + cmd/snap/cmd_model_test.go | 647 + cmd/snap/cmd_pack.go | 133 + cmd/snap/cmd_pack_test.go | 231 + cmd/snap/cmd_paths.go | 62 + cmd/snap/cmd_paths_test.go | 90 + cmd/snap/cmd_prefer.go | 68 + cmd/snap/cmd_prefer_test.go | 72 + cmd/snap/cmd_prepare_image.go | 201 + cmd/snap/cmd_prepare_image_test.go | 263 + cmd/snap/cmd_quota.go | 640 + cmd/snap/cmd_quota_test.go | 776 + cmd/snap/cmd_reboot.go | 129 + cmd/snap/cmd_reboot_test.go | 175 + cmd/snap/cmd_recovery.go | 124 + cmd/snap/cmd_recovery_test.go | 211 + cmd/snap/cmd_remodel.go | 113 + cmd/snap/cmd_remodel_test.go | 144 + cmd/snap/cmd_remove_user.go | 75 + cmd/snap/cmd_remove_user_test.go | 109 + cmd/snap/cmd_repair_repairs.go | 97 + cmd/snap/cmd_repair_repairs_test.go | 71 + cmd/snap/cmd_routine.go | 35 + cmd/snap/cmd_routine_console_conf.go | 140 + cmd/snap/cmd_routine_console_conf_test.go | 495 + cmd/snap/cmd_routine_file_access.go | 216 + cmd/snap/cmd_routine_file_access_test.go | 185 + cmd/snap/cmd_routine_portal_info.go | 162 + cmd/snap/cmd_routine_portal_info_test.go | 241 + cmd/snap/cmd_run.go | 1378 ++ cmd/snap/cmd_run_test.go | 2539 +++ cmd/snap/cmd_sandbox_features.go | 88 + cmd/snap/cmd_sandbox_features_test.go | 61 + cmd/snap/cmd_services.go | 326 + cmd/snap/cmd_services_test.go | 623 + cmd/snap/cmd_set.go | 172 + cmd/snap/cmd_set_test.go | 313 + cmd/snap/cmd_sign.go | 166 + cmd/snap/cmd_sign_build.go | 129 + cmd/snap/cmd_sign_build_test.go | 133 + cmd/snap/cmd_sign_test.go | 241 + cmd/snap/cmd_snap_op.go | 1351 ++ cmd/snap/cmd_snap_op_test.go | 3115 ++++ cmd/snap/cmd_snapshot.go | 523 + cmd/snap/cmd_snapshot_test.go | 169 + cmd/snap/cmd_unalias.go | 69 + cmd/snap/cmd_unalias_test.go | 73 + cmd/snap/cmd_unset.go | 114 + cmd/snap/cmd_unset_test.go | 101 + cmd/snap/cmd_userd.go | 205 + cmd/snap/cmd_userd_test.go | 336 + cmd/snap/cmd_validate.go | 218 + cmd/snap/cmd_validate_test.go | 299 + cmd/snap/cmd_version.go | 74 + cmd/snap/cmd_version_linux.go | 53 + cmd/snap/cmd_version_other.go | 41 + cmd/snap/cmd_version_test.go | 75 + cmd/snap/cmd_wait.go | 139 + cmd/snap/cmd_wait_test.go | 174 + cmd/snap/cmd_warnings.go | 230 + cmd/snap/cmd_warnings_test.go | 229 + cmd/snap/cmd_watch.go | 61 + cmd/snap/cmd_watch_test.go | 190 + cmd/snap/cmd_whoami.go | 58 + cmd/snap/cmd_whoami_test.go | 68 + cmd/snap/color.go | 226 + cmd/snap/color_test.go | 218 + cmd/snap/complete.go | 530 + cmd/snap/error.go | 448 + cmd/snap/export_test.go | 472 + cmd/snap/fallocate_darwin.go | 28 + cmd/snap/fallocate_linux.go | 35 + cmd/snap/gnupg2_test.go | 27 + cmd/snap/inhibit.go | 199 + cmd/snap/inhibit_test.go | 464 + cmd/snap/interfaces_common.go | 86 + cmd/snap/interfaces_common_test.go | 86 + cmd/snap/last.go | 117 + cmd/snap/main.go | 595 + cmd/snap/main_test.go | 475 + cmd/snap/notes.go | 180 + cmd/snap/notes_test.go | 139 + cmd/snap/test-data/pubring.gpg | Bin 0 -> 2192 bytes cmd/snap/test-data/secring.gpg | Bin 0 -> 4774 bytes cmd/snap/test-data/trustdb.gpg | Bin 0 -> 1360 bytes cmd/snap/times.go | 63 + cmd/snap/wait.go | 198 + cmd/snapctl/main.go | 108 + cmd/snapctl/main_test.go | 129 + cmd/snapd-apparmor/export_test.go | 29 + cmd/snapd-apparmor/main.go | 176 + cmd/snapd-apparmor/main_test.go | 289 + cmd/snapd-env-generator/main.c | 51 + .../snapd-env-generator.rst | 30 + cmd/snapd-generator/main.c | 434 + cmd/snapd/export_test.go | 44 + cmd/snapd/main.go | 165 + cmd/snapd/main_test.go | 127 + cmd/snaplock/lock.go | 48 + cmd/snaplock/lock_test.go | 59 + cmd/snaplock/runinhibit/export_test.go | 24 + cmd/snaplock/runinhibit/inhibit.go | 404 + cmd/snaplock/runinhibit/inhibit_test.go | 460 + .../system-shutdown-utils-test.c | 21 + cmd/system-shutdown/system-shutdown-utils.c | 147 + cmd/system-shutdown/system-shutdown-utils.h | 35 + cmd/system-shutdown/system-shutdown.c | 159 + codecov.yml | 2 + daemon/access.go | 300 + daemon/access_test.go | 424 + daemon/api.go | 197 + daemon/api_accessories.go | 54 + daemon/api_accessories_test.go | 67 + daemon/api_aliases.go | 170 + daemon/api_aliases_test.go | 668 + daemon/api_apps.go | 349 + daemon/api_apps_test.go | 1160 ++ daemon/api_aspects.go | 128 + daemon/api_aspects_test.go | 604 + daemon/api_asserts.go | 181 + daemon/api_asserts_test.go | 473 + daemon/api_base_test.go | 692 + daemon/api_buy_unsupp.go | 100 + daemon/api_buy_unsupp_test.go | 256 + daemon/api_categories.go | 58 + daemon/api_cohort.go | 61 + daemon/api_cohort_test.go | 115 + daemon/api_connections.go | 288 + daemon/api_connections_test.go | 1044 ++ daemon/api_console_conf.go | 96 + daemon/api_console_conf_test.go | 115 + daemon/api_debug.go | 417 + daemon/api_debug_migrate.go | 55 + daemon/api_debug_pprof.go | 47 + daemon/api_debug_pprof_test.go | 60 + daemon/api_debug_seeding.go | 132 + daemon/api_debug_seeding_test.go | 218 + daemon/api_debug_stacktrace.go | 31 + daemon/api_debug_test.go | 338 + daemon/api_download.go | 265 + daemon/api_download_test.go | 416 + daemon/api_find.go | 274 + daemon/api_find_test.go | 624 + daemon/api_general.go | 456 + daemon/api_general_test.go | 822 + daemon/api_icons.go | 71 + daemon/api_icons_test.go | 117 + daemon/api_interfaces.go | 256 + daemon/api_interfaces_test.go | 1187 ++ daemon/api_json.go | 91 + daemon/api_model.go | 366 + daemon/api_model_test.go | 673 + daemon/api_notices.go | 377 + daemon/api_notices_test.go | 1192 ++ daemon/api_quotas.go | 296 + daemon/api_quotas_test.go | 870 + daemon/api_sections.go | 61 + daemon/api_sideload_n_try.go | 605 + daemon/api_sideload_n_try_test.go | 1405 ++ daemon/api_snap_conf.go | 122 + daemon/api_snap_conf_test.go | 339 + daemon/api_snap_file.go | 67 + daemon/api_snap_file_test.go | 89 + daemon/api_snapctl.go | 111 + daemon/api_snapctl_test.go | 97 + daemon/api_snaps.go | 1037 ++ daemon/api_snaps_test.go | 3239 ++++ daemon/api_snapshots.go | 240 + daemon/api_snapshots_test.go | 467 + daemon/api_system_recovery_keys.go | 85 + daemon/api_system_recovery_keys_test.go | 167 + daemon/api_systems.go | 558 + daemon/api_systems_test.go | 1924 ++ daemon/api_test.go | 132 + daemon/api_themes.go | 253 + daemon/api_themes_test.go | 440 + daemon/api_users.go | 414 + daemon/api_users_test.go | 1112 ++ daemon/api_validate.go | 417 + daemon/api_validate_test.go | 964 + daemon/command_counter_test.go | 220 + daemon/daemon.go | 717 + daemon/daemon_test.go | 1467 ++ daemon/errors.go | 384 + daemon/errors_test.go | 267 + daemon/export_access_test.go | 73 + daemon/export_api_aliases_test.go | 25 + daemon/export_api_apps_test.go | 48 + daemon/export_api_console_conf_test.go | 24 + daemon/export_api_debug_seeding_test.go | 24 + daemon/export_api_debug_test.go | 28 + daemon/export_api_download_test.go | 29 + daemon/export_api_general_test.go | 58 + daemon/export_api_model_test.go | 47 + daemon/export_api_notices_test.go | 37 + daemon/export_api_quotas_test.go | 63 + daemon/export_api_sideload_n_try_test.go | 24 + daemon/export_api_snapctl_test.go | 32 + daemon/export_api_snaps_test.go | 42 + daemon/export_api_snapshots_test.go | 107 + .../export_api_system_recovery_keys_test.go | 33 + daemon/export_api_systems_test.go | 70 + daemon/export_api_themes_test.go | 36 + daemon/export_api_users_test.go | 57 + daemon/export_api_validate_test.go | 54 + daemon/export_snap_test.go | 24 + daemon/export_test.go | 395 + daemon/request.go | 35 + daemon/response.go | 365 + daemon/response_test.go | 110 + daemon/snap.go | 286 + daemon/ucrednet.go | 188 + daemon/ucrednet_test.go | 269 + data/Makefile | 8 + data/apt/20snapd.conf | 1 + data/completion/bash/complete.sh | 129 + data/completion/bash/etelpmoc.sh | 225 + data/completion/bash/snap | 83 + data/completion/zsh/_snap | 59 + data/dbus/Makefile | 42 + data/dbus/io.snapcraft.Launcher.service.in | 4 + .../dbus/io.snapcraft.SessionAgent.service.in | 6 + data/dbus/io.snapcraft.Settings.service.in | 4 + data/dbus/snapd.session-services.conf | 4 + data/dbus/snapd.system-services.conf | 4 + data/desktop/Makefile | 54 + .../io.snapcraft.SessionAgent.desktop.in | 8 + data/desktop/snap-handle-link.desktop.in | 7 + data/desktop/snap-userd-autostart.desktop.in | 6 + data/desktop/snapcraft-logo-bird.svg | 30 + data/env/Makefile | 50 + data/env/snapd.fish.in | 22 + data/env/snapd.sh.in | 22 + data/failure.txt | 8 + data/polkit/io.snapcraft.snapd.policy | 50 + data/preseed.json | 30 + data/selinux/COPYING | 339 + data/selinux/INSTALL.md | 32 + data/selinux/Makefile | 35 + data/selinux/README.md | 25 + data/selinux/snappy.fc | 55 + data/selinux/snappy.if | 313 + data/selinux/snappy.te | 972 + data/success.txt | 20 + data/sysctl/rhel7-snap.conf | 11 + data/systemd-env/990-snapd.conf.in | 2 + data/systemd-env/Makefile | 37 + data/systemd-tmpfiles/Makefile | 31 + data/systemd-tmpfiles/snapd.conf | 1 + data/systemd-user/Makefile | 40 + .../snapd.session-agent.service.in | 7 + data/systemd-user/snapd.session-agent.socket | 8 + data/systemd/Makefile | 55 + data/systemd/snapd.apparmor.service.in | 30 + data/systemd/snapd.autoimport.service.in | 16 + data/systemd/snapd.core-fixup.service.in | 16 + data/systemd/snapd.core-fixup.sh | 86 + data/systemd/snapd.failure.service.in | 7 + data/systemd/snapd.mounts-pre.target | 6 + data/systemd/snapd.mounts.target | 6 + .../snapd.recovery-chooser-trigger.service.in | 20 + data/systemd/snapd.run-from-snap | 6 + data/systemd/snapd.seeded.service.in | 14 + data/systemd/snapd.service.in | 29 + data/systemd/snapd.snap-repair.service.in | 14 + data/systemd/snapd.snap-repair.timer | 15 + data/systemd/snapd.socket | 13 + data/systemd/snapd.system-shutdown.service.in | 20 + data/udev/rules.d/66-snapd-autoimport.rules | 3 + dbusutil/dbustest/dbustest.go | 256 + dbusutil/dbustest/stub.go | 47 + dbusutil/dbusutil.go | 133 + dbusutil/dbusutil_test.go | 122 + dbusutil/export_test.go | 22 + dbusutil/netplantest/netplantest.go | 165 + debian | 1 + .../gce-serial-output-continuously-append.sh | 70 + debug-tools/snap-debug-info.sh | 68 + debug-tools/startup-timings | 49 + desktop/desktopentry/desktopentry.go | 239 + desktop/desktopentry/desktopentry_test.go | 615 + desktop/desktopentry/expand_exec.go | 156 + desktop/desktopentry/expand_exec_test.go | 168 + desktop/desktopentry/export_test.go | 25 + desktop/notification/caps.go | 67 + desktop/notification/export_test.go | 50 + desktop/notification/fdo.go | 291 + desktop/notification/fdo_test.go | 403 + desktop/notification/gtk.go | 111 + desktop/notification/gtk_test.go | 111 + desktop/notification/hints.go | 206 + desktop/notification/hints_test.go | 101 + desktop/notification/manager.go | 45 + desktop/notification/manager_test.go | 81 + desktop/notification/notificationtest/fdo.go | 209 + desktop/notification/notificationtest/gtk.go | 166 + desktop/notification/notify.go | 160 + desktop/notification/notify_test.go | 42 + desktop/portal/document.go | 85 + desktop/portal/document_test.go | 171 + desktop/portal/export_test.go | 59 + desktop/portal/launcher.go | 189 + desktop/portal/launcher_test.go | 247 + dirs/dirs.go | 634 + dirs/dirs_test.go | 280 + dirs/export_test.go | 32 + docs/MOVED.md | 1 + docs/error-kinds.go | 100 + features/export_test.go | 24 + features/features.go | 300 + features/features_test.go | 285 + gadget/device.go | 28 + gadget/device/encrypt.go | 139 + gadget/device/encrypt_test.go | 152 + gadget/device_darwin.go | 30 + gadget/device_linux.go | 93 + gadget/device_test.go | 246 + gadget/edition/number.go | 45 + gadget/edition/number_test.go | 64 + gadget/export_test.go | 99 + gadget/gadget.go | 1933 ++ gadget/gadget_test.go | 5249 ++++++ gadget/gadgettest/examples.go | 1731 ++ gadget/gadgettest/gadgettest.go | 362 + gadget/install/content.go | 114 + gadget/install/content_test.go | 388 + gadget/install/encrypt.go | 98 + gadget/install/encrypt_test.go | 118 + gadget/install/export_secboot_test.go | 47 + gadget/install/export_test.go | 95 + gadget/install/install.go | 832 + gadget/install/install_dummy.go | 65 + gadget/install/install_test.go | 1535 ++ gadget/install/mount_linux.go | 29 + gadget/install/mount_other.go | 35 + gadget/install/params.go | 97 + gadget/install/partition.go | 454 + gadget/install/partition_test.go | 1225 ++ gadget/kcmdline.go | 50 + gadget/kcmdline_test.go | 245 + gadget/layout.go | 551 + gadget/layout_test.go | 1500 ++ gadget/mountedfilesystem.go | 1036 ++ gadget/mountedfilesystem_test.go | 3546 ++++ gadget/ondisk.go | 292 + gadget/ondisk_test.go | 524 + gadget/partial.go | 142 + gadget/partial_test.go | 313 + gadget/quantity/offset.go | 72 + gadget/quantity/offset_test.go | 101 + gadget/quantity/size.go | 122 + gadget/quantity/size_test.go | 104 + gadget/raw.go | 334 + gadget/raw_test.go | 845 + gadget/update.go | 1798 ++ gadget/update_test.go | 6070 +++++++ gadget/validate.go | 497 + gadget/validate_test.go | 1289 ++ gen-coverage.sh | 9 + generate-packaging-dir | 17 + get-deps.sh | 12 + go.mod | 47 + go.sum | 97 + httputil/client.go | 196 + httputil/client_test.go | 321 + httputil/export_test.go | 24 + httputil/logger.go | 104 + httputil/logger_test.go | 180 + httputil/retry.go | 311 + httputil/retry_test.go | 508 + httputil/transport.go | 44 + i18n/i18n.go | 135 + i18n/i18n_test.go | 161 + i18n/xgettext-go/main.go | 348 + i18n/xgettext-go/main_test.go | 537 + image/export_test.go | 67 + image/helpers.go | 125 + image/helpers_test.go | 177 + image/image_darwin.go | 28 + image/image_linux.go | 929 + image/image_test.go | 4878 +++++ image/options.go | 91 + image/preseed/export_test.go | 69 + image/preseed/preseed.go | 224 + image/preseed/preseed_classic_test.go | 526 + image/preseed/preseed_linux.go | 801 + image/preseed/preseed_other.go | 35 + image/preseed/preseed_test.go | 347 + image/preseed/preseed_uc20_test.go | 438 + image/preseed/reset.go | 185 + include/lk/snappy_boot_common.h | 56 + include/lk/snappy_boot_v1.h | 143 + include/lk/snappy_boot_v2.h | 256 + interfaces/apparmor/apparmor.go | 44 + interfaces/apparmor/apparmor_test.go | 67 + interfaces/apparmor/backend.go | 1014 ++ interfaces/apparmor/backend_test.go | 2721 +++ interfaces/apparmor/export_test.go | 104 + interfaces/apparmor/spec.go | 831 + interfaces/apparmor/spec_test.go | 604 + interfaces/apparmor/template.go | 1104 ++ interfaces/apparmor/template_vars.go | 45 + interfaces/backend.go | 135 + interfaces/backends/backends.go | 72 + interfaces/backends/backends_test.go | 76 + interfaces/builtin/README.md | 592 + interfaces/builtin/account_control.go | 165 + interfaces/builtin/account_control_test.go | 108 + interfaces/builtin/accounts_service.go | 80 + interfaces/builtin/accounts_service_test.go | 81 + interfaces/builtin/acrn_support.go | 58 + interfaces/builtin/acrn_support_test.go | 119 + interfaces/builtin/adb_support.go | 191 + interfaces/builtin/adb_support_test.go | 161 + interfaces/builtin/all.go | 155 + interfaces/builtin/all_test.go | 460 + interfaces/builtin/allegro_vcu.go | 64 + interfaces/builtin/allegro_vcu_test.go | 112 + interfaces/builtin/alsa.go | 69 + interfaces/builtin/alsa_test.go | 109 + interfaces/builtin/appstream_metadata.go | 131 + interfaces/builtin/appstream_metadata_test.go | 150 + interfaces/builtin/audio_playback.go | 202 + interfaces/builtin/audio_playback_test.go | 223 + interfaces/builtin/audio_record.go | 82 + interfaces/builtin/audio_record_test.go | 146 + interfaces/builtin/autopilot.go | 75 + interfaces/builtin/autopilot_test.go | 96 + interfaces/builtin/avahi_control.go | 183 + interfaces/builtin/avahi_control_test.go | 242 + interfaces/builtin/avahi_observe.go | 484 + interfaces/builtin/avahi_observe_test.go | 242 + interfaces/builtin/block_devices.go | 140 + interfaces/builtin/block_devices_test.go | 114 + interfaces/builtin/bluetooth_control.go | 77 + interfaces/builtin/bluetooth_control_test.go | 115 + interfaces/builtin/bluez.go | 318 + interfaces/builtin/bluez_test.go | 283 + interfaces/builtin/bool_file.go | 160 + interfaces/builtin/bool_file_test.go | 266 + interfaces/builtin/broadcom_asic_control.go | 78 + .../builtin/broadcom_asic_control_test.go | 124 + interfaces/builtin/browser_support.go | 429 + interfaces/builtin/browser_support_test.go | 232 + interfaces/builtin/calendar_service.go | 141 + interfaces/builtin/calendar_service_test.go | 93 + interfaces/builtin/camera.go | 70 + interfaces/builtin/camera_test.go | 111 + interfaces/builtin/can_bus.go | 55 + interfaces/builtin/can_bus_test.go | 100 + interfaces/builtin/cifs_mount.go | 77 + interfaces/builtin/cifs_mount_test.go | 100 + interfaces/builtin/classic_support.go | 132 + interfaces/builtin/classic_support_test.go | 98 + interfaces/builtin/common.go | 216 + interfaces/builtin/common_files.go | 167 + interfaces/builtin/common_test.go | 270 + interfaces/builtin/contacts_service.go | 160 + interfaces/builtin/contacts_service_test.go | 93 + interfaces/builtin/content.go | 328 + interfaces/builtin/content_test.go | 1059 ++ interfaces/builtin/core_support.go | 51 + interfaces/builtin/core_support_test.go | 88 + interfaces/builtin/cpu_control.go | 93 + interfaces/builtin/cpu_control_test.go | 88 + interfaces/builtin/cups.go | 218 + interfaces/builtin/cups_control.go | 195 + interfaces/builtin/cups_control_test.go | 203 + interfaces/builtin/cups_test.go | 260 + interfaces/builtin/custom_device.go | 488 + interfaces/builtin/custom_device_test.go | 678 + interfaces/builtin/daemon_notify.go | 104 + interfaces/builtin/daemon_notify_test.go | 166 + interfaces/builtin/dbus.go | 449 + interfaces/builtin/dbus_test.go | 702 + interfaces/builtin/dcdbas_control.go | 63 + interfaces/builtin/dcdbas_control_test.go | 87 + interfaces/builtin/desktop.go | 604 + interfaces/builtin/desktop_launch.go | 68 + interfaces/builtin/desktop_launch_test.go | 101 + interfaces/builtin/desktop_legacy.go | 404 + interfaces/builtin/desktop_legacy_test.go | 109 + interfaces/builtin/desktop_test.go | 330 + interfaces/builtin/device_buttons.go | 92 + interfaces/builtin/device_buttons_test.go | 113 + interfaces/builtin/display_control.go | 139 + interfaces/builtin/display_control_test.go | 118 + interfaces/builtin/dm_crypt.go | 109 + interfaces/builtin/dm_crypt_test.go | 145 + interfaces/builtin/docker.go | 55 + interfaces/builtin/docker_support.go | 1084 ++ interfaces/builtin/docker_support_test.go | 289 + interfaces/builtin/docker_test.go | 96 + interfaces/builtin/dsp.go | 133 + interfaces/builtin/dsp_test.go | 129 + interfaces/builtin/dvb.go | 51 + interfaces/builtin/dvb_test.go | 106 + interfaces/builtin/empty.go | 101 + interfaces/builtin/export_test.go | 127 + interfaces/builtin/firewall_control.go | 180 + interfaces/builtin/firewall_control_test.go | 116 + interfaces/builtin/fpga.go | 72 + interfaces/builtin/fpga_test.go | 112 + interfaces/builtin/framebuffer.go | 52 + interfaces/builtin/framebuffer_test.go | 111 + interfaces/builtin/fuse_support.go | 107 + interfaces/builtin/fuse_support_test.go | 118 + interfaces/builtin/fwupd.go | 489 + interfaces/builtin/fwupd_test.go | 312 + interfaces/builtin/gconf.go | 74 + interfaces/builtin/gconf_test.go | 103 + interfaces/builtin/gpg_keys.go | 63 + interfaces/builtin/gpg_keys_test.go | 96 + interfaces/builtin/gpg_public_keys.go | 66 + interfaces/builtin/gpg_public_keys_test.go | 96 + interfaces/builtin/gpio.go | 131 + interfaces/builtin/gpio_control.go | 62 + interfaces/builtin/gpio_control_test.go | 97 + interfaces/builtin/gpio_memory_control.go | 53 + .../builtin/gpio_memory_control_test.go | 105 + interfaces/builtin/gpio_test.go | 181 + interfaces/builtin/greengrass_support.go | 493 + interfaces/builtin/greengrass_support_test.go | 259 + interfaces/builtin/gsettings.go | 57 + interfaces/builtin/gsettings_test.go | 104 + interfaces/builtin/hardware_observe.go | 166 + interfaces/builtin/hardware_observe_test.go | 98 + interfaces/builtin/hardware_random_control.go | 60 + .../builtin/hardware_random_control_test.go | 109 + interfaces/builtin/hardware_random_observe.go | 55 + .../builtin/hardware_random_observe_test.go | 109 + interfaces/builtin/hidraw.go | 209 + interfaces/builtin/hidraw_test.go | 370 + interfaces/builtin/home.go | 133 + interfaces/builtin/home_test.go | 178 + interfaces/builtin/hostname_control.go | 89 + interfaces/builtin/hostname_control_test.go | 103 + interfaces/builtin/hugepages_control.go | 79 + interfaces/builtin/hugepages_control_test.go | 104 + interfaces/builtin/i2c.go | 155 + interfaces/builtin/i2c_test.go | 280 + interfaces/builtin/iio.go | 151 + interfaces/builtin/iio_test.go | 262 + interfaces/builtin/intel_mei.go | 52 + interfaces/builtin/intel_mei_test.go | 109 + interfaces/builtin/io_ports_control.go | 64 + interfaces/builtin/io_ports_control_test.go | 117 + interfaces/builtin/ion_memory_control.go | 61 + interfaces/builtin/ion_memory_control_test.go | 111 + interfaces/builtin/jack1.go | 46 + interfaces/builtin/jack1_test.go | 93 + interfaces/builtin/joystick.go | 109 + interfaces/builtin/joystick_test.go | 115 + interfaces/builtin/juju_client_observe.go | 45 + .../builtin/juju_client_observe_test.go | 92 + interfaces/builtin/kernel_crypto_api.go | 66 + interfaces/builtin/kernel_crypto_api_test.go | 103 + interfaces/builtin/kernel_firmware_control.go | 54 + .../builtin/kernel_firmware_control_test.go | 87 + interfaces/builtin/kernel_module_control.go | 86 + .../builtin/kernel_module_control_test.go | 119 + interfaces/builtin/kernel_module_load.go | 240 + interfaces/builtin/kernel_module_load_test.go | 213 + interfaces/builtin/kernel_module_observe.go | 59 + .../builtin/kernel_module_observe_test.go | 95 + interfaces/builtin/kubernetes_support.go | 398 + interfaces/builtin/kubernetes_support_test.go | 294 + interfaces/builtin/kvm.go | 122 + interfaces/builtin/kvm_test.go | 211 + interfaces/builtin/libvirt.go | 54 + interfaces/builtin/libvirt_test.go | 61 + interfaces/builtin/locale_control.go | 76 + interfaces/builtin/locale_control_test.go | 89 + interfaces/builtin/location_control.go | 256 + interfaces/builtin/location_control_test.go | 228 + interfaces/builtin/location_observe.go | 310 + interfaces/builtin/location_observe_test.go | 211 + interfaces/builtin/log_observe.go | 86 + interfaces/builtin/log_observe_test.go | 87 + interfaces/builtin/login_session_control.go | 73 + .../builtin/login_session_control_test.go | 110 + interfaces/builtin/login_session_observe.go | 127 + .../builtin/login_session_observe_test.go | 95 + interfaces/builtin/lxd.go | 53 + interfaces/builtin/lxd_support.go | 133 + interfaces/builtin/lxd_support_test.go | 165 + interfaces/builtin/lxd_test.go | 111 + interfaces/builtin/maliit.go | 173 + interfaces/builtin/maliit_test.go | 267 + interfaces/builtin/media_control.go | 61 + interfaces/builtin/media_control_test.go | 109 + interfaces/builtin/media_hub.go | 204 + interfaces/builtin/media_hub_test.go | 194 + interfaces/builtin/microceph.go | 51 + interfaces/builtin/microceph_support.go | 69 + interfaces/builtin/microceph_support_test.go | 96 + interfaces/builtin/microceph_test.go | 111 + interfaces/builtin/microovn.go | 51 + interfaces/builtin/microovn_test.go | 111 + interfaces/builtin/microstack_support.go | 259 + interfaces/builtin/microstack_support_test.go | 140 + interfaces/builtin/mir.go | 157 + interfaces/builtin/mir_test.go | 158 + interfaces/builtin/modem_manager.go | 1400 ++ interfaces/builtin/modem_manager_test.go | 261 + interfaces/builtin/mount_control.go | 628 + interfaces/builtin/mount_control_test.go | 420 + interfaces/builtin/mount_observe.go | 91 + interfaces/builtin/mount_observe_test.go | 87 + interfaces/builtin/mpris.go | 256 + interfaces/builtin/mpris_test.go | 349 + interfaces/builtin/multipass_support.go | 128 + interfaces/builtin/multipass_support_test.go | 108 + interfaces/builtin/netlink_audit.go | 95 + interfaces/builtin/netlink_audit_test.go | 113 + interfaces/builtin/netlink_connector.go | 61 + interfaces/builtin/netlink_connector_test.go | 87 + interfaces/builtin/netlink_driver.go | 152 + interfaces/builtin/netlink_driver_test.go | 270 + interfaces/builtin/network.go | 103 + interfaces/builtin/network_bind.go | 108 + interfaces/builtin/network_bind_test.go | 95 + interfaces/builtin/network_control.go | 436 + interfaces/builtin/network_control_test.go | 176 + interfaces/builtin/network_manager.go | 586 + interfaces/builtin/network_manager_observe.go | 228 + .../builtin/network_manager_observe_test.go | 145 + interfaces/builtin/network_manager_test.go | 249 + interfaces/builtin/network_observe.go | 163 + interfaces/builtin/network_observe_test.go | 96 + interfaces/builtin/network_setup_control.go | 95 + .../builtin/network_setup_control_test.go | 87 + interfaces/builtin/network_setup_observe.go | 84 + .../builtin/network_setup_observe_test.go | 88 + interfaces/builtin/network_status.go | 59 + interfaces/builtin/network_status_test.go | 90 + interfaces/builtin/network_test.go | 96 + interfaces/builtin/nfs_mount.go | 91 + interfaces/builtin/nfs_mount_test.go | 100 + interfaces/builtin/nvidia_drivers_support.go | 82 + .../builtin/nvidia_drivers_support_test.go | 106 + interfaces/builtin/ofono.go | 362 + interfaces/builtin/ofono_test.go | 220 + interfaces/builtin/online_accounts_service.go | 143 + .../builtin/online_accounts_service_test.go | 111 + interfaces/builtin/opengl.go | 274 + interfaces/builtin/opengl_test.go | 176 + interfaces/builtin/openvswitch.go | 52 + interfaces/builtin/openvswitch_support.go | 43 + .../builtin/openvswitch_support_test.go | 87 + interfaces/builtin/openvswitch_test.go | 89 + interfaces/builtin/optical_drive.go | 111 + interfaces/builtin/optical_drive_test.go | 167 + interfaces/builtin/packagekit_control.go | 112 + interfaces/builtin/packagekit_control_test.go | 92 + .../builtin/password_manager_service.go | 92 + .../builtin/password_manager_service_test.go | 88 + interfaces/builtin/pcscd.go | 46 + interfaces/builtin/pcscd_test.go | 94 + interfaces/builtin/personal_files.go | 158 + interfaces/builtin/personal_files_test.go | 271 + interfaces/builtin/physical_memory_control.go | 56 + .../builtin/physical_memory_control_test.go | 109 + interfaces/builtin/physical_memory_observe.go | 55 + .../builtin/physical_memory_observe_test.go | 110 + interfaces/builtin/pkcs11.go | 164 + interfaces/builtin/pkcs11_test.go | 311 + interfaces/builtin/polkit.go | 217 + interfaces/builtin/polkit_agent.go | 148 + interfaces/builtin/polkit_agent_test.go | 117 + interfaces/builtin/polkit_test.go | 376 + interfaces/builtin/posix_mq.go | 381 + interfaces/builtin/posix_mq_test.go | 851 + interfaces/builtin/power_control.go | 63 + interfaces/builtin/power_control_test.go | 88 + interfaces/builtin/ppp.go | 77 + interfaces/builtin/ppp_test.go | 118 + interfaces/builtin/process_control.go | 80 + interfaces/builtin/process_control_test.go | 95 + interfaces/builtin/ptp.go | 55 + interfaces/builtin/ptp_test.go | 109 + interfaces/builtin/pulseaudio.go | 199 + interfaces/builtin/pulseaudio_test.go | 170 + interfaces/builtin/pwm.go | 142 + interfaces/builtin/pwm_test.go | 207 + interfaces/builtin/qualcomm_ipc_router.go | 327 + .../builtin/qualcomm_ipc_router_test.go | 387 + interfaces/builtin/raw_input.go | 86 + interfaces/builtin/raw_input_test.go | 128 + interfaces/builtin/raw_usb.go | 81 + interfaces/builtin/raw_usb_test.go | 121 + interfaces/builtin/raw_volume.go | 159 + interfaces/builtin/raw_volume_test.go | 350 + interfaces/builtin/remoteproc.go | 58 + interfaces/builtin/remoteproc_test.go | 91 + interfaces/builtin/removable_media.go | 61 + interfaces/builtin/removable_media_test.go | 88 + interfaces/builtin/ros_opt_data.go | 64 + interfaces/builtin/ros_opt_data_test.go | 103 + interfaces/builtin/screen_inhibit_control.go | 101 + .../builtin/screen_inhibit_control_test.go | 87 + interfaces/builtin/screencast_legacy.go | 63 + interfaces/builtin/screencast_legacy_test.go | 99 + interfaces/builtin/scsi_generic.go | 58 + interfaces/builtin/scsi_generic_test.go | 108 + interfaces/builtin/sd_control.go | 98 + interfaces/builtin/sd_control_test.go | 128 + interfaces/builtin/serial_port.go | 291 + interfaces/builtin/serial_port_test.go | 759 + interfaces/builtin/shared_memory.go | 357 + interfaces/builtin/shared_memory_test.go | 511 + interfaces/builtin/shutdown.go | 81 + interfaces/builtin/shutdown_test.go | 86 + interfaces/builtin/snap_refresh_control.go | 51 + .../builtin/snap_refresh_control_test.go | 84 + interfaces/builtin/snap_refresh_observe.go | 47 + .../builtin/snap_refresh_observe_test.go | 98 + interfaces/builtin/snap_themes_control.go | 47 + .../builtin/snap_themes_control_test.go | 98 + interfaces/builtin/snapd_control.go | 74 + interfaces/builtin/snapd_control_test.go | 111 + interfaces/builtin/spi.go | 110 + interfaces/builtin/spi_test.go | 250 + interfaces/builtin/ssh_keys.go | 55 + interfaces/builtin/ssh_keys_test.go | 97 + interfaces/builtin/ssh_public_keys.go | 50 + interfaces/builtin/ssh_public_keys_test.go | 96 + interfaces/builtin/steam_support.go | 415 + interfaces/builtin/steam_support_test.go | 110 + .../builtin/storage_framework_service.go | 162 + .../builtin/storage_framework_service_test.go | 105 + interfaces/builtin/system_backup.go | 61 + interfaces/builtin/system_backup_test.go | 103 + interfaces/builtin/system_files.go | 78 + interfaces/builtin/system_files_test.go | 175 + interfaces/builtin/system_observe.go | 248 + interfaces/builtin/system_observe_test.go | 135 + interfaces/builtin/system_packages_doc.go | 188 + .../builtin/system_packages_doc_test.go | 252 + interfaces/builtin/system_source_code.go | 56 + interfaces/builtin/system_source_code_test.go | 97 + interfaces/builtin/system_trace.go | 74 + interfaces/builtin/system_trace_test.go | 87 + interfaces/builtin/tee.go | 66 + interfaces/builtin/tee_test.go | 116 + interfaces/builtin/thumbnailer_service.go | 148 + .../builtin/thumbnailer_service_test.go | 124 + interfaces/builtin/time_control.go | 147 + interfaces/builtin/time_control_test.go | 131 + interfaces/builtin/timeserver_control.go | 99 + interfaces/builtin/timeserver_control_test.go | 87 + interfaces/builtin/timezone_control.go | 108 + interfaces/builtin/timezone_control_test.go | 87 + interfaces/builtin/tpm.go | 55 + interfaces/builtin/tpm_test.go | 111 + interfaces/builtin/u2f_devices.go | 235 + interfaces/builtin/u2f_devices_test.go | 113 + interfaces/builtin/ubuntu_download_manager.go | 246 + .../builtin/ubuntu_download_manager_test.go | 93 + interfaces/builtin/udisks2.go | 487 + interfaces/builtin/udisks2_test.go | 368 + interfaces/builtin/uhid.go | 51 + interfaces/builtin/uhid_test.go | 96 + interfaces/builtin/uinput.go | 81 + interfaces/builtin/uinput_test.go | 112 + interfaces/builtin/uio.go | 138 + interfaces/builtin/uio_test.go | 175 + interfaces/builtin/unity7.go | 713 + interfaces/builtin/unity7_test.go | 129 + interfaces/builtin/unity8.go | 124 + interfaces/builtin/unity8_calendar.go | 157 + interfaces/builtin/unity8_calendar_test.go | 218 + interfaces/builtin/unity8_contacts.go | 194 + interfaces/builtin/unity8_contacts_test.go | 221 + interfaces/builtin/unity8_pim_common.go | 171 + interfaces/builtin/unity8_test.go | 94 + interfaces/builtin/upower_observe.go | 281 + interfaces/builtin/upower_observe_test.go | 232 + interfaces/builtin/userns.go | 106 + interfaces/builtin/userns_test.go | 120 + interfaces/builtin/utils.go | 145 + interfaces/builtin/utils_test.go | 273 + interfaces/builtin/vcio.go | 65 + interfaces/builtin/vcio_test.go | 105 + interfaces/builtin/wayland.go | 192 + interfaces/builtin/wayland_test.go | 206 + interfaces/builtin/x11.go | 279 + interfaces/builtin/x11_test.go | 286 + interfaces/builtin/xilinx_dma.go | 70 + interfaces/builtin/xilinx_dma_test.go | 110 + interfaces/connection.go | 255 + interfaces/connection_test.go | 386 + interfaces/core.go | 330 + interfaces/core_test.go | 281 + interfaces/dbus/backend.go | 259 + interfaces/dbus/backend_test.go | 437 + interfaces/dbus/dbus.go | 52 + interfaces/dbus/dbus_test.go | 42 + interfaces/dbus/export_test.go | 35 + interfaces/dbus/spec.go | 161 + interfaces/dbus/spec_test.go | 111 + interfaces/dbus/template.go | 29 + interfaces/ensure_dir.go | 84 + interfaces/ensure_dir_test.go | 89 + interfaces/export_test.go | 79 + interfaces/helpers.go | 54 + interfaces/helpers_test.go | 176 + interfaces/hotplug/deviceinfo.go | 150 + interfaces/hotplug/deviceinfo_test.go | 191 + interfaces/hotplug/proposed_slot.go | 73 + interfaces/hotplug/proposed_slot_test.go | 80 + interfaces/hotplug/udevadm.go | 126 + interfaces/hotplug/udevadm_test.go | 126 + interfaces/ifacetest/backend.go | 131 + interfaces/ifacetest/backendtest.go | 261 + interfaces/ifacetest/ifacetest_test.go | 30 + interfaces/ifacetest/spec.go | 81 + interfaces/ifacetest/spec_test.go | 93 + interfaces/ifacetest/testiface.go | 474 + interfaces/ifacetest/testiface_test.go | 239 + interfaces/kmod/backend.go | 222 + interfaces/kmod/backend_test.go | 200 + interfaces/kmod/export_test.go | 34 + interfaces/kmod/kmod.go | 43 + interfaces/kmod/kmod_test.go | 85 + interfaces/kmod/spec.go | 147 + interfaces/kmod/spec_test.go | 129 + interfaces/mount/backend.go | 168 + interfaces/mount/backend_test.go | 423 + interfaces/mount/ns.go | 69 + interfaces/mount/ns_test.go | 143 + interfaces/mount/spec.go | 396 + interfaces/mount/spec_test.go | 323 + interfaces/naming.go | 30 + interfaces/naming_test.go | 34 + interfaces/policy/basedeclaration.go | 95 + interfaces/policy/basedeclaration_test.go | 1821 ++ interfaces/policy/export_test.go | 26 + interfaces/policy/helpers.go | 332 + interfaces/policy/helpers_test.go | 100 + interfaces/policy/policy.go | 325 + interfaces/policy/policy_test.go | 3025 ++++ interfaces/polkit/backend.go | 125 + interfaces/polkit/backend_test.go | 128 + interfaces/polkit/spec.go | 106 + interfaces/polkit/spec_test.go | 99 + interfaces/repo.go | 1172 ++ interfaces/repo_test.go | 2201 +++ interfaces/seccomp/backend.go | 499 + interfaces/seccomp/backend_test.go | 980 + interfaces/seccomp/export_test.go | 100 + interfaces/seccomp/seccomp_test.go | 30 + interfaces/seccomp/spec.go | 166 + interfaces/seccomp/spec_test.go | 112 + interfaces/seccomp/template.go | 834 + interfaces/snap_app_set.go | 169 + interfaces/snap_app_set_test.go | 266 + interfaces/sorting.go | 106 + interfaces/sorting_test.go | 134 + interfaces/system_key.go | 333 + interfaces/system_key_test.go | 436 + interfaces/systemd/backend.go | 217 + interfaces/systemd/backend_test.go | 225 + interfaces/systemd/service.go | 58 + interfaces/systemd/service_test.go | 45 + interfaces/systemd/spec.go | 136 + interfaces/systemd/spec_test.go | 181 + interfaces/systemd/systemd_test.go | 30 + interfaces/udev/backend.go | 237 + interfaces/udev/backend_test.go | 660 + interfaces/udev/export_test.go | 24 + interfaces/udev/spec.go | 255 + interfaces/udev/spec_test.go | 173 + interfaces/udev/udev.go | 126 + interfaces/udev/udev_test.go | 207 + interfaces/utils/export_test.go | 26 + interfaces/utils/path_patterns.go | 180 + interfaces/utils/path_patterns_test.go | 208 + interfaces/utils/utils.go | 82 + interfaces/utils/utils_test.go | 82 + jsonutil/json.go | 66 + jsonutil/json_test.go | 90 + jsonutil/safejson/safejson.go | 202 + jsonutil/safejson/safejson_test.go | 148 + kernel/export_test.go | 27 + kernel/fde/cmd_helper.go | 49 + kernel/fde/export_test.go | 32 + kernel/fde/fde.go | 152 + kernel/fde/fde_test.go | 407 + kernel/fde/reveal_key.go | 142 + kernel/kernel.go | 78 + kernel/kernel_drivers.go | 328 + kernel/kernel_drivers_test.go | 501 + kernel/kernel_test.go | 138 + kernel/validate.go | 72 + kernel/validate_test.go | 114 + logger/export_test.go | 62 + logger/logger.go | 235 + logger/logger_test.go | 238 + mdlint.py | 37 + metautil/export_test.go | 24 + metautil/normalize.go | 80 + metautil/normalize_test.go | 71 + metautil/type_conversions.go | 117 + metautil/type_conversions_test.go | 149 + mkversion.sh | 146 + netutil/activation.go | 85 + netutil/metered.go | 65 + osutil/bootid.go | 40 + osutil/bootid_test.go | 36 + osutil/buildid.go | 118 + osutil/buildid_test.go | 171 + osutil/chattr.go | 76 + osutil/chattr_32.go | 27 + osutil/chattr_64.go | 28 + osutil/chdir.go | 38 + osutil/chdir_test.go | 61 + osutil/cmp.go | 103 + osutil/cmp_test.go | 155 + osutil/context.go | 89 + osutil/context_test.go | 138 + osutil/cp.go | 213 + osutil/cp_linux.go | 48 + osutil/cp_linux_test.go | 54 + osutil/cp_other.go | 31 + osutil/cp_test.go | 386 + osutil/digest.go | 46 + osutil/digest_test.go | 50 + osutil/disk.go | 61 + osutil/disk_test.go | 70 + osutil/disks/blockdev.go | 61 + osutil/disks/disks.go | 239 + osutil/disks/disks_darwin.go | 84 + osutil/disks/disks_linux.go | 1017 ++ osutil/disks/disks_linux_test.go | 2050 +++ osutil/disks/export_test.go | 28 + osutil/disks/gpt.go | 156 + osutil/disks/gpt_test.go | 298 + osutil/disks/labels.go | 82 + osutil/disks/labels_darwin.go | 28 + osutil/disks/labels_linux.go | 83 + osutil/disks/labels_test.go | 153 + osutil/disks/luks.go | 91 + osutil/disks/mapper.go | 66 + osutil/disks/mapper_test.go | 80 + osutil/disks/mockdisk.go | 409 + osutil/disks/mockdisk_linux.go | 32 + osutil/disks/mockdisk_test.go | 533 + osutil/disks/testdata/generate.sh | 45 + osutil/disks/testdata/gpt_footer | Bin 0 -> 512 bytes osutil/disks/testdata/gpt_footer_4k | Bin 0 -> 4096 bytes osutil/disks/testdata/gpt_footer_4k_big | Bin 0 -> 4096 bytes osutil/disks/testdata/gpt_footer_4k_small | Bin 0 -> 4096 bytes osutil/disks/testdata/gpt_footer_big | Bin 0 -> 512 bytes osutil/disks/testdata/gpt_footer_small | Bin 0 -> 512 bytes osutil/disks/testdata/gpt_header | Bin 0 -> 1024 bytes osutil/disks/testdata/gpt_header_4k | Bin 0 -> 8192 bytes osutil/disks/testdata/gpt_header_4k_big | Bin 0 -> 8192 bytes osutil/disks/testdata/gpt_header_4k_small | Bin 0 -> 8192 bytes osutil/disks/testdata/gpt_header_big | Bin 0 -> 1024 bytes osutil/disks/testdata/gpt_header_small | Bin 0 -> 1024 bytes osutil/doc.go | 23 + osutil/env.go | 259 + osutil/env_test.go | 332 + osutil/epoll/epoll.go | 290 + osutil/epoll/epoll_test.go | 680 + osutil/epoll/export_test.go | 36 + osutil/exec.go | 200 + osutil/exec_test.go | 233 + osutil/exitcode.go | 39 + osutil/exitcode_test.go | 58 + osutil/export_fault_test.go | 49 + osutil/export_test.go | 248 + osutil/faultinject.go | 121 + osutil/faultinject_fake.go | 27 + osutil/faultinject_fake_test.go | 53 + osutil/faultinject_test.go | 198 + osutil/flock.go | 106 + osutil/flock_test.go | 238 + osutil/fshelpers.go | 39 + osutil/fshelpers_test.go | 52 + osutil/group.go | 205 + osutil/group_cgo.go | 29 + osutil/group_no_cgo.go | 21 + osutil/group_test.go | 227 + osutil/inotify/LICENSE | 27 + osutil/inotify/PATENTS | 22 + osutil/inotify/README.md | 1 + osutil/inotify/inotify.go | 47 + osutil/inotify/inotify_linux.go | 310 + osutil/inotify/inotify_linux_test.go | 140 + osutil/inotify/inotify_others.go | 59 + osutil/io.go | 344 + osutil/io_test.go | 523 + osutil/kcmdline/kcmdline.go | 490 + osutil/kcmdline/kcmdline_test.go | 615 + osutil/kmod/export_test.go | 34 + osutil/kmod/kmod.go | 48 + osutil/kmod/kmod_test.go | 131 + osutil/meminfo.go | 87 + osutil/meminfo_test.go | 217 + osutil/mkdirallchown.go | 88 + osutil/mkdirallchown_test.go | 48 + osutil/mkfs/mkfs.go | 219 + osutil/mkfs/mkfs_test.go | 387 + osutil/mount/mount_linux.go | 90 + osutil/mount/mount_linux_test.go | 115 + osutil/mount_darwin.go | 25 + osutil/mount_linux.go | 34 + osutil/mount_linux_test.go | 65 + osutil/mountentry.go | 384 + osutil/mountentry_linux.go | 144 + osutil/mountentry_linux_test.go | 514 + osutil/mountinfo.go | 38 + osutil/mountinfo_darwin.go | 29 + osutil/mountinfo_linux.go | 211 + osutil/mountinfo_linux_test.go | 215 + osutil/mountprofile_darwin.go | 28 + osutil/mountprofile_linux.go | 122 + osutil/mountprofile_linux_test.go | 168 + osutil/nfs.go | 35 + osutil/nfs_darwin.go | 25 + osutil/nfs_linux.go | 60 + osutil/nfs_linux_test.go | 108 + osutil/osutil_darwin.go | 27 + osutil/osutil_test.go | 29 + osutil/outputerr.go | 57 + osutil/outputerr_test.go | 69 + osutil/overlay.go | 33 + osutil/overlay_darwin.go | 25 + osutil/overlay_linux.go | 93 + osutil/overlay_linux_test.go | 107 + osutil/rename.go | 27 + osutil/rename_darwin.go | 25 + osutil/rename_linux.go | 28 + osutil/rename_linux_test.go | 50 + osutil/resolve_path.go | 143 + osutil/resolve_path_test.go | 207 + osutil/settime.go | 40 + osutil/settime_32bit.go | 38 + osutil/settime_64bit.go | 38 + osutil/settime_test.go | 48 + osutil/sizer.go | 38 + osutil/sizer_test.go | 47 + osutil/squashfs/fstype.go | 89 + osutil/stat.go | 135 + osutil/stat_test.go | 296 + osutil/strace/export_test.go | 28 + osutil/strace/strace.go | 112 + osutil/strace/strace_test.go | 141 + osutil/strace/timing.go | 228 + osutil/strace/timing_test.go | 137 + osutil/syncdir.go | 326 + osutil/syncdir_test.go | 450 + osutil/synctree.go | 191 + osutil/synctree_test.go | 186 + osutil/sys/runas.go | 129 + osutil/sys/syscall.go | 109 + osutil/sys/sysnum_16_linux.go | 35 + osutil/sys/sysnum_32_linux.go | 34 + osutil/sys/sysnum_darwin.go | 32 + osutil/sys/sysnum_linux.go | 26 + osutil/sys_linux.go | 64 + osutil/sys_linux_test.go | 72 + osutil/testhelper.go | 40 + osutil/testhelper_test.go | 66 + osutil/udev/.gitignore | 18 + osutil/udev/.travis.yml | 8 + osutil/udev/LICENSE | 674 + osutil/udev/README.md | 126 + osutil/udev/crawler/device.go | 112 + osutil/udev/main.go.sample | 144 + osutil/udev/matcher.sample | 21 + osutil/udev/netlink/conn.go | 174 + osutil/udev/netlink/conn_test.go | 69 + osutil/udev/netlink/matcher.go | 169 + osutil/udev/netlink/matcher_test.go | 119 + osutil/udev/netlink/rawsockstop.go | 68 + osutil/udev/netlink/rawsockstop_arm64.go | 14 + osutil/udev/netlink/rawsockstop_other.go | 15 + osutil/udev/netlink/rawsockstop_test.go | 68 + osutil/udev/netlink/uevent.go | 178 + osutil/udev/netlink/uevent_test.go | 188 + osutil/uname.go | 79 + osutil/uname_darwin.go | 30 + osutil/uname_linux.go | 32 + osutil/uname_linux_test.go | 96 + osutil/unlink.go | 72 + osutil/unlink_darwin.go | 29 + osutil/unlink_linux.go | 27 + osutil/unlink_test.go | 108 + osutil/user.go | 405 + osutil/user_test.go | 668 + osutil/winsize.go | 48 + overlord/README.md | 81 + overlord/aspectstate/aspectstate.go | 201 + overlord/aspectstate/aspectstate_test.go | 292 + overlord/assertstate/assertmgr.go | 155 + overlord/assertstate/assertstate.go | 1244 ++ overlord/assertstate/assertstate_test.go | 5218 ++++++ .../assertstate/assertstatetest/add_many.go | 42 + overlord/assertstate/bulk.go | 329 + overlord/assertstate/export_test.go | 45 + overlord/assertstate/helpers.go | 91 + .../assertstate/validation_set_tracking.go | 373 + .../validation_set_tracking_test.go | 633 + overlord/auth/auth.go | 399 + overlord/auth/auth_test.go | 603 + overlord/backend.go | 39 + overlord/cmdstate/cmdmgr.go | 91 + overlord/cmdstate/cmdstate.go | 37 + overlord/cmdstate/cmdstate_test.go | 223 + overlord/cmdstate/export_test.go | 32 + overlord/configstate/config/export_test.go | 42 + overlord/configstate/config/helpers.go | 324 + overlord/configstate/config/helpers_test.go | 342 + overlord/configstate/config/transaction.go | 568 + .../configstate/config/transaction_test.go | 892 + overlord/configstate/configcore/backlight.go | 71 + .../configstate/configcore/backlight_test.go | 83 + overlord/configstate/configcore/certs.go | 115 + overlord/configstate/configcore/certs_test.go | 148 + overlord/configstate/configcore/cloud.go | 122 + overlord/configstate/configcore/cloud_test.go | 210 + overlord/configstate/configcore/corecfg.go | 119 + .../configstate/configcore/corecfg_test.go | 278 + overlord/configstate/configcore/ctrlaltdel.go | 133 + .../configstate/configcore/ctrlaltdel_test.go | 199 + overlord/configstate/configcore/early_test.go | 54 + .../configstate/configcore/experimental.go | 89 + .../configcore/experimental_test.go | 98 + .../configcore/export_runwithstate_test.go | 58 + .../configstate/configcore/export_test.go | 93 + overlord/configstate/configcore/handlers.go | 203 + overlord/configstate/configcore/homedirs.go | 203 + .../configstate/configcore/homedirs_test.go | 320 + overlord/configstate/configcore/hostname.go | 127 + .../configstate/configcore/hostname_test.go | 217 + overlord/configstate/configcore/journal.go | 120 + .../configstate/configcore/journal_test.go | 181 + overlord/configstate/configcore/kernel.go | 213 + .../configstate/configcore/kernel_test.go | 476 + overlord/configstate/configcore/lockout.go | 63 + .../configstate/configcore/lockout_test.go | 97 + overlord/configstate/configcore/netplan.go | 303 + .../configstate/configcore/netplan_test.go | 585 + overlord/configstate/configcore/network.go | 94 + .../configstate/configcore/network_test.go | 131 + overlord/configstate/configcore/picfg.go | 193 + overlord/configstate/configcore/picfg_test.go | 339 + overlord/configstate/configcore/powerbtn.go | 90 + .../configstate/configcore/powerbtn_test.go | 81 + overlord/configstate/configcore/proxy.go | 168 + overlord/configstate/configcore/proxy_test.go | 235 + overlord/configstate/configcore/refresh.go | 147 + .../configstate/configcore/refresh_test.go | 222 + .../configstate/configcore/runwithstate.go | 198 + .../configcore/runwithstate_test.go | 43 + overlord/configstate/configcore/services.go | 381 + .../configstate/configcore/services_test.go | 497 + overlord/configstate/configcore/snapshots.go | 48 + .../configstate/configcore/snapshots_test.go | 73 + overlord/configstate/configcore/store.go | 82 + overlord/configstate/configcore/store_test.go | 98 + overlord/configstate/configcore/swap.go | 153 + overlord/configstate/configcore/swap_test.go | 233 + overlord/configstate/configcore/sysctl.go | 118 + .../configstate/configcore/sysctl_test.go | 138 + overlord/configstate/configcore/timezone.go | 126 + .../configstate/configcore/timezone_test.go | 101 + overlord/configstate/configcore/tmp.go | 143 + overlord/configstate/configcore/tmp_test.go | 222 + overlord/configstate/configcore/users.go | 58 + overlord/configstate/configcore/users_test.go | 78 + overlord/configstate/configcore/utils.go | 122 + overlord/configstate/configcore/utils_test.go | 65 + overlord/configstate/configcore/vitality.go | 202 + .../configstate/configcore/vitality_test.go | 383 + overlord/configstate/configcore/watchdog.go | 143 + .../configstate/configcore/watchdog_test.go | 251 + overlord/configstate/configmgr.go | 86 + overlord/configstate/configstate.go | 227 + overlord/configstate/configstate_test.go | 578 + overlord/configstate/export_test.go | 45 + overlord/configstate/handler_test.go | 605 + overlord/configstate/hooks.go | 204 + overlord/configstate/proxyconf/proxy.go | 57 + overlord/configstate/proxyconf/proxy_test.go | 74 + overlord/devicestate/crypto.go | 79 + overlord/devicestate/devicectx.go | 142 + overlord/devicestate/devicectx_test.go | 120 + overlord/devicestate/devicemgr.go | 2743 +++ overlord/devicestate/devicestate.go | 1949 ++ .../devicestate_bootconfig_test.go | 608 + .../devicestate/devicestate_cloudinit_test.go | 1165 ++ .../devicestate/devicestate_gadget_test.go | 2130 +++ .../devicestate_install_api_test.go | 823 + .../devicestate_install_mode_test.go | 2572 +++ .../devicestate_recovery_keys_test.go | 299 + .../devicestate/devicestate_remodel_test.go | 7873 +++++++++ .../devicestate/devicestate_serial_test.go | 2631 +++ .../devicestate/devicestate_systems_test.go | 4896 +++++ overlord/devicestate/devicestate_test.go | 2747 +++ .../devicestate/devicestatetest/devicesvc.go | 268 + .../devicestate/devicestatetest/gadget.go | 107 + overlord/devicestate/devicestatetest/state.go | 52 + overlord/devicestate/export_test.go | 592 + overlord/devicestate/firstboot.go | 483 + overlord/devicestate/firstboot20_test.go | 1392 ++ .../devicestate/firstboot_preseed_test.go | 643 + overlord/devicestate/firstboot_test.go | 2491 +++ overlord/devicestate/handlers.go | 205 + overlord/devicestate/handlers_bootconfig.go | 99 + overlord/devicestate/handlers_gadget.go | 440 + overlord/devicestate/handlers_install.go | 1146 ++ overlord/devicestate/handlers_remodel.go | 380 + overlord/devicestate/handlers_serial.go | 935 + overlord/devicestate/handlers_systems.go | 736 + overlord/devicestate/handlers_test.go | 962 + overlord/devicestate/helpers.go | 66 + overlord/devicestate/internal/state.go | 65 + overlord/devicestate/internal/state_test.go | 58 + overlord/devicestate/remodel.go | 547 + overlord/devicestate/remodel_test.go | 1346 ++ overlord/devicestate/systems.go | 451 + overlord/devicestate/systems_test.go | 815 + overlord/devicestate/users.go | 343 + overlord/devicestate/users_test.go | 901 + overlord/export_test.go | 118 + overlord/healthstate/export_test.go | 34 + overlord/healthstate/healthstate.go | 227 + overlord/healthstate/healthstate_test.go | 253 + overlord/hookstate/context.go | 321 + overlord/hookstate/context_test.go | 251 + overlord/hookstate/ctlcmd/ctlcmd.go | 193 + overlord/hookstate/ctlcmd/ctlcmd_test.go | 155 + overlord/hookstate/ctlcmd/export_test.go | 166 + overlord/hookstate/ctlcmd/fde_setup.go | 139 + overlord/hookstate/ctlcmd/fde_setup_test.go | 168 + overlord/hookstate/ctlcmd/get.go | 340 + overlord/hookstate/ctlcmd/get_test.go | 438 + overlord/hookstate/ctlcmd/health.go | 148 + overlord/hookstate/ctlcmd/health_test.go | 144 + overlord/hookstate/ctlcmd/helpers.go | 234 + overlord/hookstate/ctlcmd/is_connected.go | 229 + .../hookstate/ctlcmd/is_connected_test.go | 353 + overlord/hookstate/ctlcmd/kmod.go | 182 + overlord/hookstate/ctlcmd/kmod_test.go | 316 + overlord/hookstate/ctlcmd/model.go | 274 + overlord/hookstate/ctlcmd/model_test.go | 712 + overlord/hookstate/ctlcmd/mount.go | 230 + overlord/hookstate/ctlcmd/mount_test.go | 399 + overlord/hookstate/ctlcmd/reboot.go | 95 + overlord/hookstate/ctlcmd/reboot_test.go | 140 + overlord/hookstate/ctlcmd/refresh.go | 374 + overlord/hookstate/ctlcmd/refresh_test.go | 535 + overlord/hookstate/ctlcmd/restart.go | 64 + overlord/hookstate/ctlcmd/services.go | 123 + overlord/hookstate/ctlcmd/services_test.go | 859 + overlord/hookstate/ctlcmd/set.go | 230 + overlord/hookstate/ctlcmd/set_test.go | 404 + overlord/hookstate/ctlcmd/start.go | 64 + overlord/hookstate/ctlcmd/stop.go | 64 + overlord/hookstate/ctlcmd/system_mode.go | 93 + overlord/hookstate/ctlcmd/system_mode_test.go | 126 + overlord/hookstate/ctlcmd/umount.go | 79 + overlord/hookstate/ctlcmd/umount_test.go | 164 + overlord/hookstate/ctlcmd/unset.go | 74 + overlord/hookstate/ctlcmd/unset_test.go | 162 + overlord/hookstate/export_test.go | 40 + overlord/hookstate/hookmgr.go | 493 + overlord/hookstate/hooks.go | 333 + overlord/hookstate/hooks_test.go | 539 + overlord/hookstate/hookstate.go | 48 + overlord/hookstate/hookstate_test.go | 1308 ++ overlord/hookstate/hooktest/handler.go | 79 + overlord/hookstate/hooktest/handler_test.go | 105 + overlord/hookstate/repository.go | 74 + overlord/hookstate/repository_test.go | 69 + overlord/ifacestate/export_test.go | 195 + overlord/ifacestate/handlers.go | 1962 ++ overlord/ifacestate/handlers_test.go | 170 + overlord/ifacestate/helpers.go | 1390 ++ overlord/ifacestate/helpers_test.go | 790 + overlord/ifacestate/hooks.go | 58 + overlord/ifacestate/hotplug.go | 456 + overlord/ifacestate/hotplug_test.go | 1157 ++ overlord/ifacestate/ifacemgr.go | 499 + overlord/ifacestate/ifacerepo/repo.go | 41 + overlord/ifacestate/ifacerepo/repo_test.go | 63 + overlord/ifacestate/ifacestate.go | 578 + overlord/ifacestate/ifacestate_test.go | 9307 ++++++++++ overlord/ifacestate/implicit.go | 99 + overlord/ifacestate/implicit_test.go | 117 + overlord/ifacestate/schema/schema.go | 44 + overlord/ifacestate/udevmonitor/udevmon.go | 207 + .../ifacestate/udevmonitor/udevmon_test.go | 196 + overlord/install/export_test.go | 47 + overlord/install/install.go | 601 + overlord/install/install_test.go | 1568 ++ overlord/managers_test.go | 14214 +++++++++++++++ overlord/overlord.go | 727 + overlord/overlord_test.go | 1386 ++ overlord/patch/export_test.go | 90 + overlord/patch/patch.go | 194 + overlord/patch/patch1.go | 117 + overlord/patch/patch1_test.go | 157 + overlord/patch/patch2.go | 167 + overlord/patch/patch2_test.go | 178 + overlord/patch/patch3.go | 61 + overlord/patch/patch3_test.go | 148 + overlord/patch/patch4.go | 316 + overlord/patch/patch4_test.go | 451 + overlord/patch/patch5.go | 90 + overlord/patch/patch6.go | 117 + overlord/patch/patch6_1.go | 151 + overlord/patch/patch6_1_test.go | 283 + overlord/patch/patch6_2.go | 174 + overlord/patch/patch6_2_test.go | 380 + overlord/patch/patch6_3.go | 112 + overlord/patch/patch6_3_test.go | 355 + overlord/patch/patch6_test.go | 209 + overlord/patch/patch_test.go | 397 + overlord/restart/export_test.go | 34 + overlord/restart/restart.go | 623 + overlord/restart/restart_parameters.go | 63 + overlord/restart/restart_parameters_test.go | 109 + overlord/restart/restart_test.go | 1251 ++ overlord/servicestate/conflict.go | 94 + overlord/servicestate/export_test.go | 78 + overlord/servicestate/helpers.go | 99 + overlord/servicestate/internal/quotas.go | 152 + overlord/servicestate/internal/quotas_test.go | 201 + overlord/servicestate/quota_control.go | 449 + overlord/servicestate/quota_control_test.go | 1342 ++ overlord/servicestate/quota_handlers.go | 1065 ++ overlord/servicestate/quota_handlers_test.go | 3203 ++++ overlord/servicestate/quotas.go | 51 + overlord/servicestate/quotas_test.go | 115 + overlord/servicestate/service_control.go | 202 + overlord/servicestate/service_control_test.go | 1324 ++ overlord/servicestate/servicemgr.go | 370 + overlord/servicestate/servicemgr_test.go | 1419 ++ overlord/servicestate/servicestate.go | 613 + overlord/servicestate/servicestate_test.go | 1042 ++ .../servicestate/servicestatetest/quotas.go | 53 + overlord/snapshotstate/backend/backend.go | 1120 ++ .../snapshotstate/backend/backend_test.go | 1678 ++ overlord/snapshotstate/backend/export_test.go | 157 + overlord/snapshotstate/backend/helpers.go | 196 + overlord/snapshotstate/backend/reader.go | 408 + .../snapshotstate/backend/restorestate.go | 96 + overlord/snapshotstate/export_test.go | 217 + overlord/snapshotstate/snapshotmgr.go | 477 + overlord/snapshotstate/snapshotmgr_test.go | 929 + overlord/snapshotstate/snapshotstate.go | 647 + overlord/snapshotstate/snapshotstate_test.go | 2117 +++ overlord/snapstate/agentnotify/agentnotify.go | 79 + .../snapstate/agentnotify/agentnotify_test.go | 85 + overlord/snapstate/agentnotify/export_test.go | 35 + overlord/snapstate/aliasesv2.go | 705 + overlord/snapstate/aliasesv2_test.go | 1545 ++ overlord/snapstate/autorefresh.go | 985 ++ overlord/snapstate/autorefresh_gating.go | 834 + overlord/snapstate/autorefresh_gating_test.go | 3207 ++++ overlord/snapstate/autorefresh_test.go | 1628 ++ overlord/snapstate/aux_store_info.go | 116 + overlord/snapstate/aux_store_info_test.go | 98 + overlord/snapstate/backend.go | 131 + overlord/snapstate/backend/aliases.go | 124 + overlord/snapstate/backend/aliases_test.go | 429 + overlord/snapstate/backend/apparmor.go | 29 + overlord/snapstate/backend/backend.go | 75 + overlord/snapstate/backend/backend_test.go | 121 + overlord/snapstate/backend/copydata.go | 480 + overlord/snapstate/backend/copydata_test.go | 1586 ++ overlord/snapstate/backend/export_test.go | 60 + overlord/snapstate/backend/link.go | 414 + overlord/snapstate/backend/link_test.go | 1086 ++ overlord/snapstate/backend/locking.go | 94 + overlord/snapstate/backend/locking_test.go | 102 + overlord/snapstate/backend/mountns.go | 29 + overlord/snapstate/backend/mountunit.go | 56 + overlord/snapstate/backend/mountunit_test.go | 254 + overlord/snapstate/backend/setup.go | 375 + overlord/snapstate/backend/setup_test.go | 834 + overlord/snapstate/backend/snapdata.go | 351 + overlord/snapstate/backend/snapdata_test.go | 308 + overlord/snapstate/backend/utils.go | 30 + overlord/snapstate/backend_test.go | 1504 ++ overlord/snapstate/booted.go | 95 + overlord/snapstate/booted_test.go | 594 + overlord/snapstate/catalogrefresh.go | 208 + overlord/snapstate/catalogrefresh_test.go | 330 + overlord/snapstate/check_snap.go | 586 + overlord/snapstate/check_snap_test.go | 1526 ++ overlord/snapstate/component.go | 212 + overlord/snapstate/component_install_test.go | 526 + overlord/snapstate/component_test.go | 136 + overlord/snapstate/conflict.go | 369 + overlord/snapstate/conflict_test.go | 47 + overlord/snapstate/cookies.go | 175 + overlord/snapstate/cookies_test.go | 159 + overlord/snapstate/dbus.go | 86 + overlord/snapstate/dbus_test.go | 296 + overlord/snapstate/devicectx.go | 131 + overlord/snapstate/devicectx_test.go | 224 + overlord/snapstate/export_test.go | 563 + overlord/snapstate/flags.go | 134 + overlord/snapstate/handlers.go | 5048 ++++++ overlord/snapstate/handlers_aliasesv2_test.go | 2928 +++ overlord/snapstate/handlers_components.go | 570 + .../handlers_components_discard_test.go | 130 + .../handlers_components_kernel_test.go | 179 + .../handlers_components_link_test.go | 444 + .../handlers_components_mount_test.go | 312 + .../handlers_components_prepare_test.go | 109 + .../snapstate/handlers_components_test.go | 95 + overlord/snapstate/handlers_copy_test.go | 84 + overlord/snapstate/handlers_discard_test.go | 286 + overlord/snapstate/handlers_download_test.go | 305 + overlord/snapstate/handlers_link_test.go | 2466 +++ overlord/snapstate/handlers_mount_test.go | 498 + overlord/snapstate/handlers_prepare_test.go | 117 + overlord/snapstate/handlers_prereq_test.go | 907 + overlord/snapstate/handlers_rerefresh_test.go | 854 + .../snapstate/handlers_setup_kernel_test.go | 258 + overlord/snapstate/handlers_test.go | 485 + overlord/snapstate/models_test.go | 126 + overlord/snapstate/policy.go | 37 + overlord/snapstate/policy/base.go | 117 + overlord/snapstate/policy/canremove_test.go | 463 + overlord/snapstate/policy/errors.go | 55 + overlord/snapstate/policy/export_test.go | 25 + overlord/snapstate/policy/gadget.go | 52 + overlord/snapstate/policy/kernel.go | 57 + overlord/snapstate/policy/os.go | 97 + overlord/snapstate/policy/policy.go | 83 + overlord/snapstate/policy/policy_test.go | 32 + overlord/snapstate/policy/snapd.go | 62 + overlord/snapstate/progress.go | 110 + overlord/snapstate/progress_test.go | 90 + overlord/snapstate/readme.go | 65 + overlord/snapstate/readme_test.go | 75 + overlord/snapstate/reboot.go | 527 + overlord/snapstate/reboot_test.go | 939 + overlord/snapstate/refresh.go | 213 + overlord/snapstate/refresh_test.go | 304 + overlord/snapstate/refreshhints.go | 326 + overlord/snapstate/refreshhints_test.go | 719 + overlord/snapstate/sequence/sequence.go | 229 + overlord/snapstate/sequence/sequence_test.go | 245 + overlord/snapstate/snapmgr.go | 1441 ++ overlord/snapstate/snapstate.go | 4438 +++++ .../snapstate_config_defaults_test.go | 282 + overlord/snapstate/snapstate_install_test.go | 5924 +++++++ overlord/snapstate/snapstate_remove_test.go | 2061 +++ overlord/snapstate/snapstate_test.go | 10086 +++++++++++ overlord/snapstate/snapstate_try_test.go | 123 + overlord/snapstate/snapstate_update_test.go | 12455 +++++++++++++ overlord/snapstate/snapstatetest/devicectx.go | 165 + overlord/snapstate/snapstatetest/restart.go | 45 + overlord/snapstate/snapstatetest/snapstate.go | 116 + overlord/snapstate/storehelpers.go | 1027 ++ overlord/snapstate/storehelpers_test.go | 550 + overlord/standby/export_test.go | 27 + overlord/standby/standby.go | 144 + overlord/standby/standby_test.go | 216 + overlord/state/change.go | 903 + overlord/state/change_test.go | 1581 ++ overlord/state/copy.go | 141 + overlord/state/copy_test.go | 146 + overlord/state/export_test.go | 74 + overlord/state/notices.go | 474 + overlord/state/notices_test.go | 695 + overlord/state/state.go | 606 + overlord/state/state_test.go | 1280 ++ overlord/state/task.go | 667 + overlord/state/task_test.go | 696 + overlord/state/taskrunner.go | 568 + overlord/state/taskrunner_test.go | 1330 ++ overlord/state/timings.go | 44 + overlord/state/timings_test.go | 102 + overlord/state/warning.go | 329 + overlord/state/warning_test.go | 297 + overlord/stateengine.go | 193 + overlord/stateengine_test.go | 181 + overlord/storecontext/context.go | 283 + overlord/storecontext/context_test.go | 508 + overlord/unknowntask.go | 34 + packaging/amzn-2 | 1 + packaging/amzn-2023 | 1 + packaging/arch/PKGBUILD | 209 + packaging/arch/snapd.install | 49 + packaging/build-tools/go | 16 + packaging/centos-7 | 1 + packaging/centos-8 | 1 + packaging/centos-9 | 1 + packaging/debian-sid/README.Source | 35 + packaging/debian-sid/changelog | 10439 +++++++++++ packaging/debian-sid/compat | 1 + packaging/debian-sid/control | 127 + packaging/debian-sid/copyright | 22 + packaging/debian-sid/gbp.conf | 4 + .../golang-github-snapcore-snapd-dev.install | 1 + packaging/debian-sid/not-installed | 7 + ...snap-seccomp-skip-tests-that-use-m32.patch | 45 + ...kip-tests-depending-on-text-wrapping.patch | 129 + ...-localizations-to-avoid-dependencies.patch | 291 + .../patches/0010-man-page-sections.patch | 22 + packaging/debian-sid/patches/series | 4 + packaging/debian-sid/rules | 289 + packaging/debian-sid/snap-confine.maintscript | 1 + packaging/debian-sid/snapd.autoimport.udev | 3 + packaging/debian-sid/snapd.dirs | 15 + packaging/debian-sid/snapd.install | 35 + packaging/debian-sid/snapd.links | 5 + packaging/debian-sid/snapd.lintian-overrides | 13 + packaging/debian-sid/snapd.maintscript | 6 + packaging/debian-sid/snapd.manpages | 1 + packaging/debian-sid/snapd.postinst | 41 + packaging/debian-sid/snapd.postrm | 149 + packaging/debian-sid/snapd.prerm | 37 + packaging/debian-sid/source/format | 1 + packaging/debian-sid/source/options | 1 + packaging/debian-sid/tests/README.md | 10 + packaging/debian-sid/tests/control | 12 + packaging/debian-sid/tests/integrationtests | 51 + packaging/debian-sid/tests/testconfig.json | 3 + packaging/debian-sid/watch | 4 + packaging/fedora-38 | 1 + packaging/fedora-39 | 1 + packaging/fedora-rawhide | 1 + packaging/fedora/snapd.spec | 12364 +++++++++++++ packaging/opensuse-15.5 | 1 + packaging/opensuse-tumbleweed | 1 + packaging/opensuse/permissions | 1 + packaging/opensuse/permissions.easy | 1 + packaging/opensuse/permissions.paranoid | 1 + packaging/opensuse/permissions.secure | 1 + packaging/opensuse/snapd-rpmlintrc | 4 + packaging/opensuse/snapd.changes | 840 + packaging/opensuse/snapd.spec | 498 + packaging/pack-source | 63 + packaging/snapd.mk | 202 + packaging/ubuntu-14.04/changelog | 14714 +++++++++++++++ packaging/ubuntu-14.04/compat | 1 + packaging/ubuntu-14.04/control | 143 + packaging/ubuntu-14.04/copyright | 22 + .../golang-github-snapcore-snapd-dev.install | 1 + packaging/ubuntu-14.04/rules | 244 + .../ubuntu-14.04/snap-confine.maintscript | 1 + packaging/ubuntu-14.04/snap.mount.service | 16 + packaging/ubuntu-14.04/snapd.autoimport.udev | 1 + packaging/ubuntu-14.04/snapd.dirs | 18 + packaging/ubuntu-14.04/snapd.install | 39 + packaging/ubuntu-14.04/snapd.links | 2 + packaging/ubuntu-14.04/snapd.maintscript | 6 + packaging/ubuntu-14.04/snapd.manpages | 1 + packaging/ubuntu-14.04/snapd.postinst | 34 + packaging/ubuntu-14.04/snapd.postrm | 152 + packaging/ubuntu-14.04/snapd.prerm | 41 + packaging/ubuntu-14.04/source | 1 + packaging/ubuntu-14.04/tests/README.md | 1 + packaging/ubuntu-14.04/tests/control | 11 + packaging/ubuntu-14.04/tests/integrationtests | 41 + packaging/ubuntu-14.04/tests/testconfig.json | 1 + packaging/ubuntu-14.04/ubuntu-snappy-cli.dirs | 2 + packaging/ubuntu-16.04/README.powerpc | 1 + packaging/ubuntu-16.04/changelog | 14752 ++++++++++++++++ packaging/ubuntu-16.04/compat | 1 + packaging/ubuntu-16.04/control | 145 + packaging/ubuntu-16.04/copyright | 22 + packaging/ubuntu-16.04/gbp.conf | 7 + .../golang-github-snapcore-snapd-dev.install | 1 + packaging/ubuntu-16.04/rules | 339 + .../ubuntu-16.04/snap-confine.maintscript | 1 + packaging/ubuntu-16.04/snapd.autoimport.udev | 3 + packaging/ubuntu-16.04/snapd.dirs | 24 + packaging/ubuntu-16.04/snapd.install.in | 52 + packaging/ubuntu-16.04/snapd.links | 5 + packaging/ubuntu-16.04/snapd.maintscript | 6 + packaging/ubuntu-16.04/snapd.manpages | 1 + packaging/ubuntu-16.04/snapd.postinst | 75 + packaging/ubuntu-16.04/snapd.postrm | 162 + packaging/ubuntu-16.04/snapd.preinst | 10 + packaging/ubuntu-16.04/snapd.prerm | 42 + packaging/ubuntu-16.04/source/format | 1 + packaging/ubuntu-16.04/source/options | 1 + packaging/ubuntu-16.04/tests/README.md | 10 + packaging/ubuntu-16.04/tests/control | 12 + packaging/ubuntu-16.04/tests/integrationtests | 94 + packaging/ubuntu-16.04/tests/testconfig.json | 3 + packaging/ubuntu-16.04/ubuntu-snappy-cli.dirs | 2 + po/af.po | 2448 +++ po/am.po | 2184 +++ po/ar.po | 2184 +++ po/bn.po | 2184 +++ po/bs.po | 2202 +++ po/ca.po | 2184 +++ po/cs.po | 2378 +++ po/cy.po | 2184 +++ po/da.po | 2421 +++ po/de.po | 2526 +++ po/el.po | 2246 +++ po/en_GB.po | 2611 +++ po/eo.po | 2184 +++ po/es.po | 2609 +++ po/fa.po | 2184 +++ po/fi.po | 2194 +++ po/fr.po | 2690 +++ po/gl.po | 2597 +++ po/hr.po | 2573 +++ po/hu.po | 2184 +++ po/ia.po | 2184 +++ po/id.po | 2184 +++ po/it.po | 2185 +++ po/its/polkit.its | 8 + po/ja.po | 2184 +++ po/km.po | 2184 +++ po/ko.po | 2184 +++ po/lt.po | 2184 +++ po/lv.po | 2184 +++ po/mnw.po | 2184 +++ po/ms.po | 2184 +++ po/nb.po | 2278 +++ po/nl.po | 2255 +++ po/oc.po | 2678 +++ po/pl.po | 2184 +++ po/pt.po | 2185 +++ po/pt_BR.po | 2184 +++ po/ro.po | 2184 +++ po/ru.po | 2202 +++ po/sd.po | 2184 +++ po/sq.po | 2188 +++ po/sv.po | 2298 +++ po/tg.po | 2184 +++ po/th.po | 2184 +++ po/tr.po | 2204 +++ po/ug.po | 2184 +++ po/uk.po | 2236 +++ po/zh_CN.po | 2184 +++ po/zh_TW.po | 2184 +++ polkit/authority.go | 94 + polkit/pid_start_time.go | 69 + polkit/pid_start_time_test.go | 70 + polkit/validate/validate.go | 285 + polkit/validate/validate_test.go | 386 + progress/ansimeter.go | 213 + progress/ansimeter_test.go | 286 + progress/export_test.go | 120 + progress/progress.go | 117 + progress/progress_test.go | 93 + progress/progresstest/progresstest.go | 68 + randutil/crypto.go | 64 + randutil/crypto_test.go | 115 + randutil/export_test.go | 30 + randutil/rand.go | 95 + randutil/rand_test.go | 62 + release-tools/changelog.py | 322 + release-tools/debian-package-builder | 125 + release-tools/repack-debian-tarball.sh | 93 + release-tools/test/changelog.py | 1 + release-tools/test/test_changelog.py | 61 + release-tools/test/test_flake8.py | 15 + release/export_test.go | 50 + release/release.go | 234 + release/release_test.go | 285 + run-checks | 446 + sandbox/apparmor/apparmor.go | 644 + sandbox/apparmor/apparmor_test.go | 610 + sandbox/apparmor/export_test.go | 110 + sandbox/apparmor/notify/export_test.go | 12 + sandbox/apparmor/notify/ioctl.go | 137 + sandbox/apparmor/notify/ioctl_test.go | 182 + .../apparmor/notify/listener/export_test.go | 143 + sandbox/apparmor/notify/listener/listener.go | 474 + .../apparmor/notify/listener/listener_test.go | 888 + sandbox/apparmor/notify/mclass.go | 22 + sandbox/apparmor/notify/mclass_test.go | 23 + sandbox/apparmor/notify/message.go | 504 + sandbox/apparmor/notify/message_test.go | 548 + sandbox/apparmor/notify/modeset.go | 63 + sandbox/apparmor/notify/modeset_test.go | 59 + sandbox/apparmor/notify/notify.go | 26 + sandbox/apparmor/notify/notify_test.go | 47 + sandbox/apparmor/notify/ntype.go | 43 + sandbox/apparmor/notify/ntype_test.go | 37 + sandbox/apparmor/notify/permission.go | 155 + sandbox/apparmor/notify/permission_test.go | 74 + sandbox/apparmor/notify/strings.go | 80 + sandbox/apparmor/process.go | 75 + sandbox/apparmor/process_test.go | 133 + sandbox/apparmor/profile.go | 365 + sandbox/apparmor/profile_test.go | 741 + sandbox/cgroup/cgroup.go | 355 + sandbox/cgroup/cgroup_test.go | 308 + sandbox/cgroup/export_test.go | 137 + sandbox/cgroup/freezer.go | 274 + sandbox/cgroup/freezer_test.go | 472 + sandbox/cgroup/memory.go | 70 + sandbox/cgroup/memory_test.go | 97 + sandbox/cgroup/monitor.go | 282 + sandbox/cgroup/monitor_test.go | 399 + sandbox/cgroup/pids.go | 68 + sandbox/cgroup/pids_test.go | 47 + sandbox/cgroup/process.go | 68 + sandbox/cgroup/process_test.go | 82 + sandbox/cgroup/scanning.go | 234 + sandbox/cgroup/scanning_test.go | 440 + sandbox/cgroup/tracking.go | 478 + sandbox/cgroup/tracking_test.go | 1069 ++ sandbox/forcedevmode.go | 50 + sandbox/forcedevmode_test.go | 73 + sandbox/seccomp/compiler.go | 236 + sandbox/seccomp/compiler_test.go | 299 + sandbox/seccomp/export_test.go | 32 + sandbox/seccomp/seccomp.go | 84 + sandbox/seccomp/seccomp_test.go | 69 + sandbox/selinux/export_test.go | 25 + sandbox/selinux/label.go | 27 + sandbox/selinux/label_darwin.go | 36 + sandbox/selinux/label_linux.go | 89 + sandbox/selinux/label_linux_test.go | 140 + sandbox/selinux/selinux.go | 97 + sandbox/selinux/selinux_darwin.go | 30 + sandbox/selinux/selinux_linux.go | 75 + sandbox/selinux/selinux_linux_test.go | 143 + sandbox/selinux/selinux_test.go | 98 + secboot/encrypt.go | 43 + secboot/encrypt_dummy.go | 41 + secboot/encrypt_sb.go | 245 + secboot/encrypt_sb_test.go | 446 + secboot/encrypt_test.go | 38 + secboot/export_sb_test.go | 238 + secboot/keymgr/export_test.go | 33 + secboot/keymgr/keymgr_luks2.go | 277 + secboot/keymgr/keymgr_luks2_test.go | 861 + secboot/keyring/keyring.go | 38 + secboot/keys/export_test.go | 28 + secboot/keys/keys.go | 118 + secboot/keys/keys_dummy.go | 25 + secboot/keys/keys_sb.go | 29 + secboot/keys/keys_test.go | 116 + secboot/luks2/cryptsetup.go | 171 + secboot/luks2/cryptsetup_test.go | 96 + secboot/luks2/luks2.go | 49 + secboot/secboot.go | 228 + secboot/secboot_dummy.go | 61 + secboot/secboot_hooks.go | 228 + secboot/secboot_sb.go | 185 + secboot/secboot_sb_test.go | 2208 +++ secboot/secboot_tpm.go | 716 + secboot/test-data/keyfile | Bin 0 -> 131303 bytes seed/export_test.go | 50 + seed/helpers.go | 168 + seed/helpers_test.go | 167 + seed/internal/auxinfo20.go | 27 + seed/internal/doc.go | 22 + seed/internal/helpers.go | 36 + seed/internal/options20.go | 123 + seed/internal/options20_test.go | 177 + seed/internal/seed_yaml.go | 117 + seed/internal/seed_yaml_test.go | 166 + seed/seed.go | 328 + seed/seed16.go | 467 + seed/seed16_test.go | 1577 ++ seed/seed20.go | 938 + seed/seed20_test.go | 3717 ++++ seed/seedtest/sample.go | 143 + seed/seedtest/seedtest.go | 450 + seed/seedwriter/export_test.go | 34 + seed/seedwriter/fetcher.go | 87 + seed/seedwriter/fetcher_test.go | 161 + seed/seedwriter/helpers.go | 132 + seed/seedwriter/manifest.go | 393 + seed/seedwriter/manifest_test.go | 411 + seed/seedwriter/seed16.go | 296 + seed/seedwriter/seed20.go | 427 + seed/seedwriter/writer.go | 1589 ++ seed/seedwriter/writer_test.go | 4645 +++++ seed/validate.go | 136 + seed/validate_test.go | 434 + snap/broken.go | 119 + snap/broken_test.go | 150 + snap/channel/channel.go | 298 + snap/channel/channel_test.go | 393 + snap/component.go | 191 + snap/component_test.go | 309 + snap/container.go | 431 + snap/container_test.go | 718 + snap/device.go | 40 + snap/epoch.go | 374 + snap/epoch_test.go | 376 + snap/errors.go | 66 + snap/errors_test.go | 49 + snap/export_test.go | 43 + snap/helpers.go | 97 + snap/hooktypes.go | 102 + snap/hotplug_key.go | 34 + snap/hotplug_key_test.go | 45 + snap/implicit.go | 80 + snap/implicit_test.go | 28 + snap/info.go | 1801 ++ snap/info_snap_yaml.go | 816 + snap/info_snap_yaml_test.go | 2362 +++ snap/info_test.go | 2259 +++ snap/integrity/dmverity/export_test.go | 25 + snap/integrity/dmverity/veritysetup.go | 154 + snap/integrity/dmverity/veritysetup_test.go | 207 + snap/integrity/export_test.go | 27 + snap/integrity/integrity.go | 163 + snap/integrity/integrity_test.go | 280 + snap/internal/file.go | 42 + snap/naming/componentref.go | 67 + snap/naming/componentref_test.go | 56 + snap/naming/core_version.go | 56 + snap/naming/core_version_test.go | 55 + snap/naming/naming_test.go | 29 + snap/naming/snapref.go | 138 + snap/naming/snapref_test.go | 98 + snap/naming/tag.go | 149 + snap/naming/tag_test.go | 130 + snap/naming/validate.go | 243 + snap/naming/validate_test.go | 390 + snap/naming/wellknown.go | 69 + snap/naming/wellknown_test.go | 53 + snap/pack/export_test.go | 24 + snap/pack/pack.go | 305 + snap/pack/pack_test.go | 626 + snap/quota/export_test.go | 70 + snap/quota/quota.go | 1090 ++ snap/quota/quota_test.go | 1615 ++ snap/quota/resources.go | 428 + snap/quota/resources_builder.go | 144 + snap/quota/resources_test.go | 345 + snap/restartcond.go | 77 + snap/restartcond_test.go | 51 + snap/revision.go | 123 + snap/revision_test.go | 198 + snap/snapdir/snapdir.go | 213 + snap/snapdir/snapdir_test.go | 248 + snap/snapenv/snapenv.go | 154 + snap/snapenv/snapenv_test.go | 366 + snap/snapfile/snapfile.go | 90 + snap/snapfile/snapfile_test.go | 167 + snap/snapshots.go | 141 + snap/snapshots_export_test.go | 32 + snap/snapshots_test.go | 233 + snap/snaptest/snaptest.go | 351 + snap/snaptest/snaptest_test.go | 265 + snap/squashfs/export_test.go | 90 + snap/squashfs/squashfs.go | 658 + snap/squashfs/squashfs_test.go | 1094 ++ snap/squashfs/stat.go | 370 + snap/squashfs/stat_test.go | 299 + snap/sysparams/export_test.go | 33 + snap/sysparams/sysparams.go | 123 + snap/sysparams/sysparams_test.go | 154 + snap/system_usernames.go | 166 + snap/types.go | 221 + snap/types_test.go | 292 + snap/validate.go | 1433 ++ snap/validate_test.go | 2653 +++ snapd.code-workspace | 66 + snapdenv/export_test.go | 33 + snapdenv/snapdenv.go | 94 + snapdenv/snapdenv_test.go | 146 + snapdenv/useragent.go | 91 + snapdenv/useragent_test.go | 108 + snapdenv/withtestkeys.go | 26 + snapdtool/cmdutil.go | 140 + snapdtool/cmdutil_test.go | 113 + snapdtool/export_test.go | 52 + snapdtool/info_file.go | 84 + snapdtool/info_file_test.go | 70 + snapdtool/tool_linux.go | 229 + snapdtool/tool_linux_test.go | 117 + snapdtool/tool_other.go | 49 + snapdtool/tool_test.go | 485 + snapdtool/version.go | 34 + spdx/licenses.go | 632 + spdx/parser.go | 143 + spdx/parser_test.go | 78 + spdx/scanner.go | 69 + spdx/scanner_test.go | 49 + spdx/validate.go | 34 + spread.yaml | 1359 ++ store/auth.go | 329 + store/auth_u1.go | 290 + store/auth_u1_test.go | 434 + store/cache.go | 235 + store/cache_test.go | 229 + store/details.go | 120 + store/details_v2.go | 374 + store/details_v2_test.go | 457 + store/devicenauthctx.go | 70 + store/download_test.go | 710 + store/errors.go | 291 + store/export_test.go | 262 + store/search_v2.go | 53 + store/store.go | 1720 ++ store/store_action.go | 784 + store/store_action_fetch_assertions_test.go | 978 + store/store_action_test.go | 3427 ++++ store/store_asserts.go | 259 + store/store_asserts_test.go | 625 + store/store_download.go | 770 + store/store_download_test.go | 1040 ++ store/store_test.go | 4494 +++++ store/storetest/storetest.go | 124 + store/stringlist_test.go | 41 + store/tooling/auth.go | 256 + store/tooling/export_test.go | 43 + store/tooling/tooling.go | 439 + store/tooling/tooling_test.go | 565 + store/uacontext.go | 42 + store/uacontext_test.go | 47 + store/userinfo.go | 85 + store/userinfo_test.go | 153 + strutil/chrorder.go | 21 + strutil/chrorder/main.go | 74 + strutil/ctrl16.go | 51 + strutil/ctrl17.go | 52 + strutil/export_test.go | 24 + strutil/intersection.go | 65 + strutil/intersection_test.go | 116 + strutil/limbuffer.go | 51 + strutil/limbuffer_test.go | 67 + strutil/map.go | 121 + strutil/map_test.go | 95 + strutil/matchcounter.go | 119 + strutil/matchcounter_benchmark_test.go | 52 + strutil/matchcounter_test.go | 268 + strutil/pathiter.go | 153 + strutil/pathiter_test.go | 285 + strutil/quantity/example_test.go | 117 + strutil/quantity/quantity.go | 202 + strutil/set.go | 81 + strutil/set_test.go | 79 + strutil/shlex/shlex.go | 415 + strutil/shlex/shlex_test.go | 159 + strutil/strutil.go | 382 + strutil/strutil_test.go | 380 + strutil/version.go | 181 + strutil/version_benchmark_test.go | 111 + strutil/version_test.go | 124 + syscheck/apparmor_lxd.go | 51 + syscheck/apparmor_lxd_test.go | 40 + syscheck/cgroup.go | 42 + syscheck/cgroup_test.go | 51 + syscheck/check.go | 37 + syscheck/check_test.go | 132 + syscheck/export_test.go | 58 + syscheck/squashfs.go | 138 + syscheck/squashfs_test.go | 149 + syscheck/version.go | 102 + syscheck/version_test.go | 166 + syscheck/wsl.go | 38 + syscheck/wsl_test.go | 64 + sysconfig/cloudinit.go | 943 + sysconfig/cloudinit_test.go | 1451 ++ sysconfig/export_test.go | 42 + sysconfig/gadget_defaults_test.go | 208 + sysconfig/sysconfig.go | 160 + systemd/emulation.go | 240 + systemd/escape.go | 69 + systemd/escape_test.go | 154 + systemd/export_test.go | 80 + systemd/journal.go | 72 + systemd/journal_test.go | 89 + systemd/sdnotify.go | 59 + systemd/sdnotify_test.go | 87 + systemd/sysctl.go | 64 + systemd/sysctl_test.go | 96 + systemd/systemd.go | 1747 ++ systemd/systemd_test.go | 2612 +++ systemd/systemdtest/systemdtest.go | 100 + tests/bin/MATCH | 1 + tests/bin/NOMATCH | 8 + tests/bin/README | 4 + tests/bin/REBOOT | 1 + tests/bin/mountinfo.query | 1 + tests/bin/not | 1 + tests/bin/os.paths | 1 + tests/bin/os.query | 1 + tests/bin/quiet | 1 + tests/bin/remote.exec | 1 + tests/bin/remote.pull | 1 + tests/bin/remote.push | 1 + tests/bin/remote.refresh | 1 + tests/bin/remote.retry | 1 + tests/bin/remote.setup | 1 + tests/bin/remote.wait-for | 1 + tests/bin/retry | 1 + tests/bin/snapd.tool | 1 + tests/bin/snaps.name | 1 + tests/bin/tests.backup | 1 + tests/bin/tests.cleanup | 1 + tests/bin/tests.device-cgroup | 1 + tests/bin/tests.env | 1 + tests/bin/tests.invariant | 1 + tests/bin/tests.nested | 1 + tests/bin/tests.pkgs | 1 + tests/bin/tests.session | 1 + tests/bin/tests.systemd | 1 + tests/completion/data/files/a/a_thing.txt | 0 tests/completion/data/files/b/b_thing.txt | 0 tests/completion/data/files/b/c/b_c_thing.txt | 0 tests/completion/data/files/d/d_thing.txt | 0 tests/completion/data/files/thing.txt | 0 tests/completion/data/hosts.txt | 1 + tests/completion/data/twisted/twisted.tar | Bin 0 -> 158 bytes tests/completion/dirs.complete | 5 + tests/completion/dirs.sh | 1 + tests/completion/dirs.vars | 8 + tests/completion/files.complete | 4 + tests/completion/files.sh | 1 + tests/completion/files.vars | 7 + tests/completion/func.complete | 8 + tests/completion/func.sh | 0 tests/completion/func.vars | 7 + tests/completion/funcarg.complete | 12 + tests/completion/funcarg.sh | 0 tests/completion/funcarg.vars | 7 + tests/completion/funky.complete | 4 + tests/completion/funky.sh | 0 tests/completion/funky.vars | 7 + tests/completion/funkyfunc.complete | 12 + tests/completion/funkyfunc.sh | 0 tests/completion/funkyfunc.vars | 7 + tests/completion/hosts.complete | 4 + tests/completion/hosts.sh | 1 + tests/completion/hosts.vars | 7 + tests/completion/hosts_n_dirs.complete | 4 + tests/completion/hosts_n_dirs.sh | 2 + tests/completion/hosts_n_dirs.vars | 7 + tests/completion/indirect/task.exp | 21 + tests/completion/indirect/task.yaml | 29 + tests/completion/lib.exp0 | 70 + tests/completion/plain.complete | 4 + tests/completion/plain.sh | 0 tests/completion/plain.vars | 7 + tests/completion/plain_plusdirs.complete | 4 + tests/completion/plain_plusdirs.sh | 1 + tests/completion/plain_plusdirs.vars | 7 + tests/completion/simple/task.exp | 16 + tests/completion/simple/task.yaml | 8 + tests/completion/snippets/task.exp | 17 + tests/completion/snippets/task.yaml | 24 + tests/completion/twisted.complete | 4 + tests/completion/twisted.sh | 2 + tests/completion/twisted.vars | 7 + tests/core/apt/task.yaml | 20 + tests/core/backlight/task.yaml | 25 + tests/core/bash-completion/task.yaml | 27 + tests/core/bash-completion/test-completion.py | 123 + tests/core/bash-completion/test-rc | 8 + tests/core/basic18/task.yaml | 40 + tests/core/basic20plus/task.yaml | 127 + tests/core/classic-snap16/task.yaml | 66 + tests/core/compat/task.yaml | 16 + .../config-defaults-once/gadget-defaults.yaml | 7 + tests/core/config-defaults-once/task.yaml | 144 + .../core-dump/core-dump-snap/bin/crash.sh | 6 + .../core-dump/core-dump-snap/meta/snap.yaml | 8 + tests/core/core-dump/task.yaml | 31 + tests/core/core-to-snapd-failover16/task.yaml | 67 + tests/core/create-user-2/task.yaml | 66 + tests/core/create-user/task.yaml | 69 + .../custom-device-reg-extras/prepare-device | 5 + tests/core/custom-device-reg-extras/task.yaml | 98 + tests/core/custom-device-reg/prepare-device | 2 + tests/core/custom-device-reg/task.yaml | 93 + tests/core/dbus-activation/task.yaml | 22 + tests/core/desktop-files/task.yaml | 11 + tests/core/device-reg/task.yaml | 59 + .../core/enable-disable-units-gpio/task.yaml | 64 + tests/core/failover/task.yaml | 214 + tests/core/fan/task.yaml | 25 + tests/core/fsck-on-boot/task.yaml | 118 + tests/core/fsck-vfat/task.yaml | 65 + .../gadget-rsyslog.yaml | 13 + .../gadget-ssh-common.yaml | 13 + .../gadget-ssh-oneline.yaml | 10 + .../gadget-config-defaults-to-snaps/task.yaml | 213 + .../gadget-vitality-hint.yaml | 4 + .../gadget-config-defaults-vitality/task.yaml | 137 + .../gadget-kernel-refs-update-pc/task.yaml | 167 + tests/core/gadget-update-pc/generate.py | 220 + tests/core/gadget-update-pc/task.yaml | 243 + tests/core/generic-device-reg/task.yaml | 79 + tests/core/grub-no-unpacked-assets/task.yaml | 20 + tests/core/iio/iio-consumer/bin/read | 2 + tests/core/iio/iio-consumer/bin/write | 2 + tests/core/iio/iio-consumer/meta/snap.yaml | 12 + tests/core/iio/task.yaml | 49 + .../task.yaml | 203 + .../task.yaml | 173 + .../task.yaml | 96 + .../task.yaml | 137 + .../kernel-snap-refresh-on-core/task.yaml | 106 + tests/core/kernel-ver/task.yaml | 23 + tests/core/mem-cgroup-disabled/task.yaml | 132 + tests/core/netplan-cfg/task.yaml | 59 + tests/core/netplan/task.yaml | 123 + tests/core/network-config/task.yaml | 28 + tests/core/os-release/task.yaml | 28 + .../persistent-journal-namespace/task.yaml | 69 + tests/core/persistent-journal/task.yaml | 52 + tests/core/reboot/task.yaml | 33 + tests/core/remodel-base/task.yaml | 147 + tests/core/remodel-gadget/task.yaml | 161 + tests/core/remodel-kernel/task.yaml | 152 + tests/core/remodel/task.yaml | 124 + tests/core/remove-user/task.yaml | 78 + tests/core/remove/task.yaml | 48 + tests/core/seed-base-symlinks/task.yaml | 27 + tests/core/services/task.yaml | 13 + .../snap-auto-import-asserts-spools/task.yaml | 55 + tests/core/snap-auto-import-asserts/task.yaml | 39 + tests/core/snap-auto-mount/task.yaml | 73 + tests/core/snap-core-fixup/task.yaml | 47 + tests/core/snap-core-fixup/test.img.tar.gz | Bin 0 -> 378945 bytes tests/core/snap-debug-bootvars/task.yaml | 40 + tests/core/snap-repair/retry.sh | 4 + tests/core/snap-repair/task.yaml | 165 + tests/core/snap-repair/uc16.json | 6 + tests/core/snap-repair/uc16.sh | 7 + tests/core/snap-repair/uc18.json | 6 + tests/core/snap-repair/uc18.sh | 7 + tests/core/snap-repair/uc20-recover.json | 10 + tests/core/snap-repair/uc20-recover.sh | 11 + tests/core/snap-repair/uc20-run.json | 9 + tests/core/snap-repair/uc20-run.sh | 7 + tests/core/snap-repair/uc22-recover.json | 10 + tests/core/snap-repair/uc22-recover.sh | 11 + tests/core/snap-repair/uc22-run.json | 9 + tests/core/snap-repair/uc22-run.sh | 7 + tests/core/snap-repair/uc24-recover.json | 10 + tests/core/snap-repair/uc24-recover.sh | 11 + tests/core/snap-repair/uc24-run.json | 9 + tests/core/snap-repair/uc24-run.sh | 7 + tests/core/snap-set-core-config/task.yaml | 159 + tests/core/snapd-failover/task.yaml | 169 + tests/core/snapd-maintenance-msg/task.yaml | 62 + .../task.yaml | 107 + .../test-snapd-svc-flip-flop/bin/svc.sh | 14 + .../test-snapd-svc-flip-flop/meta/snap.yaml | 9 + .../core/snapd-refresh-vs-services/task.yaml | 186 + tests/core/snapd-refresh/task.yaml | 59 + tests/core/snapd16/task.yaml | 82 + tests/core/swapfiles/task.yaml | 15 + tests/core/system-settings/task.yaml | 22 + tests/core/system-snap-refresh/task.yaml | 76 + tests/core/tmp/task.yaml | 46 + tests/core/uboot-unpacked-assets/task.yaml | 29 + tests/core/uc20-recovery/task.yaml | 103 + tests/core/update-snapd-symlink/task.yaml | 101 + tests/core/upgrade/task.yaml | 110 + tests/core/watchdog/task.yaml | 42 + tests/core/writablepaths/task.yaml | 48 + tests/core/xdg-open-on-core/task.yaml | 23 + tests/cross/go-build/task.yaml | 64 + tests/external-backend.md | 24 + ...WPhspDQK63Er46Uxz2SO7ez.auto-import.assert | 76 + ...DQK63Er46Uxz2SO7ez.auto-import.assert.json | 13 + tests/lib/assertions/README.md | 56 + tests/lib/assertions/auto-import.assert | 79 + tests/lib/assertions/auto-import.assert.json | 13 + .../lib/assertions/classic-model-rev1.assert | 50 + tests/lib/assertions/classic-model-rev1.json | 47 + tests/lib/assertions/classic-model.assert | 44 + tests/lib/assertions/classic-model.json | 40 + .../developer1-20-auto-import.assert | 77 + .../assertions/developer1-20-auto-import.json | 18 + .../assertions/developer1-20-dangerous.json | 40 + .../assertions/developer1-20-dangerous.model | 44 + .../lib/assertions/developer1-20-secured.json | 40 + .../assertions/developer1-20-secured.model | 44 + .../lib/assertions/developer1-20-signed.json | 40 + .../lib/assertions/developer1-20-signed.model | 44 + ...-20-storage-safety-prefer-unencrypted.json | 41 + ...20-storage-safety-prefer-unencrypted.model | 45 + .../developer1-22-auto-import.assert | 77 + .../assertions/developer1-22-auto-import.json | 18 + .../developer1-22-classic-dangerous.json | 42 + .../assertions/developer1-22-dangerous.json | 40 + .../assertions/developer1-22-dangerous.model | 44 + .../lib/assertions/developer1-22-secured.json | 40 + .../assertions/developer1-22-secured.model | 44 + .../lib/assertions/developer1-22-signed.json | 40 + .../lib/assertions/developer1-22-signed.model | 44 + ...-22-storage-safety-prefer-unencrypted.json | 41 + ...22-storage-safety-prefer-unencrypted.model | 45 + .../developer1-my-classic-w-gadget-18.model | 22 + .../developer1-my-classic-w-gadget.model | 20 + .../assertions/developer1-my-classic.model | 19 + .../developer1-network-aspect-bundle.json | 19 + .../developer1-network.aspect-bundle | 100 + tests/lib/assertions/developer1-pc-18.model | 22 + .../assertions/developer1-pc-18.model.json | 11 + .../developer1-pc-new-base-18.model | 23 + .../developer1-pc-new-gadget-18.model | 23 + .../developer1-pc-new-gadget-18.model.json | 12 + .../assertions/developer1-pc-new-kernel.model | 22 + .../assertions/developer1-pc-revno2-18.model | 25 + .../lib/assertions/developer1-pc-revno2.model | 24 + .../assertions/developer1-pc-revno3-18.model | 23 + .../lib/assertions/developer1-pc-revno3.model | 22 + .../developer1-pc-w-config-18.model | 24 + .../developer1-pc-w-config-18.model.json | 12 + .../assertions/developer1-pc-w-config.model | 24 + tests/lib/assertions/developer1-pc.model | 21 + .../assertions/developer1-pi-20.model.json | 34 + tests/lib/assertions/developer1.account | 19 + tests/lib/assertions/developer1.account-key | 30 + tests/lib/assertions/fake.store | 19 + tests/lib/assertions/gating-20-amd64.model | 57 + .../lib/assertions/gating-20-amd64.model.json | 20 + tests/lib/assertions/nested-18-amd64.model | 24 + .../lib/assertions/nested-18-amd64.model.json | 13 + .../nested-20-amd64-connections.model | 49 + .../nested-20-amd64-connections.model.json | 19 + tests/lib/assertions/nested-20-amd64.model | 44 + .../lib/assertions/nested-20-amd64.model.json | 18 + tests/lib/assertions/nested-22-amd64.model | 42 + tests/lib/assertions/nested-22-arm64.model | 43 + tests/lib/assertions/nested-amd64.model | 23 + tests/lib/assertions/nested-amd64.model.json | 12 + tests/lib/assertions/nested-i386.model | 23 + tests/lib/assertions/nested-i386.model.json | 12 + .../pc-18-amd64-accept-generic.model | 24 + .../pc-18-amd64-accept-generic.model.json | 13 + tests/lib/assertions/pc-production.model | 21 + tests/lib/assertions/pc-staging.model | 21 + tests/lib/assertions/pi2.model | 21 + tests/lib/assertions/pi2.model.json | 11 + .../test-snapd-account-key-rrP2xy.assert | 30 + .../test-snapd-core22-required-vset.assert | 25 + .../test-snapd-core22-required-vset.json | 17 + .../test-snapd-recovery-system-pc-20.json | 53 + .../test-snapd-recovery-system-pc-20.model | 61 + .../test-snapd-recovery-system-pc-22.json | 53 + .../test-snapd-recovery-system-pc-22.model | 61 + .../test-snapd-recovery-system-pinned.assert | 44 + .../test-snapd-remodel-auto-import.assert | 77 + ...test-snapd-remodel-auto-import.assert.json | 13 + .../test-snapd-remodel-bases-20.json | 45 + .../test-snapd-remodel-bases-20.model | 49 + .../test-snapd-remodel-bases-22.json | 45 + .../test-snapd-remodel-bases-22.model | 49 + ...test-snapd-remodel-invalid-vset-pc-22.json | 57 + ...est-snapd-remodel-invalid-vset-pc-22.model | 59 + .../test-snapd-remodel-offline-rev0.json | 39 + .../test-snapd-remodel-offline-rev0.model | 45 + .../test-snapd-remodel-offline-rev1.json | 58 + .../test-snapd-remodel-offline-rev1.model | 60 + .../assertions/test-snapd-remodel-pc-18.json | 14 + .../assertions/test-snapd-remodel-pc-18.model | 24 + .../assertions/test-snapd-remodel-pc-20.json | 39 + .../assertions/test-snapd-remodel-pc-20.model | 44 + .../assertions/test-snapd-remodel-pc-22.json | 39 + .../assertions/test-snapd-remodel-pc-22.model | 44 + .../test-snapd-remodel-pc-cross-store-18.json | 15 + ...test-snapd-remodel-pc-cross-store-18.model | 26 + .../test-snapd-remodel-pc-cross-store-20.json | 44 + ...test-snapd-remodel-pc-cross-store-20.model | 48 + .../test-snapd-remodel-pc-cross-store-22.json | 44 + ...test-snapd-remodel-pc-cross-store-22.model | 48 + .../test-snapd-remodel-pc-just-model-18.json | 15 + .../test-snapd-remodel-pc-just-model-18.model | 27 + .../test-snapd-remodel-pc-just-model-20.json | 50 + .../test-snapd-remodel-pc-just-model-20.model | 53 + .../test-snapd-remodel-pc-just-model-22.json | 50 + .../test-snapd-remodel-pc-just-model-22.model | 53 + .../test-snapd-remodel-pc-min-size-22.json | 39 + .../test-snapd-remodel-pc-min-size-22.model | 44 + .../test-snapd-remodel-pc-rev0-22.json | 39 + .../test-snapd-remodel-pc-rev0-22.model | 44 + .../test-snapd-remodel-pc-rev1-22.json | 40 + .../test-snapd-remodel-pc-rev1-22.model | 45 + ...napd-remodel-pinned-hello-world-pc-22.json | 57 + ...apd-remodel-pinned-hello-world-pc-22.model | 59 + ...test-snapd-remodel-without-vset-pc-22.json | 51 + ...est-snapd-remodel-without-vset-pc-22.model | 55 + .../assertions/testrootorg-store.account-key | 30 + .../lib/assertions/ubuntu-core-18-amd64.model | 23 + .../lib/assertions/ubuntu-core-20-amd64.model | 42 + .../lib/assertions/ubuntu-core-22-amd64.model | 42 + .../lib/assertions/ubuntu-core-22-arm64.model | 43 + .../lib/assertions/ubuntu-core-24-amd64.model | 49 + .../assertions/valid-for-testing-pc-18.json | 13 + .../assertions/valid-for-testing-pc-18.model | 24 + .../assertions/valid-for-testing-pc-20.json | 38 + .../assertions/valid-for-testing-pc-20.model | 44 + .../valid-for-testing-pc-22-from-20.json | 39 + .../valid-for-testing-pc-22-from-20.model | 45 + .../assertions/valid-for-testing-pc-22.json | 38 + .../assertions/valid-for-testing-pc-22.model | 44 + ...id-for-testing-pc-new-base-revno-2-18.json | 14 + ...d-for-testing-pc-new-base-revno-2-18.model | 25 + ...-for-testing-pc-new-gadget-revno-2-18.json | 14 + ...for-testing-pc-new-gadget-revno-2-18.model | 25 + ...-for-testing-pc-new-kernel-revno-2-18.json | 14 + ...for-testing-pc-new-kernel-revno-2-18.model | 25 + ...lid-for-testing-pc-new-kernel-revno-2.json | 13 + ...id-for-testing-pc-new-kernel-revno-2.model | 24 + .../valid-for-testing-pc-new-model-20.json | 42 + .../valid-for-testing-pc-new-model-20.model | 47 + .../valid-for-testing-pc-revno-2-18.json | 15 + .../valid-for-testing-pc-revno-2-18.model | 27 + .../valid-for-testing-pc-revno-2-20.json | 56 + .../valid-for-testing-pc-revno-2-20.model | 59 + .../valid-for-testing-pc-revno-2-22.json | 56 + .../valid-for-testing-pc-revno-2-22.model | 59 + .../valid-for-testing-pc-revno-2.json | 14 + .../valid-for-testing-pc-revno-2.model | 26 + .../valid-for-testing-pc-revno-3-18.json | 14 + .../valid-for-testing-pc-revno-3-18.model | 25 + .../valid-for-testing-pc-revno-3-20.json | 40 + .../valid-for-testing-pc-revno-3-20.model | 45 + .../valid-for-testing-pc-revno-3-22.json | 39 + .../valid-for-testing-pc-revno-3-22.model | 45 + .../valid-for-testing-pc-revno-3.json | 13 + .../valid-for-testing-pc-revno-3.model | 24 + .../lib/assertions/valid-for-testing-pc.json | 12 + .../lib/assertions/valid-for-testing-pc.model | 23 + .../lib/assertions/valid-for-testing.account | 19 + .../assertions/valid-for-testing.account-key | 30 + tests/lib/best_golang.py | 18 + tests/lib/cache/README.txt | 3 + tests/lib/changes.sh | 8 + .../cloud-init-seeds/attacker-user/meta-data | 2 + .../cloud-init-seeds/attacker-user/user-data | 7 + .../emptykthxbai/emptykthxbai | 0 .../cloud-init-seeds/normal-user/meta-data | 2 + .../cloud-init-seeds/normal-user/user-data | 7 + tests/lib/core-config.sh | 110 + tests/lib/desktop-portal.sh | 56 + tests/lib/disabled-svcs.sh | 28 + tests/lib/ensure_ubuntu_save.py | 71 + tests/lib/external/prepare-ssh.sh | 19 + .../snapd-testing-tools/.github/labeler.yml | 6 + .../.github/workflows/labeler.yaml | 12 + .../.github/workflows/tests.yaml | 124 + .../snapd-testing-tools/CODE_OF_CONDUCT.md | 76 + .../lib/external/snapd-testing-tools/COPYING | 674 + .../external/snapd-testing-tools/README.md | 74 + .../snapd-testing-tools/remote/remote.exec | 88 + .../snapd-testing-tools/remote/remote.pull | 61 + .../snapd-testing-tools/remote/remote.push | 61 + .../snapd-testing-tools/remote/remote.refresh | 322 + .../snapd-testing-tools/remote/remote.retry | 59 + .../snapd-testing-tools/remote/remote.setup | 100 + .../remote/remote.wait-for | 310 + .../lib/external/snapd-testing-tools/setup.sh | 11 + .../external/snapd-testing-tools/spread.yaml | 76 + .../tests/check-test-format/task.yaml | 42 + .../tests/check-test-format/tasks/task1.yaml | 9 + .../tests/check-test-format/tasks/task2.yaml | 9 + .../tests/check-test-format/tasks/task3.yaml | 7 + .../tests/check-test-format/tasks/task4.yaml | 8 + .../tests/check-test-format/tasks/task5.yaml | 10 + .../tests/check-test-format/tasks/task6.yaml | 6 + ...borted-and-failed-execute-and-restore.json | 443 + ...aborted-and-failed-execute-and-restore.log | 82 + .../tests/log-analyzer/data/all-aborted.json | 215 + .../tests/log-analyzer/data/all-aborted.log | 40 + .../tests/log-analyzer/data/all-failed.json | 613 + .../tests/log-analyzer/data/all-failed.log | 138 + .../data/all-success-failed-restore.json | 453 + .../data/all-success-failed-restore.log | 74 + .../tests/log-analyzer/data/all-success.json | 400 + .../tests/log-analyzer/data/all-success.log | 57 + .../data/failed-prepare-and-restore.json | 513 + .../data/failed-prepare-and-restore.log | 91 + .../data/failed-prepare-project.json | 315 + .../data/failed-prepare-project.log | 50 + .../data/failed-prepare-suite.json | 327 + .../data/failed-prepare-suite.log | 50 + .../data/failed-prepare-task.json | 607 + .../log-analyzer/data/failed-prepare-task.log | 94 + .../data/with-aborted-and-failed-restore.json | 285 + .../data/with-aborted-and-failed-restore.log | 50 + .../with-failed-and-failed-restore-suite.json | 493 + .../with-failed-and-failed-restore-suite.log | 79 + .../tests/log-analyzer/spread.yaml | 35 + .../tests/log-analyzer/task.yaml | 243 + .../tests/log-analyzer/tests/test-1/task.yaml | 10 + .../tests/log-analyzer/tests/test-2/task.yaml | 10 + .../tests/log-analyzer/tests/test-3/task.yaml | 10 + .../tests/log-analyzer/tests/test-4/task.yaml | 10 + .../tests/log-analyzer/tests/test-5/task.yaml | 10 + .../tests/log-parser/all-aborted.log.spread | 46 + .../log-parser/all-successful.log.spread | 112 + .../tests/log-parser/task.yaml | 106 + .../log-parser/with-all-results.log.spread | 163 + .../with-failed-and-aborted.log.spread | 265 + .../with-failed-project-restore.log.spread | 146 + ...ith-failed-repeated-and-aborted.log.spread | 154 + .../with-failed-repeated.log.spread | 154 + .../with-failed-suite-restore.log.spread | 146 + .../tests/log-parser/with-failed.log.spread | 216 + .../with-results-in-detail.log.spread | 149 + .../snapd-testing-tools/tests/not/task.yaml | 6 + .../tests/os.paths/task.yaml | 41 + .../tests/os.query/task.yaml | 174 + .../snapd-testing-tools/tests/quiet/task.yaml | 9 + .../tests/remote.exec/task.yaml | 40 + .../tests/remote.pull/task.yaml | 33 + .../tests/remote.push/task.yaml | 33 + .../tests/remote.refresh/task.yaml | 56 + .../tests/remote.retry/task.yaml | 43 + .../tests/remote.setup/task.yaml | 54 + .../tests/remote.wait-for/task.yaml | 83 + .../tests/repack-kernel/task.yaml | 47 + .../snapd-testing-tools/tests/retry/task.yaml | 26 + .../tests/snaps.cleanup/task.yaml | 69 + .../tests/snaps.name/task.yaml | 25 + .../spread-manager/checks/task1/task.yaml | 13 + .../spread-manager/checks/task2/task.yaml | 15 + .../spread-manager/checks/task3/task.yaml | 15 + .../spread-manager/checks/task4/task.yaml | 15 + .../tests/spread-manager/checks/task5/.empty | 0 .../tests/spread-manager/spread.yaml | 12 + .../tests/spread-manager/task.yaml | 92 + .../tests/spread-shellcheck/task.yaml | 44 + .../tests/spread-shellcheck/tasks/task1 | 13 + .../tests/spread-shellcheck/tasks/task2 | 13 + .../tests/spread-shellcheck/tasks/task3 | 13 + .../tests/spread-shellcheck/tasks/task4 | 13 + .../tests/tests.backup/task.yaml | 59 + .../tests/tests.cleanup/task.yaml | 77 + .../tests/tests.pkgs/task.yaml | 48 + .../tests/tests.systemd/task.yaml | 64 + .../external/snapd-testing-tools/tools/not | 6 + .../snapd-testing-tools/tools/os.paths | 64 + .../snapd-testing-tools/tools/os.query | 282 + .../external/snapd-testing-tools/tools/quiet | 32 + .../snapd-testing-tools/tools/repack-kernel | 230 + .../external/snapd-testing-tools/tools/retry | 157 + .../snapd-testing-tools/tools/snaps.cleanup | 94 + .../snapd-testing-tools/tools/snaps.name | 77 + .../snapd-testing-tools/tools/tests.backup | 69 + .../snapd-testing-tools/tools/tests.cleanup | 97 + .../snapd-testing-tools/tools/tests.pkgs | 163 + .../tools/tests.pkgs.apt.sh | 65 + .../tools/tests.pkgs.dnf-yum.sh | 75 + .../tools/tests.pkgs.pacman.sh | 71 + .../tools/tests.pkgs.zypper.sh | 66 + .../snapd-testing-tools/tools/tests.systemd | 177 + .../utils/check-test-format | 150 + .../snapd-testing-tools/utils/log-analyzer | 330 + .../snapd-testing-tools/utils/log-parser | 590 + .../snapd-testing-tools/utils/spread-manager | 167 + .../utils/spread-shellcheck | 423 + .../snapd-testing-tools/utils/spreadJ | 119 + tests/lib/fakedevicesvc/main.go | 150 + tests/lib/fakegpio/fake-gpio.py | 107 + tests/lib/fakeportalui/portalui.py | 93 + .../cmd/fakestore/cmd_make_refreshable.go | 49 + .../fakestore/cmd/fakestore/cmd_new_repair.go | 67 + .../cmd/fakestore/cmd_new_snap_decl.go | 69 + .../cmd/fakestore/cmd_new_snap_rev.go | 71 + tests/lib/fakestore/cmd/fakestore/cmd_run.go | 70 + tests/lib/fakestore/cmd/fakestore/main.go | 51 + tests/lib/fakestore/refresh/refresh.go | 314 + tests/lib/fakestore/refresh/snap_asserts.go | 134 + tests/lib/fakestore/store/store.go | 874 + tests/lib/fakestore/store/store_test.go | 782 + tests/lib/fde-setup-hook-v1/fde-setup.go | 173 + tests/lib/fde-setup-hook/export_test.go | 24 + tests/lib/fde-setup-hook/fde-setup.go | 236 + tests/lib/fde-setup-hook/fde-setup_test.go | 80 + tests/lib/gendeveloper1/main.go | 95 + tests/lib/gendeveloper1assert/main.sh | 73 + tests/lib/hotplug.sh | 110 + tests/lib/image.sh | 123 + tests/lib/list-interfaces.go | 13 + tests/lib/manip_seed.py | 29 + tests/lib/mkpinentry.sh | 21 + tests/lib/mock-shutdown | 13 + tests/lib/muinstaller/go.mod | 26 + tests/lib/muinstaller/go.sum | 69 + tests/lib/muinstaller/main.go | 589 + tests/lib/muinstaller/mk-classic-rootfs.sh | 109 + tests/lib/muinstaller/snapcraft.yaml | 29 + tests/lib/nested.sh | 1671 ++ tests/lib/network.sh | 28 + tests/lib/os-release.16 | 7 + tests/lib/pinentry-fake.sh | 20 + tests/lib/pkgdb.sh | 925 + tests/lib/prepare-restore.sh | 934 + tests/lib/prepare.sh | 1542 ++ tests/lib/preseed.sh | 97 + tests/lib/ramdisk.sh | 8 + tests/lib/random.sh | 39 + tests/lib/reflash.sh | 77 + tests/lib/remodel-store-viewer.auth | 6 + tests/lib/reset.sh | 214 + tests/lib/snaps.sh | 28 + tests/lib/snaps/aliases/bin/cmd1 | 2 + tests/lib/snaps/aliases/bin/cmd2 | 2 + tests/lib/snaps/aliases/meta/snap.yaml | 9 + tests/lib/snaps/basic-desktop/bin/echo | 3 + .../snaps/basic-desktop/meta/gui/echo.desktop | 10 + .../lib/snaps/basic-desktop/meta/gui/icon.png | Bin 0 -> 3371 bytes .../meta/gui/io.snapcraft.echoecho.desktop | 10 + tests/lib/snaps/basic-desktop/meta/snap.yaml | 10 + .../snaps/basic-hooks/meta/hooks/configure | 7 + .../snaps/basic-hooks/meta/hooks/invalid-hook | 3 + tests/lib/snaps/basic-hooks/meta/snap.yaml | 11 + tests/lib/snaps/basic/meta/snap.yaml | 4 + tests/lib/snaps/basic18/meta/snap.yaml | 5 + .../lib/snaps/classic-gadget/meta/gadget.yaml | 1 + .../classic-gadget/meta/hooks/prepare-device | 2 + tests/lib/snaps/classic-gadget/meta/snap.yaml | 4 + tests/lib/snaps/config-versions-v2/bin/sh | 3 + .../config-versions-v2/meta/hooks/configure | 9 + .../meta/hooks/post-refresh | 3 + .../snaps/config-versions-v2/meta/snap.yaml | 7 + tests/lib/snaps/config-versions/bin/sh | 3 + .../config-versions/meta/hooks/configure | 3 + .../config-versions/meta/hooks/post-refresh | 3 + .../lib/snaps/config-versions/meta/snap.yaml | 7 + .../lib/snaps/disabled-svcs-kept/bin/service | 2 + .../disabled-svcs-kept/meta/hooks/configure | 5 + .../disabled-svcs-kept/meta/snap.yaml.in | 6 + tests/lib/snaps/generic-consumer/bin/cmd | 6 + .../snaps/generic-consumer/meta/snap.yaml.in | 6 + tests/lib/snaps/gpio-consumer/bin/read | 3 + tests/lib/snaps/gpio-consumer/meta/snap.yaml | 9 + tests/lib/snaps/home-consumer/bin/reader | 14 + tests/lib/snaps/home-consumer/bin/writer | 15 + tests/lib/snaps/home-consumer/meta/snap.yaml | 12 + tests/lib/snaps/log-observe-consumer/bin/cmd | 6 + .../snaps/log-observe-consumer/bin/consumer | 15 + .../snaps/log-observe-consumer/meta/snap.yaml | 12 + .../snaps/netplan-snap/bin/netplan-info.sh | 3 + tests/lib/snaps/netplan-snap/bin/netplan.sh | 6 + .../lib/snaps/netplan-snap/meta/snap.yaml.in | 22 + tests/lib/snaps/network-consumer/bin/consumer | 21 + .../lib/snaps/network-consumer/meta/snap.yaml | 9 + .../snaps/serial-port-hotplug/bin/consumer | 15 + .../meta/hooks/connect-plug-serial-port | 4 + .../snaps/serial-port-hotplug/meta/snap.yaml | 9 + .../snap-hooks-bad-install/meta/hooks/install | 9 + .../snap-hooks-bad-install/meta/snap.yaml | 5 + tests/lib/snaps/snap-hooks-bad-install/true | 0 .../lib/snaps/snap-hooks/meta/hooks/configure | 3 + tests/lib/snaps/snap-hooks/meta/hooks/install | 3 + .../snaps/snap-hooks/meta/hooks/post-refresh | 3 + .../snaps/snap-hooks/meta/hooks/pre-refresh | 3 + tests/lib/snaps/snap-hooks/meta/hooks/remove | 5 + tests/lib/snaps/snap-hooks/meta/snap.yaml | 8 + tests/lib/snaps/snap-hooks/true | 0 .../meta/hooks/install | 4 + .../snap-install-hook-broken/meta/snap.yaml | 5 + tests/lib/snaps/snap-store/bin/snap-store | 2 + tests/lib/snaps/snap-store/meta/snap.yaml | 8 + .../snapctl-hooks-v2/meta/hooks/configure | 13 + .../lib/snaps/snapctl-hooks-v2/meta/snap.yaml | 2 + .../snaps/snapctl-hooks/meta/hooks/configure | 154 + tests/lib/snaps/snapctl-hooks/meta/snap.yaml | 2 + tests/lib/snaps/socket-activation/bin/sleep | 3 + .../snaps/socket-activation/meta/snap.yaml | 20 + .../list-accounts.c | 69 + .../snapcraft.yaml | 25 + .../store/test-snapd-audio-record/Makefile | 2 + .../test-snapd-audio-record/files/bin/pawrap | 16 + .../test-snapd-audio-record/snapcraft.yaml | 81 + .../test-snapd-audio-record/src/Makefile | 17 + .../src/parec-simple.c | 73 + .../test-snapd-autopilot-consumer/consumer | 22 + .../test-snapd-autopilot-consumer/provider.py | 38 + .../snapcraft.yaml | 28 + .../test-snapd-autopilot-consumer/wrapper | 3 + .../snaps/store/test-snapd-base-bare/Makefile | 13 + .../store/test-snapd-base-bare/snapcraft.yaml | 12 + .../test-snapd-busybox-static/snapcraft.yaml | 17 + .../shared-content | 3 + .../snapcraft.yaml | 20 + .../bin/content-plug | 5 + .../snapcraft.yaml | 23 + .../snapcraft.yaml | 14 + .../store/test-snapd-curl/snapcraft.yaml | 22 + .../store/test-snapd-daemon-user/Makefile | 8 + .../test-snapd-daemon-user/snapcraft.yaml | 98 + .../store/test-snapd-daemon-user/src/Makefile | 263 + .../store/test-snapd-daemon-user/src/chown.c | 58 + .../test-snapd-daemon-user/src/chown32.c | 1 + .../test-snapd-daemon-user/src/display.c | 66 + .../test-snapd-daemon-user/src/display.h | 2 + .../test-snapd-daemon-user/src/display32.c | 1 + .../test-snapd-daemon-user/src/drop-exec.c | 60 + .../test-snapd-daemon-user/src/drop-exec32.c | 1 + .../test-snapd-daemon-user/src/drop-syscall.c | 68 + .../src/drop-syscall32.c | 68 + .../store/test-snapd-daemon-user/src/drop.c | 68 + .../store/test-snapd-daemon-user/src/drop32.c | 1 + .../store/test-snapd-daemon-user/src/fchown.c | 67 + .../test-snapd-daemon-user/src/fchown32.c | 1 + .../test-snapd-daemon-user/src/fchownat.c | 85 + .../test-snapd-daemon-user/src/fchownat32.c | 1 + .../store/test-snapd-daemon-user/src/lchown.c | 58 + .../test-snapd-daemon-user/src/lchown32.c | 1 + .../store/test-snapd-daemon-user/src/setgid.c | 40 + .../test-snapd-daemon-user/src/setgid32.c | 1 + .../test-snapd-daemon-user/src/setregid.c | 50 + .../test-snapd-daemon-user/src/setregid32.c | 1 + .../test-snapd-daemon-user/src/setresgid.c | 50 + .../test-snapd-daemon-user/src/setresgid32.c | 1 + .../test-snapd-daemon-user/src/setresuid.c | 50 + .../test-snapd-daemon-user/src/setresuid32.c | 1 + .../test-snapd-daemon-user/src/setreuid.c | 50 + .../test-snapd-daemon-user/src/setreuid32.c | 1 + .../store/test-snapd-daemon-user/src/setuid.c | 40 + .../test-snapd-daemon-user/src/setuid32.c | 1 + .../test-snapd-dbus-consumer/consumer.py | 16 + .../test-snapd-dbus-consumer/snapcraft.yaml | 32 + .../test-snapd-dbus-provider/consumer.py | 16 + .../test-snapd-dbus-provider/provider.py | 32 + .../test-snapd-dbus-provider/snapcraft.yaml | 49 + .../store/test-snapd-dbus-provider/wrapper | 8 + .../bin/test-snapd-dbus-service | 48 + .../store/test-snapd-dbus-service/setup.py | 11 + .../test-snapd-dbus-service/snapcraft.yaml | 44 + .../lib/snaps/store/test-snapd-eds/calendar.c | 201 + .../lib/snaps/store/test-snapd-eds/contacts.c | 222 + .../snaps/store/test-snapd-eds/meson.build | 14 + .../store/test-snapd-eds/snap/snapcraft.yaml | 47 + .../store/test-snapd-fuse-consumer/Makefile | 9 + .../test-snapd-fuse-consumer/snapcraft.yaml | 18 + .../store/test-snapd-go-webserver/main.go | 25 + .../test-snapd-go-webserver/snapcraft.yaml | 19 + .../test-snapd-gpio-memory-control/Makefile | 5 + .../test-snapd-gpio-memory-control/gpiomem.c | 59 + .../snapcraft.yaml | 17 + .../store/test-snapd-hello-classic/Makefile | 9 + .../test-snapd-hello-classic/snapcraft.yaml | 16 + .../test-snapd-hello-classic.c | 12 + .../store/test-snapd-just-beta/snap-name | 3 + .../store/test-snapd-just-beta/snapcraft.yaml | 15 + .../store/test-snapd-just-edge/snap-name | 3 + .../store/test-snapd-just-edge/snapcraft.yaml | 15 + .../snapcraft.yaml | 24 + .../README.md | 11 + .../daemon.sh | 6 + .../snap/hooks/configure | 5 + .../snap/keys/B05498B7.asc | 43 + .../snap/snapcraft.yaml | 131 + .../snapcraft_new.yaml | 132 + .../snapcraft_old.yaml | 121 + .../store/test-snapd-layout-change/README.md | 11 + .../snap/hooks/configure | 5 + .../snap/keys/B05498B7.asc | 43 + .../snapcraft_new.yaml | 125 + .../snapcraft_old.yaml | 121 + .../bin/machine-down | 3 + .../bin/machine-up | 3 + .../meta/hooks/install | 3 + .../snapcraft.yaml | 53 + .../vm/ping-unikernel.xml | 23 + .../test-snapd-load-generator/load-generator | 67 + .../test-snapd-load-generator/snapcraft.yaml | 19 + .../consumer | 22 + .../provider.py | 29 + .../snapcraft.yaml | 26 + .../wrapper | 3 + .../store/test-snapd-mokutil/snapcraft.yaml | 23 + .../bin/ovs-vsctl | 3 + .../snapcraft.yaml | 21 + .../test-snapd-packagekit/snapcraft.yaml | 31 + .../store/test-snapd-portal-client/client.py | 146 + .../store/test-snapd-portal-client/setup.py | 7 + .../test-snapd-portal-client/snapcraft.yaml | 20 + .../store/test-snapd-profiler/config.ini | 7 + .../store/test-snapd-profiler/profiler.py | 95 + .../store/test-snapd-profiler/snapcraft.yaml | 23 + .../store/test-snapd-pulseaudio/Makefile | 2 + .../test-snapd-pulseaudio/files/bin/pawrap | 16 + .../test-snapd-pulseaudio/snapcraft.yaml | 79 + .../store/test-snapd-pulseaudio/src/Makefile | 17 + .../test-snapd-pulseaudio/src/parec-simple.c | 73 + .../test-snapd-python-webserver/index.html | 43 + .../test-snapd-python-webserver/server.py | 46 + .../snapcraft.yaml | 21 + .../build-aux/snap/snapcraft.yaml | 24 + .../build-aux/snap/snapcraft.yaml | 24 + .../build-aux/snap/snapcraft.yaml | 24 + .../test-snapd-refresh-control.v1/bin/pending | 2 + .../test-snapd-refresh-control.v1/bin/proceed | 2 + .../build-aux/snap/hooks/gate-auto-refresh | 10 + .../build-aux/snap/snapcraft.yaml | 32 + .../test-snapd-refresh-control.v2/bin/pending | 2 + .../test-snapd-refresh-control.v2/bin/proceed | 2 + .../build-aux/snap/hooks/gate-auto-refresh | 10 + .../build-aux/snap/snapcraft.yaml | 32 + .../store/test-snapd-rsync/snapcraft.yaml | 16 + .../store/test-snapd-setpriority/Makefile | 5 + .../test-snapd-setpriority/setpriority.c | 35 + .../test-snapd-setpriority/snapcraft.yaml | 16 + .../consumer.py | 13 + .../dbus-introspect.py | 14 + .../snapcraft.yaml | 25 + .../store/test-snapd-udisks2/snapcraft.yaml | 17 + .../snaps/store/test-snapd-udisks2/udisksctl | 3 + .../lib/snaps/store/test-snapd-uhid/Makefile | 5 + .../store/test-snapd-uhid/snapcraft.yaml | 17 + .../snaps/store/test-snapd-uhid/uhid-test.c | 190 + .../snapcraft.yaml | 14 + .../store/test-snapd-upower/bin/upowerd.sh | 37 + .../store/test-snapd-upower/snapcraft.yaml | 88 + .../build-aux/snap/snapcraft.yaml | 17 + .../build-aux/snap/snapcraft.yaml | 17 + .../meta/hooks/configure | 18 + .../meta/hooks/default-configure | 18 + .../meta/snap.yaml | 12 + .../service | 4 + .../snap/snapcraft.yaml | 19 + .../meta/hooks/configure | 18 + .../meta/hooks/default-configure | 18 + .../meta/snap.yaml | 11 + .../test-snapd-with-default-configure/service | 4 + .../snap/snapcraft.yaml | 18 + .../snaps/test-devmode-cgroup/bin/read-dev | 4 + .../snaps/test-devmode-cgroup/meta/snap.yaml | 11 + .../test-snapd-after-before-service/bin/start | 11 + .../meta/snap.yaml | 19 + .../test-snapd-appstream-metadata/bin/sh | 3 + .../meta/snap.yaml | 8 + .../test-snapd-auto-aliases/bin/wellknown1 | 2 + .../test-snapd-auto-aliases/bin/wellknown2 | 2 + .../test-snapd-auto-aliases/meta/snap.yaml | 9 + .../lib/snaps/test-snapd-base/meta/snap.yaml | 4 + tests/lib/snaps/test-snapd-base/random-file | 1 + .../bin/classic-confinement | 4 + .../bin/recurse | 6 + .../test-snapd-classic-confinement/bin/sh | 3 + .../meta/snap.yaml | 10 + .../bin/test-snapd-complexion | 7 + .../test-snapd-complexion/meta/snap.yaml | 11 + .../test-snapd-complexion.bash-completer | 31 + .../test-snapd-content-advanced-plug/bin/sh | 3 + .../meta/snap.yaml | 22 + .../test-snapd-content-advanced-slot/bin/sh | 3 + .../meta/snap.yaml | 16 + .../source/canary | 1 + .../test-snapd-content-plug/bin/content-plug | 11 + .../import/.placeholder | 0 .../test-snapd-content-plug/meta/snap.yaml | 12 + .../test-snapd-content-slot/meta/snap.yaml | 8 + .../test-snapd-content-slot/shared-content | 3 + .../test-snapd-content-slot2/meta/snap.yaml | 8 + .../test-snapd-content-slot2/shared-content | 3 + .../test-snapd-control-consumer/bin/install | 22 + .../test-snapd-control-consumer/bin/list | 19 + .../meta/snap.yaml | 26 + .../snaps/test-snapd-daemon-notify/bin/notify | 3 + .../test-snapd-daemon-notify/meta/snap.yaml | 10 + .../bin/client.sh | 5 + .../meta/snap.yaml | 22 + .../snaps/test-snapd-desktop/bin/check-dirs | 5 + .../snaps/test-snapd-desktop/bin/check-files | 5 + tests/lib/snaps/test-snapd-desktop/bin/cmd | 4 + tests/lib/snaps/test-snapd-desktop/bin/sh | 4 + .../test-snapd-desktop/meta/gui/cmd.desktop | 6 + .../snaps/test-snapd-desktop/meta/snap.yaml | 18 + .../snaps/test-snapd-devmode/meta/snap.yaml | 8 + tests/lib/snaps/test-snapd-devmode/true | 0 .../lib/snaps/test-snapd-icon-theme/bin/echo | 2 + .../meta/gui/echo.desktop | 5 + .../apps/snap.test-snapd-icon-theme.foo.svg | 5 + .../test-snapd-icon-theme/meta/snap.yaml | 6 + .../snaps/test-snapd-journal-quota/bin/logger | 8 + .../test-snapd-journal-quota/meta/snap.yaml | 9 + tests/lib/snaps/test-snapd-kvm/bin/sh | 3 + tests/lib/snaps/test-snapd-kvm/meta/snap.yaml | 8 + tests/lib/snaps/test-snapd-layout/bin/sh | 3 + .../snaps/test-snapd-layout/meta/snap.yaml | 39 + .../lib/snaps/test-snapd-layout/opt/demo/file | 1 + .../test-snapd-layout/usr/share/demo/file | 1 + .../snaps/test-snapd-mount-control/bin/cmd | 6 + .../test-snapd-mount-control/meta/snap.yaml | 24 + .../test-snapd-number-version/meta/snap.yaml | 3 + .../test-snapd-policy-app-consumer/bin/run | 10 + .../meta/gui/test-desktop.desktop | 6 + .../meta/polkit/polkit.test.policy | 11 + .../meta/snap.yaml | 597 + .../snaps/test-snapd-private/meta/snap.yaml | 6 + .../snaps/test-snapd-public/meta/snap.yaml | 6 + .../snaps/test-snapd-remodel-pc-18/grub.cfg | 42 + .../snaps/test-snapd-remodel-pc-18/grub.conf | 42 + .../test-snapd-remodel-pc-18/grubx64.efi | Bin 0 -> 1119104 bytes .../test-snapd-remodel-pc-18/meta/gadget.yaml | 30 + .../meta/gui/icon.png | Bin 0 -> 39908 bytes .../meta/hooks/configure | 3 + .../meta/hooks/prepare-device | 20 + .../test-snapd-remodel-pc-18/meta/snap.yaml | 17 + .../snaps/test-snapd-remodel-pc-18/mmx64.efi | Bin 0 -> 1269496 bytes .../test-snapd-remodel-pc-18/pc-boot.img | Bin 0 -> 440 bytes .../test-snapd-remodel-pc-18/pc-core.img | Bin 0 -> 183367 bytes .../test-snapd-remodel-pc-18/shim.efi.signed | Bin 0 -> 1334816 bytes .../test-snapd-remodel-pc-20/cmdline.extra | 2 + .../snaps/test-snapd-remodel-pc-20/grub.conf | 0 .../test-snapd-remodel-pc-20/grubx64.efi | Bin 0 -> 1681296 bytes .../test-snapd-remodel-pc-20/meta/gadget.yaml | 57 + .../meta/gui/icon.png | Bin 0 -> 39908 bytes .../meta/hooks/configure | 3 + .../meta/hooks/prepare-device | 21 + .../test-snapd-remodel-pc-20/meta/snap.yaml | 17 + .../test-snapd-remodel-pc-20/pc-boot.img | Bin 0 -> 440 bytes .../test-snapd-remodel-pc-20/pc-core.img | Bin 0 -> 218072 bytes .../test-snapd-remodel-pc-20/shim.efi.signed | Bin 0 -> 1343496 bytes .../test-snapd-remodel-pc-22/cmdline.extra | 2 + .../snaps/test-snapd-remodel-pc-22/grub.conf | 0 .../test-snapd-remodel-pc-22/grubx64.efi | Bin 0 -> 1681296 bytes .../test-snapd-remodel-pc-22/meta/gadget.yaml | 57 + .../meta/gui/icon.png | Bin 0 -> 39908 bytes .../meta/hooks/configure | 3 + .../meta/hooks/prepare-device | 21 + .../test-snapd-remodel-pc-22/meta/snap.yaml | 17 + .../test-snapd-remodel-pc-22/pc-boot.img | Bin 0 -> 440 bytes .../test-snapd-remodel-pc-22/pc-core.img | Bin 0 -> 218072 bytes .../test-snapd-remodel-pc-22/shim.efi.signed | Bin 0 -> 1343496 bytes .../cmdline.extra | 2 + .../grub.conf | 0 .../grubx64.efi | Bin 0 -> 1681296 bytes .../meta/gadget.yaml | 58 + .../meta/gui/icon.png | Bin 0 -> 39908 bytes .../meta/hooks/configure | 3 + .../meta/hooks/prepare-device | 21 + .../meta/snap.yaml | 17 + .../pc-boot.img | Bin 0 -> 440 bytes .../pc-core.img | Bin 0 -> 218072 bytes .../shim.efi.signed | Bin 0 -> 1343496 bytes .../meta/snap.yaml | 4 + .../test-snapd-requires-base/meta/snap.yaml | 4 + .../snaps/test-snapd-service-many/bin/start | 6 + .../test-snapd-service-many/meta/snap.yaml | 184 + .../test-snapd-service-restart/bin/start | 8 + .../test-snapd-service-restart/meta/snap.yaml | 19 + .../snaps/test-snapd-service-v1-good/bin/good | 3 + .../test-snapd-service-v1-good/meta/snap.yaml | 7 + .../snaps/test-snapd-service-v2-bad/bin/bad | 4 + .../test-snapd-service-v2-bad/meta/snap.yaml | 7 + tests/lib/snaps/test-snapd-service/bin/reload | 8 + tests/lib/snaps/test-snapd-service/bin/start | 9 + .../snaps/test-snapd-service/bin/start-other | 6 + .../test-snapd-service/bin/start-stop-mode | 68 + .../bin/start-stop-mode-sigterm | 15 + tests/lib/snaps/test-snapd-service/bin/stop | 7 + .../test-snapd-service/bin/stop-stop-mode | 4 + .../test-snapd-service/meta/hooks/configure | 19 + .../snaps/test-snapd-service/meta/snap.yaml | 69 + tests/lib/snaps/test-snapd-sh-core16/bin/sh | 3 + .../snaps/test-snapd-sh-core16/meta/snap.yaml | 7 + tests/lib/snaps/test-snapd-sh-core18/bin/sh | 3 + .../snaps/test-snapd-sh-core18/meta/snap.yaml | 8 + tests/lib/snaps/test-snapd-sh-core20/bin/sh | 3 + .../snaps/test-snapd-sh-core20/meta/snap.yaml | 8 + tests/lib/snaps/test-snapd-sh-core22/bin/sh | 3 + .../snaps/test-snapd-sh-core22/meta/snap.yaml | 8 + tests/lib/snaps/test-snapd-sh-core24/bin/sh | 3 + .../snaps/test-snapd-sh-core24/meta/snap.yaml | 8 + tests/lib/snaps/test-snapd-sh/bin/cmd | 6 + tests/lib/snaps/test-snapd-sh/bin/sh | 3 + tests/lib/snaps/test-snapd-sh/meta/snap.yaml | 9 + .../test-snapd-simple-service/bin/service | 2 + .../test-snapd-simple-service/meta/snap.yaml | 8 + .../test-snapd-snapctl-core18/bin/service | 5 + .../meta/hooks/install | 3 + .../test-snapd-snapctl-core18/meta/snap.yaml | 10 + .../bin/date | 2 + .../bin/hwclock | 2 + .../bin/timedatectl | 2 + .../meta/snap.yaml | 21 + .../snaps/test-snapd-timer-service/bin/loop | 10 + .../test-snapd-timer-service/meta/snap.yaml | 25 + .../snaps/test-snapd-tools-core18/bin/block | 14 + .../lib/snaps/test-snapd-tools-core18/bin/cat | 3 + .../lib/snaps/test-snapd-tools-core18/bin/cmd | 6 + .../snaps/test-snapd-tools-core18/bin/echo | 3 + .../lib/snaps/test-snapd-tools-core18/bin/env | 3 + .../snaps/test-snapd-tools-core18/bin/fail | 3 + .../snaps/test-snapd-tools-core18/bin/head | 3 + .../lib/snaps/test-snapd-tools-core18/bin/sh | 13 + .../snaps/test-snapd-tools-core18/bin/success | 3 + .../test-snapd-tools-core18/meta/snap.yaml | 30 + .../snaps/test-snapd-tools-core22/bin/block | 14 + .../lib/snaps/test-snapd-tools-core22/bin/cat | 3 + .../lib/snaps/test-snapd-tools-core22/bin/cmd | 6 + .../snaps/test-snapd-tools-core22/bin/echo | 3 + .../lib/snaps/test-snapd-tools-core22/bin/env | 3 + .../snaps/test-snapd-tools-core22/bin/fail | 3 + .../snaps/test-snapd-tools-core22/bin/head | 3 + .../lib/snaps/test-snapd-tools-core22/bin/sh | 13 + .../snaps/test-snapd-tools-core22/bin/success | 3 + .../test-snapd-tools-core22/meta/snap.yaml | 30 + tests/lib/snaps/test-snapd-tools/bin/block | 14 + tests/lib/snaps/test-snapd-tools/bin/cat | 3 + tests/lib/snaps/test-snapd-tools/bin/cmd | 6 + tests/lib/snaps/test-snapd-tools/bin/echo | 3 + tests/lib/snaps/test-snapd-tools/bin/env | 3 + tests/lib/snaps/test-snapd-tools/bin/fail | 3 + tests/lib/snaps/test-snapd-tools/bin/head | 3 + tests/lib/snaps/test-snapd-tools/bin/sh | 13 + tests/lib/snaps/test-snapd-tools/bin/success | 3 + .../snaps/test-snapd-tools/meta/gui/icon.png | Bin 0 -> 3371 bytes .../lib/snaps/test-snapd-tools/meta/snap.yaml | 29 + .../test-snapd-user-service-sockets/bin/start | 53 + .../meta/snap.yaml | 15 + .../test-snapd-user-service-v2-bad/bin/bad | 4 + .../meta/snap.yaml | 8 + .../snaps/test-snapd-user-service/bin/start | 6 + .../test-snapd-user-service/meta/snap.yaml | 7 + .../test-snapd-user-timer-service/bin/loop | 10 + .../meta/snap.yaml | 29 + tests/lib/snaps/test-snapd-userns/bin/sh | 3 + .../snaps/test-snapd-userns/meta/snap.yaml | 8 + .../meta/hooks/configure | 3 + .../meta/snap.yaml | 10 + .../test-snapd-with-configure-core18/service | 4 + .../snapcraft.yaml | 10 + .../meta/hooks/configure | 2 + .../test-snapd-with-configure/meta/snap.yaml | 9 + .../snaps/test-snapd-with-configure/service | 4 + tests/lib/spread-funcs.sh | 14 + tests/lib/state.sh | 139 + tests/lib/successful_login.exp | 13 + tests/lib/systemd-escape/main.go | 52 + tests/lib/systems.sh | 41 + tests/lib/tinyproxy/tinyproxy.py | 136 + tests/lib/tools/MATCH | 8 + tests/lib/tools/README | 115 + tests/lib/tools/REBOOT | 8 + tests/lib/tools/boot-state | 201 + tests/lib/tools/cleanup-state | 99 + tests/lib/tools/fs-state | 164 + tests/lib/tools/journal-state | 162 + tests/lib/tools/lxd-state | 85 + tests/lib/tools/memory-observe-do | 84 + tests/lib/tools/mkimage-uc22 | 559 + tests/lib/tools/mountinfo.query | 1325 ++ tests/lib/tools/network-state | 67 + tests/lib/tools/not | 5 + tests/lib/tools/os.paths | 5 + tests/lib/tools/os.query | 5 + tests/lib/tools/query-mondodb | 120 + tests/lib/tools/quiet | 5 + tests/lib/tools/remote.exec | 5 + tests/lib/tools/remote.pull | 5 + tests/lib/tools/remote.push | 5 + tests/lib/tools/remote.refresh | 5 + tests/lib/tools/remote.retry | 5 + tests/lib/tools/remote.setup | 5 + tests/lib/tools/remote.wait-for | 5 + tests/lib/tools/report-mongodb | 118 + tests/lib/tools/retry | 5 + tests/lib/tools/setup_nested_hybrid_system.sh | 304 + tests/lib/tools/sha3-384 | 22 + tests/lib/tools/snapd-state | 147 + tests/lib/tools/snapd.tool | 54 + tests/lib/tools/snaps-state | 259 + tests/lib/tools/snaps.name | 5 + tests/lib/tools/store-state | 221 + tests/lib/tools/suite/fs-state/task.yaml | 80 + tests/lib/tools/suite/journal-state/task.yaml | 45 + .../lib/tools/suite/mountinfo.query/task.yaml | 5 + tests/lib/tools/suite/tests.env/task.yaml | 59 + .../lib/tools/suite/tests.invariant/task.yaml | 66 + .../suite/tests.session-support/task.yaml | 56 + tests/lib/tools/suite/tests.session/task.yaml | 90 + tests/lib/tools/suite/to-one-line/task.yaml | 10 + tests/lib/tools/suite/user-state/task.yaml | 34 + .../lib/tools/suite/version-compare/task.yaml | 44 + tests/lib/tools/tests.backup | 5 + tests/lib/tools/tests.cleanup | 5 + tests/lib/tools/tests.device-cgroup | 226 + tests/lib/tools/tests.env | 130 + tests/lib/tools/tests.invariant | 277 + tests/lib/tools/tests.nested | 465 + tests/lib/tools/tests.pkgs | 5 + tests/lib/tools/tests.pkgs.apt.sh | 5 + tests/lib/tools/tests.pkgs.dnf-yum.sh | 6 + tests/lib/tools/tests.pkgs.pacman.sh | 5 + tests/lib/tools/tests.pkgs.zypper.sh | 5 + tests/lib/tools/tests.session | 426 + tests/lib/tools/tests.systemd | 5 + tests/lib/tools/to-one-line | 27 + tests/lib/tools/user-state | 80 + tests/lib/tools/version-compare | 308 + tests/lib/tweak-gadget.py | 19 + tests/lib/uc16-reflash.sh | 69 + tests/lib/uc20-create-partitions/main.go | 112 + tests/lib/uc20-recovery.sh | 139 + tests/main/abort/task.yaml | 40 + tests/main/ack/alice.account | 19 + tests/main/ack/alice.account-key | 31 + tests/main/ack/bob.assertions | 51 + tests/main/ack/task.yaml | 36 + tests/main/alias/task.yaml | 56 + tests/main/api-get-systems-label/task.yaml | 36 + .../bin/apparmor_parser.fake | 41 + tests/main/apparmor-batch-reload/task.yaml | 105 + tests/main/appstream-id/task.yaml | 36 + .../test-snapd-appstreamid/bin/run | 3 + .../test-snapd-appstreamid/meta/snap.yaml | 14 + tests/main/apt-hooks/task.yaml | 80 + tests/main/aspects/task.yaml | 60 + tests/main/auth-errors/task.yaml | 32 + tests/main/auto-aliases/task.yaml | 37 + .../auto-refresh-gating-from-snap/task.yaml | 109 + .../meta/snap.yaml | 9 + .../test-snap-refresh-control-iface/proceed | 2 + tests/main/auto-refresh-gating/task.yaml | 186 + .../main/auto-refresh-pre-download/task.yaml | 136 + .../auto-refresh-private/expired_macaroons.sh | 13 + .../auto-refresh-private/successful_login.exp | 13 + tests/main/auto-refresh-private/task.yaml | 108 + tests/main/auto-refresh-retry/task.yaml | 67 + tests/main/auto-refresh/task.yaml | 72 + tests/main/bad-interfaces-warn/task.yaml | 23 + .../test-snap/meta/snap.yaml | 9 + tests/main/bad-meta-file-types/task.yaml | 72 + .../test-bad-file-types/meta/snap.yaml | 4 + tests/main/base-invalid-type/task.yaml | 11 + .../test-snapd-invalid-base/meta/snap.yaml | 4 + tests/main/base-migration/task.yaml | 118 + .../bin/sh | 3 + .../meta/snap.yaml | 7 + .../bin/sh | 3 + .../meta/snap.yaml | 6 + tests/main/base-none/task.yaml | 23 + .../test-snapd-base-none-invalid/bin/cmd | 2 + .../meta/snap.yaml | 8 + .../test-snapd-base-none/meta/snap.yaml | 4 + tests/main/base-policy/task.yaml | 55 + .../base-policy/test-snapd-sh-core/bin/sh | 3 + .../test-snapd-sh-core/meta/snap.yaml | 8 + .../base-policy/test-snapd-sh-other18/bin/sh | 3 + .../test-snapd-sh-other18/meta/snap.yaml | 8 + tests/main/base-snaps-refresh/task.yaml | 26 + tests/main/base-snaps/task.yaml | 48 + .../basic-target-socket-activation/task.yaml | 79 + tests/main/boot-state/task.yaml | 90 + tests/main/broken-seeding/task.yaml | 86 + tests/main/buildmode/task.yaml | 25 + tests/main/ca-certs-for-snaps/task.yaml | 26 + .../main/canonical-livepatch-14.04/task.yaml | 41 + tests/main/canonical-livepatch/task.yaml | 23 + tests/main/catalog-update/task.yaml | 39 + tests/main/cgroup-devices-v1/task.sh | 93 + tests/main/cgroup-devices-v1/task.yaml | 25 + .../test-snapd-service/bin/service | 6 + .../test-snapd-service/meta/snap.yaml | 7 + tests/main/cgroup-devices-v2/task.yaml | 177 + .../test-snapd-service/bin/service | 6 + .../test-snapd-service/bin/sh | 3 + .../test-snapd-service/meta/snap.yaml | 11 + tests/main/cgroup-freezer/task.yaml | 62 + tests/main/cgroup-tracking-failure/task.yaml | 251 + .../container-mgr-snap/bin/simple.sh | 3 + .../container-mgr-snap/meta/snap.yaml | 91 + tests/main/cgroup-tracking/task.yaml | 192 + .../test-snapd-tracking/bin/nap | 3 + .../test-snapd-tracking/bin/sh | 3 + .../test-snapd-tracking/meta/hooks/configure | 2 + .../test-snapd-tracking/meta/snap.yaml | 12 + tests/main/change-errors/task.yaml | 16 + tests/main/chattr/task.yaml | 24 + tests/main/chattr/toggle.go | 51 + .../task.yaml | 39 + tests/main/classic-confinement/task.yaml | 67 + .../main/classic-custom-device-reg/task.yaml | 89 + tests/main/classic-firstboot/task.yaml | 103 + .../classic-gadget-18/meta/gadget.yaml | 1 + .../meta/hooks/prepare-device | 2 + .../classic-gadget-18/meta/snap.yaml | 5 + .../classic-prepare-image-no-core/task.yaml | 93 + tests/main/classic-prepare-image/task.yaml | 93 + tests/main/classic-snapd-firstboot/task.yaml | 85 + tests/main/cloud-init/task.yaml | 83 + tests/main/cmdline/task.yaml | 13 + tests/main/cohorts/task.yaml | 33 + tests/main/command-chain/command-chain/chain1 | 6 + tests/main/command-chain/command-chain/chain2 | 6 + tests/main/command-chain/command-chain/chain3 | 5 + tests/main/command-chain/command-chain/chain4 | 5 + tests/main/command-chain/command-chain/hello | 3 + .../command-chain/meta/hooks/configure | 4 + .../command-chain/meta/snap.yaml | 17 + tests/main/command-chain/command-chain/run | 6 + tests/main/command-chain/task.yaml | 34 + tests/main/completion/abort.exp | 7 + tests/main/completion/ack.exp | 8 + tests/main/completion/alias.exp | 13 + tests/main/completion/buy.exp | 8 + tests/main/completion/change.exp | 7 + tests/main/completion/delete-key.exp | 6 + tests/main/completion/disable.exp | 6 + tests/main/completion/download.exp | 7 + tests/main/completion/enable.exp | 6 + tests/main/completion/export-key.exp | 6 + tests/main/completion/get.exp | 6 + tests/main/completion/info.exp | 11 + tests/main/completion/install.exp | 22 + tests/main/completion/key.exp0 | 17 + tests/main/completion/lib.exp0 | 1 + tests/main/completion/list.exp | 7 + tests/main/completion/refresh.exp | 6 + tests/main/completion/remove.exp | 6 + tests/main/completion/revert.exp | 6 + tests/main/completion/set.exp | 6 + tests/main/completion/sign-build.exp | 6 + tests/main/completion/sign.exp | 6 + tests/main/completion/task.yaml | 64 + tests/main/completion/toplevel.exp | 11 + tests/main/completion/try.exp | 6 + tests/main/completion/watch.exp | 7 + .../main/component/comp1/meta/component.yaml | 5 + .../component/snap-with-comps/meta/snap.yaml | 11 + tests/main/component/snap-with-comps/test | 4 + tests/main/component/task.yaml | 74 + tests/main/config-versions/task.yaml | 73 + .../task.yaml | 19 + .../meta/hooks/configure | 6 + .../meta/snap.yaml | 8 + tests/main/confinement-classic/task.yaml | 45 + tests/main/connect-undo/task.yaml | 44 + .../connect-undo/test-connect.v1/bin/consumer | 2 + .../test-connect.v1/meta/snap.yaml | 10 + .../connect-undo/test-connect.v2/bin/consumer | 2 + .../meta/hooks/connect-plug-network | 3 + .../test-connect.v2/meta/snap.yaml | 10 + .../task.yaml | 43 + .../test-snap-v1/meta/snap.yaml | 7 + .../test-snap-v2/meta/hooks/configure | 3 + .../test-snap-v2/meta/snap.yaml | 7 + tests/main/core-snap-not-test-test/task.yaml | 8 + .../shm-plug/bin/cmd | 2 + .../shm-plug/meta/snap.yaml | 23 + .../shm-slot/bin/cmd | 2 + .../shm-slot/meta/snap.yaml | 12 + .../task.yaml | 82 + tests/main/core-snap-refresh/task.yaml | 44 + tests/main/core18-configure-hook/task.yaml | 25 + tests/main/core18-with-hooks/task.yaml | 12 + tests/main/create-key/passphrase_mismatch.exp | 17 + tests/main/create-key/successful_default.exp | 48 + .../create-key/successful_non_default.exp | 48 + tests/main/create-key/task.yaml | 30 + tests/main/cwd/task.yaml | 61 + .../dbus-activation-name-conflict/task.yaml | 31 + .../bin/server.sh | 2 + .../meta/snap.yaml | 16 + .../dbus-activation-session-legacy/task.yaml | 48 + tests/main/dbus-activation-session/task.yaml | 56 + tests/main/dbus-activation-system/task.yaml | 36 + tests/main/deb-restart-behavior/task.yaml | 48 + tests/main/debs/task.yaml | 19 + tests/main/debug-confinement/task.yaml | 12 + tests/main/debug-migrate-home/task.yaml | 38 + tests/main/debug-paths/task.yaml | 13 + tests/main/debug-pprof/task.yaml | 23 + tests/main/debug-sandbox/task.yaml | 29 + tests/main/default-tracks/task.yaml | 39 + tests/main/degraded/task.yaml | 39 + .../main/desktop-portal-filechooser/task.yaml | 84 + tests/main/desktop-portal-open-file/editor.sh | 7 + tests/main/desktop-portal-open-file/task.yaml | 80 + tests/main/desktop-portal-open-uri/task.yaml | 69 + .../desktop-portal-open-uri/web-browser.sh | 5 + .../main/desktop-portal-screenshot/task.yaml | 70 + .../main/dirs-not-shared-with-host/task.yaml | 33 + tests/main/disable-autoconnect/task.yaml | 40 + tests/main/disconnect-undo/task.yaml | 24 + .../meta/hooks/disconnect-plug-network | 6 + .../test-disconnect/meta/snap.yaml | 6 + tests/main/disk-space-awareness/task.yaml | 111 + tests/main/docker-smoke/task.yaml | 50 + .../fake-document-portal.py | 60 + .../main/document-portal-activation/task.yaml | 107 + .../snapcraft-export-login.exp | 16 + tests/main/download-private/task.yaml | 31 + tests/main/download-timeout/task.yaml | 63 + .../drop-privs/runas-1/runas-verify-uidgid.go | 57 + .../runas-2/runas-verify-thread-locked.go | 79 + tests/main/drop-privs/runas-3/runas-errors.go | 72 + tests/main/drop-privs/task.yaml | 21 + tests/main/econnreset/task.yaml | 62 + tests/main/enable-disable/task.yaml | 47 + tests/main/exitcodes/task.yaml | 29 + tests/main/experimental-features/task.yaml | 27 + .../fake-netplan-service.py | 61 + .../io.netplan.Netplan.conf | 18 + tests/main/fake-netplan-apply/task.yaml | 181 + tests/main/fakestore-install/task.yaml | 34 + tests/main/fedora-base-smoke/task.yaml | 17 + tests/main/find-private/task.yaml | 51 + tests/main/generic-classic-reg/task.yaml | 30 + tests/main/generic-unregister/task.yaml | 62 + tests/main/health/task.yaml | 21 + tests/main/health/test-snapd-health/health | 3 + .../test-snapd-health/meta/hooks/check-health | 3 + .../test-snapd-health/meta/hooks/configure | 9 + .../health/test-snapd-health/meta/snap.yaml | 5 + tests/main/help/task.yaml | 22 + tests/main/hidden-snap-dir/task.yaml | 369 + tests/main/high-user-handling/task.yaml | 20 + tests/main/high-user-handling/test.go | 16 + tests/main/hook-permissions/task.yaml | 31 + .../test-snap/meta/hooks/post-refresh | 13 + .../hook-permissions/test-snap/meta/snap.yaml | 5 + tests/main/i18n/task.yaml | 42 + tests/main/install-cache/task.yaml | 16 + tests/main/install-closed-channel/task.yaml | 9 + tests/main/install-errors/task.yaml | 83 + tests/main/install-hook-misbehaving/task.yaml | 11 + .../main/install-many-transactional/task.yaml | 23 + tests/main/install-refresh-private/task.yaml | 52 + .../install-refresh-remove-hooks/task.yaml | 90 + tests/main/install-remove-multi/task.yaml | 37 + tests/main/install-sideload-epochs/task.yaml | 27 + .../test-snapd-epoch-1/meta/snap.yaml | 5 + .../test-snapd-epoch-2/meta/snap.yaml | 5 + tests/main/install-sideload/task.yaml | 101 + tests/main/install-store-laaaarge/task.yaml | 17 + tests/main/install-store/task.yaml | 47 + .../bin/chpasswd | 3 + .../bin/deluser | 3 + .../bin/useradd | 3 + .../meta/snap.yaml | 16 + .../bin/chpasswd | 3 + .../bin/deluser | 3 + .../bin/useradd | 3 + .../meta/snap.yaml | 16 + .../bin/chpasswd | 3 + .../bin/deluser | 3 + .../bin/useradd | 3 + .../meta/snap.yaml | 16 + .../account-control-consumer/bin/chpasswd | 3 + .../account-control-consumer/bin/deluser | 3 + .../account-control-consumer/bin/useradd | 3 + .../account-control-consumer/meta/snap.yaml | 15 + .../main/interfaces-account-control/task.yaml | 46 + .../interfaces-accounts-service/task.yaml | 53 + tests/main/interfaces-adb-support/task.yaml | 26 + .../test-snapd-adb-support/bin/sh | 3 + .../test-snapd-adb-support/meta/snap.yaml | 7 + tests/main/interfaces-alsa/task.yaml | 95 + .../interfaces-appstream-metadata/task.yaml | 58 + .../task.yaml | 153 + .../task.yaml | 66 + tests/main/interfaces-avahi-observe/task.yaml | 51 + .../interfaces-bluetooth-control/task.yaml | 62 + tests/main/interfaces-bluez/task.yaml | 15 + .../task.yaml | 78 + .../test-snapd-sh/bin/sh | 3 + .../test-snapd-sh/meta/snap.yaml | 8 + .../browser-support-consumer/bin/cmd | 3 + .../meta/snap.yaml.in | 10 + .../main/interfaces-browser-support/task.yaml | 177 + .../interfaces-calendar-service/task.yaml | 80 + .../interfaces-classic-content-slot/task.yaml | 70 + tests/main/interfaces-cli/task.yaml | 26 + .../interfaces-contacts-service/task.yaml | 80 + .../interfaces-content-circular/task.yaml | 18 + .../bin/content-plug | 6 + .../import/.placeholder | 0 .../meta/snap.yaml | 18 + .../bin/content-plug | 6 + .../import/.placeholder | 0 .../meta/snap.yaml | 18 + .../task.yaml | 23 + .../task.yaml | 34 + .../bin/content-plug | 11 + .../import/.placeholder | 0 .../meta/snap.yaml | 11 + .../meta/snap.yaml | 7 + .../shared-content | 3 + tests/main/interfaces-content-mimic/task.yaml | 63 + .../test-snapd-content-mimic-plug/bin/sh | 3 + .../dir/stuff-in-dir | 0 .../test-snapd-content-mimic-plug/file | 1 + .../meta/snap.yaml | 17 + .../test-snapd-content-mimic-plug/symlink | 1 + .../symlink-target | 0 .../test-snapd-content-mimic-slot/bin/sh | 3 + .../meta/snap.yaml | 11 + .../source/canary | 1 + .../task.yaml | 90 + tests/main/interfaces-content/task.yaml | 63 + .../cups-consumer/meta/snap.yaml | 7 + .../cups-provider/meta/snap.yaml | 7 + .../task.yaml | 91 + tests/main/interfaces-cups-control/task.yaml | 75 + tests/main/interfaces-cups/task.yaml | 119 + .../device-app/bin/cmd | 2 + .../device-app/meta/snap.yaml | 18 + .../task.yaml | 94 + tests/main/interfaces-daemon-notify/task.yaml | 58 + tests/main/interfaces-dbus/task.yaml | 65 + .../task.yaml | 58 + .../desktop-provider/meta/snap.yaml | 4 + .../task.yaml | 59 + .../interfaces-desktop-host-fonts/task.yaml | 76 + .../main/interfaces-desktop-launch/task.yaml | 53 + .../test-app/bin/app.sh | 12 + .../test-app/meta/gui/test-app.desktop | 5 + .../test-app/meta/snap.yaml | 9 + .../test-launcher/bin/launcher.sh | 6 + .../test-launcher/meta/snap.yaml | 9 + tests/main/interfaces-desktop/task.yaml | 59 + .../main/interfaces-device-buttons/task.yaml | 48 + .../test-snapd-sh/bin/sh | 3 + .../test-snapd-sh/meta/snap.yaml | 8 + tests/main/interfaces-dvb/task.yaml | 42 + .../main/interfaces-dvb/test-snapd-sh/bin/sh | 3 + .../test-snapd-sh/meta/snap.yaml | 8 + .../firewall-control-consumer/bin/consumer | 3 + .../firewall-control-consumer/meta/snap.yaml | 12 + .../interfaces-firewall-control/task.yaml | 78 + tests/main/interfaces-framebuffer/task.yaml | 49 + .../test-snapd-framebuffer/bin/read | 3 + .../test-snapd-framebuffer/bin/write | 3 + .../test-snapd-framebuffer/meta/snap.yaml | 12 + tests/main/interfaces-fuse-support/task.yaml | 107 + tests/main/interfaces-fwupd-classic/task.yaml | 46 + .../test-snapd-fwupd/bin/get-version.sh | 6 + .../test-snapd-fwupd/meta/snap.yaml | 9 + tests/main/interfaces-gpg-keys/task.yaml | 60 + .../interfaces-gpg-keys/test-snapd-sh/bin/sh | 3 + .../test-snapd-sh/meta/snap.yaml | 8 + .../main/interfaces-gpg-public-keys/task.yaml | 60 + .../test-snapd-sh/bin/sh | 3 + .../test-snapd-sh/meta/snap.yaml | 8 + .../interfaces-gpio-memory-control/task.yaml | 40 + .../hardware-observe-consumer/bin/consumer | 5 + .../hardware-observe-consumer/meta/snap.yaml | 9 + .../interfaces-hardware-observe/task.yaml | 38 + .../task.yaml | 50 + .../bin/check | 5 + .../meta/snap.yaml | 10 + .../task.yaml | 50 + .../bin/check | 5 + .../meta/snap.yaml | 10 + tests/main/interfaces-home/task.yaml | 119 + .../task.yaml | 11 + .../meta/hooks/connect-plug-consumer0 | 3 + .../meta/hooks/prepare-plug-consumer0 | 3 + .../test-snap/meta/snap.yaml | 6 + .../meta/hooks/configure | 3 + .../meta/hooks/connect-plug-consumer | 68 + .../meta/hooks/disconnect-plug-consumer | 61 + .../meta/hooks/prepare-plug-consumer | 41 + .../meta/hooks/unprepare-plug-consumer | 5 + .../basic-iface-hooks-consumer/meta/snap.yaml | 7 + .../meta/hooks/configure | 1 + .../meta/hooks/connect-slot-producer | 52 + .../meta/hooks/disconnect-slot-producer | 52 + .../meta/hooks/prepare-slot-producer | 35 + .../meta/hooks/unprepare-slot-producer | 3 + .../basic-iface-hooks-producer/meta/snap.yaml | 8 + tests/main/interfaces-hooks/task.yaml | 97 + .../interfaces-hostname-control/task.yaml | 56 + .../test-snapd-sh/bin/sh | 3 + .../test-snapd-sh/meta/snap.yaml | 8 + tests/main/interfaces-input/task.yaml | 64 + .../interfaces-input/test-snapd-sh/bin/sh | 3 + .../test-snapd-sh/meta/snap.yaml | 8 + tests/main/interfaces-joystick/task.yaml | 58 + .../interfaces-joystick/test-snapd-sh/bin/sh | 3 + .../test-snapd-sh/meta/snap.yaml | 8 + .../interfaces-juju-client-observe/task.yaml | 42 + .../test-snapd-sh/bin/sh | 3 + .../test-snapd-sh/meta/snap.yaml | 8 + .../task.yaml | 129 + .../interfaces-kernel-module-load/task.yaml | 97 + .../test-snapd-kernel-module-load/bin/cmd | 2 + .../meta/snap.yaml | 33 + tests/main/interfaces-kvm/task.yaml | 49 + tests/main/interfaces-libvirt/task.yaml | 87 + .../locale-control-consumer/bin/get | 14 + .../locale-control-consumer/bin/set | 23 + .../locale-control-consumer/meta/snap.yaml | 12 + .../main/interfaces-locale-control/task.yaml | 96 + .../interfaces-location-control/task.yaml | 67 + tests/main/interfaces-log-observe/task.yaml | 46 + .../interfaces-many-core-provided/task.yaml | 153 + .../interfaces-many-snap-provided/task.yaml | 84 + .../bin/run | 10 + .../meta/snap.yaml | 102 + .../bin/run | 10 + .../meta/snap.yaml | 164 + .../interfaces-microstack-support/task.yaml | 30 + .../test-snapd-sh/bin/inspect | 4 + .../test-snapd-sh/meta/snap.yaml | 9 + tests/main/interfaces-mount-control/task.yaml | 163 + .../test-mount-control-invalid/bin/cmd | 3 + .../test-mount-control-invalid/meta/snap.yaml | 16 + .../mount-observe-consumer/bin/consumer | 12 + .../mount-observe-consumer/meta/snap.yaml | 9 + tests/main/interfaces-mount-observe/task.yaml | 51 + tests/main/interfaces-netlink-audit/task.yaml | 36 + .../test-snapd-netlink-audit/bin/bind | 15 + .../test-snapd-netlink-audit/meta/snap.yaml | 9 + .../interfaces-netlink-connector/task.yaml | 33 + .../test-snapd-netlink-connector/bin/bind | 15 + .../meta/snap.yaml | 9 + .../network-bind-consumer/bin/consumer | 22 + .../network-bind-consumer/meta/snap.yaml | 10 + tests/main/interfaces-network-bind/task.yaml | 57 + .../task.yaml | 49 + .../task.yaml | 42 + .../test-snapd-tuntap/bin/tuntap.py | 79 + .../test-snapd-tuntap/meta/snap.yaml | 10 + .../network-control-consumer/bin/cmd | 6 + .../network-control-consumer/meta/snap.yaml | 10 + .../main/interfaces-network-control/task.yaml | 160 + .../main/interfaces-network-manager/task.yaml | 67 + .../network-observe-consumer/bin/consumer | 10 + .../network-observe-consumer/meta/snap.yaml | 9 + .../main/interfaces-network-observe/task.yaml | 54 + .../task.yaml | 50 + .../test-snapd-sh/bin/sh | 3 + .../test-snapd-sh/meta/snap.yaml | 8 + .../task.yaml | 45 + .../test-snapd-sh/bin/sh | 3 + .../test-snapd-sh/meta/snap.yaml | 8 + .../fake-portal-network-monitor.py | 90 + .../task.yaml | 50 + .../bin/get-connectivity.sh | 5 + .../meta/snap.yaml | 9 + tests/main/interfaces-network/task.yaml | 57 + .../task.yaml | 46 + .../bin/check | 6 + .../meta/snap.yaml | 9 + .../gl-core16/bin/run | 3 + .../gl-core16/meta/snap.yaml | 9 + .../gl-core20/bin/run | 3 + .../gl-core20/meta/snap.yaml | 10 + tests/main/interfaces-opengl-nvidia/task.yaml | 171 + .../interfaces-packagekit-control/task.yaml | 31 + .../task.yaml | 38 + .../main/interfaces-personal-files/task.yaml | 185 + .../test-snapd-sh/bin/sh | 3 + .../test-snapd-sh/meta/snap.yaml | 13 + .../task.yaml | 46 + .../bin/head-mem | 3 + .../meta/snap.yaml | 9 + tests/main/interfaces-polkit/task.yaml | 56 + .../test-snapd-pk-service/bin/check-pid.sh | 17 + .../meta/polkit/polkit.foo.policy | 26 + .../test-snapd-pk-service/meta/snap.yaml | 10 + .../process-control-consumer/bin/signal | 6 + .../process-control-consumer/meta/snap.yaml | 9 + .../main/interfaces-process-control/task.yaml | 50 + tests/main/interfaces-pulseaudio/task.yaml | 127 + tests/main/interfaces-raw-usb/task.yaml | 47 + .../interfaces-raw-usb/test-snapd-sh/bin/sh | 3 + .../test-snapd-sh/meta/snap.yaml | 8 + .../main/interfaces-removable-media/task.yaml | 84 + .../test-snapd-sh/bin/sh | 3 + .../test-snapd-sh/meta/snap.yaml | 8 + tests/main/interfaces-ros-opt-data/task.yaml | 95 + .../test-snapd-sh/bin/sh | 3 + .../test-snapd-sh/meta/snap.yaml | 12 + .../shm-private/bin/cmd | 2 + .../shm-private/meta/snap.yaml | 10 + .../task.yaml | 52 + .../interfaces-shared-memory/shm-plug/bin/cmd | 2 + .../shm-plug/meta/snap.yaml | 13 + .../interfaces-shared-memory/shm-slot/bin/cmd | 2 + .../shm-slot/meta/snap.yaml | 17 + tests/main/interfaces-shared-memory/task.yaml | 94 + .../bin/consumer | 5 + .../meta/snap.yaml | 9 + .../task.yaml | 37 + .../api-client/bin/api-client.py | 40 + .../api-client/meta/snap.yaml | 8 + .../interfaces-snap-refresh-observe/task.yaml | 47 + .../task.yaml | 126 + tests/main/interfaces-snapd-control/task.yaml | 42 + tests/main/interfaces-ssh-keys/task.yaml | 61 + .../interfaces-ssh-keys/test-snapd-sh/bin/sh | 3 + .../test-snapd-sh/meta/snap.yaml | 8 + .../main/interfaces-ssh-public-keys/task.yaml | 53 + .../test-snapd-sh/bin/sh | 3 + .../test-snapd-sh/meta/snap.yaml | 8 + tests/main/interfaces-system-dbus/task.yaml | 47 + tests/main/interfaces-system-files/task.yaml | 81 + .../test-snapd-sh/bin/sh | 3 + .../test-snapd-sh/meta/snap.yaml | 13 + .../main/interfaces-system-observe/task.yaml | 126 + .../testsnap/bin/cmd | 2 + .../testsnap/meta/snap.yaml | 11 + .../interfaces-system-packages-doc/task.yaml | 55 + .../test-snapd-app/bin/sh | 3 + .../test-snapd-app/meta/snap.yaml | 7 + tests/main/interfaces-time-control/task.yaml | 83 + .../interfaces-timeserver-control/task.yaml | 79 + .../interfaces-timezone-control/task.yaml | 65 + .../modem-manager-consumer/bin/consumer | 3 + .../modem-manager-consumer/meta/snap.yaml | 9 + tests/main/interfaces-udev/task.yaml | 27 + tests/main/interfaces-udisks2/task.yaml | 62 + tests/main/interfaces-uhid/task.yaml | 42 + .../main/interfaces-upower-observe/task.yaml | 58 + tests/main/interfaces-userns/task.yaml | 119 + tests/main/interfaces-userns/unshare.c | 67 + tests/main/interfaces-wayland/task.yaml | 44 + .../main/interfaces-x11-unix-socket/task.yaml | 52 + .../x11-client/bin/rm.sh | 2 + .../x11-client/bin/xclient.sh | 2 + .../x11-client/meta/snap.yaml | 12 + .../x11-server/bin/xserver.sh | 7 + .../x11-server/meta/snap.yaml | 10 + tests/main/known-remote/task.yaml | 9 + tests/main/known/task.yaml | 16 + tests/main/layout-change/task.yaml | 44 + tests/main/layout-remove/task.yaml | 36 + .../layout-remove/test-layout-v1/bin/test | 3 + .../test-layout-v1/meta/snap.yaml | 19 + .../layout-remove/test-layout-v2/bin/test | 3 + .../test-layout-v2/meta/snap.yaml | 15 + .../layout-symlink-bind-revert/app.v1/bin/app | 2 + .../app.v1/meta/snap.yaml | 18 + .../app.v1/runtime/.keep | 0 .../layout-symlink-bind-revert/app.v2/bin/app | 2 + .../app.v2/meta/snap.yaml | 18 + .../app.v2/runtime/.keep | 0 .../runtime/meta/snap.yaml | 7 + .../runtime/opt/runtime/runner | 2 + .../main/layout-symlink-bind-revert/task.yaml | 35 + tests/main/layout/task.yaml | 98 + tests/main/listing/task.yaml | 94 + tests/main/local-install-w-metadata/digest.go | 17 + tests/main/local-install-w-metadata/task.yaml | 23 + tests/main/login/missing_email_error.exp | 9 + tests/main/login/task.yaml | 33 + tests/main/login/unsuccessful_login.exp | 14 + tests/main/lxd-mount-units/task.yaml | 81 + tests/main/lxd-no-fuse/task.yaml | 40 + .../lxd-postrm-purge/prep-snapd-in-lxd.sh | 60 + tests/main/lxd-postrm-purge/task.yaml | 89 + tests/main/lxd-services-smoke/task.yaml | 47 + tests/main/lxd-snapfuse/task.yaml | 70 + tests/main/lxd-try/task.yaml | 45 + tests/main/lxd/prep-snapd-in-lxd.sh | 63 + tests/main/lxd/task.yaml | 204 + tests/main/manpages/task.yaml | 31 + tests/main/media-sharing/task.yaml | 28 + tests/main/microk8s-smoke/task.yaml | 88 + tests/main/mkimage-uc22/task.yaml | 65 + .../google.ubuntu-16.04-64/HOST.expected.txt | 40 + .../PER-SNAP-16.expected.txt | 76 + .../PER-SNAP-18.expected.txt | 77 + .../PER-SNAP-C7.expected.txt | 40 + .../PER-USER-16.expected.txt | 76 + .../PER-USER-18.expected.txt | 77 + .../PER-USER-C7.expected.txt | 40 + .../google.ubuntu-18.04-64/HOST.expected.txt | 41 + .../PER-SNAP-16.expected.txt | 89 + .../PER-SNAP-18.expected.txt | 91 + .../PER-SNAP-C7.expected.txt | 41 + .../PER-USER-16.expected.txt | 89 + .../PER-USER-18.expected.txt | 91 + .../PER-USER-C7.expected.txt | 41 + .../HOST.expected.txt | 120 + .../PER-SNAP-16.expected.txt | 211 + .../PER-SNAP-18.expected.txt | 198 + .../PER-USER-16.expected.txt | 211 + .../PER-USER-18.expected.txt | 198 + .../HOST.expected.txt | 98 + .../PER-SNAP-16.expected.txt | 153 + .../PER-SNAP-18.expected.txt | 153 + .../PER-USER-16.expected.txt | 153 + .../PER-USER-18.expected.txt | 153 + .../HOST.expected.txt | 104 + .../PER-SNAP-16.expected.txt | 160 + .../PER-SNAP-18.expected.txt | 160 + .../PER-USER-16.expected.txt | 160 + .../PER-USER-18.expected.txt | 160 + tests/main/mount-ns/task.yaml | 231 + .../bin/mountinfo | 2 + .../meta/snap.yaml | 8 + .../test-snapd-mountinfo-core16/bin/mountinfo | 2 + .../meta/snap.yaml | 17 + .../test-snapd-mountinfo-core18/bin/mountinfo | 2 + .../meta/snap.yaml | 14 + tests/main/mount-protocol-error/task.yaml | 32 + .../task.yaml | 60 + .../bin.sh | 3 + .../meta/snap.yaml | 23 + tests/main/network-retry/task.yaml | 51 + tests/main/nfs-support/task.yaml | 281 + tests/main/nfs-support/test-snapd-sh/bin/sh | 3 + .../nfs-support/test-snapd-sh/meta/snap.yaml | 8 + tests/main/no-snap-repair-classic/task.yaml | 14 + tests/main/non-home/task.yaml | 36 + tests/main/op-install-failed-undone/task.yaml | 51 + tests/main/op-remove-retry/task.yaml | 46 + tests/main/op-remove/task.yaml | 38 + tests/main/parallel-install-aliases/task.yaml | 75 + .../parallel-install-auto-aliases/task.yaml | 133 + tests/main/parallel-install-basic/task.yaml | 89 + tests/main/parallel-install-classic/task.yaml | 82 + .../task.yaml | 33 + .../parallel-install-common-dirs/task.yaml | 81 + tests/main/parallel-install-desktop/task.yaml | 40 + .../task.yaml | 74 + .../parallel-install-interfaces/task.yaml | 68 + tests/main/parallel-install-layout/task.yaml | 84 + tests/main/parallel-install-local/task.yaml | 43 + .../parallel-install-remove-after/task.yaml | 69 + .../main/parallel-install-services/task.yaml | 58 + .../parallel-install-snap-icons/task.yaml | 34 + tests/main/parallel-install-store/task.yaml | 31 + tests/main/postrm-purge/task.yaml | 138 + tests/main/prefer/task.yaml | 34 + tests/main/prepare-image-check-arch/task.yaml | 77 + tests/main/prepare-image-classic/task.yaml | 70 + tests/main/prepare-image-gating/task.yaml | 37 + .../main/prepare-image-grub-core18/task.yaml | 47 + tests/main/prepare-image-grub/task.yaml | 82 + .../main/prepare-image-reproducible/task.yaml | 153 + tests/main/prepare-image-uboot-uc20/task.yaml | 72 + tests/main/prepare-image-uboot/task.yaml | 77 + .../asserts/core-20.json | 50 + .../asserts/vs1.json | 21 + .../asserts/vs2.json | 15 + .../prepare-image-validation-sets/task.yaml | 158 + .../preseed-core20/systemusers-snap/foo.sh | 1 + .../systemusers-snap/meta/snap.yaml | 12 + tests/main/preseed-core20/task.yaml | 184 + tests/main/preseed-lxd/metadata.yaml | 7 + tests/main/preseed-lxd/preseed-prepare.sh | 6 + tests/main/preseed-lxd/task.yaml | 158 + tests/main/preseed-reset/task.yaml | 76 + tests/main/preseed/task.yaml | 147 + tests/main/proxy-no-core/task.yaml | 49 + tests/main/proxy/task.yaml | 31 + .../quota-groups-systemd-accounting/task.yaml | 60 + tests/main/refresh-all-undo/task.yaml | 77 + tests/main/refresh-all/task.yaml | 62 + tests/main/refresh-amend/task.yaml | 25 + .../refresh-app-awareness-notify/task.yaml | 36 + tests/main/refresh-app-awareness/task.yaml | 102 + .../test-snapd-refresh.v1/bin/sh | 3 + .../test-snapd-refresh.v1/bin/version | 2 + .../test-snapd-refresh.v1/meta/snap.yaml.in | 8 + .../test-snapd-refresh.v2/bin/version | 2 + .../test-snapd-refresh.v2/meta/snap.yaml.in | 9 + tests/main/refresh-classic/task.yaml | 36 + tests/main/refresh-delta-from-core/task.yaml | 27 + tests/main/refresh-delta/task.yaml | 25 + tests/main/refresh-devmode/task.yaml | 76 + tests/main/refresh-hold/task.yaml | 20 + .../refresh-many-transactional-undo/task.yaml | 74 + .../main/refresh-many-transactional/task.yaml | 56 + .../refresh-mode-ignore-running/task.yaml | 34 + .../test-snapd-refresh/bin/sh | 2 + .../test-snapd-refresh/meta/snap.yaml | 10 + tests/main/refresh-undo/task.yaml | 48 + tests/main/refresh-with-epoch-bump/task.yaml | 31 + tests/main/refresh/task.yaml | 168 + .../regression-home-snap-root-owned/task.yaml | 39 + .../simplesnap.v1/meta/snap.yaml | 5 + .../simplesnap.v2/meta/snap.yaml | 4 + tests/main/remove-auto-connections/task.yaml | 46 + tests/main/remove-core/task.yaml | 32 + tests/main/remove-errors/task.yaml | 20 + tests/main/retry-network/detect-retry.go | 22 + tests/main/retry-network/task.yaml | 53 + tests/main/retryable-error/task.yaml | 31 + .../meta/hooks/configure | 3 + .../test-snapd-sleep-install/meta/snap.yaml | 3 + tests/main/revert-devmode/task.yaml | 85 + tests/main/revert-sideload/task.yaml | 17 + tests/main/revert/task.yaml | 99 + tests/main/searching/task.yaml | 83 + tests/main/seccomp-statx/task.yaml | 17 + .../test-snapd-statx/bin/statx.py | 100 + .../test-snapd-statx/meta/snap.yaml | 7 + tests/main/security-apparmor/task.yaml | 20 + .../security-dev-input-event-denied/task.yaml | 96 + .../test-snapd-event/bin/read-evdev-device | 29 + .../test-snapd-event/meta/snap.yaml | 12 + .../security-device-cgroups-classic/task.yaml | 35 + .../test-classic-cgroup/bin/read-fb | 3 + .../test-classic-cgroup/bin/read-kmsg | 3 + .../test-classic-cgroup/meta/snap.yaml | 12 + .../security-device-cgroups-devmode/task.yaml | 50 + .../security-device-cgroups-helper/task.yaml | 140 + .../test-strict-cgroup-helper/bin/sh | 3 + .../test-strict-cgroup-helper/meta/snap.yaml | 8 + .../task.yaml | 53 + .../task.yaml | 77 + .../container-mgr-snap/bin/sh | 3 + .../container-mgr-snap/meta/snap.yaml | 11 + .../task.yaml | 113 + .../task.yaml | 78 + .../task.yaml | 93 + .../test-strict-cgroup/bin/sh | 3 + .../test-strict-cgroup/meta/snap.yaml | 8 + .../security-device-cgroups-strict/task.yaml | 43 + .../test-strict-cgroup/bin/read-dev | 4 + .../test-strict-cgroup/meta/snap.yaml | 11 + tests/main/security-device-cgroups/task.yaml | 162 + tests/main/security-devpts/task.yaml | 29 + .../test-snapd-devpts/bin/openpty | 18 + .../test-snapd-devpts/bin/useptmx | 20 + .../test-snapd-devpts/meta/snap.yaml | 9 + tests/main/security-private-tmp/task.yaml | 52 + .../main/security-private-tmp/tmp-create.exp | 20 + tests/main/security-profiles/task.yaml | 32 + tests/main/security-seccomp/task.yaml | 108 + tests/main/security-setuid-root/task.yaml | 50 + .../security-udev-input-subsystem/task.yaml | 86 + .../bin/read-evdev-kbd | 24 + .../meta/snap.yaml | 24 + .../selinux-classic-confinement/task.yaml | 45 + .../bin/service | 8 + .../meta/hooks/configure | 3 + .../meta/hooks/install | 3 + .../meta/snap.yaml | 9 + tests/main/selinux-clean/task.yaml | 116 + tests/main/selinux-data-context/task.yaml | 82 + .../test-snapd-service-writer/bin/start | 21 + .../meta/hooks/configure | 16 + .../test-snapd-service-writer/meta/snap.yaml | 10 + tests/main/selinux-lxd/task.yaml | 65 + tests/main/selinux-snap-restorecon/task.yaml | 58 + tests/main/server-snap/task.yaml | 36 + .../services-after-before-install/task.yaml | 74 + tests/main/services-after-before/task.yaml | 66 + .../services-disable-install-hook/task.yaml | 9 + .../bin/forking.sh | 3 + .../bin/simple.sh | 3 + .../meta/hooks/install | 6 + .../meta/snap.yaml | 9 + .../services-disable-refresh-hook/task.yaml | 21 + .../bin/forking.sh | 3 + .../bin/simple.sh | 3 + .../meta/hooks/post-refresh | 6 + .../meta/snap.yaml | 9 + .../services-disabled-kept-happy/task.yaml | 364 + .../services-disabled-kept-unhappy/task.yaml | 269 + .../task.yaml | 20 + .../bin/svc.sh | 5 + .../meta/hooks/install.in | 11 + .../meta/snap.yaml | 9 + .../services-multi-service-failing/task.yaml | 9 + .../test-snapd-multi-service/bin/start | 6 + .../test-snapd-multi-service/meta/snap.yaml | 9 + tests/main/services-refresh-mode/task.yaml | 31 + tests/main/services-restart/task.yaml | 71 + tests/main/services-snapctl/task.yaml | 70 + .../main/services-socket-activation/task.yaml | 68 + tests/main/services-start-timeout/task.yaml | 21 + .../forking.sh | 6 + .../meta/snap.yaml | 7 + .../main/services-stop-mode-sigkill/task.yaml | 44 + tests/main/services-stop-mode/task.yaml | 65 + tests/main/services-stop-timeout/task.yaml | 33 + .../forking.sh | 11 + .../meta/snap.yaml | 8 + .../test-snapd-service-stop-timeout/staaap.sh | 5 + tests/main/services-stress/task.yaml | 36 + tests/main/services-timer/task.yaml | 50 + tests/main/services-user/task.yaml | 195 + .../test-snapd-user-service/bin/start | 8 + .../test-snapd-user-service/meta/snap.yaml | 17 + tests/main/services-watchdog/task.yaml | 57 + .../test-snapd-service-watchdog/bin/direct | 58 + .../meta/snap.yaml | 15 + tests/main/set-proxy-store/task.yaml | 94 + tests/main/snap-advise-command/task.yaml | 89 + tests/main/snap-cli-no-managers/task.yaml | 23 + .../has-sys-admin.c | 52 + .../snap-confine-drops-sys-admin/task.yaml | 68 + tests/main/snap-confine-from-core/task.yaml | 65 + tests/main/snap-confine-privs/task.yaml | 78 + tests/main/snap-confine-privs/uids-and-gids.c | 40 + tests/main/snap-confine-tmp-mount/task.yaml | 51 + .../task.yaml | 69 + .../test-snapd-app/bin/sh | 3 + .../test-snapd-app/meta/snap.yaml | 9 + .../snap-confine-unexpected-path/task.yaml | 35 + tests/main/snap-confine/task.yaml | 48 + tests/main/snap-connect/task.yaml | 69 + tests/main/snap-connections/task.yaml | 89 + .../test-snap.v1/meta/snap.yaml | 7 + .../test-snap.v2/meta/snap.yaml | 6 + tests/main/snap-connectivity-check/task.yaml | 5 + .../snap-debug-get-base-declaration/task.yaml | 14 + tests/main/snap-debug-stacktrace/task.yaml | 8 + tests/main/snap-debug-state/task.yaml | 37 + tests/main/snap-debug-timings/task.yaml | 8 + tests/main/snap-discard-ns/mount.py | 77 + tests/main/snap-discard-ns/mount.sh | 9 + tests/main/snap-discard-ns/task.yaml | 48 + tests/main/snap-disconnect/task.yaml | 94 + .../test-snap-consumer.v1/meta/snap.yaml | 7 + .../test-snap-consumer.v2/meta/snap.yaml | 5 + .../test-snap-producer/meta/snap.yaml | 8 + tests/main/snap-download/task.yaml | 53 + tests/main/snap-env/task.yaml | 89 + tests/main/snap-get/task.yaml | 124 + tests/main/snap-handle-link/task.yaml | 41 + tests/main/snap-icons/task.yaml | 26 + tests/main/snap-info/check.py | 206 + tests/main/snap-info/task.yaml | 57 + .../snap-interface-network-core.yaml | 6 + .../snap-interface-network-snapd.yaml | 6 + tests/main/snap-interface/task.yaml | 16 + tests/main/snap-logs-journal/task.yaml | 60 + tests/main/snap-logs/task.yaml | 30 + tests/main/snap-mgmt/task.yaml | 156 + tests/main/snap-model/task.yaml | 41 + tests/main/snap-network-errors/task.yaml | 41 + .../setup_mount_namespace.sh | 72 + tests/main/snap-ns-forward-compat/task.yaml | 49 + .../snap-ns-forward-compat/testsnap/bin/cmd | 2 + .../testsnap/meta/snap.yaml | 14 + tests/main/snap-pack-integrity/task.yaml | 40 + tests/main/snap-pack/task.yaml | 37 + tests/main/snap-quota-cpu/task.yaml | 141 + tests/main/snap-quota-install/task.yaml | 76 + tests/main/snap-quota-journal/task.yaml | 117 + tests/main/snap-quota-memory/task.yaml | 202 + tests/main/snap-quota-services/task.yaml | 73 + tests/main/snap-quota-thread/task.yaml | 120 + tests/main/snap-quota/task.yaml | 87 + tests/main/snap-readme/task.yaml | 12 + .../refresh-enforce-set.yaml | 14 + tests/main/snap-refresh-enforce/task.yaml | 45 + tests/main/snap-refresh-hold/task.yaml | 89 + tests/main/snap-remove-not-mounted/task.yaml | 14 + tests/main/snap-routine-file-access/task.yaml | 103 + .../test-snapd-file-access/bin/sh | 3 + .../test-snapd-file-access/meta/snap.yaml | 10 + tests/main/snap-routine-portal-info/task.yaml | 32 + tests/main/snap-run-alias/task.yaml | 37 + tests/main/snap-run-devmode-classic/task.yaml | 199 + tests/main/snap-run-gdbserver/task.yaml | 41 + tests/main/snap-run-hook/task.yaml | 52 + .../api-client/bin/api-client.py | 40 + .../api-client/meta/snap.yaml | 8 + tests/main/snap-run-inhibition-flow/task.yaml | 56 + tests/main/snap-run-symlink-error/task.yaml | 31 + tests/main/snap-run-symlink/task.yaml | 26 + .../main/snap-run-userdata-current/task.yaml | 39 + tests/main/snap-run/basic-run/bin/echo | 3 + tests/main/snap-run/basic-run/meta/snap.yaml | 6 + tests/main/snap-run/task.yaml | 105 + .../task.yaml | 40 + .../test-tioclinux.c | 36 + .../test-tiocsti.c | 30 + tests/main/snap-seccomp-syscalls/listcalls.go | 13 + tests/main/snap-seccomp-syscalls/task.yaml | 35 + tests/main/snap-seccomp/task.yaml | 152 + .../svc.v1/meta/hooks/install | 5 + .../svc.v1/meta/snap.yaml | 19 + .../snap-service-install-mode/svc.v1/sleep | 5 + .../svc.v2/meta/snap.yaml | 14 + .../snap-service-install-mode/svc.v2/sleep | 5 + .../main/snap-service-install-mode/task.yaml | 59 + tests/main/snap-service/task.yaml | 25 + tests/main/snap-services/task.yaml | 52 + .../task.yaml | 71 + .../task.yaml | 61 + .../task.yaml | 47 + tests/main/snap-set-core-w-no-core/task.yaml | 37 + .../failing-config-hooks/meta/hooks/configure | 4 + .../failing-config-hooks/meta/snap.yaml | 2 + tests/main/snap-set/task.yaml | 78 + tests/main/snap-sign/create-key.exp | 17 + tests/main/snap-sign/sign-model.exp | 26 + tests/main/snap-sign/task.yaml | 49 + tests/main/snap-switch/task.yaml | 13 + tests/main/snap-system-env/task.yaml | 42 + tests/main/snap-system-key/task.yaml | 85 + tests/main/snap-unset/task.yaml | 30 + tests/main/snap-update-ns/task.yaml | 89 + .../main/snap-user-dir-perms-fixed/task.yaml | 39 + .../task.yaml | 65 + .../task.yaml | 44 + .../task.yaml | 57 + .../task.yaml | 68 + tests/main/snap-user-service/task.yaml | 38 + .../task.yaml | 31 + .../test-snapd-xdg-autostart/bin/foobar | 26 + .../test-snapd-xdg-autostart/meta/snap.yaml | 6 + tests/main/snap-userd-reexec/task.yaml | 34 + tests/main/snap-validate-basic/task.yaml | 86 + tests/main/snap-validate-basic/vs1.assert | 33 + tests/main/snap-validate-basic/vs1.json | 25 + tests/main/snap-validate-enforce/task.yaml | 96 + .../testenforce1-seq1.yaml | 13 + .../testenforce1-seq2.yaml | 10 + .../testenfroce2-seq1.yaml | 8 + tests/main/snap-validate-with-store/task.yaml | 58 + .../testset1-seq1.yaml | 13 + .../testset1-seq2.yaml | 13 + tests/main/snap-wait/task.yaml | 25 + .../snapctl-from-snap-core18/bin/snapctl-get | 2 + .../snapctl-from-snap-core18/bin/snapctl-set | 2 + .../meta/hooks/configure | 3 + .../snapctl-from-snap-core18/meta/snap.yaml | 8 + .../snapctl-from-snap/bin/snapctl-get | 2 + .../snapctl-from-snap/bin/snapctl-set | 2 + .../snapctl-from-snap/meta/hooks/configure | 3 + .../snapctl-from-snap/meta/snap.yaml | 7 + tests/main/snapctl-from-snap/task.yaml | 96 + .../main/snapctl-is-connected-list/task.yaml | 29 + .../test-snap/bin/listconn | 3 + .../test-snap/meta/snap.yaml | 8 + tests/main/snapctl-is-connected-pid/task.yaml | 83 + .../test-snap-classic/bin/service.sh | 4 + .../test-snap-classic/meta/snap.yaml | 13 + .../test-snap1/bin/service.sh | 4 + .../test-snap1/meta/snap.yaml | 12 + .../test-snap2/bin/run-snapctl.sh | 2 + .../test-snap2/meta/snap.yaml | 11 + tests/main/snapctl-is-connected/task.yaml | 41 + .../test-snap/bin/checkconn | 3 + .../test-snap/meta/snap.yaml | 8 + tests/main/snapctl/task.yaml | 57 + tests/main/snapd-apparmor/task.yaml | 79 + tests/main/snapd-certs/task.yaml | 47 + .../main/snapd-go-socket-activated/task.yaml | 34 + tests/main/snapd-homedirs-vendored/task.yaml | 87 + .../test-snapd-sh/bin/sh | 2 + .../test-snapd-sh/meta/snap.yaml | 7 + tests/main/snapd-homedirs/task.yaml | 92 + tests/main/snapd-notify/task.yaml | 34 + tests/main/snapd-reexec-snapd-snap/task.yaml | 49 + tests/main/snapd-reexec/task.yaml | 102 + tests/main/snapd-sigterm/task.yaml | 50 + tests/main/snapd-slow-startup/task.yaml | 41 + tests/main/snapd-snap-auto-install/task.yaml | 30 + tests/main/snapd-snap-removal/task.yaml | 37 + tests/main/snapd-snap-transition/task.yaml | 26 + tests/main/snapd-snap/task.yaml | 421 + tests/main/snapd-state/task.yaml | 107 + tests/main/snapd-update-services/task.yaml | 87 + tests/main/snapd-without-core/task.yaml | 46 + tests/main/snaps-state/task.yaml | 177 + tests/main/snapshot-basic/task.yaml | 187 + tests/main/snapshot-basic/test-snap/bin/sh | 1 + .../test-snap/meta/hooks/configure | 4 + .../snapshot-basic/test-snap/meta/snap.yaml | 6 + tests/main/snapshot-cross-revno/task.yaml | 71 + .../snapshot-exclusions-dynamic/task.yaml | 106 + .../test-snap/bin/sh | 2 + .../test-snap/meta/snap.yaml | 6 + .../test-snap/meta/snapshots.yaml | 5 + .../main/snapshot-exclusions-static/task.yaml | 108 + .../test-snap/bin/sh | 2 + .../test-snap/meta/snap.yaml | 6 + .../test-snap/meta/snapshots.yaml | 11 + tests/main/snapshot-users/task.yaml | 126 + .../task.yaml | 51 + .../squashfs-precondition-check/task.yaml | 26 + tests/main/stale-base-snap/task.yaml | 79 + tests/main/static/task.yaml | 9 + tests/main/store-state/snap/bin/sh | 3 + tests/main/store-state/snap/meta/snap.yaml.in | 7 + tests/main/store-state/task.yaml | 80 + tests/main/sudo-env/task.yaml | 49 + tests/main/system-core-alias/task.yaml | 20 + tests/main/system-usernames-illegal/task.yaml | 15 + .../test-snapd-illegal-system-username/bin/sh | 3 + .../meta/snap.yaml | 10 + .../system-usernames-install-twice/task.yaml | 28 + .../system-usernames-missing-user/task.yaml | 28 + .../system-usernames-snap-scoped/snap/bin/sh | 3 + .../snap/meta/snap.yaml.in | 7 + .../system-usernames-snap-scoped/task.yaml | 110 + tests/main/system-usernames/task.yaml | 812 + .../daemon-user/bin/sh | 3 + .../daemon-user/meta/snap.yaml.in | 7 + tests/main/system-users-are-created/task.yaml | 37 + tests/main/systemd-service/task.yaml | 15 + .../api-client/bin/api-client.py | 40 + .../theme-install/api-client/meta/snap.yaml | 9 + tests/main/theme-install/task.yaml | 60 + tests/main/try-non-fatal/task.yaml | 19 + tests/main/try-snap-goes-away/task.yaml | 50 + tests/main/try-snap-is-optional/task.yaml | 12 + tests/main/try-twice-with-daemon/task.yaml | 36 + .../test-snapd-service-try-v1/bin/service | 3 + .../test-snapd-service-try-v1/meta/snap.yaml | 5 + .../test-snapd-service-try-v2/bin/service | 3 + .../test-snapd-service-try-v2/meta/snap.yaml | 6 + tests/main/try-with-hooks/task.yaml | 32 + tests/main/try/task.yaml | 82 + .../uc20-create-partitions-encrypt/task.yaml | 372 + .../task.yaml | 132 + tests/main/uc20-create-partitions/task.yaml | 276 + tests/main/umask/task.yaml | 26 + .../fwupd-client/bin/cmd | 2 + .../fwupd-client/meta/snap.yaml | 7 + tests/main/unclash-mount-entries/task.yaml | 37 + .../unclash-mount-entries/testsnap/bin/cmd | 2 + .../testsnap/meta/snap.yaml | 8 + tests/main/unhandled-task/task.yaml | 30 + tests/main/upgrade-from-2.15/task.yaml | 76 + tests/main/upgrade-from-release/task.yaml | 78 + tests/main/user-data-handling/task.yaml | 35 + tests/main/user-libnss/findid.go | 29 + tests/main/user-libnss/task.yaml | 53 + tests/main/user-mounts/task.yaml | 42 + tests/main/user-session-env/task.yaml | 101 + .../validate-container-failures/task.yaml | 28 + .../bin/bar | 0 .../bin/foo | 0 .../comp.sh | 0 .../meta/hooks/what | 0 .../meta/snap.yaml | 11 + .../meta/unreadable | 0 tests/main/validate-container-happy/task.yaml | 38 + .../bin/validate-container | 0 .../hell/hell.tar | Bin 0 -> 254 bytes .../meta/snap.yaml | 5 + tests/main/vitality/task.yaml | 34 + tests/main/whoami/task.yaml | 19 + .../writable-areas/data-writer/bin/write-data | 20 + .../writable-areas/data-writer/meta/snap.yaml | 9 + tests/main/writable-areas/task.yaml | 32 + tests/main/xauth-migration/task.yaml | 88 + tests/main/xdg-open-compat/task.yaml | 117 + tests/main/xdg-open-portal/editor.sh | 7 + tests/main/xdg-open-portal/task.yaml | 96 + tests/main/xdg-open-portal/web-browser.sh | 5 + tests/main/xdg-open/task.yaml | 99 + tests/main/xdg-settings/task.yaml | 104 + .../test-snapd-xdg-settings/bin/browser | 3 + .../bin/xdg-settings-wrapper | 35 + .../meta/gui/browser.desktop | 6 + .../test-snapd-xdg-settings/meta/snap.yaml | 9 + tests/manual-tests.md | 236 + tests/nested/classic/hotplug/task.yaml | 179 + .../task.yaml | 50 + .../core/bad-try-kernel-no-reboot/task.yaml | 18 + .../core/base-revert-after-boot/task.yaml | 85 + .../connected-after-reboot-revert/task.yaml | 92 + .../nested/core/core-gadget-mounted/task.yaml | 38 + tests/nested/core/core-revert/task.yaml | 66 + .../core/core-snap-refresh-on-core/task.yaml | 54 + tests/nested/core/core20-basic/task.yaml | 104 + .../core/core20-create-recovery/task.yaml | 39 + tests/nested/core/core20-degraded/task.yaml | 48 + .../core/core20-factory-reset/task.yaml | 150 + .../nested/core/core20-fault-inject/task.yaml | 60 + .../core/core20-gadget-reseal/manip_gadget.py | 50 + .../core/core20-gadget-reseal/task.yaml | 57 + .../core/core20-kernel-failover/task.yaml | 122 + .../core/core20-kernel-reseal/task.yaml | 49 + .../core20-reinstall-partitions/task.yaml | 44 + tests/nested/core/core20-tpm/task.yaml | 97 + tests/nested/core/core22-basic/task.yaml | 36 + .../nested/core/coreconfig-services/task.yaml | 19 + tests/nested/core/hotplug/task.yaml | 167 + tests/nested/core/image-build/task.yaml | 14 + .../devices-plug/bin/cmd | 2 + .../devices-plug/meta/snap.yaml | 9 + .../core/interfaces-custom-devices/task.yaml | 73 + .../core/kernel-revert-after-boot/task.yaml | 61 + tests/nested/core/save-data/task.yaml | 51 + .../cloud-init-never-used-not-vuln/task.yaml | 154 + .../cloud-init-nocloud-not-vuln/task.yaml | 161 + tests/nested/manual/cmdline-option/cloud.conf | 7 + .../manual/cmdline-option/defaults.yaml | 6 + .../manual/cmdline-option/prepare-device | 3 + tests/nested/manual/cmdline-option/task.yaml | 171 + .../manual/cmdline-remove-append/task.yaml | 35 + .../manual/core-early-config/defaults.yaml | 10 + tests/nested/manual/core-early-config/install | 10 + .../nested/manual/core-early-config/task.yaml | 53 + .../manual/core-seeding-devmode/task.yaml | 19 + .../manual/core20-4k-sector-size/task.yaml | 57 + .../core20-auto-remove-user/defaults.yaml | 6 + .../core20-auto-remove-user/prepare-device | 3 + .../manual/core20-auto-remove-user/task.yaml | 232 + .../core20-auto-remove-user/user2-2.json | 20 + .../manual/core20-auto-remove-user/user2.json | 20 + .../manual/core20-auto-remove-user/user3.json | 20 + .../core20-boot-config-update/task.yaml | 145 + .../50-cloudconfig-gce-unsupported-config.cfg | 5 + .../50-cloudconfig-maas-cloud-config.cfg | 7 + .../50-cloudconfig-maas-datasource.cfg | 1 + .../50-cloudconfig-maas-reporting.cfg | 8 + .../50-cloudconfig-maas-ubuntu-sso.cfg | 3 + .../50-curtin-networking.cfg | 20 + .../defaults.yaml | 6 + .../gadget-says-maas.conf | 3 + .../gadget-says-none.conf | 3 + .../prepare-device | 3 + .../task.yaml | 284 + .../task.yaml | 107 + .../manual/core20-da-lockout/getdalockout.go | 35 + .../getdalockout_nosecboot.go | 9 + .../nested/manual/core20-da-lockout/task.yaml | 36 + .../manual/core20-early-config/defaults.yaml | 30 + .../nested/manual/core20-early-config/install | 16 + .../manual/core20-early-config/task.yaml | 132 + .../defaults.yaml | 6 + .../install-device | 25 + .../pc-snap-decl-extras.json | 9 + .../prepare-device | 3 + .../snap-yaml-extras.yaml | 20 + .../task.yaml | 176 + .../core20-gadget-cloud-conf/cloud.conf | 7 + .../core20-gadget-cloud-conf/defaults.yaml | 6 + .../core20-gadget-cloud-conf/prepare-device | 3 + .../manual/core20-gadget-cloud-conf/task.yaml | 137 + .../defaults.yaml | 6 + .../prepare-device | 3 + .../task.yaml | 128 + .../defaults.yaml | 6 + .../prepare-device | 3 + .../task.yaml | 159 + .../task.yaml | 32 + .../defaults.yaml | 6 + .../install-device | 14 + .../prepare-device | 3 + .../snap-yaml-extras.yaml | 2 + .../task.yaml | 138 + .../defaults.yaml | 6 + .../install-device | 20 + .../pc-snap-decl-extras.json | 9 + .../prepare-device | 3 + .../snap-yaml-extras.yaml | 20 + .../task.yaml | 156 + .../install-device | 3 + .../task.yaml | 94 + .../task.yaml | 112 + tests/nested/manual/core20-preseed/task.yaml | 64 + tests/nested/manual/core20-remodel/task.yaml | 128 + tests/nested/manual/core20-save/task.yaml | 94 + .../nested/manual/core20-to-core22/task.yaml | 89 + .../asserts/bar-vs.json | 15 + .../asserts/core-20-model.json | 58 + .../manual/core20-validation-sets/task.yaml | 107 + .../devmode-snap-seeded-dangerous/task.yaml | 26 + .../uc20-devmode/meta/snap.yaml | 9 + .../uc20-devmode/true | 3 + .../uc22-devmode/meta/snap.yaml | 9 + .../uc22-devmode/true | 3 + .../task.yaml | 201 + .../fde-on-classic/classic-model.assert | 44 + .../manual/fde-on-classic/classic-model.json | 40 + .../nested/manual/fde-on-classic/mk-image.sh | 333 + tests/nested/manual/fde-on-classic/model-etc | 92 + .../fde-on-classic/replace-image-files.sh | 81 + tests/nested/manual/fde-on-classic/task.yaml | 177 + .../manual/fde-on-classic/tweak-gadget.py | 19 + .../manual/gadget-connections/task.yaml | 61 + .../test-snapd-connections/bin/test | 3 + .../test-snapd-connections/meta/snap.yaml | 15 + tests/nested/manual/hybrid-remodel/task.yaml | 52 + .../nested/manual/install-min-size/task.yaml | 58 + tests/nested/manual/minimal-smoke/task.yaml | 37 + .../nested/manual/muinstaller-core/task.yaml | 241 + .../muinstaller-real/gadget-partial.yaml | 50 + .../nested/manual/muinstaller-real/task.yaml | 257 + tests/nested/manual/muinstaller/task.yaml | 183 + tests/nested/manual/preseed/task.yaml | 141 + .../manual/recovery-system-offline/task.yaml | 110 + .../manual/recovery-system-reboot/task.yaml | 134 + tests/nested/manual/recovery-system/task.yaml | 97 + .../refresh-revert-fundamentals/task.yaml | 113 + .../manual/remodel-cross-store/task.yaml | 100 + .../nested/manual/remodel-min-size/task.yaml | 99 + tests/nested/manual/remodel-offline/task.yaml | 166 + tests/nested/manual/remodel-simple/task.yaml | 134 + .../remodel-target-base-installed/task.yaml | 78 + .../manual/remodel-uc20-to-uc22/task.yaml | 112 + .../assets/validation-set.yaml | 8 + .../task.yaml | 45 + .../assets/validation-set.yaml | 8 + .../remodel-validation-sets-invalid/task.yaml | 40 + tests/nested/manual/run-spread/task.yaml | 47 + .../manual/snapd-refresh-from-old/task.yaml | 52 + .../task.yaml | 104 + .../uc-grub-boot-chains/modify-gadget.py | 46 + .../manual/uc-grub-boot-chains/task.yaml | 109 + .../task.yaml | 154 + .../generate_vendor_cert_section.py | 45 + .../manual/uc-update-assets-secure/task.yaml | 119 + .../uc-update-command-line-secure/task.yaml | 30 + .../manual/uc20-fde-hooks-ice/task.yaml | 58 + .../nested/manual/uc20-fde-hooks-v1/task.yaml | 32 + tests/nested/manual/uc20-fde-hooks/task.yaml | 35 + .../manual/uc20-install-in-initrd/task.yaml | 101 + .../manual/uc20-storage-safety/task.yaml | 59 + tests/nightly/install-snaps/task.yaml | 132 + .../nightly/interfaces-openvswitch/task.yaml | 103 + tests/nightly/sbuild/task.yaml | 51 + tests/nightly/upload-snapd-to-gce/task.yaml | 77 + .../main/install-many-snaps-no-wait/task.yaml | 59 + tests/perf/main/install-many-snaps/task.yaml | 74 + .../main/interfaces-core-provided/task.yaml | 124 + .../main/interfaces-snap-provided/task.yaml | 58 + .../bin/run | 10 + .../meta/snap.yaml | 102 + .../bin/run | 10 + .../meta/snap.yaml | 164 + tests/perf/main/parallel-installs/task.yaml | 37 + .../install-many-snaps-no-wait/task.yaml | 29 + .../perf/nested/install-many-snaps/task.yaml | 29 + tests/perf/nested/interfaces-many/task.yaml | 56 + tests/perf/nested/parallel-installs/task.yaml | 39 + .../regression/exploding-namespace/task.yaml | 21 + tests/regression/lp-1595444/task.yaml | 30 + tests/regression/lp-1597839/task.yaml | 16 + tests/regression/lp-1597842/task.yaml | 27 + tests/regression/lp-1599891/task.yaml | 11 + tests/regression/lp-1606277/task.yaml | 15 + tests/regression/lp-1607796/task.yaml | 14 + tests/regression/lp-1615113/task.yaml | 14 + tests/regression/lp-1618683/task.yaml | 28 + tests/regression/lp-1630479/task.yaml | 30 + tests/regression/lp-1641885/task.yaml | 30 + tests/regression/lp-1644439/task.yaml | 47 + tests/regression/lp-1665004/task.yaml | 19 + tests/regression/lp-1667385/task.yaml | 23 + tests/regression/lp-1693042/task.yaml | 17 + tests/regression/lp-1704860/snap-env-query.sh | 1 + tests/regression/lp-1704860/task.yaml | 27 + tests/regression/lp-1732555/task.yaml | 17 + .../test-snapd-unknown-interfaces/bin/sh | 3 + .../meta/snap.yaml | 10 + tests/regression/lp-1764977/task.yaml | 29 + tests/regression/lp-1797556/task.yaml | 30 + .../lp-1797556/test-snapd-sh/bin/sh | 3 + .../lp-1797556/test-snapd-sh/meta/snap.yaml | 8 + tests/regression/lp-1800004/task.yaml | 11 + tests/regression/lp-1801955/task.yaml | 13 + tests/regression/lp-1802581/task.yaml | 76 + tests/regression/lp-1803535/task.yaml | 8 + .../lp-1803535/test-snapd-lp-1803535/bin/sh | 3 + .../etc/OpenCL/vendors/foo.icd | 1 + .../test-snapd-lp-1803535/meta/snap.yaml | 9 + tests/regression/lp-1803542/task.yaml | 58 + tests/regression/lp-1805485/task.yaml | 15 + tests/regression/lp-1805838/task.yaml | 55 + tests/regression/lp-1808821/task.sh | 8 + tests/regression/lp-1808821/task.yaml | 19 + .../lp-1808821/test-snapd-app/bin/sh | 3 + .../lp-1808821/test-snapd-app/meta/snap.yaml | 13 + .../lp-1808821/test-snapd-app/stub.txt | 1 + tests/regression/lp-1812973/Makefile | 25 + tests/regression/lp-1812973/lp-1812973.c | 63 + tests/regression/lp-1812973/task.yaml | 22 + .../test-snapd-lp-1812973/meta/snap.yaml | 9 + tests/regression/lp-1813365/helper | 30 + tests/regression/lp-1813365/logger | 2 + tests/regression/lp-1813365/task.yaml | 29 + tests/regression/lp-1813963/task.yaml | 105 + tests/regression/lp-1815722/task.yaml | 10 + tests/regression/lp-1815869/hello.py | 3 + tests/regression/lp-1815869/task.yaml | 56 + tests/regression/lp-1819728/task.yaml | 32 + tests/regression/lp-1825883/task.yaml | 32 + .../lp-1825883/test-snapd-app/bin/sh | 3 + .../lp-1825883/test-snapd-app/meta/snap.yaml | 13 + .../lp-1825883/test-snapd-app/things/README | 2 + .../test-snapd-content.v1/meta/snap.yaml | 11 + .../test-snapd-content.v1/things/a/thing | 1 + .../test-snapd-content.v1/things/b/thing | 1 + .../test-snapd-content.v2/meta/snap.yaml | 12 + .../test-snapd-content.v2/things/a/thing | 1 + .../test-snapd-content.v2/things/b/thing | 1 + .../test-snapd-content.v2/things/c/thing | 1 + tests/regression/lp-1831010/task.yaml | 18 + .../lp-1831010/test-snapd-layout/a/.keep | 0 .../lp-1831010/test-snapd-layout/b/c/.keep | 0 .../lp-1831010/test-snapd-layout/bin/sh | 3 + .../lp-1831010/test-snapd-layout/d/.keep | 0 .../test-snapd-layout/meta/snap.yaml | 10 + tests/regression/lp-1844496/task.yaml | 38 + .../lp-1844496/test-snapd-layout/bin/sh | 3 + .../test-snapd-layout/meta/snap.yaml | 25 + .../x86_64-linux-gnu/wpe-webkit-1.0/canary | 1 + .../usr/wpe-webkit-1.0/canary | 1 + tests/regression/lp-1848567/task.yaml | 37 + .../lp-1848567/test-snapd-app/bin/sh | 3 + .../test-snapd-app/data-dirs/icons/.keep | 0 .../test-snapd-app/data-dirs/sounds/.keep | 0 .../test-snapd-app/data-dirs/themes/.keep | 0 .../test-snapd-app/gnome-platform/.keep | 0 .../lp-1848567/test-snapd-app/meta/snap.yaml | 35 + .../test-snapd-gnome-3-28-1804/meta/snap.yaml | 12 + .../meta/snap.yaml | 101 + .../share/gtk2/Adwaita-dark/.keep | 0 .../share/gtk2/Adwaita/.keep | 0 .../share/gtk2/Ambiance/.keep | 0 .../share/gtk2/Ambiant-MATE-Dark/.keep | 0 .../share/gtk2/Ambiant-MATE/.keep | 0 .../share/gtk2/Arc-Dark/.keep | 0 .../share/gtk2/Arc-Darker/.keep | 0 .../share/gtk2/Arc/.keep | 0 .../share/gtk2/Breeze-Dark/.keep | 0 .../share/gtk2/Breeze/.keep | 0 .../share/gtk2/Communitheme-dark/.keep | 0 .../share/gtk2/Communitheme-light/.keep | 0 .../share/gtk2/Communitheme/.keep | 0 .../share/gtk2/HighContrast/.keep | 0 .../share/gtk2/Matcha-aliz/.keep | 0 .../share/gtk2/Matcha-azul/.keep | 0 .../share/gtk2/Matcha-dark-aliz/.keep | 0 .../share/gtk2/Matcha-dark-azul/.keep | 0 .../share/gtk2/Matcha-dark-sea/.keep | 0 .../share/gtk2/Matcha-sea/.keep | 0 .../share/gtk2/Radiance/.keep | 0 .../share/gtk2/Radiant-MATE/.keep | 0 .../share/gtk2/Yaru-dark/.keep | 0 .../share/gtk2/Yaru-light/.keep | 0 .../share/gtk2/Yaru/.keep | 0 .../share/gtk2/elementary/.keep | 0 .../share/icons/Adwaita/.keep | 0 .../share/icons/Ambiant-MATE/.keep | 0 .../share/icons/DMZ-Black/.keep | 0 .../share/icons/DMZ-White/.keep | 0 .../share/icons/HighContrast/.keep | 0 .../share/icons/Humanity-Dark/.keep | 0 .../share/icons/Humanity/.keep | 0 .../share/icons/Papirus-Adapta-Maia/.keep | 0 .../icons/Papirus-Adapta-Nokto-Maia/.keep | 0 .../share/icons/Papirus-Dark-Maia/.keep | 0 .../share/icons/Papirus-Light-Maia/.keep | 0 .../share/icons/Papirus-Maia/.keep | 0 .../share/icons/Radiant-MATE/.keep | 0 .../share/icons/Suru/.keep | 0 .../share/icons/Yaru/.keep | 0 .../share/icons/communitheme/.keep | 0 .../share/icons/elementary/.keep | 0 .../share/icons/hicolor/.keep | 0 .../share/icons/ubuntu-mono-dark/.keep | 0 .../share/icons/ubuntu-mono-light/.keep | 0 .../share/sounds/Yaru/.keep | 0 .../share/sounds/communitheme/.keep | 0 .../share/themes/Adwaita-dark/.keep | 0 .../share/themes/Adwaita/.keep | 0 .../share/themes/Ambiance/.keep | 0 .../share/themes/Ambiant-MATE-Dark/.keep | 0 .../share/themes/Ambiant-MATE/.keep | 0 .../share/themes/Arc-Dark/.keep | 0 .../share/themes/Arc-Darker/.keep | 0 .../share/themes/Arc/.keep | 0 .../share/themes/Breeze-Dark/.keep | 0 .../share/themes/Breeze/.keep | 0 .../share/themes/Communitheme-dark/.keep | 0 .../share/themes/Communitheme-light/.keep | 0 .../share/themes/Communitheme/.keep | 0 .../share/themes/HighContrast/.keep | 0 .../share/themes/Matcha-aliz/.keep | 0 .../share/themes/Matcha-azul/.keep | 0 .../share/themes/Matcha-dark-aliz/.keep | 0 .../share/themes/Matcha-dark-azul/.keep | 0 .../share/themes/Matcha-dark-sea/.keep | 0 .../share/themes/Matcha-sea/.keep | 0 .../share/themes/Radiance/.keep | 0 .../share/themes/Radiant-MATE/.keep | 0 .../share/themes/Yaru-dark/.keep | 0 .../share/themes/Yaru-light/.keep | 0 .../share/themes/Yaru/.keep | 0 .../share/themes/elementary/.keep | 0 tests/regression/lp-1849845/task.yaml | 51 + .../lp-1849845/test-snapd-app/bin/sh | 3 + .../lp-1849845/test-snapd-app/meta/snap.yaml | 10 + .../test-snapd-assets-bar/meta/hooks/install | 3 + .../test-snapd-assets-bar/meta/snap.yaml | 7 + .../test-snapd-assets-foo/meta/hooks/install | 3 + .../test-snapd-assets-foo/meta/snap.yaml | 7 + tests/regression/lp-1852361/task.yaml | 26 + .../lp-1852361/test-snapd-layout/bin/sh | 3 + .../test-snapd-layout/etc/vtpath.ini | 1 + .../test-snapd-layout/meta/snap.yaml | 17 + .../usr/lib/x86_64-linux-gnu/alsa-lib/.keep | 0 .../test-snapd-layout/usr/share/pico/.keep | 0 .../test-snapd-layout/usr/share/snips/.keep | 0 .../lp-1852361/test-snapd-layout/usr/vt/.keep | 0 tests/regression/lp-1862637/task.yaml | 59 + .../lp-1862637/test-snapd-app/bin/sh | 2 + .../lp-1862637/test-snapd-app/meta/snap.yaml | 8 + tests/regression/lp-1866095/task.yaml | 17 + .../test-snapd-service/bin/test-snapd-service | 2 + .../test-snapd-service/meta/snap.yaml | 7 + tests/regression/lp-1867193/task.yaml | 24 + tests/regression/lp-1867752/task.yaml | 27 + tests/regression/lp-1871652/systemctl | 6 + tests/regression/lp-1871652/task.yaml | 70 + tests/regression/lp-1884849/task.yaml | 38 + tests/regression/lp-1886786/task.yaml | 8 + .../meta/snap.yaml | 10 + .../test-snapd-app-with-test-name/test.sh | 2 + tests/regression/lp-1891371/task.yaml | 17 + .../test-snapd-app/bin/keep-foo-open | 13 + .../test-snapd-app/extra-content/README.txt | 2 + .../regression/lp-1891371/test-snapd-app/foo | 2 + .../lp-1891371/test-snapd-app/meta/snap.yaml | 16 + .../test-snapd-extra-content/meta/snap.yaml | 9 + tests/regression/lp-1898038/task.yaml | 37 + .../bin/test-snapd-docker-support | 2 + .../meta/snap.yaml | 8 + .../test-snapd-docker-support.profile | 6 + .../bin/test-snapd-multipass-support | 2 + .../meta/snap.yaml | 8 + .../test-snapd-multipass-support.profile | 6 + tests/regression/lp-1899664/task.yaml | 19 + tests/regression/lp-1906821/task.yaml | 37 + .../test-snapd-complex-layout/bin/sh | 3 + .../test-snapd-complex-layout/meta/snap.yaml | 31 + .../test-snapd-complex-layout/node/bin/node | 1 + .../usr/bin/python2.7 | 1 + .../usr/bin/python3.8 | 1 + .../usr/lib/jvm/jre/bin/java | 1 + .../wrapper-scripts/exec-node.sh | 1 + .../container-mgr-snap/bin/simple.sh | 3 + .../container-mgr-snap/meta/snap.yaml | 91 + tests/regression/lp-1910456/task.yaml | 166 + tests/regression/lp-1942266/task.yaml | 32 + .../test-system-files-conn-snap/bin.sh | 3 + .../meta/snap.yaml.in | 13 + tests/regression/lp-1943853/task.yaml | 39 + .../lp-1949368/bad-layout/meta/snap.yaml | 5 + .../lp-1949368/content-consumer/bin/sh | 3 + .../content-consumer/meta/snap.yaml | 12 + .../content-provider/meta/snap.yaml | 9 + tests/regression/lp-1949368/task.yaml | 35 + tests/regression/lp-1996090/task.yaml | 8 + tests/regression/lp-2011485/task.yaml | 39 + .../bin/test-snapd-docker-support | 2 + .../meta/snap.yaml | 9 + tests/regression/lp-2044335/task.yaml | 40 + .../mount-order-regression/task.yaml | 95 + .../bin/cmd | 2 + .../meta/snap.yaml | 39 + .../bin/cmd | 2 + .../meta/snap.yaml | 37 + .../bin/cmd | 2 + .../meta/snap.yaml | 37 + tests/regression/rhbz-1584461/task.yaml | 40 + tests/regression/rhbz-1708991/task.yaml | 34 + .../task.yaml | 24 + tests/smoke/find-info/task.yaml | 9 + tests/smoke/install/task.yaml | 71 + tests/smoke/remove/task.yaml | 17 + tests/smoke/sandbox/task.yaml | 82 + tests/smoke/sandbox/test-snapd-sandbox/bin/sh | 3 + .../sandbox/test-snapd-sandbox/meta/snap.yaml | 10 + tests/smoke/versioning/task.yaml | 8 + tests/snapd-state.md | 11 + tests/unit/c-unit-tests-clang/task.yaml | 32 + tests/unit/c-unit-tests-gcc/task.yaml | 32 + tests/unit/go/task.yaml | 98 + .../set-e-pipe-chain-with-negation.sh | 18 + .../shell-traps/set-e-pipe-chain-with-not.sh | 15 + .../set-e-simple-cmd-with-negation.sh | 14 + .../shell-traps/set-e-simple-cmd-with-not.sh | 11 + tests/unit/shell-traps/task.yaml | 23 + tests/upgrade/basic/task.yaml | 174 + tests/upgrade/selinux-relabel/task.yaml | 37 + tests/upgrade/snapd-xdg-open/task.yaml | 41 + .../sudoers-conffile-removal/task.yaml | 29 + tests/utils/benchmark.sh | 21 + testutil/base.go | 92 + testutil/containschecker.go | 275 + testutil/containschecker_test.go | 372 + testutil/dbustest.go | 118 + testutil/dbustest_test.go | 51 + testutil/errorischecker.go | 53 + testutil/errorischecker_test.go | 72 + testutil/exec.go | 232 + testutil/exec_test.go | 138 + testutil/export_test.go | 44 + testutil/filecontentchecker.go | 132 + testutil/filecontentchecker_test.go | 116 + testutil/filepresencechecker.go | 59 + testutil/filepresencechecker_test.go | 54 + testutil/intcheckers.go | 104 + testutil/intcheckers_test.go | 55 + testutil/interfacenilchecker.go | 44 + testutil/interfacenilchecker_test.go | 67 + testutil/jsonchecker.go | 58 + testutil/jsonchecker_test.go | 89 + testutil/lowlevel.go | 577 + testutil/lowlevel_test.go | 800 + testutil/mocking_test.go | 61 + testutil/paddedchecker.go | 147 + testutil/paddedchecker_test.go | 77 + testutil/symlinktargetchecker.go | 100 + testutil/symlinktargetchecker_test.go | 79 + testutil/syscallschecker.go | 89 + testutil/syscallschecker_test.go | 78 + testutil/testutil_test.go | 54 + testutil/timeouts.go | 43 + testutil/timeouts_test.go | 45 + timeout/timeout.go | 76 + timeout/timeout_test.go | 65 + timeutil/export_test.go | 27 + timeutil/human.go | 87 + timeutil/human_test.go | 93 + timeutil/schedule.go | 903 + timeutil/schedule_test.go | 1276 ++ timeutil/synchronized.go | 73 + timeutil/synchronized_test.go | 164 + timings/export_test.go | 40 + timings/helpers.go | 28 + timings/state.go | 193 + timings/timings.go | 121 + timings/timings_test.go | 388 + update-pot | 92 + usersession/agent/export_test.go | 52 + usersession/agent/response.go | 169 + usersession/agent/rest_api.go | 490 + usersession/agent/rest_api_test.go | 1053 ++ usersession/agent/session_agent.go | 349 + usersession/agent/session_agent_test.go | 306 + usersession/autostart/autostart.go | 172 + usersession/autostart/autostart_test.go | 223 + usersession/autostart/export_test.go | 36 + usersession/client/client.go | 537 + usersession/client/client_test.go | 746 + usersession/userd/export_test.go | 46 + usersession/userd/helpers.go | 68 + usersession/userd/launcher.go | 300 + usersession/userd/launcher_test.go | 265 + .../userd/privileged_desktop_launcher.go | 228 + ...ivileged_desktop_launcher_internal_test.go | 169 + .../userd/privileged_desktop_launcher_test.go | 136 + usersession/userd/settings.go | 378 + usersession/userd/settings_test.go | 418 + usersession/userd/ui/kdialog.go | 68 + usersession/userd/ui/kdialog_test.go | 93 + usersession/userd/ui/ui.go | 94 + usersession/userd/ui/zenity.go | 64 + usersession/userd/ui/zenity_test.go | 99 + usersession/userd/userd.go | 116 + usersession/xdgopenproxy/export_test.go | 40 + usersession/xdgopenproxy/portal_launcher.go | 48 + usersession/xdgopenproxy/userd_launcher.go | 51 + .../xdgopenproxy/userd_launcher_test.go | 172 + usersession/xdgopenproxy/xdgopenproxy.go | 88 + usersession/xdgopenproxy/xdgopenproxy_test.go | 103 + wrappers/binaries.go | 236 + wrappers/binaries_test.go | 491 + wrappers/core18.go | 763 + wrappers/core18_test.go | 605 + wrappers/dbus.go | 183 + wrappers/dbus_test.go | 215 + wrappers/desktop.go | 333 + wrappers/desktop_test.go | 627 + wrappers/export_test.go | 69 + wrappers/icons.go | 129 + wrappers/icons_test.go | 147 + wrappers/internal/export_test.go | 35 + wrappers/internal/journal_conf_gen.go | 77 + wrappers/internal/service_slice_gen.go | 112 + wrappers/internal/service_socket_gen.go | 129 + wrappers/internal/service_socket_gen_test.go | 115 + wrappers/internal/service_status.go | 259 + wrappers/internal/service_status_test.go | 600 + wrappers/internal/service_timer_gen.go | 310 + wrappers/internal/service_timer_gen_test.go | 289 + wrappers/internal/service_unit_gen.go | 326 + wrappers/internal/service_unit_gen_test.go | 848 + wrappers/services.go | 1240 ++ wrappers/services_test.go | 5031 ++++++ x11/xauth.go | 150 + x11/xauth_test.go | 73 + 5178 files changed, 1096806 insertions(+) create mode 100644 .clang-format create mode 100644 .github/labeler.yml create mode 100644 .github/spread-problem-matcher.json create mode 100644 .github/workflows/cla-check.yaml create mode 100644 .github/workflows/labeler.yaml create mode 100644 .github/workflows/macos-quick.yaml create mode 100644 .github/workflows/naming.yml create mode 100644 .github/workflows/nightly.yaml create mode 100644 .github/workflows/riscv64-builds.yml create mode 100644 .github/workflows/test.yaml create mode 100644 .gitignore create mode 100644 .golangci.yml create mode 100644 .mailmap create mode 100644 .woke.yaml create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CODING.md create mode 100644 CONTRIBUTING.md create mode 100644 COPYING create mode 100644 HACKING.md create mode 100644 NEWS.md create mode 100644 PULL_REQUEST_TEMPLATE.md create mode 100644 README.md create mode 100644 advisor/backend_bolt.go create mode 100644 advisor/backend_common.go create mode 100644 advisor/backend_test.go create mode 100644 advisor/cmdfinder.go create mode 100644 advisor/cmdfinder_test.go create mode 100644 advisor/export_test.go create mode 100644 advisor/finder.go create mode 100644 advisor/pkgfinder.go create mode 100644 advisor/pkgfinder_test.go create mode 100644 arch/arch.go create mode 100644 arch/arch_test.go create mode 100644 arch/archtest/archtest.go create mode 100644 arch/endian.go create mode 100644 arch/endian_test.go create mode 100644 arch/export_test.go create mode 100644 aspects/aspects.go create mode 100644 aspects/aspects_test.go create mode 100644 aspects/export_test.go create mode 100644 aspects/schema.go create mode 100644 aspects/schema_test.go create mode 100644 aspects/transaction.go create mode 100644 aspects/transaction_test.go create mode 100644 asserts/account.go create mode 100644 asserts/account_key.go create mode 100644 asserts/account_key_test.go create mode 100644 asserts/account_test.go create mode 100644 asserts/aspect_bundle.go create mode 100644 asserts/aspect_bundle_test.go create mode 100644 asserts/asserts.go create mode 100644 asserts/asserts_test.go create mode 100644 asserts/assertstest/assertstest.go create mode 100644 asserts/assertstest/assertstest_test.go create mode 100644 asserts/batch.go create mode 100644 asserts/batch_test.go create mode 100644 asserts/constraint.go create mode 100644 asserts/constraint_test.go create mode 100644 asserts/crypto.go create mode 100644 asserts/database.go create mode 100644 asserts/database_test.go create mode 100644 asserts/digest.go create mode 100644 asserts/digest_test.go create mode 100644 asserts/export_test.go create mode 100644 asserts/extkeypairmgr.go create mode 100644 asserts/extkeypairmgr_test.go create mode 100644 asserts/fetcher.go create mode 100644 asserts/fetcher_test.go create mode 100644 asserts/findwildcard.go create mode 100644 asserts/findwildcard_test.go create mode 100644 asserts/fsbackstore.go create mode 100644 asserts/fsbackstore_test.go create mode 100644 asserts/fsentryutils.go create mode 100644 asserts/fskeypairmgr.go create mode 100644 asserts/fskeypairmgr_test.go create mode 100644 asserts/gpgkeypairmgr.go create mode 100644 asserts/gpgkeypairmgr_test.go create mode 100644 asserts/header_checks.go create mode 100644 asserts/headers.go create mode 100644 asserts/headers_test.go create mode 100644 asserts/ifacedecls.go create mode 100644 asserts/ifacedecls_test.go create mode 100644 asserts/info/main.go create mode 100644 asserts/internal/grouping.go create mode 100644 asserts/internal/grouping_test.go create mode 100644 asserts/membackstore.go create mode 100644 asserts/membackstore_test.go create mode 100644 asserts/memkeypairmgr.go create mode 100644 asserts/memkeypairmgr_test.go create mode 100644 asserts/model.go create mode 100644 asserts/model_test.go create mode 100644 asserts/pool.go create mode 100644 asserts/pool_test.go create mode 100644 asserts/preseed.go create mode 100644 asserts/preseed_test.go create mode 100644 asserts/privkeys_for_test.go create mode 100644 asserts/repair.go create mode 100644 asserts/repair_test.go create mode 100644 asserts/serial_asserts.go create mode 100644 asserts/serial_asserts_test.go create mode 100644 asserts/signtool/keymgr.go create mode 100644 asserts/signtool/keymgr_test.go create mode 100644 asserts/signtool/sign.go create mode 100644 asserts/signtool/sign_test.go create mode 100644 asserts/snap_asserts.go create mode 100644 asserts/snap_asserts_test.go create mode 100644 asserts/snap_resource_asserts.go create mode 100644 asserts/snap_resource_asserts_test.go create mode 100644 asserts/snapasserts/export_test.go create mode 100644 asserts/snapasserts/snapasserts.go create mode 100644 asserts/snapasserts/snapasserts_test.go create mode 100644 asserts/snapasserts/validation_sets.go create mode 100644 asserts/snapasserts/validation_sets_test.go create mode 100644 asserts/store_asserts.go create mode 100644 asserts/store_asserts_test.go create mode 100644 asserts/sysdb/generic.go create mode 100644 asserts/sysdb/staging.go create mode 100644 asserts/sysdb/sysdb.go create mode 100644 asserts/sysdb/sysdb_test.go create mode 100644 asserts/sysdb/testkeys.go create mode 100644 asserts/sysdb/trusted.go create mode 100644 asserts/system_user.go create mode 100644 asserts/system_user_test.go create mode 100644 asserts/systestkeys/trusted.go create mode 100644 asserts/validation_set.go create mode 100644 asserts/validation_set_test.go create mode 100644 boot/assets.go create mode 100644 boot/assets_test.go create mode 100644 boot/boot.go create mode 100644 boot/boot_robustness_test.go create mode 100644 boot/boot_test.go create mode 100644 boot/bootchain.go create mode 100644 boot/bootchain_test.go create mode 100644 boot/booted_kernel_partition_linux.go create mode 100644 boot/booted_kernel_partition_test.go create mode 100644 boot/bootstate16.go create mode 100644 boot/bootstate20.go create mode 100644 boot/bootstate20_bloader_kernel_state.go create mode 100644 boot/boottest/bootenv.go create mode 100644 boot/boottest/device.go create mode 100644 boot/boottest/device_test.go create mode 100644 boot/boottest/model.go create mode 100644 boot/cmdline.go create mode 100644 boot/cmdline_test.go create mode 100644 boot/debug.go create mode 100644 boot/errors.go create mode 100644 boot/export_test.go create mode 100644 boot/flags.go create mode 100644 boot/flags_test.go create mode 100644 boot/initramfs.go create mode 100644 boot/initramfs20dirs.go create mode 100644 boot/initramfs_test.go create mode 100644 boot/kernel_os.go create mode 100644 boot/kernel_os_test.go create mode 100644 boot/makebootable.go create mode 100644 boot/makebootable_test.go create mode 100644 boot/modeenv.go create mode 100644 boot/modeenv_test.go create mode 100644 boot/model.go create mode 100644 boot/model_test.go create mode 100644 boot/reboot.go create mode 100644 boot/reboot_test.go create mode 100644 boot/seal.go create mode 100644 boot/seal_test.go create mode 100644 boot/systems.go create mode 100644 boot/systems_test.go create mode 100644 bootloader/androidboot.go create mode 100644 bootloader/androidboot_test.go create mode 100644 bootloader/androidbootenv/androidbootenv.go create mode 100644 bootloader/androidbootenv/androidbootenv_test.go create mode 100644 bootloader/asset.go create mode 100644 bootloader/asset_test.go create mode 100644 bootloader/assets/assets.go create mode 100644 bootloader/assets/assets_test.go create mode 100644 bootloader/assets/assetstesting.go create mode 100644 bootloader/assets/data/README.grub create mode 100644 bootloader/assets/data/grub-recovery.cfg create mode 100644 bootloader/assets/data/grub.cfg create mode 100644 bootloader/assets/export_test.go create mode 100644 bootloader/assets/genasset/export_test.go create mode 100644 bootloader/assets/genasset/main.go create mode 100644 bootloader/assets/genasset/main_test.go create mode 100644 bootloader/assets/generate.go create mode 100644 bootloader/assets/grub.go create mode 100644 bootloader/assets/grub_cfg_asset.go create mode 100644 bootloader/assets/grub_recovery_cfg_asset.go create mode 100644 bootloader/assets/grub_test.go create mode 100644 bootloader/bootloader.go create mode 100644 bootloader/bootloader_test.go create mode 100644 bootloader/bootloadertest/bootloadertest.go create mode 100644 bootloader/bootloadertest/utf16.go create mode 100644 bootloader/efi/efi.go create mode 100644 bootloader/efi/efi_test.go create mode 100644 bootloader/export_test.go create mode 100644 bootloader/grub.go create mode 100644 bootloader/grub_test.go create mode 100644 bootloader/grubenv/grubenv.go create mode 100644 bootloader/grubenv/grubenv_test.go create mode 100644 bootloader/lk.go create mode 100644 bootloader/lk_test.go create mode 100644 bootloader/lkenv/export_test.go create mode 100644 bootloader/lkenv/lkenv.go create mode 100644 bootloader/lkenv/lkenv_test.go create mode 100644 bootloader/lkenv/lkenv_v1.go create mode 100644 bootloader/lkenv/lkenv_v2.go create mode 100644 bootloader/piboot.go create mode 100644 bootloader/piboot.md create mode 100644 bootloader/piboot_test.go create mode 100644 bootloader/uboot.go create mode 100644 bootloader/uboot_test.go create mode 100644 bootloader/ubootenv/env.go create mode 100644 bootloader/ubootenv/env_test.go create mode 100644 bootloader/ubootenv/export_test.go create mode 100644 bootloader/withbootassettesting.go create mode 100644 bootloader/withbootassettesting_test.go create mode 100644 build-aux/snap/local/apparmor/af_names.h create mode 100644 build-aux/snap/patches/apparmor/parser-replace-dynamic_cast-with-is_type-method.patch create mode 100644 build-aux/snap/snapcraft.yaml create mode 100644 c-vendor/README create mode 100755 c-vendor/vendor.sh create mode 100755 check-commit-email.py create mode 100755 check-pr-title.py create mode 100644 client/aliases.go create mode 100644 client/aliases_test.go create mode 100644 client/apps.go create mode 100644 client/apps_test.go create mode 100644 client/aspects.go create mode 100644 client/aspects_test.go create mode 100644 client/asserts.go create mode 100644 client/asserts_test.go create mode 100644 client/buy.go create mode 100644 client/change.go create mode 100644 client/change_test.go create mode 100644 client/client.go create mode 100644 client/client_test.go create mode 100644 client/clientutil/modelinfo.go create mode 100644 client/clientutil/modelinfo_test.go create mode 100644 client/clientutil/service_scope.go create mode 100644 client/clientutil/service_scope_test.go create mode 100644 client/clientutil/snapinfo.go create mode 100644 client/clientutil/snapinfo_test.go create mode 100644 client/cohort.go create mode 100644 client/cohort_test.go create mode 100644 client/conf.go create mode 100644 client/conf_test.go create mode 100644 client/connections.go create mode 100644 client/connections_test.go create mode 100644 client/console_conf.go create mode 100644 client/console_conf_test.go create mode 100644 client/errors.go create mode 100644 client/export_test.go create mode 100644 client/icons.go create mode 100644 client/icons_test.go create mode 100644 client/interfaces.go create mode 100644 client/interfaces_test.go create mode 100644 client/login.go create mode 100644 client/login_test.go create mode 100644 client/model.go create mode 100644 client/model_test.go create mode 100644 client/notices.go create mode 100644 client/notices_test.go create mode 100644 client/packages.go create mode 100644 client/packages_test.go create mode 100644 client/quota.go create mode 100644 client/quota_test.go create mode 100644 client/snap_op.go create mode 100644 client/snap_op_test.go create mode 100644 client/snapctl.go create mode 100644 client/snapctl_test.go create mode 100644 client/snapshot.go create mode 100644 client/snapshot_test.go create mode 100644 client/systems.go create mode 100644 client/systems_test.go create mode 100644 client/users.go create mode 100644 client/users_test.go create mode 100644 client/validate.go create mode 100644 client/validate_test.go create mode 100644 client/warnings.go create mode 100644 client/warnings_test.go create mode 100644 cmd/.clangd create mode 100644 cmd/.indent.pro create mode 100644 cmd/Makefile.am create mode 100755 cmd/autogen.sh create mode 100644 cmd/configure.ac create mode 100644 cmd/decode-mount-opts/decode-mount-opts.c create mode 100644 cmd/libsnap-confine-private/apparmor-support.c create mode 100644 cmd/libsnap-confine-private/apparmor-support.h create mode 100644 cmd/libsnap-confine-private/bpf-support.c create mode 100644 cmd/libsnap-confine-private/bpf-support.h create mode 100644 cmd/libsnap-confine-private/bpf/bpf-insn.h create mode 100644 cmd/libsnap-confine-private/bpf/vendor/linux/bpf.h create mode 100644 cmd/libsnap-confine-private/bpf/vendor/linux/bpf_common.h create mode 100644 cmd/libsnap-confine-private/cgroup-freezer-support.c create mode 100644 cmd/libsnap-confine-private/cgroup-freezer-support.h create mode 100644 cmd/libsnap-confine-private/cgroup-support-test.c create mode 100644 cmd/libsnap-confine-private/cgroup-support.c create mode 100644 cmd/libsnap-confine-private/cgroup-support.h create mode 100644 cmd/libsnap-confine-private/classic-test.c create mode 100644 cmd/libsnap-confine-private/classic.c create mode 100644 cmd/libsnap-confine-private/classic.h create mode 100644 cmd/libsnap-confine-private/cleanup-funcs-test.c create mode 100644 cmd/libsnap-confine-private/cleanup-funcs.c create mode 100644 cmd/libsnap-confine-private/cleanup-funcs.h create mode 100644 cmd/libsnap-confine-private/device-cgroup-support.c create mode 100644 cmd/libsnap-confine-private/device-cgroup-support.h create mode 100644 cmd/libsnap-confine-private/error-test.c create mode 100644 cmd/libsnap-confine-private/error.c create mode 100644 cmd/libsnap-confine-private/error.h create mode 100644 cmd/libsnap-confine-private/fault-injection-test.c create mode 100644 cmd/libsnap-confine-private/fault-injection.c create mode 100644 cmd/libsnap-confine-private/fault-injection.h create mode 100644 cmd/libsnap-confine-private/feature-test.c create mode 100644 cmd/libsnap-confine-private/feature.c create mode 100644 cmd/libsnap-confine-private/feature.h create mode 100644 cmd/libsnap-confine-private/infofile-test.c create mode 100644 cmd/libsnap-confine-private/infofile.c create mode 100644 cmd/libsnap-confine-private/infofile.h create mode 100644 cmd/libsnap-confine-private/locking-test.c create mode 100644 cmd/libsnap-confine-private/locking.c create mode 100644 cmd/libsnap-confine-private/locking.h create mode 100644 cmd/libsnap-confine-private/mount-opt-test.c create mode 100644 cmd/libsnap-confine-private/mount-opt.c create mode 100644 cmd/libsnap-confine-private/mount-opt.h create mode 100644 cmd/libsnap-confine-private/mountinfo-test.c create mode 100644 cmd/libsnap-confine-private/mountinfo.c create mode 100644 cmd/libsnap-confine-private/mountinfo.h create mode 100644 cmd/libsnap-confine-private/panic-test.c create mode 100644 cmd/libsnap-confine-private/panic.c create mode 100644 cmd/libsnap-confine-private/panic.h create mode 100644 cmd/libsnap-confine-private/privs-test.c create mode 100644 cmd/libsnap-confine-private/privs.c create mode 100644 cmd/libsnap-confine-private/privs.h create mode 100644 cmd/libsnap-confine-private/secure-getenv-test.c create mode 100644 cmd/libsnap-confine-private/secure-getenv.c create mode 100644 cmd/libsnap-confine-private/secure-getenv.h create mode 100644 cmd/libsnap-confine-private/snap-test.c create mode 100644 cmd/libsnap-confine-private/snap.c create mode 100644 cmd/libsnap-confine-private/snap.h create mode 100644 cmd/libsnap-confine-private/string-utils-test.c create mode 100644 cmd/libsnap-confine-private/string-utils.c create mode 100644 cmd/libsnap-confine-private/string-utils.h create mode 100644 cmd/libsnap-confine-private/test-utils-test.c create mode 100644 cmd/libsnap-confine-private/test-utils.c create mode 100644 cmd/libsnap-confine-private/test-utils.h create mode 100644 cmd/libsnap-confine-private/tool.c create mode 100644 cmd/libsnap-confine-private/tool.h create mode 100644 cmd/libsnap-confine-private/unit-tests-main.c create mode 100644 cmd/libsnap-confine-private/unit-tests.c create mode 100644 cmd/libsnap-confine-private/unit-tests.h create mode 100644 cmd/libsnap-confine-private/utils-test.c create mode 100644 cmd/libsnap-confine-private/utils.c create mode 100644 cmd/libsnap-confine-private/utils.h create mode 100644 cmd/snap-bootstrap/README.md create mode 100644 cmd/snap-bootstrap/cmd_initramfs_mounts.go create mode 100644 cmd/snap-bootstrap/cmd_initramfs_mounts_nosecboot.go create mode 100644 cmd/snap-bootstrap/cmd_initramfs_mounts_recover_degraded_test.go create mode 100644 cmd/snap-bootstrap/cmd_initramfs_mounts_secboot.go create mode 100644 cmd/snap-bootstrap/cmd_initramfs_mounts_test.go create mode 100644 cmd/snap-bootstrap/cmd_recovery_chooser_trigger.go create mode 100644 cmd/snap-bootstrap/cmd_recovery_chooser_trigger_test.go create mode 100644 cmd/snap-bootstrap/degraded-recover-mode.svg create mode 100644 cmd/snap-bootstrap/export_test.go create mode 100644 cmd/snap-bootstrap/initramfs_mounts_state.go create mode 100644 cmd/snap-bootstrap/initramfs_systemd_mount.go create mode 100644 cmd/snap-bootstrap/initramfs_systemd_mount_test.go create mode 100644 cmd/snap-bootstrap/main.go create mode 100644 cmd/snap-bootstrap/main_test.go create mode 100644 cmd/snap-bootstrap/triggerwatch/evdev.go create mode 100644 cmd/snap-bootstrap/triggerwatch/export_test.go create mode 100644 cmd/snap-bootstrap/triggerwatch/triggerwatch.go create mode 100644 cmd/snap-bootstrap/triggerwatch/triggerwatch_test.go create mode 100644 cmd/snap-confine/PORTING create mode 100644 cmd/snap-confine/README.mount_namespace create mode 100644 cmd/snap-confine/README.nvidia create mode 100644 cmd/snap-confine/README.syscalls create mode 100644 cmd/snap-confine/cookie-support-test.c create mode 100644 cmd/snap-confine/cookie-support.c create mode 100644 cmd/snap-confine/cookie-support.h create mode 100644 cmd/snap-confine/misc/0001-Add-printk-based-debugging-to-pivot_root.patch create mode 100644 cmd/snap-confine/mount-support-nvidia.c create mode 100644 cmd/snap-confine/mount-support-nvidia.h create mode 100644 cmd/snap-confine/mount-support-test.c create mode 100644 cmd/snap-confine/mount-support.c create mode 100644 cmd/snap-confine/mount-support.h create mode 100644 cmd/snap-confine/ns-support-test.c create mode 100644 cmd/snap-confine/ns-support.c create mode 100644 cmd/snap-confine/ns-support.h create mode 100644 cmd/snap-confine/seccomp-support-ext.c create mode 100644 cmd/snap-confine/seccomp-support-ext.h create mode 100644 cmd/snap-confine/seccomp-support-test.c create mode 100644 cmd/snap-confine/seccomp-support.c create mode 100644 cmd/snap-confine/seccomp-support.h create mode 100644 cmd/snap-confine/selinux-support.c create mode 100644 cmd/snap-confine/selinux-support.h create mode 100644 cmd/snap-confine/snap-confine-args-test.c create mode 100644 cmd/snap-confine/snap-confine-args.c create mode 100644 cmd/snap-confine/snap-confine-args.h create mode 100644 cmd/snap-confine/snap-confine-invocation-test.c create mode 100644 cmd/snap-confine/snap-confine-invocation.c create mode 100644 cmd/snap-confine/snap-confine-invocation.h create mode 100644 cmd/snap-confine/snap-confine.apparmor.in create mode 100644 cmd/snap-confine/snap-confine.c create mode 100644 cmd/snap-confine/snap-confine.rst create mode 100644 cmd/snap-confine/spread-tests/main/cgroup-used/task.yaml create mode 100644 cmd/snap-confine/spread-tests/main/core-is-preferred/task.yaml create mode 100644 cmd/snap-confine/spread-tests/main/debug-flags/task.yaml create mode 100644 cmd/snap-confine/spread-tests/main/hostfs-created-on-demand/task.yaml create mode 100644 cmd/snap-confine/spread-tests/main/media-visible-in-devmode/task.yaml create mode 100644 cmd/snap-confine/spread-tests/main/mount-ns-sharing/task.yaml create mode 100644 cmd/snap-confine/spread-tests/main/mount-profiles-bin-snap-destination/task.yaml create mode 100644 cmd/snap-confine/spread-tests/main/mount-profiles-bin-snap-source/task.yaml create mode 100644 cmd/snap-confine/spread-tests/main/mount-profiles-missing-dst/task.yaml create mode 100644 cmd/snap-confine/spread-tests/main/mount-profiles-missing-src/task.yaml create mode 100644 cmd/snap-confine/spread-tests/main/mount-profiles-mount-tmpfs/task.yaml create mode 100644 cmd/snap-confine/spread-tests/main/mount-profiles-ro-mount/task.yaml create mode 100644 cmd/snap-confine/spread-tests/main/mount-profiles-rw-mount/task.yaml create mode 100644 cmd/snap-confine/spread-tests/main/mount-usr-src/task.yaml create mode 100644 cmd/snap-confine/spread-tests/main/test-seccomp-compat/task.yaml create mode 100644 cmd/snap-confine/spread-tests/main/test-snap-runs/task.yaml create mode 100644 cmd/snap-confine/spread-tests/main/user-data-dir-created/task.yaml create mode 100644 cmd/snap-confine/spread-tests/main/user-xdg-runtime-dir-created/task.yaml create mode 100644 cmd/snap-confine/spread-tests/regression/lp-1599608/task.yaml create mode 100644 cmd/snap-confine/udev-support.c create mode 100644 cmd/snap-confine/udev-support.h create mode 100644 cmd/snap-confine/user-support.c create mode 100644 cmd/snap-confine/user-support.h create mode 100644 cmd/snap-device-helper/main.c create mode 100644 cmd/snap-device-helper/snap-device-helper-test.c create mode 100644 cmd/snap-device-helper/snap-device-helper.c create mode 100644 cmd/snap-device-helper/snap-device-helper.h create mode 100644 cmd/snap-discard-ns/snap-discard-ns.c create mode 100644 cmd/snap-discard-ns/snap-discard-ns.rst create mode 100644 cmd/snap-exec/export_test.go create mode 100644 cmd/snap-exec/main.go create mode 100644 cmd/snap-exec/main_test.go create mode 100644 cmd/snap-failure/cmd_snapd.go create mode 100644 cmd/snap-failure/cmd_snapd_test.go create mode 100644 cmd/snap-failure/export_test.go create mode 100644 cmd/snap-failure/main.go create mode 100644 cmd/snap-failure/main_test.go create mode 100644 cmd/snap-fde-keymgr/export_test.go create mode 100644 cmd/snap-fde-keymgr/main.go create mode 100644 cmd/snap-fde-keymgr/main_test.go create mode 100644 cmd/snap-gdb-shim/snap-gdb-shim.c create mode 100644 cmd/snap-gdb-shim/snap-gdbserver-shim.c create mode 100644 cmd/snap-mgmt/snap-mgmt-selinux.sh.in create mode 100755 cmd/snap-mgmt/snap-mgmt.sh.in create mode 100644 cmd/snap-preseed/export_test.go create mode 100644 cmd/snap-preseed/main.go create mode 100644 cmd/snap-preseed/preseed_classic_test.go create mode 100644 cmd/snap-preseed/preseed_uc20_test.go create mode 100644 cmd/snap-recovery-chooser/export_test.go create mode 100644 cmd/snap-recovery-chooser/main.go create mode 100644 cmd/snap-recovery-chooser/main_test.go create mode 100644 cmd/snap-repair/cmd_done_retry_skip.go create mode 100644 cmd/snap-repair/cmd_done_retry_skip_test.go create mode 100644 cmd/snap-repair/cmd_list.go create mode 100644 cmd/snap-repair/cmd_list_test.go create mode 100644 cmd/snap-repair/cmd_run.go create mode 100644 cmd/snap-repair/cmd_run_test.go create mode 100644 cmd/snap-repair/cmd_show.go create mode 100644 cmd/snap-repair/cmd_show_test.go create mode 100644 cmd/snap-repair/export_test.go create mode 100644 cmd/snap-repair/main.go create mode 100644 cmd/snap-repair/main_test.go create mode 100644 cmd/snap-repair/runner.go create mode 100644 cmd/snap-repair/runner_test.go create mode 100644 cmd/snap-repair/staging.go create mode 100644 cmd/snap-repair/testkeys.go create mode 100644 cmd/snap-repair/trace.go create mode 100644 cmd/snap-repair/trace_test.go create mode 100644 cmd/snap-repair/trusted.go create mode 100644 cmd/snap-seccomp-blacklist/.gitignore create mode 100644 cmd/snap-seccomp-blacklist/BE-bpf-script create mode 100644 cmd/snap-seccomp-blacklist/LE-bpf-script create mode 100644 cmd/snap-seccomp-blacklist/Makefile create mode 100644 cmd/snap-seccomp-blacklist/snap-seccomp-blacklist.c create mode 100644 cmd/snap-seccomp/export_test.go create mode 100644 cmd/snap-seccomp/main.go create mode 100644 cmd/snap-seccomp/main_nonriscv64.go create mode 100644 cmd/snap-seccomp/main_ppc64le.go create mode 100644 cmd/snap-seccomp/main_riscv64.go create mode 100644 cmd/snap-seccomp/main_test.go create mode 100644 cmd/snap-seccomp/old_seccomp.go create mode 100644 cmd/snap-seccomp/syscalls/syscalls.go create mode 100644 cmd/snap-seccomp/versioninfo.go create mode 100644 cmd/snap-seccomp/versioninfo_test.go create mode 100644 cmd/snap-update-ns/bootstrap.c create mode 100644 cmd/snap-update-ns/bootstrap.go create mode 100644 cmd/snap-update-ns/bootstrap.h create mode 100644 cmd/snap-update-ns/bootstrap_ppc64le.go create mode 100644 cmd/snap-update-ns/bootstrap_test.go create mode 100644 cmd/snap-update-ns/change.go create mode 100644 cmd/snap-update-ns/change_test.go create mode 100644 cmd/snap-update-ns/common.go create mode 100644 cmd/snap-update-ns/common_test.go create mode 100644 cmd/snap-update-ns/expand.go create mode 100644 cmd/snap-update-ns/expand_test.go create mode 100644 cmd/snap-update-ns/export_test.go create mode 100644 cmd/snap-update-ns/main.go create mode 100644 cmd/snap-update-ns/main_test.go create mode 100644 cmd/snap-update-ns/secure_bindmount.go create mode 100644 cmd/snap-update-ns/secure_bindmount_test.go create mode 100644 cmd/snap-update-ns/sorting.go create mode 100644 cmd/snap-update-ns/sorting_test.go create mode 100644 cmd/snap-update-ns/system.go create mode 100644 cmd/snap-update-ns/system_test.go create mode 100644 cmd/snap-update-ns/trespassing.go create mode 100644 cmd/snap-update-ns/trespassing_test.go create mode 100644 cmd/snap-update-ns/update.go create mode 100644 cmd/snap-update-ns/update_test.go create mode 100644 cmd/snap-update-ns/user.go create mode 100644 cmd/snap-update-ns/user_test.go create mode 100644 cmd/snap-update-ns/utils.go create mode 100644 cmd/snap-update-ns/utils_test.go create mode 100644 cmd/snap/cmd_abort.go create mode 100644 cmd/snap/cmd_abort_test.go create mode 100644 cmd/snap/cmd_ack.go create mode 100644 cmd/snap/cmd_advise.go create mode 100644 cmd/snap/cmd_advise_test.go create mode 100644 cmd/snap/cmd_alias.go create mode 100644 cmd/snap/cmd_alias_test.go create mode 100644 cmd/snap/cmd_aliases.go create mode 100644 cmd/snap/cmd_aliases_test.go create mode 100644 cmd/snap/cmd_auto_import.go create mode 100644 cmd/snap/cmd_auto_import_test.go create mode 100644 cmd/snap/cmd_booted.go create mode 100644 cmd/snap/cmd_buy.go create mode 100644 cmd/snap/cmd_buy_test.go create mode 100644 cmd/snap/cmd_can_manage_refreshes.go create mode 100644 cmd/snap/cmd_changes.go create mode 100644 cmd/snap/cmd_changes_test.go create mode 100644 cmd/snap/cmd_confinement.go create mode 100644 cmd/snap/cmd_confinement_test.go create mode 100644 cmd/snap/cmd_connect.go create mode 100644 cmd/snap/cmd_connect_test.go create mode 100644 cmd/snap/cmd_connections.go create mode 100644 cmd/snap/cmd_connections_test.go create mode 100644 cmd/snap/cmd_connectivity_check.go create mode 100644 cmd/snap/cmd_connectivity_check_test.go create mode 100644 cmd/snap/cmd_create_cohort.go create mode 100644 cmd/snap/cmd_create_cohort_test.go create mode 100644 cmd/snap/cmd_create_key.go create mode 100644 cmd/snap/cmd_create_key_test.go create mode 100644 cmd/snap/cmd_create_user.go create mode 100644 cmd/snap/cmd_create_user_test.go create mode 100644 cmd/snap/cmd_debug.go create mode 100644 cmd/snap/cmd_debug_bootvars.go create mode 100644 cmd/snap/cmd_debug_bootvars_test.go create mode 100644 cmd/snap/cmd_debug_disks.go create mode 100644 cmd/snap/cmd_debug_gadget_disk_mapping.go create mode 100644 cmd/snap/cmd_debug_migrate.go create mode 100644 cmd/snap/cmd_debug_migrate_test.go create mode 100644 cmd/snap/cmd_debug_model.go create mode 100644 cmd/snap/cmd_debug_model_test.go create mode 100644 cmd/snap/cmd_debug_seeding.go create mode 100644 cmd/snap/cmd_debug_seeding_test.go create mode 100644 cmd/snap/cmd_debug_stacktraces.go create mode 100644 cmd/snap/cmd_debug_state.go create mode 100644 cmd/snap/cmd_debug_state_test.go create mode 100644 cmd/snap/cmd_debug_timings.go create mode 100644 cmd/snap/cmd_debug_timings_test.go create mode 100644 cmd/snap/cmd_debug_validate_seed.go create mode 100644 cmd/snap/cmd_debug_validate_seed_test.go create mode 100644 cmd/snap/cmd_delete_key.go create mode 100644 cmd/snap/cmd_delete_key_test.go create mode 100644 cmd/snap/cmd_disconnect.go create mode 100644 cmd/snap/cmd_disconnect_test.go create mode 100644 cmd/snap/cmd_download.go create mode 100644 cmd/snap/cmd_download_test.go create mode 100644 cmd/snap/cmd_ensure_state_soon.go create mode 100644 cmd/snap/cmd_ensure_state_soon_test.go create mode 100644 cmd/snap/cmd_export_key.go create mode 100644 cmd/snap/cmd_export_key_test.go create mode 100644 cmd/snap/cmd_find.go create mode 100644 cmd/snap/cmd_find_test.go create mode 100644 cmd/snap/cmd_first_boot.go create mode 100644 cmd/snap/cmd_get.go create mode 100644 cmd/snap/cmd_get_base_declaration.go create mode 100644 cmd/snap/cmd_get_base_declaration_test.go create mode 100644 cmd/snap/cmd_get_test.go create mode 100644 cmd/snap/cmd_handle_link.go create mode 100644 cmd/snap/cmd_help.go create mode 100644 cmd/snap/cmd_help_test.go create mode 100644 cmd/snap/cmd_info.go create mode 100644 cmd/snap/cmd_info_test.go create mode 100644 cmd/snap/cmd_interface.go create mode 100644 cmd/snap/cmd_interface_test.go create mode 100644 cmd/snap/cmd_interfaces.go create mode 100644 cmd/snap/cmd_interfaces_test.go create mode 100644 cmd/snap/cmd_keys.go create mode 100644 cmd/snap/cmd_keys_test.go create mode 100644 cmd/snap/cmd_known.go create mode 100644 cmd/snap/cmd_known_test.go create mode 100644 cmd/snap/cmd_list.go create mode 100644 cmd/snap/cmd_list_test.go create mode 100644 cmd/snap/cmd_login.go create mode 100644 cmd/snap/cmd_login_test.go create mode 100644 cmd/snap/cmd_logout.go create mode 100644 cmd/snap/cmd_managed.go create mode 100644 cmd/snap/cmd_managed_test.go create mode 100644 cmd/snap/cmd_model.go create mode 100644 cmd/snap/cmd_model_test.go create mode 100644 cmd/snap/cmd_pack.go create mode 100644 cmd/snap/cmd_pack_test.go create mode 100644 cmd/snap/cmd_paths.go create mode 100644 cmd/snap/cmd_paths_test.go create mode 100644 cmd/snap/cmd_prefer.go create mode 100644 cmd/snap/cmd_prefer_test.go create mode 100644 cmd/snap/cmd_prepare_image.go create mode 100644 cmd/snap/cmd_prepare_image_test.go create mode 100644 cmd/snap/cmd_quota.go create mode 100644 cmd/snap/cmd_quota_test.go create mode 100644 cmd/snap/cmd_reboot.go create mode 100644 cmd/snap/cmd_reboot_test.go create mode 100644 cmd/snap/cmd_recovery.go create mode 100644 cmd/snap/cmd_recovery_test.go create mode 100644 cmd/snap/cmd_remodel.go create mode 100644 cmd/snap/cmd_remodel_test.go create mode 100644 cmd/snap/cmd_remove_user.go create mode 100644 cmd/snap/cmd_remove_user_test.go create mode 100644 cmd/snap/cmd_repair_repairs.go create mode 100644 cmd/snap/cmd_repair_repairs_test.go create mode 100644 cmd/snap/cmd_routine.go create mode 100644 cmd/snap/cmd_routine_console_conf.go create mode 100644 cmd/snap/cmd_routine_console_conf_test.go create mode 100644 cmd/snap/cmd_routine_file_access.go create mode 100644 cmd/snap/cmd_routine_file_access_test.go create mode 100644 cmd/snap/cmd_routine_portal_info.go create mode 100644 cmd/snap/cmd_routine_portal_info_test.go create mode 100644 cmd/snap/cmd_run.go create mode 100644 cmd/snap/cmd_run_test.go create mode 100644 cmd/snap/cmd_sandbox_features.go create mode 100644 cmd/snap/cmd_sandbox_features_test.go create mode 100644 cmd/snap/cmd_services.go create mode 100644 cmd/snap/cmd_services_test.go create mode 100644 cmd/snap/cmd_set.go create mode 100644 cmd/snap/cmd_set_test.go create mode 100644 cmd/snap/cmd_sign.go create mode 100644 cmd/snap/cmd_sign_build.go create mode 100644 cmd/snap/cmd_sign_build_test.go create mode 100644 cmd/snap/cmd_sign_test.go create mode 100644 cmd/snap/cmd_snap_op.go create mode 100644 cmd/snap/cmd_snap_op_test.go create mode 100644 cmd/snap/cmd_snapshot.go create mode 100644 cmd/snap/cmd_snapshot_test.go create mode 100644 cmd/snap/cmd_unalias.go create mode 100644 cmd/snap/cmd_unalias_test.go create mode 100644 cmd/snap/cmd_unset.go create mode 100644 cmd/snap/cmd_unset_test.go create mode 100644 cmd/snap/cmd_userd.go create mode 100644 cmd/snap/cmd_userd_test.go create mode 100644 cmd/snap/cmd_validate.go create mode 100644 cmd/snap/cmd_validate_test.go create mode 100644 cmd/snap/cmd_version.go create mode 100644 cmd/snap/cmd_version_linux.go create mode 100644 cmd/snap/cmd_version_other.go create mode 100644 cmd/snap/cmd_version_test.go create mode 100644 cmd/snap/cmd_wait.go create mode 100644 cmd/snap/cmd_wait_test.go create mode 100644 cmd/snap/cmd_warnings.go create mode 100644 cmd/snap/cmd_warnings_test.go create mode 100644 cmd/snap/cmd_watch.go create mode 100644 cmd/snap/cmd_watch_test.go create mode 100644 cmd/snap/cmd_whoami.go create mode 100644 cmd/snap/cmd_whoami_test.go create mode 100644 cmd/snap/color.go create mode 100644 cmd/snap/color_test.go create mode 100644 cmd/snap/complete.go create mode 100644 cmd/snap/error.go create mode 100644 cmd/snap/export_test.go create mode 100644 cmd/snap/fallocate_darwin.go create mode 100644 cmd/snap/fallocate_linux.go create mode 100644 cmd/snap/gnupg2_test.go create mode 100644 cmd/snap/inhibit.go create mode 100644 cmd/snap/inhibit_test.go create mode 100644 cmd/snap/interfaces_common.go create mode 100644 cmd/snap/interfaces_common_test.go create mode 100644 cmd/snap/last.go create mode 100644 cmd/snap/main.go create mode 100644 cmd/snap/main_test.go create mode 100644 cmd/snap/notes.go create mode 100644 cmd/snap/notes_test.go create mode 100644 cmd/snap/test-data/pubring.gpg create mode 100644 cmd/snap/test-data/secring.gpg create mode 100644 cmd/snap/test-data/trustdb.gpg create mode 100644 cmd/snap/times.go create mode 100644 cmd/snap/wait.go create mode 100644 cmd/snapctl/main.go create mode 100644 cmd/snapctl/main_test.go create mode 100644 cmd/snapd-apparmor/export_test.go create mode 100644 cmd/snapd-apparmor/main.go create mode 100644 cmd/snapd-apparmor/main_test.go create mode 100644 cmd/snapd-env-generator/main.c create mode 100644 cmd/snapd-env-generator/snapd-env-generator.rst create mode 100644 cmd/snapd-generator/main.c create mode 100644 cmd/snapd/export_test.go create mode 100644 cmd/snapd/main.go create mode 100644 cmd/snapd/main_test.go create mode 100644 cmd/snaplock/lock.go create mode 100644 cmd/snaplock/lock_test.go create mode 100644 cmd/snaplock/runinhibit/export_test.go create mode 100644 cmd/snaplock/runinhibit/inhibit.go create mode 100644 cmd/snaplock/runinhibit/inhibit_test.go create mode 100644 cmd/system-shutdown/system-shutdown-utils-test.c create mode 100644 cmd/system-shutdown/system-shutdown-utils.c create mode 100644 cmd/system-shutdown/system-shutdown-utils.h create mode 100644 cmd/system-shutdown/system-shutdown.c create mode 100644 codecov.yml create mode 100644 daemon/access.go create mode 100644 daemon/access_test.go create mode 100644 daemon/api.go create mode 100644 daemon/api_accessories.go create mode 100644 daemon/api_accessories_test.go create mode 100644 daemon/api_aliases.go create mode 100644 daemon/api_aliases_test.go create mode 100644 daemon/api_apps.go create mode 100644 daemon/api_apps_test.go create mode 100644 daemon/api_aspects.go create mode 100644 daemon/api_aspects_test.go create mode 100644 daemon/api_asserts.go create mode 100644 daemon/api_asserts_test.go create mode 100644 daemon/api_base_test.go create mode 100644 daemon/api_buy_unsupp.go create mode 100644 daemon/api_buy_unsupp_test.go create mode 100644 daemon/api_categories.go create mode 100644 daemon/api_cohort.go create mode 100644 daemon/api_cohort_test.go create mode 100644 daemon/api_connections.go create mode 100644 daemon/api_connections_test.go create mode 100644 daemon/api_console_conf.go create mode 100644 daemon/api_console_conf_test.go create mode 100644 daemon/api_debug.go create mode 100644 daemon/api_debug_migrate.go create mode 100644 daemon/api_debug_pprof.go create mode 100644 daemon/api_debug_pprof_test.go create mode 100644 daemon/api_debug_seeding.go create mode 100644 daemon/api_debug_seeding_test.go create mode 100644 daemon/api_debug_stacktrace.go create mode 100644 daemon/api_debug_test.go create mode 100644 daemon/api_download.go create mode 100644 daemon/api_download_test.go create mode 100644 daemon/api_find.go create mode 100644 daemon/api_find_test.go create mode 100644 daemon/api_general.go create mode 100644 daemon/api_general_test.go create mode 100644 daemon/api_icons.go create mode 100644 daemon/api_icons_test.go create mode 100644 daemon/api_interfaces.go create mode 100644 daemon/api_interfaces_test.go create mode 100644 daemon/api_json.go create mode 100644 daemon/api_model.go create mode 100644 daemon/api_model_test.go create mode 100644 daemon/api_notices.go create mode 100644 daemon/api_notices_test.go create mode 100644 daemon/api_quotas.go create mode 100644 daemon/api_quotas_test.go create mode 100644 daemon/api_sections.go create mode 100644 daemon/api_sideload_n_try.go create mode 100644 daemon/api_sideload_n_try_test.go create mode 100644 daemon/api_snap_conf.go create mode 100644 daemon/api_snap_conf_test.go create mode 100644 daemon/api_snap_file.go create mode 100644 daemon/api_snap_file_test.go create mode 100644 daemon/api_snapctl.go create mode 100644 daemon/api_snapctl_test.go create mode 100644 daemon/api_snaps.go create mode 100644 daemon/api_snaps_test.go create mode 100644 daemon/api_snapshots.go create mode 100644 daemon/api_snapshots_test.go create mode 100644 daemon/api_system_recovery_keys.go create mode 100644 daemon/api_system_recovery_keys_test.go create mode 100644 daemon/api_systems.go create mode 100644 daemon/api_systems_test.go create mode 100644 daemon/api_test.go create mode 100644 daemon/api_themes.go create mode 100644 daemon/api_themes_test.go create mode 100644 daemon/api_users.go create mode 100644 daemon/api_users_test.go create mode 100644 daemon/api_validate.go create mode 100644 daemon/api_validate_test.go create mode 100644 daemon/command_counter_test.go create mode 100644 daemon/daemon.go create mode 100644 daemon/daemon_test.go create mode 100644 daemon/errors.go create mode 100644 daemon/errors_test.go create mode 100644 daemon/export_access_test.go create mode 100644 daemon/export_api_aliases_test.go create mode 100644 daemon/export_api_apps_test.go create mode 100644 daemon/export_api_console_conf_test.go create mode 100644 daemon/export_api_debug_seeding_test.go create mode 100644 daemon/export_api_debug_test.go create mode 100644 daemon/export_api_download_test.go create mode 100644 daemon/export_api_general_test.go create mode 100644 daemon/export_api_model_test.go create mode 100644 daemon/export_api_notices_test.go create mode 100644 daemon/export_api_quotas_test.go create mode 100644 daemon/export_api_sideload_n_try_test.go create mode 100644 daemon/export_api_snapctl_test.go create mode 100644 daemon/export_api_snaps_test.go create mode 100644 daemon/export_api_snapshots_test.go create mode 100644 daemon/export_api_system_recovery_keys_test.go create mode 100644 daemon/export_api_systems_test.go create mode 100644 daemon/export_api_themes_test.go create mode 100644 daemon/export_api_users_test.go create mode 100644 daemon/export_api_validate_test.go create mode 100644 daemon/export_snap_test.go create mode 100644 daemon/export_test.go create mode 100644 daemon/request.go create mode 100644 daemon/response.go create mode 100644 daemon/response_test.go create mode 100644 daemon/snap.go create mode 100644 daemon/ucrednet.go create mode 100644 daemon/ucrednet_test.go create mode 100644 data/Makefile create mode 100644 data/apt/20snapd.conf create mode 100644 data/completion/bash/complete.sh create mode 100644 data/completion/bash/etelpmoc.sh create mode 100644 data/completion/bash/snap create mode 100644 data/completion/zsh/_snap create mode 100644 data/dbus/Makefile create mode 100644 data/dbus/io.snapcraft.Launcher.service.in create mode 100644 data/dbus/io.snapcraft.SessionAgent.service.in create mode 100644 data/dbus/io.snapcraft.Settings.service.in create mode 100644 data/dbus/snapd.session-services.conf create mode 100644 data/dbus/snapd.system-services.conf create mode 100644 data/desktop/Makefile create mode 100644 data/desktop/io.snapcraft.SessionAgent.desktop.in create mode 100644 data/desktop/snap-handle-link.desktop.in create mode 100644 data/desktop/snap-userd-autostart.desktop.in create mode 100644 data/desktop/snapcraft-logo-bird.svg create mode 100644 data/env/Makefile create mode 100644 data/env/snapd.fish.in create mode 100644 data/env/snapd.sh.in create mode 100644 data/failure.txt create mode 100644 data/polkit/io.snapcraft.snapd.policy create mode 100644 data/preseed.json create mode 100644 data/selinux/COPYING create mode 100644 data/selinux/INSTALL.md create mode 100644 data/selinux/Makefile create mode 100644 data/selinux/README.md create mode 100644 data/selinux/snappy.fc create mode 100644 data/selinux/snappy.if create mode 100644 data/selinux/snappy.te create mode 100644 data/success.txt create mode 100644 data/sysctl/rhel7-snap.conf create mode 100644 data/systemd-env/990-snapd.conf.in create mode 100644 data/systemd-env/Makefile create mode 100644 data/systemd-tmpfiles/Makefile create mode 100644 data/systemd-tmpfiles/snapd.conf create mode 100644 data/systemd-user/Makefile create mode 100644 data/systemd-user/snapd.session-agent.service.in create mode 100644 data/systemd-user/snapd.session-agent.socket create mode 100644 data/systemd/Makefile create mode 100644 data/systemd/snapd.apparmor.service.in create mode 100644 data/systemd/snapd.autoimport.service.in create mode 100644 data/systemd/snapd.core-fixup.service.in create mode 100755 data/systemd/snapd.core-fixup.sh create mode 100644 data/systemd/snapd.failure.service.in create mode 100644 data/systemd/snapd.mounts-pre.target create mode 100644 data/systemd/snapd.mounts.target create mode 100644 data/systemd/snapd.recovery-chooser-trigger.service.in create mode 100644 data/systemd/snapd.run-from-snap create mode 100644 data/systemd/snapd.seeded.service.in create mode 100644 data/systemd/snapd.service.in create mode 100644 data/systemd/snapd.snap-repair.service.in create mode 100644 data/systemd/snapd.snap-repair.timer create mode 100644 data/systemd/snapd.socket create mode 100644 data/systemd/snapd.system-shutdown.service.in create mode 100644 data/udev/rules.d/66-snapd-autoimport.rules create mode 100644 dbusutil/dbustest/dbustest.go create mode 100644 dbusutil/dbustest/stub.go create mode 100644 dbusutil/dbusutil.go create mode 100644 dbusutil/dbusutil_test.go create mode 100644 dbusutil/export_test.go create mode 100644 dbusutil/netplantest/netplantest.go create mode 120000 debian create mode 100755 debug-tools/gce-serial-output-continuously-append.sh create mode 100755 debug-tools/snap-debug-info.sh create mode 100755 debug-tools/startup-timings create mode 100644 desktop/desktopentry/desktopentry.go create mode 100644 desktop/desktopentry/desktopentry_test.go create mode 100644 desktop/desktopentry/expand_exec.go create mode 100644 desktop/desktopentry/expand_exec_test.go create mode 100644 desktop/desktopentry/export_test.go create mode 100644 desktop/notification/caps.go create mode 100644 desktop/notification/export_test.go create mode 100644 desktop/notification/fdo.go create mode 100644 desktop/notification/fdo_test.go create mode 100644 desktop/notification/gtk.go create mode 100644 desktop/notification/gtk_test.go create mode 100644 desktop/notification/hints.go create mode 100644 desktop/notification/hints_test.go create mode 100644 desktop/notification/manager.go create mode 100644 desktop/notification/manager_test.go create mode 100644 desktop/notification/notificationtest/fdo.go create mode 100644 desktop/notification/notificationtest/gtk.go create mode 100644 desktop/notification/notify.go create mode 100644 desktop/notification/notify_test.go create mode 100644 desktop/portal/document.go create mode 100644 desktop/portal/document_test.go create mode 100644 desktop/portal/export_test.go create mode 100644 desktop/portal/launcher.go create mode 100644 desktop/portal/launcher_test.go create mode 100644 dirs/dirs.go create mode 100644 dirs/dirs_test.go create mode 100644 dirs/export_test.go create mode 100644 docs/MOVED.md create mode 100644 docs/error-kinds.go create mode 100644 features/export_test.go create mode 100644 features/features.go create mode 100644 features/features_test.go create mode 100644 gadget/device.go create mode 100644 gadget/device/encrypt.go create mode 100644 gadget/device/encrypt_test.go create mode 100644 gadget/device_darwin.go create mode 100644 gadget/device_linux.go create mode 100644 gadget/device_test.go create mode 100644 gadget/edition/number.go create mode 100644 gadget/edition/number_test.go create mode 100644 gadget/export_test.go create mode 100644 gadget/gadget.go create mode 100644 gadget/gadget_test.go create mode 100644 gadget/gadgettest/examples.go create mode 100644 gadget/gadgettest/gadgettest.go create mode 100644 gadget/install/content.go create mode 100644 gadget/install/content_test.go create mode 100644 gadget/install/encrypt.go create mode 100644 gadget/install/encrypt_test.go create mode 100644 gadget/install/export_secboot_test.go create mode 100644 gadget/install/export_test.go create mode 100644 gadget/install/install.go create mode 100644 gadget/install/install_dummy.go create mode 100644 gadget/install/install_test.go create mode 100644 gadget/install/mount_linux.go create mode 100644 gadget/install/mount_other.go create mode 100644 gadget/install/params.go create mode 100644 gadget/install/partition.go create mode 100644 gadget/install/partition_test.go create mode 100644 gadget/kcmdline.go create mode 100644 gadget/kcmdline_test.go create mode 100644 gadget/layout.go create mode 100644 gadget/layout_test.go create mode 100644 gadget/mountedfilesystem.go create mode 100644 gadget/mountedfilesystem_test.go create mode 100644 gadget/ondisk.go create mode 100644 gadget/ondisk_test.go create mode 100644 gadget/partial.go create mode 100644 gadget/partial_test.go create mode 100644 gadget/quantity/offset.go create mode 100644 gadget/quantity/offset_test.go create mode 100644 gadget/quantity/size.go create mode 100644 gadget/quantity/size_test.go create mode 100644 gadget/raw.go create mode 100644 gadget/raw_test.go create mode 100644 gadget/update.go create mode 100644 gadget/update_test.go create mode 100644 gadget/validate.go create mode 100644 gadget/validate_test.go create mode 100755 gen-coverage.sh create mode 100755 generate-packaging-dir create mode 100755 get-deps.sh create mode 100644 go.mod create mode 100644 go.sum create mode 100644 httputil/client.go create mode 100644 httputil/client_test.go create mode 100644 httputil/export_test.go create mode 100644 httputil/logger.go create mode 100644 httputil/logger_test.go create mode 100644 httputil/retry.go create mode 100644 httputil/retry_test.go create mode 100644 httputil/transport.go create mode 100644 i18n/i18n.go create mode 100644 i18n/i18n_test.go create mode 100644 i18n/xgettext-go/main.go create mode 100644 i18n/xgettext-go/main_test.go create mode 100644 image/export_test.go create mode 100644 image/helpers.go create mode 100644 image/helpers_test.go create mode 100644 image/image_darwin.go create mode 100644 image/image_linux.go create mode 100644 image/image_test.go create mode 100644 image/options.go create mode 100644 image/preseed/export_test.go create mode 100644 image/preseed/preseed.go create mode 100644 image/preseed/preseed_classic_test.go create mode 100644 image/preseed/preseed_linux.go create mode 100644 image/preseed/preseed_other.go create mode 100644 image/preseed/preseed_test.go create mode 100644 image/preseed/preseed_uc20_test.go create mode 100644 image/preseed/reset.go create mode 100644 include/lk/snappy_boot_common.h create mode 100644 include/lk/snappy_boot_v1.h create mode 100644 include/lk/snappy_boot_v2.h create mode 100644 interfaces/apparmor/apparmor.go create mode 100644 interfaces/apparmor/apparmor_test.go create mode 100644 interfaces/apparmor/backend.go create mode 100644 interfaces/apparmor/backend_test.go create mode 100644 interfaces/apparmor/export_test.go create mode 100644 interfaces/apparmor/spec.go create mode 100644 interfaces/apparmor/spec_test.go create mode 100644 interfaces/apparmor/template.go create mode 100644 interfaces/apparmor/template_vars.go create mode 100644 interfaces/backend.go create mode 100644 interfaces/backends/backends.go create mode 100644 interfaces/backends/backends_test.go create mode 100644 interfaces/builtin/README.md create mode 100644 interfaces/builtin/account_control.go create mode 100644 interfaces/builtin/account_control_test.go create mode 100644 interfaces/builtin/accounts_service.go create mode 100644 interfaces/builtin/accounts_service_test.go create mode 100644 interfaces/builtin/acrn_support.go create mode 100644 interfaces/builtin/acrn_support_test.go create mode 100644 interfaces/builtin/adb_support.go create mode 100644 interfaces/builtin/adb_support_test.go create mode 100644 interfaces/builtin/all.go create mode 100644 interfaces/builtin/all_test.go create mode 100644 interfaces/builtin/allegro_vcu.go create mode 100644 interfaces/builtin/allegro_vcu_test.go create mode 100644 interfaces/builtin/alsa.go create mode 100644 interfaces/builtin/alsa_test.go create mode 100644 interfaces/builtin/appstream_metadata.go create mode 100644 interfaces/builtin/appstream_metadata_test.go create mode 100644 interfaces/builtin/audio_playback.go create mode 100644 interfaces/builtin/audio_playback_test.go create mode 100644 interfaces/builtin/audio_record.go create mode 100644 interfaces/builtin/audio_record_test.go create mode 100644 interfaces/builtin/autopilot.go create mode 100644 interfaces/builtin/autopilot_test.go create mode 100644 interfaces/builtin/avahi_control.go create mode 100644 interfaces/builtin/avahi_control_test.go create mode 100644 interfaces/builtin/avahi_observe.go create mode 100644 interfaces/builtin/avahi_observe_test.go create mode 100644 interfaces/builtin/block_devices.go create mode 100644 interfaces/builtin/block_devices_test.go create mode 100644 interfaces/builtin/bluetooth_control.go create mode 100644 interfaces/builtin/bluetooth_control_test.go create mode 100644 interfaces/builtin/bluez.go create mode 100644 interfaces/builtin/bluez_test.go create mode 100644 interfaces/builtin/bool_file.go create mode 100644 interfaces/builtin/bool_file_test.go create mode 100644 interfaces/builtin/broadcom_asic_control.go create mode 100644 interfaces/builtin/broadcom_asic_control_test.go create mode 100644 interfaces/builtin/browser_support.go create mode 100644 interfaces/builtin/browser_support_test.go create mode 100644 interfaces/builtin/calendar_service.go create mode 100644 interfaces/builtin/calendar_service_test.go create mode 100644 interfaces/builtin/camera.go create mode 100644 interfaces/builtin/camera_test.go create mode 100644 interfaces/builtin/can_bus.go create mode 100644 interfaces/builtin/can_bus_test.go create mode 100644 interfaces/builtin/cifs_mount.go create mode 100644 interfaces/builtin/cifs_mount_test.go create mode 100644 interfaces/builtin/classic_support.go create mode 100644 interfaces/builtin/classic_support_test.go create mode 100644 interfaces/builtin/common.go create mode 100644 interfaces/builtin/common_files.go create mode 100644 interfaces/builtin/common_test.go create mode 100644 interfaces/builtin/contacts_service.go create mode 100644 interfaces/builtin/contacts_service_test.go create mode 100644 interfaces/builtin/content.go create mode 100644 interfaces/builtin/content_test.go create mode 100644 interfaces/builtin/core_support.go create mode 100644 interfaces/builtin/core_support_test.go create mode 100644 interfaces/builtin/cpu_control.go create mode 100644 interfaces/builtin/cpu_control_test.go create mode 100644 interfaces/builtin/cups.go create mode 100644 interfaces/builtin/cups_control.go create mode 100644 interfaces/builtin/cups_control_test.go create mode 100644 interfaces/builtin/cups_test.go create mode 100644 interfaces/builtin/custom_device.go create mode 100644 interfaces/builtin/custom_device_test.go create mode 100644 interfaces/builtin/daemon_notify.go create mode 100644 interfaces/builtin/daemon_notify_test.go create mode 100644 interfaces/builtin/dbus.go create mode 100644 interfaces/builtin/dbus_test.go create mode 100644 interfaces/builtin/dcdbas_control.go create mode 100644 interfaces/builtin/dcdbas_control_test.go create mode 100644 interfaces/builtin/desktop.go create mode 100644 interfaces/builtin/desktop_launch.go create mode 100644 interfaces/builtin/desktop_launch_test.go create mode 100644 interfaces/builtin/desktop_legacy.go create mode 100644 interfaces/builtin/desktop_legacy_test.go create mode 100644 interfaces/builtin/desktop_test.go create mode 100644 interfaces/builtin/device_buttons.go create mode 100644 interfaces/builtin/device_buttons_test.go create mode 100644 interfaces/builtin/display_control.go create mode 100644 interfaces/builtin/display_control_test.go create mode 100644 interfaces/builtin/dm_crypt.go create mode 100644 interfaces/builtin/dm_crypt_test.go create mode 100644 interfaces/builtin/docker.go create mode 100644 interfaces/builtin/docker_support.go create mode 100644 interfaces/builtin/docker_support_test.go create mode 100644 interfaces/builtin/docker_test.go create mode 100644 interfaces/builtin/dsp.go create mode 100644 interfaces/builtin/dsp_test.go create mode 100644 interfaces/builtin/dvb.go create mode 100644 interfaces/builtin/dvb_test.go create mode 100644 interfaces/builtin/empty.go create mode 100644 interfaces/builtin/export_test.go create mode 100644 interfaces/builtin/firewall_control.go create mode 100644 interfaces/builtin/firewall_control_test.go create mode 100644 interfaces/builtin/fpga.go create mode 100644 interfaces/builtin/fpga_test.go create mode 100644 interfaces/builtin/framebuffer.go create mode 100644 interfaces/builtin/framebuffer_test.go create mode 100644 interfaces/builtin/fuse_support.go create mode 100644 interfaces/builtin/fuse_support_test.go create mode 100644 interfaces/builtin/fwupd.go create mode 100644 interfaces/builtin/fwupd_test.go create mode 100644 interfaces/builtin/gconf.go create mode 100644 interfaces/builtin/gconf_test.go create mode 100644 interfaces/builtin/gpg_keys.go create mode 100644 interfaces/builtin/gpg_keys_test.go create mode 100644 interfaces/builtin/gpg_public_keys.go create mode 100644 interfaces/builtin/gpg_public_keys_test.go create mode 100644 interfaces/builtin/gpio.go create mode 100644 interfaces/builtin/gpio_control.go create mode 100644 interfaces/builtin/gpio_control_test.go create mode 100644 interfaces/builtin/gpio_memory_control.go create mode 100644 interfaces/builtin/gpio_memory_control_test.go create mode 100644 interfaces/builtin/gpio_test.go create mode 100644 interfaces/builtin/greengrass_support.go create mode 100644 interfaces/builtin/greengrass_support_test.go create mode 100644 interfaces/builtin/gsettings.go create mode 100644 interfaces/builtin/gsettings_test.go create mode 100644 interfaces/builtin/hardware_observe.go create mode 100644 interfaces/builtin/hardware_observe_test.go create mode 100644 interfaces/builtin/hardware_random_control.go create mode 100644 interfaces/builtin/hardware_random_control_test.go create mode 100644 interfaces/builtin/hardware_random_observe.go create mode 100644 interfaces/builtin/hardware_random_observe_test.go create mode 100644 interfaces/builtin/hidraw.go create mode 100644 interfaces/builtin/hidraw_test.go create mode 100644 interfaces/builtin/home.go create mode 100644 interfaces/builtin/home_test.go create mode 100644 interfaces/builtin/hostname_control.go create mode 100644 interfaces/builtin/hostname_control_test.go create mode 100644 interfaces/builtin/hugepages_control.go create mode 100644 interfaces/builtin/hugepages_control_test.go create mode 100644 interfaces/builtin/i2c.go create mode 100644 interfaces/builtin/i2c_test.go create mode 100644 interfaces/builtin/iio.go create mode 100644 interfaces/builtin/iio_test.go create mode 100644 interfaces/builtin/intel_mei.go create mode 100644 interfaces/builtin/intel_mei_test.go create mode 100644 interfaces/builtin/io_ports_control.go create mode 100644 interfaces/builtin/io_ports_control_test.go create mode 100644 interfaces/builtin/ion_memory_control.go create mode 100644 interfaces/builtin/ion_memory_control_test.go create mode 100644 interfaces/builtin/jack1.go create mode 100644 interfaces/builtin/jack1_test.go create mode 100644 interfaces/builtin/joystick.go create mode 100644 interfaces/builtin/joystick_test.go create mode 100644 interfaces/builtin/juju_client_observe.go create mode 100644 interfaces/builtin/juju_client_observe_test.go create mode 100644 interfaces/builtin/kernel_crypto_api.go create mode 100644 interfaces/builtin/kernel_crypto_api_test.go create mode 100644 interfaces/builtin/kernel_firmware_control.go create mode 100644 interfaces/builtin/kernel_firmware_control_test.go create mode 100644 interfaces/builtin/kernel_module_control.go create mode 100644 interfaces/builtin/kernel_module_control_test.go create mode 100644 interfaces/builtin/kernel_module_load.go create mode 100644 interfaces/builtin/kernel_module_load_test.go create mode 100644 interfaces/builtin/kernel_module_observe.go create mode 100644 interfaces/builtin/kernel_module_observe_test.go create mode 100644 interfaces/builtin/kubernetes_support.go create mode 100644 interfaces/builtin/kubernetes_support_test.go create mode 100644 interfaces/builtin/kvm.go create mode 100644 interfaces/builtin/kvm_test.go create mode 100644 interfaces/builtin/libvirt.go create mode 100644 interfaces/builtin/libvirt_test.go create mode 100644 interfaces/builtin/locale_control.go create mode 100644 interfaces/builtin/locale_control_test.go create mode 100644 interfaces/builtin/location_control.go create mode 100644 interfaces/builtin/location_control_test.go create mode 100644 interfaces/builtin/location_observe.go create mode 100644 interfaces/builtin/location_observe_test.go create mode 100644 interfaces/builtin/log_observe.go create mode 100644 interfaces/builtin/log_observe_test.go create mode 100644 interfaces/builtin/login_session_control.go create mode 100644 interfaces/builtin/login_session_control_test.go create mode 100644 interfaces/builtin/login_session_observe.go create mode 100644 interfaces/builtin/login_session_observe_test.go create mode 100644 interfaces/builtin/lxd.go create mode 100644 interfaces/builtin/lxd_support.go create mode 100644 interfaces/builtin/lxd_support_test.go create mode 100644 interfaces/builtin/lxd_test.go create mode 100644 interfaces/builtin/maliit.go create mode 100644 interfaces/builtin/maliit_test.go create mode 100644 interfaces/builtin/media_control.go create mode 100644 interfaces/builtin/media_control_test.go create mode 100644 interfaces/builtin/media_hub.go create mode 100644 interfaces/builtin/media_hub_test.go create mode 100644 interfaces/builtin/microceph.go create mode 100644 interfaces/builtin/microceph_support.go create mode 100644 interfaces/builtin/microceph_support_test.go create mode 100644 interfaces/builtin/microceph_test.go create mode 100644 interfaces/builtin/microovn.go create mode 100644 interfaces/builtin/microovn_test.go create mode 100644 interfaces/builtin/microstack_support.go create mode 100644 interfaces/builtin/microstack_support_test.go create mode 100644 interfaces/builtin/mir.go create mode 100644 interfaces/builtin/mir_test.go create mode 100644 interfaces/builtin/modem_manager.go create mode 100644 interfaces/builtin/modem_manager_test.go create mode 100644 interfaces/builtin/mount_control.go create mode 100644 interfaces/builtin/mount_control_test.go create mode 100644 interfaces/builtin/mount_observe.go create mode 100644 interfaces/builtin/mount_observe_test.go create mode 100644 interfaces/builtin/mpris.go create mode 100644 interfaces/builtin/mpris_test.go create mode 100644 interfaces/builtin/multipass_support.go create mode 100644 interfaces/builtin/multipass_support_test.go create mode 100644 interfaces/builtin/netlink_audit.go create mode 100644 interfaces/builtin/netlink_audit_test.go create mode 100644 interfaces/builtin/netlink_connector.go create mode 100644 interfaces/builtin/netlink_connector_test.go create mode 100644 interfaces/builtin/netlink_driver.go create mode 100644 interfaces/builtin/netlink_driver_test.go create mode 100644 interfaces/builtin/network.go create mode 100644 interfaces/builtin/network_bind.go create mode 100644 interfaces/builtin/network_bind_test.go create mode 100644 interfaces/builtin/network_control.go create mode 100644 interfaces/builtin/network_control_test.go create mode 100644 interfaces/builtin/network_manager.go create mode 100644 interfaces/builtin/network_manager_observe.go create mode 100644 interfaces/builtin/network_manager_observe_test.go create mode 100644 interfaces/builtin/network_manager_test.go create mode 100644 interfaces/builtin/network_observe.go create mode 100644 interfaces/builtin/network_observe_test.go create mode 100644 interfaces/builtin/network_setup_control.go create mode 100644 interfaces/builtin/network_setup_control_test.go create mode 100644 interfaces/builtin/network_setup_observe.go create mode 100644 interfaces/builtin/network_setup_observe_test.go create mode 100644 interfaces/builtin/network_status.go create mode 100644 interfaces/builtin/network_status_test.go create mode 100644 interfaces/builtin/network_test.go create mode 100644 interfaces/builtin/nfs_mount.go create mode 100644 interfaces/builtin/nfs_mount_test.go create mode 100644 interfaces/builtin/nvidia_drivers_support.go create mode 100644 interfaces/builtin/nvidia_drivers_support_test.go create mode 100644 interfaces/builtin/ofono.go create mode 100644 interfaces/builtin/ofono_test.go create mode 100644 interfaces/builtin/online_accounts_service.go create mode 100644 interfaces/builtin/online_accounts_service_test.go create mode 100644 interfaces/builtin/opengl.go create mode 100644 interfaces/builtin/opengl_test.go create mode 100644 interfaces/builtin/openvswitch.go create mode 100644 interfaces/builtin/openvswitch_support.go create mode 100644 interfaces/builtin/openvswitch_support_test.go create mode 100644 interfaces/builtin/openvswitch_test.go create mode 100644 interfaces/builtin/optical_drive.go create mode 100644 interfaces/builtin/optical_drive_test.go create mode 100644 interfaces/builtin/packagekit_control.go create mode 100644 interfaces/builtin/packagekit_control_test.go create mode 100644 interfaces/builtin/password_manager_service.go create mode 100644 interfaces/builtin/password_manager_service_test.go create mode 100644 interfaces/builtin/pcscd.go create mode 100644 interfaces/builtin/pcscd_test.go create mode 100644 interfaces/builtin/personal_files.go create mode 100644 interfaces/builtin/personal_files_test.go create mode 100644 interfaces/builtin/physical_memory_control.go create mode 100644 interfaces/builtin/physical_memory_control_test.go create mode 100644 interfaces/builtin/physical_memory_observe.go create mode 100644 interfaces/builtin/physical_memory_observe_test.go create mode 100644 interfaces/builtin/pkcs11.go create mode 100644 interfaces/builtin/pkcs11_test.go create mode 100644 interfaces/builtin/polkit.go create mode 100644 interfaces/builtin/polkit_agent.go create mode 100644 interfaces/builtin/polkit_agent_test.go create mode 100644 interfaces/builtin/polkit_test.go create mode 100644 interfaces/builtin/posix_mq.go create mode 100644 interfaces/builtin/posix_mq_test.go create mode 100644 interfaces/builtin/power_control.go create mode 100644 interfaces/builtin/power_control_test.go create mode 100644 interfaces/builtin/ppp.go create mode 100644 interfaces/builtin/ppp_test.go create mode 100644 interfaces/builtin/process_control.go create mode 100644 interfaces/builtin/process_control_test.go create mode 100644 interfaces/builtin/ptp.go create mode 100644 interfaces/builtin/ptp_test.go create mode 100644 interfaces/builtin/pulseaudio.go create mode 100644 interfaces/builtin/pulseaudio_test.go create mode 100644 interfaces/builtin/pwm.go create mode 100644 interfaces/builtin/pwm_test.go create mode 100644 interfaces/builtin/qualcomm_ipc_router.go create mode 100644 interfaces/builtin/qualcomm_ipc_router_test.go create mode 100644 interfaces/builtin/raw_input.go create mode 100644 interfaces/builtin/raw_input_test.go create mode 100644 interfaces/builtin/raw_usb.go create mode 100644 interfaces/builtin/raw_usb_test.go create mode 100644 interfaces/builtin/raw_volume.go create mode 100644 interfaces/builtin/raw_volume_test.go create mode 100644 interfaces/builtin/remoteproc.go create mode 100644 interfaces/builtin/remoteproc_test.go create mode 100644 interfaces/builtin/removable_media.go create mode 100644 interfaces/builtin/removable_media_test.go create mode 100644 interfaces/builtin/ros_opt_data.go create mode 100644 interfaces/builtin/ros_opt_data_test.go create mode 100644 interfaces/builtin/screen_inhibit_control.go create mode 100644 interfaces/builtin/screen_inhibit_control_test.go create mode 100644 interfaces/builtin/screencast_legacy.go create mode 100644 interfaces/builtin/screencast_legacy_test.go create mode 100644 interfaces/builtin/scsi_generic.go create mode 100644 interfaces/builtin/scsi_generic_test.go create mode 100644 interfaces/builtin/sd_control.go create mode 100644 interfaces/builtin/sd_control_test.go create mode 100644 interfaces/builtin/serial_port.go create mode 100644 interfaces/builtin/serial_port_test.go create mode 100644 interfaces/builtin/shared_memory.go create mode 100644 interfaces/builtin/shared_memory_test.go create mode 100644 interfaces/builtin/shutdown.go create mode 100644 interfaces/builtin/shutdown_test.go create mode 100644 interfaces/builtin/snap_refresh_control.go create mode 100644 interfaces/builtin/snap_refresh_control_test.go create mode 100644 interfaces/builtin/snap_refresh_observe.go create mode 100644 interfaces/builtin/snap_refresh_observe_test.go create mode 100644 interfaces/builtin/snap_themes_control.go create mode 100644 interfaces/builtin/snap_themes_control_test.go create mode 100644 interfaces/builtin/snapd_control.go create mode 100644 interfaces/builtin/snapd_control_test.go create mode 100644 interfaces/builtin/spi.go create mode 100644 interfaces/builtin/spi_test.go create mode 100644 interfaces/builtin/ssh_keys.go create mode 100644 interfaces/builtin/ssh_keys_test.go create mode 100644 interfaces/builtin/ssh_public_keys.go create mode 100644 interfaces/builtin/ssh_public_keys_test.go create mode 100644 interfaces/builtin/steam_support.go create mode 100644 interfaces/builtin/steam_support_test.go create mode 100644 interfaces/builtin/storage_framework_service.go create mode 100644 interfaces/builtin/storage_framework_service_test.go create mode 100644 interfaces/builtin/system_backup.go create mode 100644 interfaces/builtin/system_backup_test.go create mode 100644 interfaces/builtin/system_files.go create mode 100644 interfaces/builtin/system_files_test.go create mode 100644 interfaces/builtin/system_observe.go create mode 100644 interfaces/builtin/system_observe_test.go create mode 100644 interfaces/builtin/system_packages_doc.go create mode 100644 interfaces/builtin/system_packages_doc_test.go create mode 100644 interfaces/builtin/system_source_code.go create mode 100644 interfaces/builtin/system_source_code_test.go create mode 100644 interfaces/builtin/system_trace.go create mode 100644 interfaces/builtin/system_trace_test.go create mode 100644 interfaces/builtin/tee.go create mode 100644 interfaces/builtin/tee_test.go create mode 100644 interfaces/builtin/thumbnailer_service.go create mode 100644 interfaces/builtin/thumbnailer_service_test.go create mode 100644 interfaces/builtin/time_control.go create mode 100644 interfaces/builtin/time_control_test.go create mode 100644 interfaces/builtin/timeserver_control.go create mode 100644 interfaces/builtin/timeserver_control_test.go create mode 100644 interfaces/builtin/timezone_control.go create mode 100644 interfaces/builtin/timezone_control_test.go create mode 100644 interfaces/builtin/tpm.go create mode 100644 interfaces/builtin/tpm_test.go create mode 100644 interfaces/builtin/u2f_devices.go create mode 100644 interfaces/builtin/u2f_devices_test.go create mode 100644 interfaces/builtin/ubuntu_download_manager.go create mode 100644 interfaces/builtin/ubuntu_download_manager_test.go create mode 100644 interfaces/builtin/udisks2.go create mode 100644 interfaces/builtin/udisks2_test.go create mode 100644 interfaces/builtin/uhid.go create mode 100644 interfaces/builtin/uhid_test.go create mode 100644 interfaces/builtin/uinput.go create mode 100644 interfaces/builtin/uinput_test.go create mode 100644 interfaces/builtin/uio.go create mode 100644 interfaces/builtin/uio_test.go create mode 100644 interfaces/builtin/unity7.go create mode 100644 interfaces/builtin/unity7_test.go create mode 100644 interfaces/builtin/unity8.go create mode 100644 interfaces/builtin/unity8_calendar.go create mode 100644 interfaces/builtin/unity8_calendar_test.go create mode 100644 interfaces/builtin/unity8_contacts.go create mode 100644 interfaces/builtin/unity8_contacts_test.go create mode 100644 interfaces/builtin/unity8_pim_common.go create mode 100644 interfaces/builtin/unity8_test.go create mode 100644 interfaces/builtin/upower_observe.go create mode 100644 interfaces/builtin/upower_observe_test.go create mode 100644 interfaces/builtin/userns.go create mode 100644 interfaces/builtin/userns_test.go create mode 100644 interfaces/builtin/utils.go create mode 100644 interfaces/builtin/utils_test.go create mode 100644 interfaces/builtin/vcio.go create mode 100644 interfaces/builtin/vcio_test.go create mode 100644 interfaces/builtin/wayland.go create mode 100644 interfaces/builtin/wayland_test.go create mode 100644 interfaces/builtin/x11.go create mode 100644 interfaces/builtin/x11_test.go create mode 100644 interfaces/builtin/xilinx_dma.go create mode 100644 interfaces/builtin/xilinx_dma_test.go create mode 100644 interfaces/connection.go create mode 100644 interfaces/connection_test.go create mode 100644 interfaces/core.go create mode 100644 interfaces/core_test.go create mode 100644 interfaces/dbus/backend.go create mode 100644 interfaces/dbus/backend_test.go create mode 100644 interfaces/dbus/dbus.go create mode 100644 interfaces/dbus/dbus_test.go create mode 100644 interfaces/dbus/export_test.go create mode 100644 interfaces/dbus/spec.go create mode 100644 interfaces/dbus/spec_test.go create mode 100644 interfaces/dbus/template.go create mode 100644 interfaces/ensure_dir.go create mode 100644 interfaces/ensure_dir_test.go create mode 100644 interfaces/export_test.go create mode 100644 interfaces/helpers.go create mode 100644 interfaces/helpers_test.go create mode 100644 interfaces/hotplug/deviceinfo.go create mode 100644 interfaces/hotplug/deviceinfo_test.go create mode 100644 interfaces/hotplug/proposed_slot.go create mode 100644 interfaces/hotplug/proposed_slot_test.go create mode 100644 interfaces/hotplug/udevadm.go create mode 100644 interfaces/hotplug/udevadm_test.go create mode 100644 interfaces/ifacetest/backend.go create mode 100644 interfaces/ifacetest/backendtest.go create mode 100644 interfaces/ifacetest/ifacetest_test.go create mode 100644 interfaces/ifacetest/spec.go create mode 100644 interfaces/ifacetest/spec_test.go create mode 100644 interfaces/ifacetest/testiface.go create mode 100644 interfaces/ifacetest/testiface_test.go create mode 100644 interfaces/kmod/backend.go create mode 100644 interfaces/kmod/backend_test.go create mode 100644 interfaces/kmod/export_test.go create mode 100644 interfaces/kmod/kmod.go create mode 100644 interfaces/kmod/kmod_test.go create mode 100644 interfaces/kmod/spec.go create mode 100644 interfaces/kmod/spec_test.go create mode 100644 interfaces/mount/backend.go create mode 100644 interfaces/mount/backend_test.go create mode 100644 interfaces/mount/ns.go create mode 100644 interfaces/mount/ns_test.go create mode 100644 interfaces/mount/spec.go create mode 100644 interfaces/mount/spec_test.go create mode 100644 interfaces/naming.go create mode 100644 interfaces/naming_test.go create mode 100644 interfaces/policy/basedeclaration.go create mode 100644 interfaces/policy/basedeclaration_test.go create mode 100644 interfaces/policy/export_test.go create mode 100644 interfaces/policy/helpers.go create mode 100644 interfaces/policy/helpers_test.go create mode 100644 interfaces/policy/policy.go create mode 100644 interfaces/policy/policy_test.go create mode 100644 interfaces/polkit/backend.go create mode 100644 interfaces/polkit/backend_test.go create mode 100644 interfaces/polkit/spec.go create mode 100644 interfaces/polkit/spec_test.go create mode 100644 interfaces/repo.go create mode 100644 interfaces/repo_test.go create mode 100644 interfaces/seccomp/backend.go create mode 100644 interfaces/seccomp/backend_test.go create mode 100644 interfaces/seccomp/export_test.go create mode 100644 interfaces/seccomp/seccomp_test.go create mode 100644 interfaces/seccomp/spec.go create mode 100644 interfaces/seccomp/spec_test.go create mode 100644 interfaces/seccomp/template.go create mode 100644 interfaces/snap_app_set.go create mode 100644 interfaces/snap_app_set_test.go create mode 100644 interfaces/sorting.go create mode 100644 interfaces/sorting_test.go create mode 100644 interfaces/system_key.go create mode 100644 interfaces/system_key_test.go create mode 100644 interfaces/systemd/backend.go create mode 100644 interfaces/systemd/backend_test.go create mode 100644 interfaces/systemd/service.go create mode 100644 interfaces/systemd/service_test.go create mode 100644 interfaces/systemd/spec.go create mode 100644 interfaces/systemd/spec_test.go create mode 100644 interfaces/systemd/systemd_test.go create mode 100644 interfaces/udev/backend.go create mode 100644 interfaces/udev/backend_test.go create mode 100644 interfaces/udev/export_test.go create mode 100644 interfaces/udev/spec.go create mode 100644 interfaces/udev/spec_test.go create mode 100644 interfaces/udev/udev.go create mode 100644 interfaces/udev/udev_test.go create mode 100644 interfaces/utils/export_test.go create mode 100644 interfaces/utils/path_patterns.go create mode 100644 interfaces/utils/path_patterns_test.go create mode 100644 interfaces/utils/utils.go create mode 100644 interfaces/utils/utils_test.go create mode 100644 jsonutil/json.go create mode 100644 jsonutil/json_test.go create mode 100644 jsonutil/safejson/safejson.go create mode 100644 jsonutil/safejson/safejson_test.go create mode 100644 kernel/export_test.go create mode 100644 kernel/fde/cmd_helper.go create mode 100644 kernel/fde/export_test.go create mode 100644 kernel/fde/fde.go create mode 100644 kernel/fde/fde_test.go create mode 100644 kernel/fde/reveal_key.go create mode 100644 kernel/kernel.go create mode 100644 kernel/kernel_drivers.go create mode 100644 kernel/kernel_drivers_test.go create mode 100644 kernel/kernel_test.go create mode 100644 kernel/validate.go create mode 100644 kernel/validate_test.go create mode 100644 logger/export_test.go create mode 100644 logger/logger.go create mode 100644 logger/logger_test.go create mode 100755 mdlint.py create mode 100644 metautil/export_test.go create mode 100644 metautil/normalize.go create mode 100644 metautil/normalize_test.go create mode 100644 metautil/type_conversions.go create mode 100644 metautil/type_conversions_test.go create mode 100755 mkversion.sh create mode 100644 netutil/activation.go create mode 100644 netutil/metered.go create mode 100644 osutil/bootid.go create mode 100644 osutil/bootid_test.go create mode 100644 osutil/buildid.go create mode 100644 osutil/buildid_test.go create mode 100644 osutil/chattr.go create mode 100644 osutil/chattr_32.go create mode 100644 osutil/chattr_64.go create mode 100644 osutil/chdir.go create mode 100644 osutil/chdir_test.go create mode 100644 osutil/cmp.go create mode 100644 osutil/cmp_test.go create mode 100644 osutil/context.go create mode 100644 osutil/context_test.go create mode 100644 osutil/cp.go create mode 100644 osutil/cp_linux.go create mode 100644 osutil/cp_linux_test.go create mode 100644 osutil/cp_other.go create mode 100644 osutil/cp_test.go create mode 100644 osutil/digest.go create mode 100644 osutil/digest_test.go create mode 100644 osutil/disk.go create mode 100644 osutil/disk_test.go create mode 100644 osutil/disks/blockdev.go create mode 100644 osutil/disks/disks.go create mode 100644 osutil/disks/disks_darwin.go create mode 100644 osutil/disks/disks_linux.go create mode 100644 osutil/disks/disks_linux_test.go create mode 100644 osutil/disks/export_test.go create mode 100644 osutil/disks/gpt.go create mode 100644 osutil/disks/gpt_test.go create mode 100644 osutil/disks/labels.go create mode 100644 osutil/disks/labels_darwin.go create mode 100644 osutil/disks/labels_linux.go create mode 100644 osutil/disks/labels_test.go create mode 100644 osutil/disks/luks.go create mode 100644 osutil/disks/mapper.go create mode 100644 osutil/disks/mapper_test.go create mode 100644 osutil/disks/mockdisk.go create mode 100644 osutil/disks/mockdisk_linux.go create mode 100644 osutil/disks/mockdisk_test.go create mode 100755 osutil/disks/testdata/generate.sh create mode 100644 osutil/disks/testdata/gpt_footer create mode 100644 osutil/disks/testdata/gpt_footer_4k create mode 100644 osutil/disks/testdata/gpt_footer_4k_big create mode 100644 osutil/disks/testdata/gpt_footer_4k_small create mode 100644 osutil/disks/testdata/gpt_footer_big create mode 100644 osutil/disks/testdata/gpt_footer_small create mode 100644 osutil/disks/testdata/gpt_header create mode 100644 osutil/disks/testdata/gpt_header_4k create mode 100644 osutil/disks/testdata/gpt_header_4k_big create mode 100644 osutil/disks/testdata/gpt_header_4k_small create mode 100644 osutil/disks/testdata/gpt_header_big create mode 100644 osutil/disks/testdata/gpt_header_small create mode 100644 osutil/doc.go create mode 100644 osutil/env.go create mode 100644 osutil/env_test.go create mode 100644 osutil/epoll/epoll.go create mode 100644 osutil/epoll/epoll_test.go create mode 100644 osutil/epoll/export_test.go create mode 100644 osutil/exec.go create mode 100644 osutil/exec_test.go create mode 100644 osutil/exitcode.go create mode 100644 osutil/exitcode_test.go create mode 100644 osutil/export_fault_test.go create mode 100644 osutil/export_test.go create mode 100644 osutil/faultinject.go create mode 100644 osutil/faultinject_fake.go create mode 100644 osutil/faultinject_fake_test.go create mode 100644 osutil/faultinject_test.go create mode 100644 osutil/flock.go create mode 100644 osutil/flock_test.go create mode 100644 osutil/fshelpers.go create mode 100644 osutil/fshelpers_test.go create mode 100644 osutil/group.go create mode 100644 osutil/group_cgo.go create mode 100644 osutil/group_no_cgo.go create mode 100644 osutil/group_test.go create mode 100644 osutil/inotify/LICENSE create mode 100644 osutil/inotify/PATENTS create mode 100644 osutil/inotify/README.md create mode 100644 osutil/inotify/inotify.go create mode 100644 osutil/inotify/inotify_linux.go create mode 100644 osutil/inotify/inotify_linux_test.go create mode 100644 osutil/inotify/inotify_others.go create mode 100644 osutil/io.go create mode 100644 osutil/io_test.go create mode 100644 osutil/kcmdline/kcmdline.go create mode 100644 osutil/kcmdline/kcmdline_test.go create mode 100644 osutil/kmod/export_test.go create mode 100644 osutil/kmod/kmod.go create mode 100644 osutil/kmod/kmod_test.go create mode 100644 osutil/meminfo.go create mode 100644 osutil/meminfo_test.go create mode 100644 osutil/mkdirallchown.go create mode 100644 osutil/mkdirallchown_test.go create mode 100644 osutil/mkfs/mkfs.go create mode 100644 osutil/mkfs/mkfs_test.go create mode 100644 osutil/mount/mount_linux.go create mode 100644 osutil/mount/mount_linux_test.go create mode 100644 osutil/mount_darwin.go create mode 100644 osutil/mount_linux.go create mode 100644 osutil/mount_linux_test.go create mode 100644 osutil/mountentry.go create mode 100644 osutil/mountentry_linux.go create mode 100644 osutil/mountentry_linux_test.go create mode 100644 osutil/mountinfo.go create mode 100644 osutil/mountinfo_darwin.go create mode 100644 osutil/mountinfo_linux.go create mode 100644 osutil/mountinfo_linux_test.go create mode 100644 osutil/mountprofile_darwin.go create mode 100644 osutil/mountprofile_linux.go create mode 100644 osutil/mountprofile_linux_test.go create mode 100644 osutil/nfs.go create mode 100644 osutil/nfs_darwin.go create mode 100644 osutil/nfs_linux.go create mode 100644 osutil/nfs_linux_test.go create mode 100644 osutil/osutil_darwin.go create mode 100644 osutil/osutil_test.go create mode 100644 osutil/outputerr.go create mode 100644 osutil/outputerr_test.go create mode 100644 osutil/overlay.go create mode 100644 osutil/overlay_darwin.go create mode 100644 osutil/overlay_linux.go create mode 100644 osutil/overlay_linux_test.go create mode 100644 osutil/rename.go create mode 100644 osutil/rename_darwin.go create mode 100644 osutil/rename_linux.go create mode 100644 osutil/rename_linux_test.go create mode 100644 osutil/resolve_path.go create mode 100644 osutil/resolve_path_test.go create mode 100644 osutil/settime.go create mode 100644 osutil/settime_32bit.go create mode 100644 osutil/settime_64bit.go create mode 100644 osutil/settime_test.go create mode 100644 osutil/sizer.go create mode 100644 osutil/sizer_test.go create mode 100644 osutil/squashfs/fstype.go create mode 100644 osutil/stat.go create mode 100644 osutil/stat_test.go create mode 100644 osutil/strace/export_test.go create mode 100644 osutil/strace/strace.go create mode 100644 osutil/strace/strace_test.go create mode 100644 osutil/strace/timing.go create mode 100644 osutil/strace/timing_test.go create mode 100644 osutil/syncdir.go create mode 100644 osutil/syncdir_test.go create mode 100644 osutil/synctree.go create mode 100644 osutil/synctree_test.go create mode 100644 osutil/sys/runas.go create mode 100644 osutil/sys/syscall.go create mode 100644 osutil/sys/sysnum_16_linux.go create mode 100644 osutil/sys/sysnum_32_linux.go create mode 100644 osutil/sys/sysnum_darwin.go create mode 100644 osutil/sys/sysnum_linux.go create mode 100644 osutil/sys_linux.go create mode 100644 osutil/sys_linux_test.go create mode 100644 osutil/testhelper.go create mode 100644 osutil/testhelper_test.go create mode 100644 osutil/udev/.gitignore create mode 100644 osutil/udev/.travis.yml create mode 100644 osutil/udev/LICENSE create mode 100644 osutil/udev/README.md create mode 100644 osutil/udev/crawler/device.go create mode 100644 osutil/udev/main.go.sample create mode 100644 osutil/udev/matcher.sample create mode 100644 osutil/udev/netlink/conn.go create mode 100644 osutil/udev/netlink/conn_test.go create mode 100644 osutil/udev/netlink/matcher.go create mode 100644 osutil/udev/netlink/matcher_test.go create mode 100644 osutil/udev/netlink/rawsockstop.go create mode 100644 osutil/udev/netlink/rawsockstop_arm64.go create mode 100644 osutil/udev/netlink/rawsockstop_other.go create mode 100644 osutil/udev/netlink/rawsockstop_test.go create mode 100644 osutil/udev/netlink/uevent.go create mode 100644 osutil/udev/netlink/uevent_test.go create mode 100644 osutil/uname.go create mode 100644 osutil/uname_darwin.go create mode 100644 osutil/uname_linux.go create mode 100644 osutil/uname_linux_test.go create mode 100644 osutil/unlink.go create mode 100644 osutil/unlink_darwin.go create mode 100644 osutil/unlink_linux.go create mode 100644 osutil/unlink_test.go create mode 100644 osutil/user.go create mode 100644 osutil/user_test.go create mode 100644 osutil/winsize.go create mode 100644 overlord/README.md create mode 100644 overlord/aspectstate/aspectstate.go create mode 100644 overlord/aspectstate/aspectstate_test.go create mode 100644 overlord/assertstate/assertmgr.go create mode 100644 overlord/assertstate/assertstate.go create mode 100644 overlord/assertstate/assertstate_test.go create mode 100644 overlord/assertstate/assertstatetest/add_many.go create mode 100644 overlord/assertstate/bulk.go create mode 100644 overlord/assertstate/export_test.go create mode 100644 overlord/assertstate/helpers.go create mode 100644 overlord/assertstate/validation_set_tracking.go create mode 100644 overlord/assertstate/validation_set_tracking_test.go create mode 100644 overlord/auth/auth.go create mode 100644 overlord/auth/auth_test.go create mode 100644 overlord/backend.go create mode 100644 overlord/cmdstate/cmdmgr.go create mode 100644 overlord/cmdstate/cmdstate.go create mode 100644 overlord/cmdstate/cmdstate_test.go create mode 100644 overlord/cmdstate/export_test.go create mode 100644 overlord/configstate/config/export_test.go create mode 100644 overlord/configstate/config/helpers.go create mode 100644 overlord/configstate/config/helpers_test.go create mode 100644 overlord/configstate/config/transaction.go create mode 100644 overlord/configstate/config/transaction_test.go create mode 100644 overlord/configstate/configcore/backlight.go create mode 100644 overlord/configstate/configcore/backlight_test.go create mode 100644 overlord/configstate/configcore/certs.go create mode 100644 overlord/configstate/configcore/certs_test.go create mode 100644 overlord/configstate/configcore/cloud.go create mode 100644 overlord/configstate/configcore/cloud_test.go create mode 100644 overlord/configstate/configcore/corecfg.go create mode 100644 overlord/configstate/configcore/corecfg_test.go create mode 100644 overlord/configstate/configcore/ctrlaltdel.go create mode 100644 overlord/configstate/configcore/ctrlaltdel_test.go create mode 100644 overlord/configstate/configcore/early_test.go create mode 100644 overlord/configstate/configcore/experimental.go create mode 100644 overlord/configstate/configcore/experimental_test.go create mode 100644 overlord/configstate/configcore/export_runwithstate_test.go create mode 100644 overlord/configstate/configcore/export_test.go create mode 100644 overlord/configstate/configcore/handlers.go create mode 100644 overlord/configstate/configcore/homedirs.go create mode 100644 overlord/configstate/configcore/homedirs_test.go create mode 100644 overlord/configstate/configcore/hostname.go create mode 100644 overlord/configstate/configcore/hostname_test.go create mode 100644 overlord/configstate/configcore/journal.go create mode 100644 overlord/configstate/configcore/journal_test.go create mode 100644 overlord/configstate/configcore/kernel.go create mode 100644 overlord/configstate/configcore/kernel_test.go create mode 100644 overlord/configstate/configcore/lockout.go create mode 100644 overlord/configstate/configcore/lockout_test.go create mode 100644 overlord/configstate/configcore/netplan.go create mode 100644 overlord/configstate/configcore/netplan_test.go create mode 100644 overlord/configstate/configcore/network.go create mode 100644 overlord/configstate/configcore/network_test.go create mode 100644 overlord/configstate/configcore/picfg.go create mode 100644 overlord/configstate/configcore/picfg_test.go create mode 100644 overlord/configstate/configcore/powerbtn.go create mode 100644 overlord/configstate/configcore/powerbtn_test.go create mode 100644 overlord/configstate/configcore/proxy.go create mode 100644 overlord/configstate/configcore/proxy_test.go create mode 100644 overlord/configstate/configcore/refresh.go create mode 100644 overlord/configstate/configcore/refresh_test.go create mode 100644 overlord/configstate/configcore/runwithstate.go create mode 100644 overlord/configstate/configcore/runwithstate_test.go create mode 100644 overlord/configstate/configcore/services.go create mode 100644 overlord/configstate/configcore/services_test.go create mode 100644 overlord/configstate/configcore/snapshots.go create mode 100644 overlord/configstate/configcore/snapshots_test.go create mode 100644 overlord/configstate/configcore/store.go create mode 100644 overlord/configstate/configcore/store_test.go create mode 100644 overlord/configstate/configcore/swap.go create mode 100644 overlord/configstate/configcore/swap_test.go create mode 100644 overlord/configstate/configcore/sysctl.go create mode 100644 overlord/configstate/configcore/sysctl_test.go create mode 100644 overlord/configstate/configcore/timezone.go create mode 100644 overlord/configstate/configcore/timezone_test.go create mode 100644 overlord/configstate/configcore/tmp.go create mode 100644 overlord/configstate/configcore/tmp_test.go create mode 100644 overlord/configstate/configcore/users.go create mode 100644 overlord/configstate/configcore/users_test.go create mode 100644 overlord/configstate/configcore/utils.go create mode 100644 overlord/configstate/configcore/utils_test.go create mode 100644 overlord/configstate/configcore/vitality.go create mode 100644 overlord/configstate/configcore/vitality_test.go create mode 100644 overlord/configstate/configcore/watchdog.go create mode 100644 overlord/configstate/configcore/watchdog_test.go create mode 100644 overlord/configstate/configmgr.go create mode 100644 overlord/configstate/configstate.go create mode 100644 overlord/configstate/configstate_test.go create mode 100644 overlord/configstate/export_test.go create mode 100644 overlord/configstate/handler_test.go create mode 100644 overlord/configstate/hooks.go create mode 100644 overlord/configstate/proxyconf/proxy.go create mode 100644 overlord/configstate/proxyconf/proxy_test.go create mode 100644 overlord/devicestate/crypto.go create mode 100644 overlord/devicestate/devicectx.go create mode 100644 overlord/devicestate/devicectx_test.go create mode 100644 overlord/devicestate/devicemgr.go create mode 100644 overlord/devicestate/devicestate.go create mode 100644 overlord/devicestate/devicestate_bootconfig_test.go create mode 100644 overlord/devicestate/devicestate_cloudinit_test.go create mode 100644 overlord/devicestate/devicestate_gadget_test.go create mode 100644 overlord/devicestate/devicestate_install_api_test.go create mode 100644 overlord/devicestate/devicestate_install_mode_test.go create mode 100644 overlord/devicestate/devicestate_recovery_keys_test.go create mode 100644 overlord/devicestate/devicestate_remodel_test.go create mode 100644 overlord/devicestate/devicestate_serial_test.go create mode 100644 overlord/devicestate/devicestate_systems_test.go create mode 100644 overlord/devicestate/devicestate_test.go create mode 100644 overlord/devicestate/devicestatetest/devicesvc.go create mode 100644 overlord/devicestate/devicestatetest/gadget.go create mode 100644 overlord/devicestate/devicestatetest/state.go create mode 100644 overlord/devicestate/export_test.go create mode 100644 overlord/devicestate/firstboot.go create mode 100644 overlord/devicestate/firstboot20_test.go create mode 100644 overlord/devicestate/firstboot_preseed_test.go create mode 100644 overlord/devicestate/firstboot_test.go create mode 100644 overlord/devicestate/handlers.go create mode 100644 overlord/devicestate/handlers_bootconfig.go create mode 100644 overlord/devicestate/handlers_gadget.go create mode 100644 overlord/devicestate/handlers_install.go create mode 100644 overlord/devicestate/handlers_remodel.go create mode 100644 overlord/devicestate/handlers_serial.go create mode 100644 overlord/devicestate/handlers_systems.go create mode 100644 overlord/devicestate/handlers_test.go create mode 100644 overlord/devicestate/helpers.go create mode 100644 overlord/devicestate/internal/state.go create mode 100644 overlord/devicestate/internal/state_test.go create mode 100644 overlord/devicestate/remodel.go create mode 100644 overlord/devicestate/remodel_test.go create mode 100644 overlord/devicestate/systems.go create mode 100644 overlord/devicestate/systems_test.go create mode 100644 overlord/devicestate/users.go create mode 100644 overlord/devicestate/users_test.go create mode 100644 overlord/export_test.go create mode 100644 overlord/healthstate/export_test.go create mode 100644 overlord/healthstate/healthstate.go create mode 100644 overlord/healthstate/healthstate_test.go create mode 100644 overlord/hookstate/context.go create mode 100644 overlord/hookstate/context_test.go create mode 100644 overlord/hookstate/ctlcmd/ctlcmd.go create mode 100644 overlord/hookstate/ctlcmd/ctlcmd_test.go create mode 100644 overlord/hookstate/ctlcmd/export_test.go create mode 100644 overlord/hookstate/ctlcmd/fde_setup.go create mode 100644 overlord/hookstate/ctlcmd/fde_setup_test.go create mode 100644 overlord/hookstate/ctlcmd/get.go create mode 100644 overlord/hookstate/ctlcmd/get_test.go create mode 100644 overlord/hookstate/ctlcmd/health.go create mode 100644 overlord/hookstate/ctlcmd/health_test.go create mode 100644 overlord/hookstate/ctlcmd/helpers.go create mode 100644 overlord/hookstate/ctlcmd/is_connected.go create mode 100644 overlord/hookstate/ctlcmd/is_connected_test.go create mode 100644 overlord/hookstate/ctlcmd/kmod.go create mode 100644 overlord/hookstate/ctlcmd/kmod_test.go create mode 100644 overlord/hookstate/ctlcmd/model.go create mode 100644 overlord/hookstate/ctlcmd/model_test.go create mode 100644 overlord/hookstate/ctlcmd/mount.go create mode 100644 overlord/hookstate/ctlcmd/mount_test.go create mode 100644 overlord/hookstate/ctlcmd/reboot.go create mode 100644 overlord/hookstate/ctlcmd/reboot_test.go create mode 100644 overlord/hookstate/ctlcmd/refresh.go create mode 100644 overlord/hookstate/ctlcmd/refresh_test.go create mode 100644 overlord/hookstate/ctlcmd/restart.go create mode 100644 overlord/hookstate/ctlcmd/services.go create mode 100644 overlord/hookstate/ctlcmd/services_test.go create mode 100644 overlord/hookstate/ctlcmd/set.go create mode 100644 overlord/hookstate/ctlcmd/set_test.go create mode 100644 overlord/hookstate/ctlcmd/start.go create mode 100644 overlord/hookstate/ctlcmd/stop.go create mode 100644 overlord/hookstate/ctlcmd/system_mode.go create mode 100644 overlord/hookstate/ctlcmd/system_mode_test.go create mode 100644 overlord/hookstate/ctlcmd/umount.go create mode 100644 overlord/hookstate/ctlcmd/umount_test.go create mode 100644 overlord/hookstate/ctlcmd/unset.go create mode 100644 overlord/hookstate/ctlcmd/unset_test.go create mode 100644 overlord/hookstate/export_test.go create mode 100644 overlord/hookstate/hookmgr.go create mode 100644 overlord/hookstate/hooks.go create mode 100644 overlord/hookstate/hooks_test.go create mode 100644 overlord/hookstate/hookstate.go create mode 100644 overlord/hookstate/hookstate_test.go create mode 100644 overlord/hookstate/hooktest/handler.go create mode 100644 overlord/hookstate/hooktest/handler_test.go create mode 100644 overlord/hookstate/repository.go create mode 100644 overlord/hookstate/repository_test.go create mode 100644 overlord/ifacestate/export_test.go create mode 100644 overlord/ifacestate/handlers.go create mode 100644 overlord/ifacestate/handlers_test.go create mode 100644 overlord/ifacestate/helpers.go create mode 100644 overlord/ifacestate/helpers_test.go create mode 100644 overlord/ifacestate/hooks.go create mode 100644 overlord/ifacestate/hotplug.go create mode 100644 overlord/ifacestate/hotplug_test.go create mode 100644 overlord/ifacestate/ifacemgr.go create mode 100644 overlord/ifacestate/ifacerepo/repo.go create mode 100644 overlord/ifacestate/ifacerepo/repo_test.go create mode 100644 overlord/ifacestate/ifacestate.go create mode 100644 overlord/ifacestate/ifacestate_test.go create mode 100644 overlord/ifacestate/implicit.go create mode 100644 overlord/ifacestate/implicit_test.go create mode 100644 overlord/ifacestate/schema/schema.go create mode 100644 overlord/ifacestate/udevmonitor/udevmon.go create mode 100644 overlord/ifacestate/udevmonitor/udevmon_test.go create mode 100644 overlord/install/export_test.go create mode 100644 overlord/install/install.go create mode 100644 overlord/install/install_test.go create mode 100644 overlord/managers_test.go create mode 100644 overlord/overlord.go create mode 100644 overlord/overlord_test.go create mode 100644 overlord/patch/export_test.go create mode 100644 overlord/patch/patch.go create mode 100644 overlord/patch/patch1.go create mode 100644 overlord/patch/patch1_test.go create mode 100644 overlord/patch/patch2.go create mode 100644 overlord/patch/patch2_test.go create mode 100644 overlord/patch/patch3.go create mode 100644 overlord/patch/patch3_test.go create mode 100644 overlord/patch/patch4.go create mode 100644 overlord/patch/patch4_test.go create mode 100644 overlord/patch/patch5.go create mode 100644 overlord/patch/patch6.go create mode 100644 overlord/patch/patch6_1.go create mode 100644 overlord/patch/patch6_1_test.go create mode 100644 overlord/patch/patch6_2.go create mode 100644 overlord/patch/patch6_2_test.go create mode 100644 overlord/patch/patch6_3.go create mode 100644 overlord/patch/patch6_3_test.go create mode 100644 overlord/patch/patch6_test.go create mode 100644 overlord/patch/patch_test.go create mode 100644 overlord/restart/export_test.go create mode 100644 overlord/restart/restart.go create mode 100644 overlord/restart/restart_parameters.go create mode 100644 overlord/restart/restart_parameters_test.go create mode 100644 overlord/restart/restart_test.go create mode 100644 overlord/servicestate/conflict.go create mode 100644 overlord/servicestate/export_test.go create mode 100644 overlord/servicestate/helpers.go create mode 100644 overlord/servicestate/internal/quotas.go create mode 100644 overlord/servicestate/internal/quotas_test.go create mode 100644 overlord/servicestate/quota_control.go create mode 100644 overlord/servicestate/quota_control_test.go create mode 100644 overlord/servicestate/quota_handlers.go create mode 100644 overlord/servicestate/quota_handlers_test.go create mode 100644 overlord/servicestate/quotas.go create mode 100644 overlord/servicestate/quotas_test.go create mode 100644 overlord/servicestate/service_control.go create mode 100644 overlord/servicestate/service_control_test.go create mode 100644 overlord/servicestate/servicemgr.go create mode 100644 overlord/servicestate/servicemgr_test.go create mode 100644 overlord/servicestate/servicestate.go create mode 100644 overlord/servicestate/servicestate_test.go create mode 100644 overlord/servicestate/servicestatetest/quotas.go create mode 100644 overlord/snapshotstate/backend/backend.go create mode 100644 overlord/snapshotstate/backend/backend_test.go create mode 100644 overlord/snapshotstate/backend/export_test.go create mode 100644 overlord/snapshotstate/backend/helpers.go create mode 100644 overlord/snapshotstate/backend/reader.go create mode 100644 overlord/snapshotstate/backend/restorestate.go create mode 100644 overlord/snapshotstate/export_test.go create mode 100644 overlord/snapshotstate/snapshotmgr.go create mode 100644 overlord/snapshotstate/snapshotmgr_test.go create mode 100644 overlord/snapshotstate/snapshotstate.go create mode 100644 overlord/snapshotstate/snapshotstate_test.go create mode 100644 overlord/snapstate/agentnotify/agentnotify.go create mode 100644 overlord/snapstate/agentnotify/agentnotify_test.go create mode 100644 overlord/snapstate/agentnotify/export_test.go create mode 100644 overlord/snapstate/aliasesv2.go create mode 100644 overlord/snapstate/aliasesv2_test.go create mode 100644 overlord/snapstate/autorefresh.go create mode 100644 overlord/snapstate/autorefresh_gating.go create mode 100644 overlord/snapstate/autorefresh_gating_test.go create mode 100644 overlord/snapstate/autorefresh_test.go create mode 100644 overlord/snapstate/aux_store_info.go create mode 100644 overlord/snapstate/aux_store_info_test.go create mode 100644 overlord/snapstate/backend.go create mode 100644 overlord/snapstate/backend/aliases.go create mode 100644 overlord/snapstate/backend/aliases_test.go create mode 100644 overlord/snapstate/backend/apparmor.go create mode 100644 overlord/snapstate/backend/backend.go create mode 100644 overlord/snapstate/backend/backend_test.go create mode 100644 overlord/snapstate/backend/copydata.go create mode 100644 overlord/snapstate/backend/copydata_test.go create mode 100644 overlord/snapstate/backend/export_test.go create mode 100644 overlord/snapstate/backend/link.go create mode 100644 overlord/snapstate/backend/link_test.go create mode 100644 overlord/snapstate/backend/locking.go create mode 100644 overlord/snapstate/backend/locking_test.go create mode 100644 overlord/snapstate/backend/mountns.go create mode 100644 overlord/snapstate/backend/mountunit.go create mode 100644 overlord/snapstate/backend/mountunit_test.go create mode 100644 overlord/snapstate/backend/setup.go create mode 100644 overlord/snapstate/backend/setup_test.go create mode 100644 overlord/snapstate/backend/snapdata.go create mode 100644 overlord/snapstate/backend/snapdata_test.go create mode 100644 overlord/snapstate/backend/utils.go create mode 100644 overlord/snapstate/backend_test.go create mode 100644 overlord/snapstate/booted.go create mode 100644 overlord/snapstate/booted_test.go create mode 100644 overlord/snapstate/catalogrefresh.go create mode 100644 overlord/snapstate/catalogrefresh_test.go create mode 100644 overlord/snapstate/check_snap.go create mode 100644 overlord/snapstate/check_snap_test.go create mode 100644 overlord/snapstate/component.go create mode 100644 overlord/snapstate/component_install_test.go create mode 100644 overlord/snapstate/component_test.go create mode 100644 overlord/snapstate/conflict.go create mode 100644 overlord/snapstate/conflict_test.go create mode 100644 overlord/snapstate/cookies.go create mode 100644 overlord/snapstate/cookies_test.go create mode 100644 overlord/snapstate/dbus.go create mode 100644 overlord/snapstate/dbus_test.go create mode 100644 overlord/snapstate/devicectx.go create mode 100644 overlord/snapstate/devicectx_test.go create mode 100644 overlord/snapstate/export_test.go create mode 100644 overlord/snapstate/flags.go create mode 100644 overlord/snapstate/handlers.go create mode 100644 overlord/snapstate/handlers_aliasesv2_test.go create mode 100644 overlord/snapstate/handlers_components.go create mode 100644 overlord/snapstate/handlers_components_discard_test.go create mode 100644 overlord/snapstate/handlers_components_kernel_test.go create mode 100644 overlord/snapstate/handlers_components_link_test.go create mode 100644 overlord/snapstate/handlers_components_mount_test.go create mode 100644 overlord/snapstate/handlers_components_prepare_test.go create mode 100644 overlord/snapstate/handlers_components_test.go create mode 100644 overlord/snapstate/handlers_copy_test.go create mode 100644 overlord/snapstate/handlers_discard_test.go create mode 100644 overlord/snapstate/handlers_download_test.go create mode 100644 overlord/snapstate/handlers_link_test.go create mode 100644 overlord/snapstate/handlers_mount_test.go create mode 100644 overlord/snapstate/handlers_prepare_test.go create mode 100644 overlord/snapstate/handlers_prereq_test.go create mode 100644 overlord/snapstate/handlers_rerefresh_test.go create mode 100644 overlord/snapstate/handlers_setup_kernel_test.go create mode 100644 overlord/snapstate/handlers_test.go create mode 100644 overlord/snapstate/models_test.go create mode 100644 overlord/snapstate/policy.go create mode 100644 overlord/snapstate/policy/base.go create mode 100644 overlord/snapstate/policy/canremove_test.go create mode 100644 overlord/snapstate/policy/errors.go create mode 100644 overlord/snapstate/policy/export_test.go create mode 100644 overlord/snapstate/policy/gadget.go create mode 100644 overlord/snapstate/policy/kernel.go create mode 100644 overlord/snapstate/policy/os.go create mode 100644 overlord/snapstate/policy/policy.go create mode 100644 overlord/snapstate/policy/policy_test.go create mode 100644 overlord/snapstate/policy/snapd.go create mode 100644 overlord/snapstate/progress.go create mode 100644 overlord/snapstate/progress_test.go create mode 100644 overlord/snapstate/readme.go create mode 100644 overlord/snapstate/readme_test.go create mode 100644 overlord/snapstate/reboot.go create mode 100644 overlord/snapstate/reboot_test.go create mode 100644 overlord/snapstate/refresh.go create mode 100644 overlord/snapstate/refresh_test.go create mode 100644 overlord/snapstate/refreshhints.go create mode 100644 overlord/snapstate/refreshhints_test.go create mode 100644 overlord/snapstate/sequence/sequence.go create mode 100644 overlord/snapstate/sequence/sequence_test.go create mode 100644 overlord/snapstate/snapmgr.go create mode 100644 overlord/snapstate/snapstate.go create mode 100644 overlord/snapstate/snapstate_config_defaults_test.go create mode 100644 overlord/snapstate/snapstate_install_test.go create mode 100644 overlord/snapstate/snapstate_remove_test.go create mode 100644 overlord/snapstate/snapstate_test.go create mode 100644 overlord/snapstate/snapstate_try_test.go create mode 100644 overlord/snapstate/snapstate_update_test.go create mode 100644 overlord/snapstate/snapstatetest/devicectx.go create mode 100644 overlord/snapstate/snapstatetest/restart.go create mode 100644 overlord/snapstate/snapstatetest/snapstate.go create mode 100644 overlord/snapstate/storehelpers.go create mode 100644 overlord/snapstate/storehelpers_test.go create mode 100644 overlord/standby/export_test.go create mode 100644 overlord/standby/standby.go create mode 100644 overlord/standby/standby_test.go create mode 100644 overlord/state/change.go create mode 100644 overlord/state/change_test.go create mode 100644 overlord/state/copy.go create mode 100644 overlord/state/copy_test.go create mode 100644 overlord/state/export_test.go create mode 100644 overlord/state/notices.go create mode 100644 overlord/state/notices_test.go create mode 100644 overlord/state/state.go create mode 100644 overlord/state/state_test.go create mode 100644 overlord/state/task.go create mode 100644 overlord/state/task_test.go create mode 100644 overlord/state/taskrunner.go create mode 100644 overlord/state/taskrunner_test.go create mode 100644 overlord/state/timings.go create mode 100644 overlord/state/timings_test.go create mode 100644 overlord/state/warning.go create mode 100644 overlord/state/warning_test.go create mode 100644 overlord/stateengine.go create mode 100644 overlord/stateengine_test.go create mode 100644 overlord/storecontext/context.go create mode 100644 overlord/storecontext/context_test.go create mode 100644 overlord/unknowntask.go create mode 120000 packaging/amzn-2 create mode 120000 packaging/amzn-2023 create mode 100644 packaging/arch/PKGBUILD create mode 100644 packaging/arch/snapd.install create mode 100755 packaging/build-tools/go create mode 120000 packaging/centos-7 create mode 120000 packaging/centos-8 create mode 120000 packaging/centos-9 create mode 100644 packaging/debian-sid/README.Source create mode 100644 packaging/debian-sid/changelog create mode 100644 packaging/debian-sid/compat create mode 100644 packaging/debian-sid/control create mode 100644 packaging/debian-sid/copyright create mode 100644 packaging/debian-sid/gbp.conf create mode 100644 packaging/debian-sid/golang-github-snapcore-snapd-dev.install create mode 100644 packaging/debian-sid/not-installed create mode 100644 packaging/debian-sid/patches/0003-cmd-snap-seccomp-skip-tests-that-use-m32.patch create mode 100644 packaging/debian-sid/patches/0004-cmd-snap-skip-tests-depending-on-text-wrapping.patch create mode 100644 packaging/debian-sid/patches/0007-i18n-use-dummy-localizations-to-avoid-dependencies.patch create mode 100644 packaging/debian-sid/patches/0010-man-page-sections.patch create mode 100644 packaging/debian-sid/patches/series create mode 100755 packaging/debian-sid/rules create mode 100644 packaging/debian-sid/snap-confine.maintscript create mode 100644 packaging/debian-sid/snapd.autoimport.udev create mode 100644 packaging/debian-sid/snapd.dirs create mode 100644 packaging/debian-sid/snapd.install create mode 100644 packaging/debian-sid/snapd.links create mode 100644 packaging/debian-sid/snapd.lintian-overrides create mode 100644 packaging/debian-sid/snapd.maintscript create mode 100644 packaging/debian-sid/snapd.manpages create mode 100644 packaging/debian-sid/snapd.postinst create mode 100644 packaging/debian-sid/snapd.postrm create mode 100755 packaging/debian-sid/snapd.prerm create mode 100644 packaging/debian-sid/source/format create mode 100644 packaging/debian-sid/source/options create mode 100644 packaging/debian-sid/tests/README.md create mode 100644 packaging/debian-sid/tests/control create mode 100644 packaging/debian-sid/tests/integrationtests create mode 100644 packaging/debian-sid/tests/testconfig.json create mode 100644 packaging/debian-sid/watch create mode 120000 packaging/fedora-38 create mode 120000 packaging/fedora-39 create mode 120000 packaging/fedora-rawhide create mode 100644 packaging/fedora/snapd.spec create mode 120000 packaging/opensuse-15.5 create mode 120000 packaging/opensuse-tumbleweed create mode 100644 packaging/opensuse/permissions create mode 100644 packaging/opensuse/permissions.easy create mode 100644 packaging/opensuse/permissions.paranoid create mode 100644 packaging/opensuse/permissions.secure create mode 100644 packaging/opensuse/snapd-rpmlintrc create mode 100644 packaging/opensuse/snapd.changes create mode 100644 packaging/opensuse/snapd.spec create mode 100755 packaging/pack-source create mode 100644 packaging/snapd.mk create mode 100644 packaging/ubuntu-14.04/changelog create mode 120000 packaging/ubuntu-14.04/compat create mode 100644 packaging/ubuntu-14.04/control create mode 100644 packaging/ubuntu-14.04/copyright create mode 120000 packaging/ubuntu-14.04/golang-github-snapcore-snapd-dev.install create mode 100755 packaging/ubuntu-14.04/rules create mode 120000 packaging/ubuntu-14.04/snap-confine.maintscript create mode 100644 packaging/ubuntu-14.04/snap.mount.service create mode 120000 packaging/ubuntu-14.04/snapd.autoimport.udev create mode 100644 packaging/ubuntu-14.04/snapd.dirs create mode 100644 packaging/ubuntu-14.04/snapd.install create mode 100644 packaging/ubuntu-14.04/snapd.links create mode 100644 packaging/ubuntu-14.04/snapd.maintscript create mode 120000 packaging/ubuntu-14.04/snapd.manpages create mode 100644 packaging/ubuntu-14.04/snapd.postinst create mode 100644 packaging/ubuntu-14.04/snapd.postrm create mode 100644 packaging/ubuntu-14.04/snapd.prerm create mode 120000 packaging/ubuntu-14.04/source create mode 120000 packaging/ubuntu-14.04/tests/README.md create mode 100644 packaging/ubuntu-14.04/tests/control create mode 100644 packaging/ubuntu-14.04/tests/integrationtests create mode 120000 packaging/ubuntu-14.04/tests/testconfig.json create mode 100644 packaging/ubuntu-14.04/ubuntu-snappy-cli.dirs create mode 100644 packaging/ubuntu-16.04/README.powerpc create mode 100644 packaging/ubuntu-16.04/changelog create mode 100644 packaging/ubuntu-16.04/compat create mode 100644 packaging/ubuntu-16.04/control create mode 100644 packaging/ubuntu-16.04/copyright create mode 100644 packaging/ubuntu-16.04/gbp.conf create mode 100644 packaging/ubuntu-16.04/golang-github-snapcore-snapd-dev.install create mode 100755 packaging/ubuntu-16.04/rules create mode 100644 packaging/ubuntu-16.04/snap-confine.maintscript create mode 100644 packaging/ubuntu-16.04/snapd.autoimport.udev create mode 100644 packaging/ubuntu-16.04/snapd.dirs create mode 100644 packaging/ubuntu-16.04/snapd.install.in create mode 100644 packaging/ubuntu-16.04/snapd.links create mode 100644 packaging/ubuntu-16.04/snapd.maintscript create mode 100644 packaging/ubuntu-16.04/snapd.manpages create mode 100644 packaging/ubuntu-16.04/snapd.postinst create mode 100644 packaging/ubuntu-16.04/snapd.postrm create mode 100644 packaging/ubuntu-16.04/snapd.preinst create mode 100644 packaging/ubuntu-16.04/snapd.prerm create mode 100644 packaging/ubuntu-16.04/source/format create mode 100644 packaging/ubuntu-16.04/source/options create mode 100644 packaging/ubuntu-16.04/tests/README.md create mode 100644 packaging/ubuntu-16.04/tests/control create mode 100644 packaging/ubuntu-16.04/tests/integrationtests create mode 100644 packaging/ubuntu-16.04/tests/testconfig.json create mode 100644 packaging/ubuntu-16.04/ubuntu-snappy-cli.dirs create mode 100644 po/af.po create mode 100644 po/am.po create mode 100644 po/ar.po create mode 100644 po/bn.po create mode 100644 po/bs.po create mode 100644 po/ca.po create mode 100644 po/cs.po create mode 100644 po/cy.po create mode 100644 po/da.po create mode 100644 po/de.po create mode 100644 po/el.po create mode 100644 po/en_GB.po create mode 100644 po/eo.po create mode 100644 po/es.po create mode 100644 po/fa.po create mode 100644 po/fi.po create mode 100644 po/fr.po create mode 100644 po/gl.po create mode 100644 po/hr.po create mode 100644 po/hu.po create mode 100644 po/ia.po create mode 100644 po/id.po create mode 100644 po/it.po create mode 100644 po/its/polkit.its create mode 100644 po/ja.po create mode 100644 po/km.po create mode 100644 po/ko.po create mode 100644 po/lt.po create mode 100644 po/lv.po create mode 100644 po/mnw.po create mode 100644 po/ms.po create mode 100644 po/nb.po create mode 100644 po/nl.po create mode 100644 po/oc.po create mode 100644 po/pl.po create mode 100644 po/pt.po create mode 100644 po/pt_BR.po create mode 100644 po/ro.po create mode 100644 po/ru.po create mode 100644 po/sd.po create mode 100644 po/sq.po create mode 100644 po/sv.po create mode 100644 po/tg.po create mode 100644 po/th.po create mode 100644 po/tr.po create mode 100644 po/ug.po create mode 100644 po/uk.po create mode 100644 po/zh_CN.po create mode 100644 po/zh_TW.po create mode 100644 polkit/authority.go create mode 100644 polkit/pid_start_time.go create mode 100644 polkit/pid_start_time_test.go create mode 100644 polkit/validate/validate.go create mode 100644 polkit/validate/validate_test.go create mode 100644 progress/ansimeter.go create mode 100644 progress/ansimeter_test.go create mode 100644 progress/export_test.go create mode 100644 progress/progress.go create mode 100644 progress/progress_test.go create mode 100644 progress/progresstest/progresstest.go create mode 100644 randutil/crypto.go create mode 100644 randutil/crypto_test.go create mode 100644 randutil/export_test.go create mode 100644 randutil/rand.go create mode 100644 randutil/rand_test.go create mode 100755 release-tools/changelog.py create mode 100755 release-tools/debian-package-builder create mode 100755 release-tools/repack-debian-tarball.sh create mode 120000 release-tools/test/changelog.py create mode 100644 release-tools/test/test_changelog.py create mode 100644 release-tools/test/test_flake8.py create mode 100644 release/export_test.go create mode 100644 release/release.go create mode 100644 release/release_test.go create mode 100755 run-checks create mode 100644 sandbox/apparmor/apparmor.go create mode 100644 sandbox/apparmor/apparmor_test.go create mode 100644 sandbox/apparmor/export_test.go create mode 100644 sandbox/apparmor/notify/export_test.go create mode 100644 sandbox/apparmor/notify/ioctl.go create mode 100644 sandbox/apparmor/notify/ioctl_test.go create mode 100644 sandbox/apparmor/notify/listener/export_test.go create mode 100644 sandbox/apparmor/notify/listener/listener.go create mode 100644 sandbox/apparmor/notify/listener/listener_test.go create mode 100644 sandbox/apparmor/notify/mclass.go create mode 100644 sandbox/apparmor/notify/mclass_test.go create mode 100644 sandbox/apparmor/notify/message.go create mode 100644 sandbox/apparmor/notify/message_test.go create mode 100644 sandbox/apparmor/notify/modeset.go create mode 100644 sandbox/apparmor/notify/modeset_test.go create mode 100644 sandbox/apparmor/notify/notify.go create mode 100644 sandbox/apparmor/notify/notify_test.go create mode 100644 sandbox/apparmor/notify/ntype.go create mode 100644 sandbox/apparmor/notify/ntype_test.go create mode 100644 sandbox/apparmor/notify/permission.go create mode 100644 sandbox/apparmor/notify/permission_test.go create mode 100644 sandbox/apparmor/notify/strings.go create mode 100644 sandbox/apparmor/process.go create mode 100644 sandbox/apparmor/process_test.go create mode 100644 sandbox/apparmor/profile.go create mode 100644 sandbox/apparmor/profile_test.go create mode 100644 sandbox/cgroup/cgroup.go create mode 100644 sandbox/cgroup/cgroup_test.go create mode 100644 sandbox/cgroup/export_test.go create mode 100644 sandbox/cgroup/freezer.go create mode 100644 sandbox/cgroup/freezer_test.go create mode 100644 sandbox/cgroup/memory.go create mode 100644 sandbox/cgroup/memory_test.go create mode 100644 sandbox/cgroup/monitor.go create mode 100644 sandbox/cgroup/monitor_test.go create mode 100644 sandbox/cgroup/pids.go create mode 100644 sandbox/cgroup/pids_test.go create mode 100644 sandbox/cgroup/process.go create mode 100644 sandbox/cgroup/process_test.go create mode 100644 sandbox/cgroup/scanning.go create mode 100644 sandbox/cgroup/scanning_test.go create mode 100644 sandbox/cgroup/tracking.go create mode 100644 sandbox/cgroup/tracking_test.go create mode 100644 sandbox/forcedevmode.go create mode 100644 sandbox/forcedevmode_test.go create mode 100644 sandbox/seccomp/compiler.go create mode 100644 sandbox/seccomp/compiler_test.go create mode 100644 sandbox/seccomp/export_test.go create mode 100644 sandbox/seccomp/seccomp.go create mode 100644 sandbox/seccomp/seccomp_test.go create mode 100644 sandbox/selinux/export_test.go create mode 100644 sandbox/selinux/label.go create mode 100644 sandbox/selinux/label_darwin.go create mode 100644 sandbox/selinux/label_linux.go create mode 100644 sandbox/selinux/label_linux_test.go create mode 100644 sandbox/selinux/selinux.go create mode 100644 sandbox/selinux/selinux_darwin.go create mode 100644 sandbox/selinux/selinux_linux.go create mode 100644 sandbox/selinux/selinux_linux_test.go create mode 100644 sandbox/selinux/selinux_test.go create mode 100644 secboot/encrypt.go create mode 100644 secboot/encrypt_dummy.go create mode 100644 secboot/encrypt_sb.go create mode 100644 secboot/encrypt_sb_test.go create mode 100644 secboot/encrypt_test.go create mode 100644 secboot/export_sb_test.go create mode 100644 secboot/keymgr/export_test.go create mode 100644 secboot/keymgr/keymgr_luks2.go create mode 100644 secboot/keymgr/keymgr_luks2_test.go create mode 100644 secboot/keyring/keyring.go create mode 100644 secboot/keys/export_test.go create mode 100644 secboot/keys/keys.go create mode 100644 secboot/keys/keys_dummy.go create mode 100644 secboot/keys/keys_sb.go create mode 100644 secboot/keys/keys_test.go create mode 100644 secboot/luks2/cryptsetup.go create mode 100644 secboot/luks2/cryptsetup_test.go create mode 100644 secboot/luks2/luks2.go create mode 100644 secboot/secboot.go create mode 100644 secboot/secboot_dummy.go create mode 100644 secboot/secboot_hooks.go create mode 100644 secboot/secboot_sb.go create mode 100644 secboot/secboot_sb_test.go create mode 100644 secboot/secboot_tpm.go create mode 100644 secboot/test-data/keyfile create mode 100644 seed/export_test.go create mode 100644 seed/helpers.go create mode 100644 seed/helpers_test.go create mode 100644 seed/internal/auxinfo20.go create mode 100644 seed/internal/doc.go create mode 100644 seed/internal/helpers.go create mode 100644 seed/internal/options20.go create mode 100644 seed/internal/options20_test.go create mode 100644 seed/internal/seed_yaml.go create mode 100644 seed/internal/seed_yaml_test.go create mode 100644 seed/seed.go create mode 100644 seed/seed16.go create mode 100644 seed/seed16_test.go create mode 100644 seed/seed20.go create mode 100644 seed/seed20_test.go create mode 100644 seed/seedtest/sample.go create mode 100644 seed/seedtest/seedtest.go create mode 100644 seed/seedwriter/export_test.go create mode 100644 seed/seedwriter/fetcher.go create mode 100644 seed/seedwriter/fetcher_test.go create mode 100644 seed/seedwriter/helpers.go create mode 100644 seed/seedwriter/manifest.go create mode 100644 seed/seedwriter/manifest_test.go create mode 100644 seed/seedwriter/seed16.go create mode 100644 seed/seedwriter/seed20.go create mode 100644 seed/seedwriter/writer.go create mode 100644 seed/seedwriter/writer_test.go create mode 100644 seed/validate.go create mode 100644 seed/validate_test.go create mode 100644 snap/broken.go create mode 100644 snap/broken_test.go create mode 100644 snap/channel/channel.go create mode 100644 snap/channel/channel_test.go create mode 100644 snap/component.go create mode 100644 snap/component_test.go create mode 100644 snap/container.go create mode 100644 snap/container_test.go create mode 100644 snap/device.go create mode 100644 snap/epoch.go create mode 100644 snap/epoch_test.go create mode 100644 snap/errors.go create mode 100644 snap/errors_test.go create mode 100644 snap/export_test.go create mode 100644 snap/helpers.go create mode 100644 snap/hooktypes.go create mode 100644 snap/hotplug_key.go create mode 100644 snap/hotplug_key_test.go create mode 100644 snap/implicit.go create mode 100644 snap/implicit_test.go create mode 100644 snap/info.go create mode 100644 snap/info_snap_yaml.go create mode 100644 snap/info_snap_yaml_test.go create mode 100644 snap/info_test.go create mode 100644 snap/integrity/dmverity/export_test.go create mode 100644 snap/integrity/dmverity/veritysetup.go create mode 100644 snap/integrity/dmverity/veritysetup_test.go create mode 100644 snap/integrity/export_test.go create mode 100644 snap/integrity/integrity.go create mode 100644 snap/integrity/integrity_test.go create mode 100644 snap/internal/file.go create mode 100644 snap/naming/componentref.go create mode 100644 snap/naming/componentref_test.go create mode 100644 snap/naming/core_version.go create mode 100644 snap/naming/core_version_test.go create mode 100644 snap/naming/naming_test.go create mode 100644 snap/naming/snapref.go create mode 100644 snap/naming/snapref_test.go create mode 100644 snap/naming/tag.go create mode 100644 snap/naming/tag_test.go create mode 100644 snap/naming/validate.go create mode 100644 snap/naming/validate_test.go create mode 100644 snap/naming/wellknown.go create mode 100644 snap/naming/wellknown_test.go create mode 100644 snap/pack/export_test.go create mode 100644 snap/pack/pack.go create mode 100644 snap/pack/pack_test.go create mode 100644 snap/quota/export_test.go create mode 100644 snap/quota/quota.go create mode 100644 snap/quota/quota_test.go create mode 100644 snap/quota/resources.go create mode 100644 snap/quota/resources_builder.go create mode 100644 snap/quota/resources_test.go create mode 100644 snap/restartcond.go create mode 100644 snap/restartcond_test.go create mode 100644 snap/revision.go create mode 100644 snap/revision_test.go create mode 100644 snap/snapdir/snapdir.go create mode 100644 snap/snapdir/snapdir_test.go create mode 100644 snap/snapenv/snapenv.go create mode 100644 snap/snapenv/snapenv_test.go create mode 100644 snap/snapfile/snapfile.go create mode 100644 snap/snapfile/snapfile_test.go create mode 100644 snap/snapshots.go create mode 100644 snap/snapshots_export_test.go create mode 100644 snap/snapshots_test.go create mode 100644 snap/snaptest/snaptest.go create mode 100644 snap/snaptest/snaptest_test.go create mode 100644 snap/squashfs/export_test.go create mode 100644 snap/squashfs/squashfs.go create mode 100644 snap/squashfs/squashfs_test.go create mode 100644 snap/squashfs/stat.go create mode 100644 snap/squashfs/stat_test.go create mode 100644 snap/sysparams/export_test.go create mode 100644 snap/sysparams/sysparams.go create mode 100644 snap/sysparams/sysparams_test.go create mode 100644 snap/system_usernames.go create mode 100644 snap/types.go create mode 100644 snap/types_test.go create mode 100644 snap/validate.go create mode 100644 snap/validate_test.go create mode 100644 snapd.code-workspace create mode 100644 snapdenv/export_test.go create mode 100644 snapdenv/snapdenv.go create mode 100644 snapdenv/snapdenv_test.go create mode 100644 snapdenv/useragent.go create mode 100644 snapdenv/useragent_test.go create mode 100644 snapdenv/withtestkeys.go create mode 100644 snapdtool/cmdutil.go create mode 100644 snapdtool/cmdutil_test.go create mode 100644 snapdtool/export_test.go create mode 100644 snapdtool/info_file.go create mode 100644 snapdtool/info_file_test.go create mode 100644 snapdtool/tool_linux.go create mode 100644 snapdtool/tool_linux_test.go create mode 100644 snapdtool/tool_other.go create mode 100644 snapdtool/tool_test.go create mode 100644 snapdtool/version.go create mode 100644 spdx/licenses.go create mode 100644 spdx/parser.go create mode 100644 spdx/parser_test.go create mode 100644 spdx/scanner.go create mode 100644 spdx/scanner_test.go create mode 100644 spdx/validate.go create mode 100644 spread.yaml create mode 100644 store/auth.go create mode 100644 store/auth_u1.go create mode 100644 store/auth_u1_test.go create mode 100644 store/cache.go create mode 100644 store/cache_test.go create mode 100644 store/details.go create mode 100644 store/details_v2.go create mode 100644 store/details_v2_test.go create mode 100644 store/devicenauthctx.go create mode 100644 store/download_test.go create mode 100644 store/errors.go create mode 100644 store/export_test.go create mode 100644 store/search_v2.go create mode 100644 store/store.go create mode 100644 store/store_action.go create mode 100644 store/store_action_fetch_assertions_test.go create mode 100644 store/store_action_test.go create mode 100644 store/store_asserts.go create mode 100644 store/store_asserts_test.go create mode 100644 store/store_download.go create mode 100644 store/store_download_test.go create mode 100644 store/store_test.go create mode 100644 store/storetest/storetest.go create mode 100644 store/stringlist_test.go create mode 100644 store/tooling/auth.go create mode 100644 store/tooling/export_test.go create mode 100644 store/tooling/tooling.go create mode 100644 store/tooling/tooling_test.go create mode 100644 store/uacontext.go create mode 100644 store/uacontext_test.go create mode 100644 store/userinfo.go create mode 100644 store/userinfo_test.go create mode 100644 strutil/chrorder.go create mode 100644 strutil/chrorder/main.go create mode 100644 strutil/ctrl16.go create mode 100644 strutil/ctrl17.go create mode 100644 strutil/export_test.go create mode 100644 strutil/intersection.go create mode 100644 strutil/intersection_test.go create mode 100644 strutil/limbuffer.go create mode 100644 strutil/limbuffer_test.go create mode 100644 strutil/map.go create mode 100644 strutil/map_test.go create mode 100644 strutil/matchcounter.go create mode 100644 strutil/matchcounter_benchmark_test.go create mode 100644 strutil/matchcounter_test.go create mode 100644 strutil/pathiter.go create mode 100644 strutil/pathiter_test.go create mode 100644 strutil/quantity/example_test.go create mode 100644 strutil/quantity/quantity.go create mode 100644 strutil/set.go create mode 100644 strutil/set_test.go create mode 100644 strutil/shlex/shlex.go create mode 100644 strutil/shlex/shlex_test.go create mode 100644 strutil/strutil.go create mode 100644 strutil/strutil_test.go create mode 100644 strutil/version.go create mode 100644 strutil/version_benchmark_test.go create mode 100644 strutil/version_test.go create mode 100644 syscheck/apparmor_lxd.go create mode 100644 syscheck/apparmor_lxd_test.go create mode 100644 syscheck/cgroup.go create mode 100644 syscheck/cgroup_test.go create mode 100644 syscheck/check.go create mode 100644 syscheck/check_test.go create mode 100644 syscheck/export_test.go create mode 100644 syscheck/squashfs.go create mode 100644 syscheck/squashfs_test.go create mode 100644 syscheck/version.go create mode 100644 syscheck/version_test.go create mode 100644 syscheck/wsl.go create mode 100644 syscheck/wsl_test.go create mode 100644 sysconfig/cloudinit.go create mode 100644 sysconfig/cloudinit_test.go create mode 100644 sysconfig/export_test.go create mode 100644 sysconfig/gadget_defaults_test.go create mode 100644 sysconfig/sysconfig.go create mode 100644 systemd/emulation.go create mode 100644 systemd/escape.go create mode 100644 systemd/escape_test.go create mode 100644 systemd/export_test.go create mode 100644 systemd/journal.go create mode 100644 systemd/journal_test.go create mode 100644 systemd/sdnotify.go create mode 100644 systemd/sdnotify_test.go create mode 100644 systemd/sysctl.go create mode 100644 systemd/sysctl_test.go create mode 100644 systemd/systemd.go create mode 100644 systemd/systemd_test.go create mode 100644 systemd/systemdtest/systemdtest.go create mode 120000 tests/bin/MATCH create mode 100755 tests/bin/NOMATCH create mode 100644 tests/bin/README create mode 120000 tests/bin/REBOOT create mode 120000 tests/bin/mountinfo.query create mode 120000 tests/bin/not create mode 120000 tests/bin/os.paths create mode 120000 tests/bin/os.query create mode 120000 tests/bin/quiet create mode 120000 tests/bin/remote.exec create mode 120000 tests/bin/remote.pull create mode 120000 tests/bin/remote.push create mode 120000 tests/bin/remote.refresh create mode 120000 tests/bin/remote.retry create mode 120000 tests/bin/remote.setup create mode 120000 tests/bin/remote.wait-for create mode 120000 tests/bin/retry create mode 120000 tests/bin/snapd.tool create mode 120000 tests/bin/snaps.name create mode 120000 tests/bin/tests.backup create mode 120000 tests/bin/tests.cleanup create mode 120000 tests/bin/tests.device-cgroup create mode 120000 tests/bin/tests.env create mode 120000 tests/bin/tests.invariant create mode 120000 tests/bin/tests.nested create mode 120000 tests/bin/tests.pkgs create mode 120000 tests/bin/tests.session create mode 120000 tests/bin/tests.systemd create mode 100644 tests/completion/data/files/a/a_thing.txt create mode 100644 tests/completion/data/files/b/b_thing.txt create mode 100644 tests/completion/data/files/b/c/b_c_thing.txt create mode 100644 tests/completion/data/files/d/d_thing.txt create mode 100644 tests/completion/data/files/thing.txt create mode 100644 tests/completion/data/hosts.txt create mode 100644 tests/completion/data/twisted/twisted.tar create mode 100644 tests/completion/dirs.complete create mode 100644 tests/completion/dirs.sh create mode 100644 tests/completion/dirs.vars create mode 100644 tests/completion/files.complete create mode 100644 tests/completion/files.sh create mode 100644 tests/completion/files.vars create mode 100644 tests/completion/func.complete create mode 100644 tests/completion/func.sh create mode 100644 tests/completion/func.vars create mode 100644 tests/completion/funcarg.complete create mode 100644 tests/completion/funcarg.sh create mode 100644 tests/completion/funcarg.vars create mode 100644 tests/completion/funky.complete create mode 100644 tests/completion/funky.sh create mode 100644 tests/completion/funky.vars create mode 100644 tests/completion/funkyfunc.complete create mode 100644 tests/completion/funkyfunc.sh create mode 100644 tests/completion/funkyfunc.vars create mode 100644 tests/completion/hosts.complete create mode 100644 tests/completion/hosts.sh create mode 100644 tests/completion/hosts.vars create mode 100644 tests/completion/hosts_n_dirs.complete create mode 100644 tests/completion/hosts_n_dirs.sh create mode 100644 tests/completion/hosts_n_dirs.vars create mode 100644 tests/completion/indirect/task.exp create mode 100644 tests/completion/indirect/task.yaml create mode 100644 tests/completion/lib.exp0 create mode 100644 tests/completion/plain.complete create mode 100644 tests/completion/plain.sh create mode 100644 tests/completion/plain.vars create mode 100644 tests/completion/plain_plusdirs.complete create mode 100644 tests/completion/plain_plusdirs.sh create mode 100644 tests/completion/plain_plusdirs.vars create mode 100644 tests/completion/simple/task.exp create mode 100644 tests/completion/simple/task.yaml create mode 100644 tests/completion/snippets/task.exp create mode 100644 tests/completion/snippets/task.yaml create mode 100644 tests/completion/twisted.complete create mode 100644 tests/completion/twisted.sh create mode 100644 tests/completion/twisted.vars create mode 100644 tests/core/apt/task.yaml create mode 100644 tests/core/backlight/task.yaml create mode 100644 tests/core/bash-completion/task.yaml create mode 100644 tests/core/bash-completion/test-completion.py create mode 100644 tests/core/bash-completion/test-rc create mode 100644 tests/core/basic18/task.yaml create mode 100644 tests/core/basic20plus/task.yaml create mode 100644 tests/core/classic-snap16/task.yaml create mode 100644 tests/core/compat/task.yaml create mode 100644 tests/core/config-defaults-once/gadget-defaults.yaml create mode 100644 tests/core/config-defaults-once/task.yaml create mode 100755 tests/core/core-dump/core-dump-snap/bin/crash.sh create mode 100644 tests/core/core-dump/core-dump-snap/meta/snap.yaml create mode 100644 tests/core/core-dump/task.yaml create mode 100644 tests/core/core-to-snapd-failover16/task.yaml create mode 100644 tests/core/create-user-2/task.yaml create mode 100644 tests/core/create-user/task.yaml create mode 100755 tests/core/custom-device-reg-extras/prepare-device create mode 100644 tests/core/custom-device-reg-extras/task.yaml create mode 100755 tests/core/custom-device-reg/prepare-device create mode 100644 tests/core/custom-device-reg/task.yaml create mode 100644 tests/core/dbus-activation/task.yaml create mode 100644 tests/core/desktop-files/task.yaml create mode 100644 tests/core/device-reg/task.yaml create mode 100644 tests/core/enable-disable-units-gpio/task.yaml create mode 100644 tests/core/failover/task.yaml create mode 100644 tests/core/fan/task.yaml create mode 100644 tests/core/fsck-on-boot/task.yaml create mode 100644 tests/core/fsck-vfat/task.yaml create mode 100644 tests/core/gadget-config-defaults-to-snaps/gadget-rsyslog.yaml create mode 100644 tests/core/gadget-config-defaults-to-snaps/gadget-ssh-common.yaml create mode 100644 tests/core/gadget-config-defaults-to-snaps/gadget-ssh-oneline.yaml create mode 100644 tests/core/gadget-config-defaults-to-snaps/task.yaml create mode 100644 tests/core/gadget-config-defaults-vitality/gadget-vitality-hint.yaml create mode 100644 tests/core/gadget-config-defaults-vitality/task.yaml create mode 100644 tests/core/gadget-kernel-refs-update-pc/task.yaml create mode 100755 tests/core/gadget-update-pc/generate.py create mode 100644 tests/core/gadget-update-pc/task.yaml create mode 100644 tests/core/generic-device-reg/task.yaml create mode 100644 tests/core/grub-no-unpacked-assets/task.yaml create mode 100755 tests/core/iio/iio-consumer/bin/read create mode 100755 tests/core/iio/iio-consumer/bin/write create mode 100644 tests/core/iio/iio-consumer/meta/snap.yaml create mode 100644 tests/core/iio/task.yaml create mode 100644 tests/core/kernel-base-gadget-pair-single-reboot-failover/task.yaml create mode 100644 tests/core/kernel-base-gadget-pair-single-reboot/task.yaml create mode 100644 tests/core/kernel-base-gadget-single-reboot-failover/task.yaml create mode 100644 tests/core/kernel-base-gadget-single-reboot/task.yaml create mode 100644 tests/core/kernel-snap-refresh-on-core/task.yaml create mode 100644 tests/core/kernel-ver/task.yaml create mode 100644 tests/core/mem-cgroup-disabled/task.yaml create mode 100644 tests/core/netplan-cfg/task.yaml create mode 100644 tests/core/netplan/task.yaml create mode 100644 tests/core/network-config/task.yaml create mode 100644 tests/core/os-release/task.yaml create mode 100644 tests/core/persistent-journal-namespace/task.yaml create mode 100644 tests/core/persistent-journal/task.yaml create mode 100644 tests/core/reboot/task.yaml create mode 100644 tests/core/remodel-base/task.yaml create mode 100644 tests/core/remodel-gadget/task.yaml create mode 100644 tests/core/remodel-kernel/task.yaml create mode 100644 tests/core/remodel/task.yaml create mode 100644 tests/core/remove-user/task.yaml create mode 100644 tests/core/remove/task.yaml create mode 100644 tests/core/seed-base-symlinks/task.yaml create mode 100644 tests/core/services/task.yaml create mode 100644 tests/core/snap-auto-import-asserts-spools/task.yaml create mode 100644 tests/core/snap-auto-import-asserts/task.yaml create mode 100644 tests/core/snap-auto-mount/task.yaml create mode 100644 tests/core/snap-core-fixup/task.yaml create mode 100644 tests/core/snap-core-fixup/test.img.tar.gz create mode 100644 tests/core/snap-debug-bootvars/task.yaml create mode 100755 tests/core/snap-repair/retry.sh create mode 100644 tests/core/snap-repair/task.yaml create mode 100644 tests/core/snap-repair/uc16.json create mode 100644 tests/core/snap-repair/uc16.sh create mode 100644 tests/core/snap-repair/uc18.json create mode 100644 tests/core/snap-repair/uc18.sh create mode 100644 tests/core/snap-repair/uc20-recover.json create mode 100644 tests/core/snap-repair/uc20-recover.sh create mode 100644 tests/core/snap-repair/uc20-run.json create mode 100644 tests/core/snap-repair/uc20-run.sh create mode 100644 tests/core/snap-repair/uc22-recover.json create mode 100644 tests/core/snap-repair/uc22-recover.sh create mode 100644 tests/core/snap-repair/uc22-run.json create mode 100644 tests/core/snap-repair/uc22-run.sh create mode 100644 tests/core/snap-repair/uc24-recover.json create mode 100644 tests/core/snap-repair/uc24-recover.sh create mode 100644 tests/core/snap-repair/uc24-run.json create mode 100644 tests/core/snap-repair/uc24-run.sh create mode 100644 tests/core/snap-set-core-config/task.yaml create mode 100644 tests/core/snapd-failover/task.yaml create mode 100644 tests/core/snapd-maintenance-msg/task.yaml create mode 100644 tests/core/snapd-refresh-vs-services-reboots/task.yaml create mode 100755 tests/core/snapd-refresh-vs-services-reboots/test-snapd-svc-flip-flop/bin/svc.sh create mode 100644 tests/core/snapd-refresh-vs-services-reboots/test-snapd-svc-flip-flop/meta/snap.yaml create mode 100644 tests/core/snapd-refresh-vs-services/task.yaml create mode 100644 tests/core/snapd-refresh/task.yaml create mode 100644 tests/core/snapd16/task.yaml create mode 100644 tests/core/swapfiles/task.yaml create mode 100644 tests/core/system-settings/task.yaml create mode 100644 tests/core/system-snap-refresh/task.yaml create mode 100644 tests/core/tmp/task.yaml create mode 100644 tests/core/uboot-unpacked-assets/task.yaml create mode 100644 tests/core/uc20-recovery/task.yaml create mode 100644 tests/core/update-snapd-symlink/task.yaml create mode 100644 tests/core/upgrade/task.yaml create mode 100644 tests/core/watchdog/task.yaml create mode 100644 tests/core/writablepaths/task.yaml create mode 100644 tests/core/xdg-open-on-core/task.yaml create mode 100644 tests/cross/go-build/task.yaml create mode 100644 tests/external-backend.md create mode 100644 tests/lib/assertions/CA5GLZgNQWPhspDQK63Er46Uxz2SO7ez.auto-import.assert create mode 100644 tests/lib/assertions/CA5GLZgNQWPhspDQK63Er46Uxz2SO7ez.auto-import.assert.json create mode 100644 tests/lib/assertions/README.md create mode 100644 tests/lib/assertions/auto-import.assert create mode 100644 tests/lib/assertions/auto-import.assert.json create mode 100644 tests/lib/assertions/classic-model-rev1.assert create mode 100644 tests/lib/assertions/classic-model-rev1.json create mode 100644 tests/lib/assertions/classic-model.assert create mode 100644 tests/lib/assertions/classic-model.json create mode 100644 tests/lib/assertions/developer1-20-auto-import.assert create mode 100644 tests/lib/assertions/developer1-20-auto-import.json create mode 100644 tests/lib/assertions/developer1-20-dangerous.json create mode 100644 tests/lib/assertions/developer1-20-dangerous.model create mode 100644 tests/lib/assertions/developer1-20-secured.json create mode 100644 tests/lib/assertions/developer1-20-secured.model create mode 100644 tests/lib/assertions/developer1-20-signed.json create mode 100644 tests/lib/assertions/developer1-20-signed.model create mode 100644 tests/lib/assertions/developer1-20-storage-safety-prefer-unencrypted.json create mode 100644 tests/lib/assertions/developer1-20-storage-safety-prefer-unencrypted.model create mode 100644 tests/lib/assertions/developer1-22-auto-import.assert create mode 100644 tests/lib/assertions/developer1-22-auto-import.json create mode 100644 tests/lib/assertions/developer1-22-classic-dangerous.json create mode 100644 tests/lib/assertions/developer1-22-dangerous.json create mode 100644 tests/lib/assertions/developer1-22-dangerous.model create mode 100644 tests/lib/assertions/developer1-22-secured.json create mode 100644 tests/lib/assertions/developer1-22-secured.model create mode 100644 tests/lib/assertions/developer1-22-signed.json create mode 100644 tests/lib/assertions/developer1-22-signed.model create mode 100644 tests/lib/assertions/developer1-22-storage-safety-prefer-unencrypted.json create mode 100644 tests/lib/assertions/developer1-22-storage-safety-prefer-unencrypted.model create mode 100644 tests/lib/assertions/developer1-my-classic-w-gadget-18.model create mode 100644 tests/lib/assertions/developer1-my-classic-w-gadget.model create mode 100644 tests/lib/assertions/developer1-my-classic.model create mode 100644 tests/lib/assertions/developer1-network-aspect-bundle.json create mode 100644 tests/lib/assertions/developer1-network.aspect-bundle create mode 100644 tests/lib/assertions/developer1-pc-18.model create mode 100644 tests/lib/assertions/developer1-pc-18.model.json create mode 100644 tests/lib/assertions/developer1-pc-new-base-18.model create mode 100644 tests/lib/assertions/developer1-pc-new-gadget-18.model create mode 100644 tests/lib/assertions/developer1-pc-new-gadget-18.model.json create mode 100644 tests/lib/assertions/developer1-pc-new-kernel.model create mode 100644 tests/lib/assertions/developer1-pc-revno2-18.model create mode 100644 tests/lib/assertions/developer1-pc-revno2.model create mode 100644 tests/lib/assertions/developer1-pc-revno3-18.model create mode 100644 tests/lib/assertions/developer1-pc-revno3.model create mode 100644 tests/lib/assertions/developer1-pc-w-config-18.model create mode 100644 tests/lib/assertions/developer1-pc-w-config-18.model.json create mode 100644 tests/lib/assertions/developer1-pc-w-config.model create mode 100644 tests/lib/assertions/developer1-pc.model create mode 100644 tests/lib/assertions/developer1-pi-20.model.json create mode 100644 tests/lib/assertions/developer1.account create mode 100644 tests/lib/assertions/developer1.account-key create mode 100644 tests/lib/assertions/fake.store create mode 100644 tests/lib/assertions/gating-20-amd64.model create mode 100644 tests/lib/assertions/gating-20-amd64.model.json create mode 100644 tests/lib/assertions/nested-18-amd64.model create mode 100644 tests/lib/assertions/nested-18-amd64.model.json create mode 100644 tests/lib/assertions/nested-20-amd64-connections.model create mode 100644 tests/lib/assertions/nested-20-amd64-connections.model.json create mode 100644 tests/lib/assertions/nested-20-amd64.model create mode 100644 tests/lib/assertions/nested-20-amd64.model.json create mode 100644 tests/lib/assertions/nested-22-amd64.model create mode 100644 tests/lib/assertions/nested-22-arm64.model create mode 100644 tests/lib/assertions/nested-amd64.model create mode 100644 tests/lib/assertions/nested-amd64.model.json create mode 100644 tests/lib/assertions/nested-i386.model create mode 100644 tests/lib/assertions/nested-i386.model.json create mode 100644 tests/lib/assertions/pc-18-amd64-accept-generic.model create mode 100644 tests/lib/assertions/pc-18-amd64-accept-generic.model.json create mode 100644 tests/lib/assertions/pc-production.model create mode 100644 tests/lib/assertions/pc-staging.model create mode 100644 tests/lib/assertions/pi2.model create mode 100644 tests/lib/assertions/pi2.model.json create mode 100644 tests/lib/assertions/test-snapd-account-key-rrP2xy.assert create mode 100644 tests/lib/assertions/test-snapd-core22-required-vset.assert create mode 100644 tests/lib/assertions/test-snapd-core22-required-vset.json create mode 100644 tests/lib/assertions/test-snapd-recovery-system-pc-20.json create mode 100644 tests/lib/assertions/test-snapd-recovery-system-pc-20.model create mode 100644 tests/lib/assertions/test-snapd-recovery-system-pc-22.json create mode 100644 tests/lib/assertions/test-snapd-recovery-system-pc-22.model create mode 100644 tests/lib/assertions/test-snapd-recovery-system-pinned.assert create mode 100644 tests/lib/assertions/test-snapd-remodel-auto-import.assert create mode 100644 tests/lib/assertions/test-snapd-remodel-auto-import.assert.json create mode 100644 tests/lib/assertions/test-snapd-remodel-bases-20.json create mode 100644 tests/lib/assertions/test-snapd-remodel-bases-20.model create mode 100644 tests/lib/assertions/test-snapd-remodel-bases-22.json create mode 100644 tests/lib/assertions/test-snapd-remodel-bases-22.model create mode 100644 tests/lib/assertions/test-snapd-remodel-invalid-vset-pc-22.json create mode 100644 tests/lib/assertions/test-snapd-remodel-invalid-vset-pc-22.model create mode 100644 tests/lib/assertions/test-snapd-remodel-offline-rev0.json create mode 100644 tests/lib/assertions/test-snapd-remodel-offline-rev0.model create mode 100644 tests/lib/assertions/test-snapd-remodel-offline-rev1.json create mode 100644 tests/lib/assertions/test-snapd-remodel-offline-rev1.model create mode 100644 tests/lib/assertions/test-snapd-remodel-pc-18.json create mode 100644 tests/lib/assertions/test-snapd-remodel-pc-18.model create mode 100644 tests/lib/assertions/test-snapd-remodel-pc-20.json create mode 100644 tests/lib/assertions/test-snapd-remodel-pc-20.model create mode 100644 tests/lib/assertions/test-snapd-remodel-pc-22.json create mode 100644 tests/lib/assertions/test-snapd-remodel-pc-22.model create mode 100644 tests/lib/assertions/test-snapd-remodel-pc-cross-store-18.json create mode 100644 tests/lib/assertions/test-snapd-remodel-pc-cross-store-18.model create mode 100644 tests/lib/assertions/test-snapd-remodel-pc-cross-store-20.json create mode 100644 tests/lib/assertions/test-snapd-remodel-pc-cross-store-20.model create mode 100644 tests/lib/assertions/test-snapd-remodel-pc-cross-store-22.json create mode 100644 tests/lib/assertions/test-snapd-remodel-pc-cross-store-22.model create mode 100644 tests/lib/assertions/test-snapd-remodel-pc-just-model-18.json create mode 100644 tests/lib/assertions/test-snapd-remodel-pc-just-model-18.model create mode 100644 tests/lib/assertions/test-snapd-remodel-pc-just-model-20.json create mode 100644 tests/lib/assertions/test-snapd-remodel-pc-just-model-20.model create mode 100644 tests/lib/assertions/test-snapd-remodel-pc-just-model-22.json create mode 100644 tests/lib/assertions/test-snapd-remodel-pc-just-model-22.model create mode 100644 tests/lib/assertions/test-snapd-remodel-pc-min-size-22.json create mode 100644 tests/lib/assertions/test-snapd-remodel-pc-min-size-22.model create mode 100644 tests/lib/assertions/test-snapd-remodel-pc-rev0-22.json create mode 100644 tests/lib/assertions/test-snapd-remodel-pc-rev0-22.model create mode 100644 tests/lib/assertions/test-snapd-remodel-pc-rev1-22.json create mode 100644 tests/lib/assertions/test-snapd-remodel-pc-rev1-22.model create mode 100644 tests/lib/assertions/test-snapd-remodel-pinned-hello-world-pc-22.json create mode 100644 tests/lib/assertions/test-snapd-remodel-pinned-hello-world-pc-22.model create mode 100644 tests/lib/assertions/test-snapd-remodel-without-vset-pc-22.json create mode 100644 tests/lib/assertions/test-snapd-remodel-without-vset-pc-22.model create mode 100644 tests/lib/assertions/testrootorg-store.account-key create mode 100644 tests/lib/assertions/ubuntu-core-18-amd64.model create mode 100644 tests/lib/assertions/ubuntu-core-20-amd64.model create mode 100644 tests/lib/assertions/ubuntu-core-22-amd64.model create mode 100644 tests/lib/assertions/ubuntu-core-22-arm64.model create mode 100644 tests/lib/assertions/ubuntu-core-24-amd64.model create mode 100644 tests/lib/assertions/valid-for-testing-pc-18.json create mode 100644 tests/lib/assertions/valid-for-testing-pc-18.model create mode 100644 tests/lib/assertions/valid-for-testing-pc-20.json create mode 100644 tests/lib/assertions/valid-for-testing-pc-20.model create mode 100644 tests/lib/assertions/valid-for-testing-pc-22-from-20.json create mode 100644 tests/lib/assertions/valid-for-testing-pc-22-from-20.model create mode 100644 tests/lib/assertions/valid-for-testing-pc-22.json create mode 100644 tests/lib/assertions/valid-for-testing-pc-22.model create mode 100644 tests/lib/assertions/valid-for-testing-pc-new-base-revno-2-18.json create mode 100644 tests/lib/assertions/valid-for-testing-pc-new-base-revno-2-18.model create mode 100644 tests/lib/assertions/valid-for-testing-pc-new-gadget-revno-2-18.json create mode 100644 tests/lib/assertions/valid-for-testing-pc-new-gadget-revno-2-18.model create mode 100644 tests/lib/assertions/valid-for-testing-pc-new-kernel-revno-2-18.json create mode 100644 tests/lib/assertions/valid-for-testing-pc-new-kernel-revno-2-18.model create mode 100644 tests/lib/assertions/valid-for-testing-pc-new-kernel-revno-2.json create mode 100644 tests/lib/assertions/valid-for-testing-pc-new-kernel-revno-2.model create mode 100644 tests/lib/assertions/valid-for-testing-pc-new-model-20.json create mode 100644 tests/lib/assertions/valid-for-testing-pc-new-model-20.model create mode 100644 tests/lib/assertions/valid-for-testing-pc-revno-2-18.json create mode 100644 tests/lib/assertions/valid-for-testing-pc-revno-2-18.model create mode 100644 tests/lib/assertions/valid-for-testing-pc-revno-2-20.json create mode 100644 tests/lib/assertions/valid-for-testing-pc-revno-2-20.model create mode 100644 tests/lib/assertions/valid-for-testing-pc-revno-2-22.json create mode 100644 tests/lib/assertions/valid-for-testing-pc-revno-2-22.model create mode 100644 tests/lib/assertions/valid-for-testing-pc-revno-2.json create mode 100644 tests/lib/assertions/valid-for-testing-pc-revno-2.model create mode 100644 tests/lib/assertions/valid-for-testing-pc-revno-3-18.json create mode 100644 tests/lib/assertions/valid-for-testing-pc-revno-3-18.model create mode 100644 tests/lib/assertions/valid-for-testing-pc-revno-3-20.json create mode 100644 tests/lib/assertions/valid-for-testing-pc-revno-3-20.model create mode 100644 tests/lib/assertions/valid-for-testing-pc-revno-3-22.json create mode 100644 tests/lib/assertions/valid-for-testing-pc-revno-3-22.model create mode 100644 tests/lib/assertions/valid-for-testing-pc-revno-3.json create mode 100644 tests/lib/assertions/valid-for-testing-pc-revno-3.model create mode 100644 tests/lib/assertions/valid-for-testing-pc.json create mode 100644 tests/lib/assertions/valid-for-testing-pc.model create mode 100644 tests/lib/assertions/valid-for-testing.account create mode 100644 tests/lib/assertions/valid-for-testing.account-key create mode 100755 tests/lib/best_golang.py create mode 100644 tests/lib/cache/README.txt create mode 100755 tests/lib/changes.sh create mode 100644 tests/lib/cloud-init-seeds/attacker-user/meta-data create mode 100644 tests/lib/cloud-init-seeds/attacker-user/user-data create mode 100644 tests/lib/cloud-init-seeds/emptykthxbai/emptykthxbai create mode 100644 tests/lib/cloud-init-seeds/normal-user/meta-data create mode 100644 tests/lib/cloud-init-seeds/normal-user/user-data create mode 100644 tests/lib/core-config.sh create mode 100644 tests/lib/desktop-portal.sh create mode 100755 tests/lib/disabled-svcs.sh create mode 100755 tests/lib/ensure_ubuntu_save.py create mode 100755 tests/lib/external/prepare-ssh.sh create mode 100644 tests/lib/external/snapd-testing-tools/.github/labeler.yml create mode 100644 tests/lib/external/snapd-testing-tools/.github/workflows/labeler.yaml create mode 100644 tests/lib/external/snapd-testing-tools/.github/workflows/tests.yaml create mode 100644 tests/lib/external/snapd-testing-tools/CODE_OF_CONDUCT.md create mode 100644 tests/lib/external/snapd-testing-tools/COPYING create mode 100644 tests/lib/external/snapd-testing-tools/README.md create mode 100755 tests/lib/external/snapd-testing-tools/remote/remote.exec create mode 100755 tests/lib/external/snapd-testing-tools/remote/remote.pull create mode 100755 tests/lib/external/snapd-testing-tools/remote/remote.push create mode 100755 tests/lib/external/snapd-testing-tools/remote/remote.refresh create mode 100755 tests/lib/external/snapd-testing-tools/remote/remote.retry create mode 100755 tests/lib/external/snapd-testing-tools/remote/remote.setup create mode 100755 tests/lib/external/snapd-testing-tools/remote/remote.wait-for create mode 100755 tests/lib/external/snapd-testing-tools/setup.sh create mode 100644 tests/lib/external/snapd-testing-tools/spread.yaml create mode 100644 tests/lib/external/snapd-testing-tools/tests/check-test-format/task.yaml create mode 100644 tests/lib/external/snapd-testing-tools/tests/check-test-format/tasks/task1.yaml create mode 100644 tests/lib/external/snapd-testing-tools/tests/check-test-format/tasks/task2.yaml create mode 100644 tests/lib/external/snapd-testing-tools/tests/check-test-format/tasks/task3.yaml create mode 100644 tests/lib/external/snapd-testing-tools/tests/check-test-format/tasks/task4.yaml create mode 100644 tests/lib/external/snapd-testing-tools/tests/check-test-format/tasks/task5.yaml create mode 100644 tests/lib/external/snapd-testing-tools/tests/check-test-format/tasks/task6.yaml create mode 100644 tests/lib/external/snapd-testing-tools/tests/log-analyzer/data/aborted-and-failed-execute-and-restore.json create mode 100644 tests/lib/external/snapd-testing-tools/tests/log-analyzer/data/aborted-and-failed-execute-and-restore.log create mode 100644 tests/lib/external/snapd-testing-tools/tests/log-analyzer/data/all-aborted.json create mode 100644 tests/lib/external/snapd-testing-tools/tests/log-analyzer/data/all-aborted.log create mode 100644 tests/lib/external/snapd-testing-tools/tests/log-analyzer/data/all-failed.json create mode 100644 tests/lib/external/snapd-testing-tools/tests/log-analyzer/data/all-failed.log create mode 100644 tests/lib/external/snapd-testing-tools/tests/log-analyzer/data/all-success-failed-restore.json create mode 100644 tests/lib/external/snapd-testing-tools/tests/log-analyzer/data/all-success-failed-restore.log create mode 100644 tests/lib/external/snapd-testing-tools/tests/log-analyzer/data/all-success.json create mode 100644 tests/lib/external/snapd-testing-tools/tests/log-analyzer/data/all-success.log create mode 100644 tests/lib/external/snapd-testing-tools/tests/log-analyzer/data/failed-prepare-and-restore.json create mode 100644 tests/lib/external/snapd-testing-tools/tests/log-analyzer/data/failed-prepare-and-restore.log create mode 100644 tests/lib/external/snapd-testing-tools/tests/log-analyzer/data/failed-prepare-project.json create mode 100644 tests/lib/external/snapd-testing-tools/tests/log-analyzer/data/failed-prepare-project.log create mode 100644 tests/lib/external/snapd-testing-tools/tests/log-analyzer/data/failed-prepare-suite.json create mode 100644 tests/lib/external/snapd-testing-tools/tests/log-analyzer/data/failed-prepare-suite.log create mode 100644 tests/lib/external/snapd-testing-tools/tests/log-analyzer/data/failed-prepare-task.json create mode 100644 tests/lib/external/snapd-testing-tools/tests/log-analyzer/data/failed-prepare-task.log create mode 100644 tests/lib/external/snapd-testing-tools/tests/log-analyzer/data/with-aborted-and-failed-restore.json create mode 100644 tests/lib/external/snapd-testing-tools/tests/log-analyzer/data/with-aborted-and-failed-restore.log create mode 100644 tests/lib/external/snapd-testing-tools/tests/log-analyzer/data/with-failed-and-failed-restore-suite.json create mode 100644 tests/lib/external/snapd-testing-tools/tests/log-analyzer/data/with-failed-and-failed-restore-suite.log create mode 100644 tests/lib/external/snapd-testing-tools/tests/log-analyzer/spread.yaml create mode 100644 tests/lib/external/snapd-testing-tools/tests/log-analyzer/task.yaml create mode 100644 tests/lib/external/snapd-testing-tools/tests/log-analyzer/tests/test-1/task.yaml create mode 100644 tests/lib/external/snapd-testing-tools/tests/log-analyzer/tests/test-2/task.yaml create mode 100644 tests/lib/external/snapd-testing-tools/tests/log-analyzer/tests/test-3/task.yaml create mode 100644 tests/lib/external/snapd-testing-tools/tests/log-analyzer/tests/test-4/task.yaml create mode 100644 tests/lib/external/snapd-testing-tools/tests/log-analyzer/tests/test-5/task.yaml create mode 100644 tests/lib/external/snapd-testing-tools/tests/log-parser/all-aborted.log.spread create mode 100644 tests/lib/external/snapd-testing-tools/tests/log-parser/all-successful.log.spread create mode 100644 tests/lib/external/snapd-testing-tools/tests/log-parser/task.yaml create mode 100644 tests/lib/external/snapd-testing-tools/tests/log-parser/with-all-results.log.spread create mode 100644 tests/lib/external/snapd-testing-tools/tests/log-parser/with-failed-and-aborted.log.spread create mode 100644 tests/lib/external/snapd-testing-tools/tests/log-parser/with-failed-project-restore.log.spread create mode 100644 tests/lib/external/snapd-testing-tools/tests/log-parser/with-failed-repeated-and-aborted.log.spread create mode 100644 tests/lib/external/snapd-testing-tools/tests/log-parser/with-failed-repeated.log.spread create mode 100644 tests/lib/external/snapd-testing-tools/tests/log-parser/with-failed-suite-restore.log.spread create mode 100644 tests/lib/external/snapd-testing-tools/tests/log-parser/with-failed.log.spread create mode 100644 tests/lib/external/snapd-testing-tools/tests/log-parser/with-results-in-detail.log.spread create mode 100644 tests/lib/external/snapd-testing-tools/tests/not/task.yaml create mode 100644 tests/lib/external/snapd-testing-tools/tests/os.paths/task.yaml create mode 100644 tests/lib/external/snapd-testing-tools/tests/os.query/task.yaml create mode 100644 tests/lib/external/snapd-testing-tools/tests/quiet/task.yaml create mode 100644 tests/lib/external/snapd-testing-tools/tests/remote.exec/task.yaml create mode 100644 tests/lib/external/snapd-testing-tools/tests/remote.pull/task.yaml create mode 100644 tests/lib/external/snapd-testing-tools/tests/remote.push/task.yaml create mode 100644 tests/lib/external/snapd-testing-tools/tests/remote.refresh/task.yaml create mode 100644 tests/lib/external/snapd-testing-tools/tests/remote.retry/task.yaml create mode 100644 tests/lib/external/snapd-testing-tools/tests/remote.setup/task.yaml create mode 100644 tests/lib/external/snapd-testing-tools/tests/remote.wait-for/task.yaml create mode 100644 tests/lib/external/snapd-testing-tools/tests/repack-kernel/task.yaml create mode 100644 tests/lib/external/snapd-testing-tools/tests/retry/task.yaml create mode 100644 tests/lib/external/snapd-testing-tools/tests/snaps.cleanup/task.yaml create mode 100644 tests/lib/external/snapd-testing-tools/tests/snaps.name/task.yaml create mode 100644 tests/lib/external/snapd-testing-tools/tests/spread-manager/checks/task1/task.yaml create mode 100644 tests/lib/external/snapd-testing-tools/tests/spread-manager/checks/task2/task.yaml create mode 100644 tests/lib/external/snapd-testing-tools/tests/spread-manager/checks/task3/task.yaml create mode 100644 tests/lib/external/snapd-testing-tools/tests/spread-manager/checks/task4/task.yaml create mode 100644 tests/lib/external/snapd-testing-tools/tests/spread-manager/checks/task5/.empty create mode 100644 tests/lib/external/snapd-testing-tools/tests/spread-manager/spread.yaml create mode 100644 tests/lib/external/snapd-testing-tools/tests/spread-manager/task.yaml create mode 100644 tests/lib/external/snapd-testing-tools/tests/spread-shellcheck/task.yaml create mode 100644 tests/lib/external/snapd-testing-tools/tests/spread-shellcheck/tasks/task1 create mode 100644 tests/lib/external/snapd-testing-tools/tests/spread-shellcheck/tasks/task2 create mode 100644 tests/lib/external/snapd-testing-tools/tests/spread-shellcheck/tasks/task3 create mode 100644 tests/lib/external/snapd-testing-tools/tests/spread-shellcheck/tasks/task4 create mode 100644 tests/lib/external/snapd-testing-tools/tests/tests.backup/task.yaml create mode 100644 tests/lib/external/snapd-testing-tools/tests/tests.cleanup/task.yaml create mode 100644 tests/lib/external/snapd-testing-tools/tests/tests.pkgs/task.yaml create mode 100644 tests/lib/external/snapd-testing-tools/tests/tests.systemd/task.yaml create mode 100755 tests/lib/external/snapd-testing-tools/tools/not create mode 100755 tests/lib/external/snapd-testing-tools/tools/os.paths create mode 100755 tests/lib/external/snapd-testing-tools/tools/os.query create mode 100755 tests/lib/external/snapd-testing-tools/tools/quiet create mode 100755 tests/lib/external/snapd-testing-tools/tools/repack-kernel create mode 100755 tests/lib/external/snapd-testing-tools/tools/retry create mode 100755 tests/lib/external/snapd-testing-tools/tools/snaps.cleanup create mode 100755 tests/lib/external/snapd-testing-tools/tools/snaps.name create mode 100755 tests/lib/external/snapd-testing-tools/tools/tests.backup create mode 100755 tests/lib/external/snapd-testing-tools/tools/tests.cleanup create mode 100755 tests/lib/external/snapd-testing-tools/tools/tests.pkgs create mode 100644 tests/lib/external/snapd-testing-tools/tools/tests.pkgs.apt.sh create mode 100644 tests/lib/external/snapd-testing-tools/tools/tests.pkgs.dnf-yum.sh create mode 100644 tests/lib/external/snapd-testing-tools/tools/tests.pkgs.pacman.sh create mode 100644 tests/lib/external/snapd-testing-tools/tools/tests.pkgs.zypper.sh create mode 100755 tests/lib/external/snapd-testing-tools/tools/tests.systemd create mode 100755 tests/lib/external/snapd-testing-tools/utils/check-test-format create mode 100755 tests/lib/external/snapd-testing-tools/utils/log-analyzer create mode 100755 tests/lib/external/snapd-testing-tools/utils/log-parser create mode 100755 tests/lib/external/snapd-testing-tools/utils/spread-manager create mode 100755 tests/lib/external/snapd-testing-tools/utils/spread-shellcheck create mode 100755 tests/lib/external/snapd-testing-tools/utils/spreadJ create mode 100644 tests/lib/fakedevicesvc/main.go create mode 100755 tests/lib/fakegpio/fake-gpio.py create mode 100644 tests/lib/fakeportalui/portalui.py create mode 100644 tests/lib/fakestore/cmd/fakestore/cmd_make_refreshable.go create mode 100644 tests/lib/fakestore/cmd/fakestore/cmd_new_repair.go create mode 100644 tests/lib/fakestore/cmd/fakestore/cmd_new_snap_decl.go create mode 100644 tests/lib/fakestore/cmd/fakestore/cmd_new_snap_rev.go create mode 100644 tests/lib/fakestore/cmd/fakestore/cmd_run.go create mode 100644 tests/lib/fakestore/cmd/fakestore/main.go create mode 100644 tests/lib/fakestore/refresh/refresh.go create mode 100644 tests/lib/fakestore/refresh/snap_asserts.go create mode 100644 tests/lib/fakestore/store/store.go create mode 100644 tests/lib/fakestore/store/store_test.go create mode 100644 tests/lib/fde-setup-hook-v1/fde-setup.go create mode 100644 tests/lib/fde-setup-hook/export_test.go create mode 100644 tests/lib/fde-setup-hook/fde-setup.go create mode 100644 tests/lib/fde-setup-hook/fde-setup_test.go create mode 100644 tests/lib/gendeveloper1/main.go create mode 100755 tests/lib/gendeveloper1assert/main.sh create mode 100644 tests/lib/hotplug.sh create mode 100644 tests/lib/image.sh create mode 100644 tests/lib/list-interfaces.go create mode 100644 tests/lib/manip_seed.py create mode 100755 tests/lib/mkpinentry.sh create mode 100755 tests/lib/mock-shutdown create mode 100644 tests/lib/muinstaller/go.mod create mode 100644 tests/lib/muinstaller/go.sum create mode 100644 tests/lib/muinstaller/main.go create mode 100755 tests/lib/muinstaller/mk-classic-rootfs.sh create mode 100644 tests/lib/muinstaller/snapcraft.yaml create mode 100755 tests/lib/nested.sh create mode 100644 tests/lib/network.sh create mode 100644 tests/lib/os-release.16 create mode 100755 tests/lib/pinentry-fake.sh create mode 100755 tests/lib/pkgdb.sh create mode 100755 tests/lib/prepare-restore.sh create mode 100755 tests/lib/prepare.sh create mode 100644 tests/lib/preseed.sh create mode 100644 tests/lib/ramdisk.sh create mode 100644 tests/lib/random.sh create mode 100755 tests/lib/reflash.sh create mode 100644 tests/lib/remodel-store-viewer.auth create mode 100755 tests/lib/reset.sh create mode 100644 tests/lib/snaps.sh create mode 100755 tests/lib/snaps/aliases/bin/cmd1 create mode 100755 tests/lib/snaps/aliases/bin/cmd2 create mode 100644 tests/lib/snaps/aliases/meta/snap.yaml create mode 100755 tests/lib/snaps/basic-desktop/bin/echo create mode 100644 tests/lib/snaps/basic-desktop/meta/gui/echo.desktop create mode 100644 tests/lib/snaps/basic-desktop/meta/gui/icon.png create mode 100644 tests/lib/snaps/basic-desktop/meta/gui/io.snapcraft.echoecho.desktop create mode 100644 tests/lib/snaps/basic-desktop/meta/snap.yaml create mode 100755 tests/lib/snaps/basic-hooks/meta/hooks/configure create mode 100755 tests/lib/snaps/basic-hooks/meta/hooks/invalid-hook create mode 100644 tests/lib/snaps/basic-hooks/meta/snap.yaml create mode 100644 tests/lib/snaps/basic/meta/snap.yaml create mode 100644 tests/lib/snaps/basic18/meta/snap.yaml create mode 100644 tests/lib/snaps/classic-gadget/meta/gadget.yaml create mode 100755 tests/lib/snaps/classic-gadget/meta/hooks/prepare-device create mode 100644 tests/lib/snaps/classic-gadget/meta/snap.yaml create mode 100755 tests/lib/snaps/config-versions-v2/bin/sh create mode 100755 tests/lib/snaps/config-versions-v2/meta/hooks/configure create mode 100755 tests/lib/snaps/config-versions-v2/meta/hooks/post-refresh create mode 100644 tests/lib/snaps/config-versions-v2/meta/snap.yaml create mode 100755 tests/lib/snaps/config-versions/bin/sh create mode 100755 tests/lib/snaps/config-versions/meta/hooks/configure create mode 100755 tests/lib/snaps/config-versions/meta/hooks/post-refresh create mode 100644 tests/lib/snaps/config-versions/meta/snap.yaml create mode 100755 tests/lib/snaps/disabled-svcs-kept/bin/service create mode 100755 tests/lib/snaps/disabled-svcs-kept/meta/hooks/configure create mode 100644 tests/lib/snaps/disabled-svcs-kept/meta/snap.yaml.in create mode 100755 tests/lib/snaps/generic-consumer/bin/cmd create mode 100644 tests/lib/snaps/generic-consumer/meta/snap.yaml.in create mode 100755 tests/lib/snaps/gpio-consumer/bin/read create mode 100644 tests/lib/snaps/gpio-consumer/meta/snap.yaml create mode 100755 tests/lib/snaps/home-consumer/bin/reader create mode 100755 tests/lib/snaps/home-consumer/bin/writer create mode 100644 tests/lib/snaps/home-consumer/meta/snap.yaml create mode 100755 tests/lib/snaps/log-observe-consumer/bin/cmd create mode 100755 tests/lib/snaps/log-observe-consumer/bin/consumer create mode 100644 tests/lib/snaps/log-observe-consumer/meta/snap.yaml create mode 100755 tests/lib/snaps/netplan-snap/bin/netplan-info.sh create mode 100755 tests/lib/snaps/netplan-snap/bin/netplan.sh create mode 100644 tests/lib/snaps/netplan-snap/meta/snap.yaml.in create mode 100755 tests/lib/snaps/network-consumer/bin/consumer create mode 100644 tests/lib/snaps/network-consumer/meta/snap.yaml create mode 100755 tests/lib/snaps/serial-port-hotplug/bin/consumer create mode 100755 tests/lib/snaps/serial-port-hotplug/meta/hooks/connect-plug-serial-port create mode 100644 tests/lib/snaps/serial-port-hotplug/meta/snap.yaml create mode 100755 tests/lib/snaps/snap-hooks-bad-install/meta/hooks/install create mode 100644 tests/lib/snaps/snap-hooks-bad-install/meta/snap.yaml create mode 100755 tests/lib/snaps/snap-hooks-bad-install/true create mode 100755 tests/lib/snaps/snap-hooks/meta/hooks/configure create mode 100755 tests/lib/snaps/snap-hooks/meta/hooks/install create mode 100755 tests/lib/snaps/snap-hooks/meta/hooks/post-refresh create mode 100755 tests/lib/snaps/snap-hooks/meta/hooks/pre-refresh create mode 100755 tests/lib/snaps/snap-hooks/meta/hooks/remove create mode 100644 tests/lib/snaps/snap-hooks/meta/snap.yaml create mode 100755 tests/lib/snaps/snap-hooks/true create mode 100755 tests/lib/snaps/snap-install-hook-broken/meta/hooks/install create mode 100644 tests/lib/snaps/snap-install-hook-broken/meta/snap.yaml create mode 100755 tests/lib/snaps/snap-store/bin/snap-store create mode 100644 tests/lib/snaps/snap-store/meta/snap.yaml create mode 100755 tests/lib/snaps/snapctl-hooks-v2/meta/hooks/configure create mode 100644 tests/lib/snaps/snapctl-hooks-v2/meta/snap.yaml create mode 100755 tests/lib/snaps/snapctl-hooks/meta/hooks/configure create mode 100644 tests/lib/snaps/snapctl-hooks/meta/snap.yaml create mode 100755 tests/lib/snaps/socket-activation/bin/sleep create mode 100644 tests/lib/snaps/socket-activation/meta/snap.yaml create mode 100644 tests/lib/snaps/store/test-snapd-accounts-service/list-accounts.c create mode 100644 tests/lib/snaps/store/test-snapd-accounts-service/snapcraft.yaml create mode 100644 tests/lib/snaps/store/test-snapd-audio-record/Makefile create mode 100755 tests/lib/snaps/store/test-snapd-audio-record/files/bin/pawrap create mode 100644 tests/lib/snaps/store/test-snapd-audio-record/snapcraft.yaml create mode 100644 tests/lib/snaps/store/test-snapd-audio-record/src/Makefile create mode 100644 tests/lib/snaps/store/test-snapd-audio-record/src/parec-simple.c create mode 100755 tests/lib/snaps/store/test-snapd-autopilot-consumer/consumer create mode 100644 tests/lib/snaps/store/test-snapd-autopilot-consumer/provider.py create mode 100644 tests/lib/snaps/store/test-snapd-autopilot-consumer/snapcraft.yaml create mode 100755 tests/lib/snaps/store/test-snapd-autopilot-consumer/wrapper create mode 100644 tests/lib/snaps/store/test-snapd-base-bare/Makefile create mode 100644 tests/lib/snaps/store/test-snapd-base-bare/snapcraft.yaml create mode 100644 tests/lib/snaps/store/test-snapd-busybox-static/snapcraft.yaml create mode 100644 tests/lib/snaps/store/test-snapd-classic-content-slot/shared-content create mode 100644 tests/lib/snaps/store/test-snapd-classic-content-slot/snapcraft.yaml create mode 100755 tests/lib/snaps/store/test-snapd-content-plug-to-classic-slot/bin/content-plug create mode 100644 tests/lib/snaps/store/test-snapd-content-plug-to-classic-slot/snapcraft.yaml create mode 100644 tests/lib/snaps/store/test-snapd-cups-control-consumer/snapcraft.yaml create mode 100644 tests/lib/snaps/store/test-snapd-curl/snapcraft.yaml create mode 100644 tests/lib/snaps/store/test-snapd-daemon-user/Makefile create mode 100644 tests/lib/snaps/store/test-snapd-daemon-user/snapcraft.yaml create mode 100644 tests/lib/snaps/store/test-snapd-daemon-user/src/Makefile create mode 100644 tests/lib/snaps/store/test-snapd-daemon-user/src/chown.c create mode 120000 tests/lib/snaps/store/test-snapd-daemon-user/src/chown32.c create mode 100644 tests/lib/snaps/store/test-snapd-daemon-user/src/display.c create mode 100644 tests/lib/snaps/store/test-snapd-daemon-user/src/display.h create mode 120000 tests/lib/snaps/store/test-snapd-daemon-user/src/display32.c create mode 100644 tests/lib/snaps/store/test-snapd-daemon-user/src/drop-exec.c create mode 120000 tests/lib/snaps/store/test-snapd-daemon-user/src/drop-exec32.c create mode 100644 tests/lib/snaps/store/test-snapd-daemon-user/src/drop-syscall.c create mode 100644 tests/lib/snaps/store/test-snapd-daemon-user/src/drop-syscall32.c create mode 100644 tests/lib/snaps/store/test-snapd-daemon-user/src/drop.c create mode 120000 tests/lib/snaps/store/test-snapd-daemon-user/src/drop32.c create mode 100644 tests/lib/snaps/store/test-snapd-daemon-user/src/fchown.c create mode 120000 tests/lib/snaps/store/test-snapd-daemon-user/src/fchown32.c create mode 100644 tests/lib/snaps/store/test-snapd-daemon-user/src/fchownat.c create mode 120000 tests/lib/snaps/store/test-snapd-daemon-user/src/fchownat32.c create mode 100644 tests/lib/snaps/store/test-snapd-daemon-user/src/lchown.c create mode 120000 tests/lib/snaps/store/test-snapd-daemon-user/src/lchown32.c create mode 100644 tests/lib/snaps/store/test-snapd-daemon-user/src/setgid.c create mode 120000 tests/lib/snaps/store/test-snapd-daemon-user/src/setgid32.c create mode 100644 tests/lib/snaps/store/test-snapd-daemon-user/src/setregid.c create mode 120000 tests/lib/snaps/store/test-snapd-daemon-user/src/setregid32.c create mode 100644 tests/lib/snaps/store/test-snapd-daemon-user/src/setresgid.c create mode 120000 tests/lib/snaps/store/test-snapd-daemon-user/src/setresgid32.c create mode 100644 tests/lib/snaps/store/test-snapd-daemon-user/src/setresuid.c create mode 120000 tests/lib/snaps/store/test-snapd-daemon-user/src/setresuid32.c create mode 100644 tests/lib/snaps/store/test-snapd-daemon-user/src/setreuid.c create mode 120000 tests/lib/snaps/store/test-snapd-daemon-user/src/setreuid32.c create mode 100644 tests/lib/snaps/store/test-snapd-daemon-user/src/setuid.c create mode 120000 tests/lib/snaps/store/test-snapd-daemon-user/src/setuid32.c create mode 100755 tests/lib/snaps/store/test-snapd-dbus-consumer/consumer.py create mode 100644 tests/lib/snaps/store/test-snapd-dbus-consumer/snapcraft.yaml create mode 100755 tests/lib/snaps/store/test-snapd-dbus-provider/consumer.py create mode 100644 tests/lib/snaps/store/test-snapd-dbus-provider/provider.py create mode 100644 tests/lib/snaps/store/test-snapd-dbus-provider/snapcraft.yaml create mode 100755 tests/lib/snaps/store/test-snapd-dbus-provider/wrapper create mode 100755 tests/lib/snaps/store/test-snapd-dbus-service/bin/test-snapd-dbus-service create mode 100644 tests/lib/snaps/store/test-snapd-dbus-service/setup.py create mode 100644 tests/lib/snaps/store/test-snapd-dbus-service/snapcraft.yaml create mode 100644 tests/lib/snaps/store/test-snapd-eds/calendar.c create mode 100644 tests/lib/snaps/store/test-snapd-eds/contacts.c create mode 100644 tests/lib/snaps/store/test-snapd-eds/meson.build create mode 100644 tests/lib/snaps/store/test-snapd-eds/snap/snapcraft.yaml create mode 100644 tests/lib/snaps/store/test-snapd-fuse-consumer/Makefile create mode 100644 tests/lib/snaps/store/test-snapd-fuse-consumer/snapcraft.yaml create mode 100644 tests/lib/snaps/store/test-snapd-go-webserver/main.go create mode 100644 tests/lib/snaps/store/test-snapd-go-webserver/snapcraft.yaml create mode 100644 tests/lib/snaps/store/test-snapd-gpio-memory-control/Makefile create mode 100644 tests/lib/snaps/store/test-snapd-gpio-memory-control/gpiomem.c create mode 100644 tests/lib/snaps/store/test-snapd-gpio-memory-control/snapcraft.yaml create mode 100644 tests/lib/snaps/store/test-snapd-hello-classic/Makefile create mode 100644 tests/lib/snaps/store/test-snapd-hello-classic/snapcraft.yaml create mode 100644 tests/lib/snaps/store/test-snapd-hello-classic/test-snapd-hello-classic.c create mode 100755 tests/lib/snaps/store/test-snapd-just-beta/snap-name create mode 100644 tests/lib/snaps/store/test-snapd-just-beta/snapcraft.yaml create mode 100755 tests/lib/snaps/store/test-snapd-just-edge/snap-name create mode 100644 tests/lib/snaps/store/test-snapd-just-edge/snapcraft.yaml create mode 100644 tests/lib/snaps/store/test-snapd-kernel-module-control-consumer/snapcraft.yaml create mode 100644 tests/lib/snaps/store/test-snapd-layout-change-with-daemon/README.md create mode 100755 tests/lib/snaps/store/test-snapd-layout-change-with-daemon/daemon.sh create mode 100644 tests/lib/snaps/store/test-snapd-layout-change-with-daemon/snap/hooks/configure create mode 100644 tests/lib/snaps/store/test-snapd-layout-change-with-daemon/snap/keys/B05498B7.asc create mode 100644 tests/lib/snaps/store/test-snapd-layout-change-with-daemon/snap/snapcraft.yaml create mode 100644 tests/lib/snaps/store/test-snapd-layout-change-with-daemon/snapcraft_new.yaml create mode 100644 tests/lib/snaps/store/test-snapd-layout-change-with-daemon/snapcraft_old.yaml create mode 100644 tests/lib/snaps/store/test-snapd-layout-change/README.md create mode 100644 tests/lib/snaps/store/test-snapd-layout-change/snap/hooks/configure create mode 100644 tests/lib/snaps/store/test-snapd-layout-change/snap/keys/B05498B7.asc create mode 100644 tests/lib/snaps/store/test-snapd-layout-change/snapcraft_new.yaml create mode 100644 tests/lib/snaps/store/test-snapd-layout-change/snapcraft_old.yaml create mode 100755 tests/lib/snaps/store/test-snapd-libvirt-consumer/bin/machine-down create mode 100755 tests/lib/snaps/store/test-snapd-libvirt-consumer/bin/machine-up create mode 100755 tests/lib/snaps/store/test-snapd-libvirt-consumer/meta/hooks/install create mode 100644 tests/lib/snaps/store/test-snapd-libvirt-consumer/snapcraft.yaml create mode 100644 tests/lib/snaps/store/test-snapd-libvirt-consumer/vm/ping-unikernel.xml create mode 100755 tests/lib/snaps/store/test-snapd-load-generator/load-generator create mode 100644 tests/lib/snaps/store/test-snapd-load-generator/snapcraft.yaml create mode 100755 tests/lib/snaps/store/test-snapd-location-control-provider/consumer create mode 100644 tests/lib/snaps/store/test-snapd-location-control-provider/provider.py create mode 100644 tests/lib/snaps/store/test-snapd-location-control-provider/snapcraft.yaml create mode 100755 tests/lib/snaps/store/test-snapd-location-control-provider/wrapper create mode 100644 tests/lib/snaps/store/test-snapd-mokutil/snapcraft.yaml create mode 100755 tests/lib/snaps/store/test-snapd-openvswitch-consumer/bin/ovs-vsctl create mode 100644 tests/lib/snaps/store/test-snapd-openvswitch-consumer/snapcraft.yaml create mode 100644 tests/lib/snaps/store/test-snapd-packagekit/snapcraft.yaml create mode 100644 tests/lib/snaps/store/test-snapd-portal-client/client.py create mode 100644 tests/lib/snaps/store/test-snapd-portal-client/setup.py create mode 100644 tests/lib/snaps/store/test-snapd-portal-client/snapcraft.yaml create mode 100644 tests/lib/snaps/store/test-snapd-profiler/config.ini create mode 100644 tests/lib/snaps/store/test-snapd-profiler/profiler.py create mode 100644 tests/lib/snaps/store/test-snapd-profiler/snapcraft.yaml create mode 100644 tests/lib/snaps/store/test-snapd-pulseaudio/Makefile create mode 100755 tests/lib/snaps/store/test-snapd-pulseaudio/files/bin/pawrap create mode 100644 tests/lib/snaps/store/test-snapd-pulseaudio/snapcraft.yaml create mode 100644 tests/lib/snaps/store/test-snapd-pulseaudio/src/Makefile create mode 100644 tests/lib/snaps/store/test-snapd-pulseaudio/src/parec-simple.c create mode 100644 tests/lib/snaps/store/test-snapd-python-webserver/index.html create mode 100755 tests/lib/snaps/store/test-snapd-python-webserver/server.py create mode 100644 tests/lib/snaps/store/test-snapd-python-webserver/snapcraft.yaml create mode 100644 tests/lib/snaps/store/test-snapd-refresh-control-provider.v1/build-aux/snap/snapcraft.yaml create mode 100644 tests/lib/snaps/store/test-snapd-refresh-control-provider.v2/build-aux/snap/snapcraft.yaml create mode 100644 tests/lib/snaps/store/test-snapd-refresh-control-provider.v3/build-aux/snap/snapcraft.yaml create mode 100755 tests/lib/snaps/store/test-snapd-refresh-control.v1/bin/pending create mode 100755 tests/lib/snaps/store/test-snapd-refresh-control.v1/bin/proceed create mode 100644 tests/lib/snaps/store/test-snapd-refresh-control.v1/build-aux/snap/hooks/gate-auto-refresh create mode 100644 tests/lib/snaps/store/test-snapd-refresh-control.v1/build-aux/snap/snapcraft.yaml create mode 100755 tests/lib/snaps/store/test-snapd-refresh-control.v2/bin/pending create mode 100755 tests/lib/snaps/store/test-snapd-refresh-control.v2/bin/proceed create mode 100644 tests/lib/snaps/store/test-snapd-refresh-control.v2/build-aux/snap/hooks/gate-auto-refresh create mode 100644 tests/lib/snaps/store/test-snapd-refresh-control.v2/build-aux/snap/snapcraft.yaml create mode 100644 tests/lib/snaps/store/test-snapd-rsync/snapcraft.yaml create mode 100644 tests/lib/snaps/store/test-snapd-setpriority/Makefile create mode 100644 tests/lib/snaps/store/test-snapd-setpriority/setpriority.c create mode 100644 tests/lib/snaps/store/test-snapd-setpriority/snapcraft.yaml create mode 100755 tests/lib/snaps/store/test-snapd-system-observe-consumer/consumer.py create mode 100755 tests/lib/snaps/store/test-snapd-system-observe-consumer/dbus-introspect.py create mode 100644 tests/lib/snaps/store/test-snapd-system-observe-consumer/snapcraft.yaml create mode 100644 tests/lib/snaps/store/test-snapd-udisks2/snapcraft.yaml create mode 100755 tests/lib/snaps/store/test-snapd-udisks2/udisksctl create mode 100644 tests/lib/snaps/store/test-snapd-uhid/Makefile create mode 100644 tests/lib/snaps/store/test-snapd-uhid/snapcraft.yaml create mode 100644 tests/lib/snaps/store/test-snapd-uhid/uhid-test.c create mode 100644 tests/lib/snaps/store/test-snapd-upower-observe-consumer/snapcraft.yaml create mode 100644 tests/lib/snaps/store/test-snapd-upower/bin/upowerd.sh create mode 100644 tests/lib/snaps/store/test-snapd-upower/snapcraft.yaml create mode 100644 tests/lib/snaps/store/test-snapd-validation-set-enforcing.v1/build-aux/snap/snapcraft.yaml create mode 100644 tests/lib/snaps/store/test-snapd-validation-set-enforcing.v2/build-aux/snap/snapcraft.yaml create mode 100755 tests/lib/snaps/store/test-snapd-with-default-configure-core18/meta/hooks/configure create mode 100755 tests/lib/snaps/store/test-snapd-with-default-configure-core18/meta/hooks/default-configure create mode 100644 tests/lib/snaps/store/test-snapd-with-default-configure-core18/meta/snap.yaml create mode 100755 tests/lib/snaps/store/test-snapd-with-default-configure-core18/service create mode 100644 tests/lib/snaps/store/test-snapd-with-default-configure-core18/snap/snapcraft.yaml create mode 100755 tests/lib/snaps/store/test-snapd-with-default-configure/meta/hooks/configure create mode 100755 tests/lib/snaps/store/test-snapd-with-default-configure/meta/hooks/default-configure create mode 100644 tests/lib/snaps/store/test-snapd-with-default-configure/meta/snap.yaml create mode 100755 tests/lib/snaps/store/test-snapd-with-default-configure/service create mode 100644 tests/lib/snaps/store/test-snapd-with-default-configure/snap/snapcraft.yaml create mode 100755 tests/lib/snaps/test-devmode-cgroup/bin/read-dev create mode 100644 tests/lib/snaps/test-devmode-cgroup/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-after-before-service/bin/start create mode 100644 tests/lib/snaps/test-snapd-after-before-service/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-appstream-metadata/bin/sh create mode 100644 tests/lib/snaps/test-snapd-appstream-metadata/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-auto-aliases/bin/wellknown1 create mode 100755 tests/lib/snaps/test-snapd-auto-aliases/bin/wellknown2 create mode 100644 tests/lib/snaps/test-snapd-auto-aliases/meta/snap.yaml create mode 100644 tests/lib/snaps/test-snapd-base/meta/snap.yaml create mode 100644 tests/lib/snaps/test-snapd-base/random-file create mode 100755 tests/lib/snaps/test-snapd-classic-confinement/bin/classic-confinement create mode 100755 tests/lib/snaps/test-snapd-classic-confinement/bin/recurse create mode 100755 tests/lib/snaps/test-snapd-classic-confinement/bin/sh create mode 100644 tests/lib/snaps/test-snapd-classic-confinement/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-complexion/bin/test-snapd-complexion create mode 100644 tests/lib/snaps/test-snapd-complexion/meta/snap.yaml create mode 100644 tests/lib/snaps/test-snapd-complexion/test-snapd-complexion.bash-completer create mode 100755 tests/lib/snaps/test-snapd-content-advanced-plug/bin/sh create mode 100644 tests/lib/snaps/test-snapd-content-advanced-plug/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-content-advanced-slot/bin/sh create mode 100644 tests/lib/snaps/test-snapd-content-advanced-slot/meta/snap.yaml create mode 100644 tests/lib/snaps/test-snapd-content-advanced-slot/source/canary create mode 100755 tests/lib/snaps/test-snapd-content-plug/bin/content-plug create mode 100644 tests/lib/snaps/test-snapd-content-plug/import/.placeholder create mode 100644 tests/lib/snaps/test-snapd-content-plug/meta/snap.yaml create mode 100644 tests/lib/snaps/test-snapd-content-slot/meta/snap.yaml create mode 100644 tests/lib/snaps/test-snapd-content-slot/shared-content create mode 100644 tests/lib/snaps/test-snapd-content-slot2/meta/snap.yaml create mode 100644 tests/lib/snaps/test-snapd-content-slot2/shared-content create mode 100755 tests/lib/snaps/test-snapd-control-consumer/bin/install create mode 100755 tests/lib/snaps/test-snapd-control-consumer/bin/list create mode 100644 tests/lib/snaps/test-snapd-control-consumer/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-daemon-notify/bin/notify create mode 100644 tests/lib/snaps/test-snapd-daemon-notify/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-dbus-service-client/bin/client.sh create mode 100644 tests/lib/snaps/test-snapd-dbus-service-client/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-desktop/bin/check-dirs create mode 100755 tests/lib/snaps/test-snapd-desktop/bin/check-files create mode 100755 tests/lib/snaps/test-snapd-desktop/bin/cmd create mode 100755 tests/lib/snaps/test-snapd-desktop/bin/sh create mode 100644 tests/lib/snaps/test-snapd-desktop/meta/gui/cmd.desktop create mode 100644 tests/lib/snaps/test-snapd-desktop/meta/snap.yaml create mode 100644 tests/lib/snaps/test-snapd-devmode/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-devmode/true create mode 100755 tests/lib/snaps/test-snapd-icon-theme/bin/echo create mode 100644 tests/lib/snaps/test-snapd-icon-theme/meta/gui/echo.desktop create mode 100644 tests/lib/snaps/test-snapd-icon-theme/meta/gui/icons/hicolor/scalable/apps/snap.test-snapd-icon-theme.foo.svg create mode 100644 tests/lib/snaps/test-snapd-icon-theme/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-journal-quota/bin/logger create mode 100644 tests/lib/snaps/test-snapd-journal-quota/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-kvm/bin/sh create mode 100644 tests/lib/snaps/test-snapd-kvm/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-layout/bin/sh create mode 100644 tests/lib/snaps/test-snapd-layout/meta/snap.yaml create mode 100644 tests/lib/snaps/test-snapd-layout/opt/demo/file create mode 100644 tests/lib/snaps/test-snapd-layout/usr/share/demo/file create mode 100755 tests/lib/snaps/test-snapd-mount-control/bin/cmd create mode 100644 tests/lib/snaps/test-snapd-mount-control/meta/snap.yaml create mode 100644 tests/lib/snaps/test-snapd-number-version/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-policy-app-consumer/bin/run create mode 100644 tests/lib/snaps/test-snapd-policy-app-consumer/meta/gui/test-desktop.desktop create mode 100644 tests/lib/snaps/test-snapd-policy-app-consumer/meta/polkit/polkit.test.policy create mode 100644 tests/lib/snaps/test-snapd-policy-app-consumer/meta/snap.yaml create mode 100644 tests/lib/snaps/test-snapd-private/meta/snap.yaml create mode 100644 tests/lib/snaps/test-snapd-public/meta/snap.yaml create mode 100644 tests/lib/snaps/test-snapd-remodel-pc-18/grub.cfg create mode 100644 tests/lib/snaps/test-snapd-remodel-pc-18/grub.conf create mode 100644 tests/lib/snaps/test-snapd-remodel-pc-18/grubx64.efi create mode 100644 tests/lib/snaps/test-snapd-remodel-pc-18/meta/gadget.yaml create mode 100644 tests/lib/snaps/test-snapd-remodel-pc-18/meta/gui/icon.png create mode 100755 tests/lib/snaps/test-snapd-remodel-pc-18/meta/hooks/configure create mode 100755 tests/lib/snaps/test-snapd-remodel-pc-18/meta/hooks/prepare-device create mode 100644 tests/lib/snaps/test-snapd-remodel-pc-18/meta/snap.yaml create mode 100644 tests/lib/snaps/test-snapd-remodel-pc-18/mmx64.efi create mode 100644 tests/lib/snaps/test-snapd-remodel-pc-18/pc-boot.img create mode 100644 tests/lib/snaps/test-snapd-remodel-pc-18/pc-core.img create mode 100644 tests/lib/snaps/test-snapd-remodel-pc-18/shim.efi.signed create mode 100644 tests/lib/snaps/test-snapd-remodel-pc-20/cmdline.extra create mode 100644 tests/lib/snaps/test-snapd-remodel-pc-20/grub.conf create mode 100644 tests/lib/snaps/test-snapd-remodel-pc-20/grubx64.efi create mode 100644 tests/lib/snaps/test-snapd-remodel-pc-20/meta/gadget.yaml create mode 100644 tests/lib/snaps/test-snapd-remodel-pc-20/meta/gui/icon.png create mode 100755 tests/lib/snaps/test-snapd-remodel-pc-20/meta/hooks/configure create mode 100755 tests/lib/snaps/test-snapd-remodel-pc-20/meta/hooks/prepare-device create mode 100644 tests/lib/snaps/test-snapd-remodel-pc-20/meta/snap.yaml create mode 100644 tests/lib/snaps/test-snapd-remodel-pc-20/pc-boot.img create mode 100644 tests/lib/snaps/test-snapd-remodel-pc-20/pc-core.img create mode 100644 tests/lib/snaps/test-snapd-remodel-pc-20/shim.efi.signed create mode 100644 tests/lib/snaps/test-snapd-remodel-pc-22/cmdline.extra create mode 100644 tests/lib/snaps/test-snapd-remodel-pc-22/grub.conf create mode 100644 tests/lib/snaps/test-snapd-remodel-pc-22/grubx64.efi create mode 100644 tests/lib/snaps/test-snapd-remodel-pc-22/meta/gadget.yaml create mode 100644 tests/lib/snaps/test-snapd-remodel-pc-22/meta/gui/icon.png create mode 100755 tests/lib/snaps/test-snapd-remodel-pc-22/meta/hooks/configure create mode 100755 tests/lib/snaps/test-snapd-remodel-pc-22/meta/hooks/prepare-device create mode 100644 tests/lib/snaps/test-snapd-remodel-pc-22/meta/snap.yaml create mode 100644 tests/lib/snaps/test-snapd-remodel-pc-22/pc-boot.img create mode 100644 tests/lib/snaps/test-snapd-remodel-pc-22/pc-core.img create mode 100644 tests/lib/snaps/test-snapd-remodel-pc-22/shim.efi.signed create mode 100644 tests/lib/snaps/test-snapd-remodel-pc-min-size-22/cmdline.extra create mode 100644 tests/lib/snaps/test-snapd-remodel-pc-min-size-22/grub.conf create mode 100644 tests/lib/snaps/test-snapd-remodel-pc-min-size-22/grubx64.efi create mode 100644 tests/lib/snaps/test-snapd-remodel-pc-min-size-22/meta/gadget.yaml create mode 100644 tests/lib/snaps/test-snapd-remodel-pc-min-size-22/meta/gui/icon.png create mode 100755 tests/lib/snaps/test-snapd-remodel-pc-min-size-22/meta/hooks/configure create mode 100755 tests/lib/snaps/test-snapd-remodel-pc-min-size-22/meta/hooks/prepare-device create mode 100644 tests/lib/snaps/test-snapd-remodel-pc-min-size-22/meta/snap.yaml create mode 100644 tests/lib/snaps/test-snapd-remodel-pc-min-size-22/pc-boot.img create mode 100644 tests/lib/snaps/test-snapd-remodel-pc-min-size-22/pc-core.img create mode 100644 tests/lib/snaps/test-snapd-remodel-pc-min-size-22/shim.efi.signed create mode 100644 tests/lib/snaps/test-snapd-requires-base-bare/meta/snap.yaml create mode 100644 tests/lib/snaps/test-snapd-requires-base/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-service-many/bin/start create mode 100644 tests/lib/snaps/test-snapd-service-many/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-service-restart/bin/start create mode 100644 tests/lib/snaps/test-snapd-service-restart/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-service-v1-good/bin/good create mode 100644 tests/lib/snaps/test-snapd-service-v1-good/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-service-v2-bad/bin/bad create mode 100644 tests/lib/snaps/test-snapd-service-v2-bad/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-service/bin/reload create mode 100755 tests/lib/snaps/test-snapd-service/bin/start create mode 100755 tests/lib/snaps/test-snapd-service/bin/start-other create mode 100755 tests/lib/snaps/test-snapd-service/bin/start-stop-mode create mode 100755 tests/lib/snaps/test-snapd-service/bin/start-stop-mode-sigterm create mode 100755 tests/lib/snaps/test-snapd-service/bin/stop create mode 100755 tests/lib/snaps/test-snapd-service/bin/stop-stop-mode create mode 100755 tests/lib/snaps/test-snapd-service/meta/hooks/configure create mode 100644 tests/lib/snaps/test-snapd-service/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-sh-core16/bin/sh create mode 100644 tests/lib/snaps/test-snapd-sh-core16/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-sh-core18/bin/sh create mode 100644 tests/lib/snaps/test-snapd-sh-core18/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-sh-core20/bin/sh create mode 100644 tests/lib/snaps/test-snapd-sh-core20/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-sh-core22/bin/sh create mode 100644 tests/lib/snaps/test-snapd-sh-core22/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-sh-core24/bin/sh create mode 100644 tests/lib/snaps/test-snapd-sh-core24/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-sh/bin/cmd create mode 100755 tests/lib/snaps/test-snapd-sh/bin/sh create mode 100644 tests/lib/snaps/test-snapd-sh/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-simple-service/bin/service create mode 100644 tests/lib/snaps/test-snapd-simple-service/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-snapctl-core18/bin/service create mode 100755 tests/lib/snaps/test-snapd-snapctl-core18/meta/hooks/install create mode 100644 tests/lib/snaps/test-snapd-snapctl-core18/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-timedate-control-consumer/bin/date create mode 100755 tests/lib/snaps/test-snapd-timedate-control-consumer/bin/hwclock create mode 100755 tests/lib/snaps/test-snapd-timedate-control-consumer/bin/timedatectl create mode 100644 tests/lib/snaps/test-snapd-timedate-control-consumer/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-timer-service/bin/loop create mode 100644 tests/lib/snaps/test-snapd-timer-service/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-tools-core18/bin/block create mode 100755 tests/lib/snaps/test-snapd-tools-core18/bin/cat create mode 100755 tests/lib/snaps/test-snapd-tools-core18/bin/cmd create mode 100755 tests/lib/snaps/test-snapd-tools-core18/bin/echo create mode 100755 tests/lib/snaps/test-snapd-tools-core18/bin/env create mode 100755 tests/lib/snaps/test-snapd-tools-core18/bin/fail create mode 100755 tests/lib/snaps/test-snapd-tools-core18/bin/head create mode 100755 tests/lib/snaps/test-snapd-tools-core18/bin/sh create mode 100755 tests/lib/snaps/test-snapd-tools-core18/bin/success create mode 100644 tests/lib/snaps/test-snapd-tools-core18/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-tools-core22/bin/block create mode 100755 tests/lib/snaps/test-snapd-tools-core22/bin/cat create mode 100755 tests/lib/snaps/test-snapd-tools-core22/bin/cmd create mode 100755 tests/lib/snaps/test-snapd-tools-core22/bin/echo create mode 100755 tests/lib/snaps/test-snapd-tools-core22/bin/env create mode 100755 tests/lib/snaps/test-snapd-tools-core22/bin/fail create mode 100755 tests/lib/snaps/test-snapd-tools-core22/bin/head create mode 100755 tests/lib/snaps/test-snapd-tools-core22/bin/sh create mode 100755 tests/lib/snaps/test-snapd-tools-core22/bin/success create mode 100644 tests/lib/snaps/test-snapd-tools-core22/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-tools/bin/block create mode 100755 tests/lib/snaps/test-snapd-tools/bin/cat create mode 100755 tests/lib/snaps/test-snapd-tools/bin/cmd create mode 100755 tests/lib/snaps/test-snapd-tools/bin/echo create mode 100755 tests/lib/snaps/test-snapd-tools/bin/env create mode 100755 tests/lib/snaps/test-snapd-tools/bin/fail create mode 100755 tests/lib/snaps/test-snapd-tools/bin/head create mode 100755 tests/lib/snaps/test-snapd-tools/bin/sh create mode 100755 tests/lib/snaps/test-snapd-tools/bin/success create mode 100644 tests/lib/snaps/test-snapd-tools/meta/gui/icon.png create mode 100644 tests/lib/snaps/test-snapd-tools/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-user-service-sockets/bin/start create mode 100644 tests/lib/snaps/test-snapd-user-service-sockets/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-user-service-v2-bad/bin/bad create mode 100644 tests/lib/snaps/test-snapd-user-service-v2-bad/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-user-service/bin/start create mode 100644 tests/lib/snaps/test-snapd-user-service/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-user-timer-service/bin/loop create mode 100644 tests/lib/snaps/test-snapd-user-timer-service/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-userns/bin/sh create mode 100644 tests/lib/snaps/test-snapd-userns/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-with-configure-core18/meta/hooks/configure create mode 100644 tests/lib/snaps/test-snapd-with-configure-core18/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-with-configure-core18/service create mode 100644 tests/lib/snaps/test-snapd-with-configure-core18/snapcraft.yaml create mode 100755 tests/lib/snaps/test-snapd-with-configure/meta/hooks/configure create mode 100644 tests/lib/snaps/test-snapd-with-configure/meta/snap.yaml create mode 100755 tests/lib/snaps/test-snapd-with-configure/service create mode 100644 tests/lib/spread-funcs.sh create mode 100755 tests/lib/state.sh create mode 100644 tests/lib/successful_login.exp create mode 100644 tests/lib/systemd-escape/main.go create mode 100644 tests/lib/systems.sh create mode 100644 tests/lib/tinyproxy/tinyproxy.py create mode 100755 tests/lib/tools/MATCH create mode 100644 tests/lib/tools/README create mode 100755 tests/lib/tools/REBOOT create mode 100755 tests/lib/tools/boot-state create mode 100755 tests/lib/tools/cleanup-state create mode 100755 tests/lib/tools/fs-state create mode 100755 tests/lib/tools/journal-state create mode 100755 tests/lib/tools/lxd-state create mode 100755 tests/lib/tools/memory-observe-do create mode 100755 tests/lib/tools/mkimage-uc22 create mode 100755 tests/lib/tools/mountinfo.query create mode 100755 tests/lib/tools/network-state create mode 100755 tests/lib/tools/not create mode 100755 tests/lib/tools/os.paths create mode 100755 tests/lib/tools/os.query create mode 100755 tests/lib/tools/query-mondodb create mode 100755 tests/lib/tools/quiet create mode 100755 tests/lib/tools/remote.exec create mode 100755 tests/lib/tools/remote.pull create mode 100755 tests/lib/tools/remote.push create mode 100755 tests/lib/tools/remote.refresh create mode 100755 tests/lib/tools/remote.retry create mode 100755 tests/lib/tools/remote.setup create mode 100755 tests/lib/tools/remote.wait-for create mode 100755 tests/lib/tools/report-mongodb create mode 100755 tests/lib/tools/retry create mode 100755 tests/lib/tools/setup_nested_hybrid_system.sh create mode 100755 tests/lib/tools/sha3-384 create mode 100755 tests/lib/tools/snapd-state create mode 100755 tests/lib/tools/snapd.tool create mode 100755 tests/lib/tools/snaps-state create mode 100755 tests/lib/tools/snaps.name create mode 100755 tests/lib/tools/store-state create mode 100644 tests/lib/tools/suite/fs-state/task.yaml create mode 100644 tests/lib/tools/suite/journal-state/task.yaml create mode 100644 tests/lib/tools/suite/mountinfo.query/task.yaml create mode 100644 tests/lib/tools/suite/tests.env/task.yaml create mode 100644 tests/lib/tools/suite/tests.invariant/task.yaml create mode 100644 tests/lib/tools/suite/tests.session-support/task.yaml create mode 100644 tests/lib/tools/suite/tests.session/task.yaml create mode 100644 tests/lib/tools/suite/to-one-line/task.yaml create mode 100644 tests/lib/tools/suite/user-state/task.yaml create mode 100644 tests/lib/tools/suite/version-compare/task.yaml create mode 100755 tests/lib/tools/tests.backup create mode 100755 tests/lib/tools/tests.cleanup create mode 100755 tests/lib/tools/tests.device-cgroup create mode 100755 tests/lib/tools/tests.env create mode 100755 tests/lib/tools/tests.invariant create mode 100755 tests/lib/tools/tests.nested create mode 100755 tests/lib/tools/tests.pkgs create mode 100644 tests/lib/tools/tests.pkgs.apt.sh create mode 100644 tests/lib/tools/tests.pkgs.dnf-yum.sh create mode 100644 tests/lib/tools/tests.pkgs.pacman.sh create mode 100644 tests/lib/tools/tests.pkgs.zypper.sh create mode 100755 tests/lib/tools/tests.session create mode 100755 tests/lib/tools/tests.systemd create mode 100755 tests/lib/tools/to-one-line create mode 100755 tests/lib/tools/user-state create mode 100755 tests/lib/tools/version-compare create mode 100644 tests/lib/tweak-gadget.py create mode 100755 tests/lib/uc16-reflash.sh create mode 100644 tests/lib/uc20-create-partitions/main.go create mode 100644 tests/lib/uc20-recovery.sh create mode 100644 tests/main/abort/task.yaml create mode 100644 tests/main/ack/alice.account create mode 100644 tests/main/ack/alice.account-key create mode 100644 tests/main/ack/bob.assertions create mode 100644 tests/main/ack/task.yaml create mode 100644 tests/main/alias/task.yaml create mode 100644 tests/main/api-get-systems-label/task.yaml create mode 100755 tests/main/apparmor-batch-reload/bin/apparmor_parser.fake create mode 100644 tests/main/apparmor-batch-reload/task.yaml create mode 100644 tests/main/appstream-id/task.yaml create mode 100755 tests/main/appstream-id/test-snapd-appstreamid/bin/run create mode 100644 tests/main/appstream-id/test-snapd-appstreamid/meta/snap.yaml create mode 100644 tests/main/apt-hooks/task.yaml create mode 100644 tests/main/aspects/task.yaml create mode 100644 tests/main/auth-errors/task.yaml create mode 100644 tests/main/auto-aliases/task.yaml create mode 100644 tests/main/auto-refresh-gating-from-snap/task.yaml create mode 100644 tests/main/auto-refresh-gating-from-snap/test-snap-refresh-control-iface/meta/snap.yaml create mode 100755 tests/main/auto-refresh-gating-from-snap/test-snap-refresh-control-iface/proceed create mode 100644 tests/main/auto-refresh-gating/task.yaml create mode 100644 tests/main/auto-refresh-pre-download/task.yaml create mode 100644 tests/main/auto-refresh-private/expired_macaroons.sh create mode 100644 tests/main/auto-refresh-private/successful_login.exp create mode 100644 tests/main/auto-refresh-private/task.yaml create mode 100644 tests/main/auto-refresh-retry/task.yaml create mode 100644 tests/main/auto-refresh/task.yaml create mode 100644 tests/main/bad-interfaces-warn/task.yaml create mode 100644 tests/main/bad-interfaces-warn/test-snap/meta/snap.yaml create mode 100644 tests/main/bad-meta-file-types/task.yaml create mode 100644 tests/main/bad-meta-file-types/test-bad-file-types/meta/snap.yaml create mode 100644 tests/main/base-invalid-type/task.yaml create mode 100644 tests/main/base-invalid-type/test-snapd-invalid-base/meta/snap.yaml create mode 100644 tests/main/base-migration/task.yaml create mode 100755 tests/main/base-migration/test-snapd-core-migration.base-core/bin/sh create mode 100644 tests/main/base-migration/test-snapd-core-migration.base-core/meta/snap.yaml create mode 100755 tests/main/base-migration/test-snapd-core-migration.base-core18/bin/sh create mode 100644 tests/main/base-migration/test-snapd-core-migration.base-core18/meta/snap.yaml create mode 100644 tests/main/base-none/task.yaml create mode 100755 tests/main/base-none/test-snapd-base-none-invalid/bin/cmd create mode 100644 tests/main/base-none/test-snapd-base-none-invalid/meta/snap.yaml create mode 100644 tests/main/base-none/test-snapd-base-none/meta/snap.yaml create mode 100644 tests/main/base-policy/task.yaml create mode 100755 tests/main/base-policy/test-snapd-sh-core/bin/sh create mode 100644 tests/main/base-policy/test-snapd-sh-core/meta/snap.yaml create mode 100755 tests/main/base-policy/test-snapd-sh-other18/bin/sh create mode 100644 tests/main/base-policy/test-snapd-sh-other18/meta/snap.yaml create mode 100644 tests/main/base-snaps-refresh/task.yaml create mode 100644 tests/main/base-snaps/task.yaml create mode 100644 tests/main/basic-target-socket-activation/task.yaml create mode 100644 tests/main/boot-state/task.yaml create mode 100644 tests/main/broken-seeding/task.yaml create mode 100644 tests/main/buildmode/task.yaml create mode 100644 tests/main/ca-certs-for-snaps/task.yaml create mode 100644 tests/main/canonical-livepatch-14.04/task.yaml create mode 100644 tests/main/canonical-livepatch/task.yaml create mode 100644 tests/main/catalog-update/task.yaml create mode 100755 tests/main/cgroup-devices-v1/task.sh create mode 100644 tests/main/cgroup-devices-v1/task.yaml create mode 100755 tests/main/cgroup-devices-v1/test-snapd-service/bin/service create mode 100644 tests/main/cgroup-devices-v1/test-snapd-service/meta/snap.yaml create mode 100644 tests/main/cgroup-devices-v2/task.yaml create mode 100755 tests/main/cgroup-devices-v2/test-snapd-service/bin/service create mode 100755 tests/main/cgroup-devices-v2/test-snapd-service/bin/sh create mode 100644 tests/main/cgroup-devices-v2/test-snapd-service/meta/snap.yaml create mode 100644 tests/main/cgroup-freezer/task.yaml create mode 100644 tests/main/cgroup-tracking-failure/task.yaml create mode 100755 tests/main/cgroup-tracking/container-mgr-snap/bin/simple.sh create mode 100644 tests/main/cgroup-tracking/container-mgr-snap/meta/snap.yaml create mode 100644 tests/main/cgroup-tracking/task.yaml create mode 100755 tests/main/cgroup-tracking/test-snapd-tracking/bin/nap create mode 100755 tests/main/cgroup-tracking/test-snapd-tracking/bin/sh create mode 100755 tests/main/cgroup-tracking/test-snapd-tracking/meta/hooks/configure create mode 100644 tests/main/cgroup-tracking/test-snapd-tracking/meta/snap.yaml create mode 100644 tests/main/change-errors/task.yaml create mode 100644 tests/main/chattr/task.yaml create mode 100644 tests/main/chattr/toggle.go create mode 100644 tests/main/classic-confinement-not-supported/task.yaml create mode 100644 tests/main/classic-confinement/task.yaml create mode 100644 tests/main/classic-custom-device-reg/task.yaml create mode 100644 tests/main/classic-firstboot/task.yaml create mode 100644 tests/main/classic-prepare-image-no-core/classic-gadget-18/meta/gadget.yaml create mode 100755 tests/main/classic-prepare-image-no-core/classic-gadget-18/meta/hooks/prepare-device create mode 100644 tests/main/classic-prepare-image-no-core/classic-gadget-18/meta/snap.yaml create mode 100644 tests/main/classic-prepare-image-no-core/task.yaml create mode 100644 tests/main/classic-prepare-image/task.yaml create mode 100644 tests/main/classic-snapd-firstboot/task.yaml create mode 100644 tests/main/cloud-init/task.yaml create mode 100644 tests/main/cmdline/task.yaml create mode 100644 tests/main/cohorts/task.yaml create mode 100755 tests/main/command-chain/command-chain/chain1 create mode 100755 tests/main/command-chain/command-chain/chain2 create mode 100755 tests/main/command-chain/command-chain/chain3 create mode 100755 tests/main/command-chain/command-chain/chain4 create mode 100755 tests/main/command-chain/command-chain/hello create mode 100755 tests/main/command-chain/command-chain/meta/hooks/configure create mode 100644 tests/main/command-chain/command-chain/meta/snap.yaml create mode 100755 tests/main/command-chain/command-chain/run create mode 100644 tests/main/command-chain/task.yaml create mode 100644 tests/main/completion/abort.exp create mode 100644 tests/main/completion/ack.exp create mode 100644 tests/main/completion/alias.exp create mode 100644 tests/main/completion/buy.exp create mode 100644 tests/main/completion/change.exp create mode 100644 tests/main/completion/delete-key.exp create mode 100644 tests/main/completion/disable.exp create mode 100644 tests/main/completion/download.exp create mode 100644 tests/main/completion/enable.exp create mode 100644 tests/main/completion/export-key.exp create mode 100644 tests/main/completion/get.exp create mode 100644 tests/main/completion/info.exp create mode 100644 tests/main/completion/install.exp create mode 100644 tests/main/completion/key.exp0 create mode 120000 tests/main/completion/lib.exp0 create mode 100644 tests/main/completion/list.exp create mode 100644 tests/main/completion/refresh.exp create mode 100644 tests/main/completion/remove.exp create mode 100644 tests/main/completion/revert.exp create mode 100644 tests/main/completion/set.exp create mode 100644 tests/main/completion/sign-build.exp create mode 100644 tests/main/completion/sign.exp create mode 100644 tests/main/completion/task.yaml create mode 100644 tests/main/completion/toplevel.exp create mode 100644 tests/main/completion/try.exp create mode 100644 tests/main/completion/watch.exp create mode 100644 tests/main/component/comp1/meta/component.yaml create mode 100644 tests/main/component/snap-with-comps/meta/snap.yaml create mode 100755 tests/main/component/snap-with-comps/test create mode 100644 tests/main/component/task.yaml create mode 100644 tests/main/config-versions/task.yaml create mode 100644 tests/main/configure-hook-with-network-control/task.yaml create mode 100755 tests/main/configure-hook-with-network-control/test-snapd-with-configure-nc/meta/hooks/configure create mode 100644 tests/main/configure-hook-with-network-control/test-snapd-with-configure-nc/meta/snap.yaml create mode 100644 tests/main/confinement-classic/task.yaml create mode 100644 tests/main/connect-undo/task.yaml create mode 100755 tests/main/connect-undo/test-connect.v1/bin/consumer create mode 100644 tests/main/connect-undo/test-connect.v1/meta/snap.yaml create mode 100755 tests/main/connect-undo/test-connect.v2/bin/consumer create mode 100755 tests/main/connect-undo/test-connect.v2/meta/hooks/connect-plug-network create mode 100644 tests/main/connect-undo/test-connect.v2/meta/snap.yaml create mode 100644 tests/main/connections-after-failed-refresh/task.yaml create mode 100644 tests/main/connections-after-failed-refresh/test-snap-v1/meta/snap.yaml create mode 100755 tests/main/connections-after-failed-refresh/test-snap-v2/meta/hooks/configure create mode 100644 tests/main/connections-after-failed-refresh/test-snap-v2/meta/snap.yaml create mode 100644 tests/main/core-snap-not-test-test/task.yaml create mode 100755 tests/main/core-snap-refresh-with-shared-memory/shm-plug/bin/cmd create mode 100644 tests/main/core-snap-refresh-with-shared-memory/shm-plug/meta/snap.yaml create mode 100755 tests/main/core-snap-refresh-with-shared-memory/shm-slot/bin/cmd create mode 100644 tests/main/core-snap-refresh-with-shared-memory/shm-slot/meta/snap.yaml create mode 100644 tests/main/core-snap-refresh-with-shared-memory/task.yaml create mode 100644 tests/main/core-snap-refresh/task.yaml create mode 100644 tests/main/core18-configure-hook/task.yaml create mode 100644 tests/main/core18-with-hooks/task.yaml create mode 100644 tests/main/create-key/passphrase_mismatch.exp create mode 100644 tests/main/create-key/successful_default.exp create mode 100644 tests/main/create-key/successful_non_default.exp create mode 100644 tests/main/create-key/task.yaml create mode 100644 tests/main/cwd/task.yaml create mode 100644 tests/main/dbus-activation-name-conflict/task.yaml create mode 100755 tests/main/dbus-activation-name-conflict/test-snapd-dbus-service-conflicting/bin/server.sh create mode 100644 tests/main/dbus-activation-name-conflict/test-snapd-dbus-service-conflicting/meta/snap.yaml create mode 100644 tests/main/dbus-activation-session-legacy/task.yaml create mode 100644 tests/main/dbus-activation-session/task.yaml create mode 100644 tests/main/dbus-activation-system/task.yaml create mode 100644 tests/main/deb-restart-behavior/task.yaml create mode 100644 tests/main/debs/task.yaml create mode 100644 tests/main/debug-confinement/task.yaml create mode 100644 tests/main/debug-migrate-home/task.yaml create mode 100644 tests/main/debug-paths/task.yaml create mode 100644 tests/main/debug-pprof/task.yaml create mode 100644 tests/main/debug-sandbox/task.yaml create mode 100644 tests/main/default-tracks/task.yaml create mode 100644 tests/main/degraded/task.yaml create mode 100644 tests/main/desktop-portal-filechooser/task.yaml create mode 100755 tests/main/desktop-portal-open-file/editor.sh create mode 100644 tests/main/desktop-portal-open-file/task.yaml create mode 100644 tests/main/desktop-portal-open-uri/task.yaml create mode 100755 tests/main/desktop-portal-open-uri/web-browser.sh create mode 100644 tests/main/desktop-portal-screenshot/task.yaml create mode 100644 tests/main/dirs-not-shared-with-host/task.yaml create mode 100644 tests/main/disable-autoconnect/task.yaml create mode 100644 tests/main/disconnect-undo/task.yaml create mode 100755 tests/main/disconnect-undo/test-disconnect/meta/hooks/disconnect-plug-network create mode 100644 tests/main/disconnect-undo/test-disconnect/meta/snap.yaml create mode 100644 tests/main/disk-space-awareness/task.yaml create mode 100644 tests/main/docker-smoke/task.yaml create mode 100755 tests/main/document-portal-activation/fake-document-portal.py create mode 100644 tests/main/document-portal-activation/task.yaml create mode 100644 tests/main/download-private/snapcraft-export-login.exp create mode 100644 tests/main/download-private/task.yaml create mode 100644 tests/main/download-timeout/task.yaml create mode 100644 tests/main/drop-privs/runas-1/runas-verify-uidgid.go create mode 100644 tests/main/drop-privs/runas-2/runas-verify-thread-locked.go create mode 100644 tests/main/drop-privs/runas-3/runas-errors.go create mode 100644 tests/main/drop-privs/task.yaml create mode 100644 tests/main/econnreset/task.yaml create mode 100644 tests/main/enable-disable/task.yaml create mode 100644 tests/main/exitcodes/task.yaml create mode 100644 tests/main/experimental-features/task.yaml create mode 100755 tests/main/fake-netplan-apply/fake-netplan-service.py create mode 100644 tests/main/fake-netplan-apply/io.netplan.Netplan.conf create mode 100644 tests/main/fake-netplan-apply/task.yaml create mode 100644 tests/main/fakestore-install/task.yaml create mode 100644 tests/main/fedora-base-smoke/task.yaml create mode 100644 tests/main/find-private/task.yaml create mode 100644 tests/main/generic-classic-reg/task.yaml create mode 100644 tests/main/generic-unregister/task.yaml create mode 100644 tests/main/health/task.yaml create mode 100755 tests/main/health/test-snapd-health/health create mode 100755 tests/main/health/test-snapd-health/meta/hooks/check-health create mode 100755 tests/main/health/test-snapd-health/meta/hooks/configure create mode 100644 tests/main/health/test-snapd-health/meta/snap.yaml create mode 100644 tests/main/help/task.yaml create mode 100644 tests/main/hidden-snap-dir/task.yaml create mode 100644 tests/main/high-user-handling/task.yaml create mode 100644 tests/main/high-user-handling/test.go create mode 100644 tests/main/hook-permissions/task.yaml create mode 100755 tests/main/hook-permissions/test-snap/meta/hooks/post-refresh create mode 100644 tests/main/hook-permissions/test-snap/meta/snap.yaml create mode 100644 tests/main/i18n/task.yaml create mode 100644 tests/main/install-cache/task.yaml create mode 100644 tests/main/install-closed-channel/task.yaml create mode 100644 tests/main/install-errors/task.yaml create mode 100644 tests/main/install-hook-misbehaving/task.yaml create mode 100644 tests/main/install-many-transactional/task.yaml create mode 100644 tests/main/install-refresh-private/task.yaml create mode 100644 tests/main/install-refresh-remove-hooks/task.yaml create mode 100644 tests/main/install-remove-multi/task.yaml create mode 100644 tests/main/install-sideload-epochs/task.yaml create mode 100644 tests/main/install-sideload-epochs/test-snapd-epoch-1/meta/snap.yaml create mode 100644 tests/main/install-sideload-epochs/test-snapd-epoch-2/meta/snap.yaml create mode 100644 tests/main/install-sideload/task.yaml create mode 100644 tests/main/install-store-laaaarge/task.yaml create mode 100644 tests/main/install-store/task.yaml create mode 100755 tests/main/interfaces-account-control/account-control-consumer-core18/bin/chpasswd create mode 100755 tests/main/interfaces-account-control/account-control-consumer-core18/bin/deluser create mode 100755 tests/main/interfaces-account-control/account-control-consumer-core18/bin/useradd create mode 100644 tests/main/interfaces-account-control/account-control-consumer-core18/meta/snap.yaml create mode 100755 tests/main/interfaces-account-control/account-control-consumer-core20/bin/chpasswd create mode 100755 tests/main/interfaces-account-control/account-control-consumer-core20/bin/deluser create mode 100755 tests/main/interfaces-account-control/account-control-consumer-core20/bin/useradd create mode 100644 tests/main/interfaces-account-control/account-control-consumer-core20/meta/snap.yaml create mode 100755 tests/main/interfaces-account-control/account-control-consumer-core22/bin/chpasswd create mode 100755 tests/main/interfaces-account-control/account-control-consumer-core22/bin/deluser create mode 100755 tests/main/interfaces-account-control/account-control-consumer-core22/bin/useradd create mode 100644 tests/main/interfaces-account-control/account-control-consumer-core22/meta/snap.yaml create mode 100755 tests/main/interfaces-account-control/account-control-consumer/bin/chpasswd create mode 100755 tests/main/interfaces-account-control/account-control-consumer/bin/deluser create mode 100755 tests/main/interfaces-account-control/account-control-consumer/bin/useradd create mode 100644 tests/main/interfaces-account-control/account-control-consumer/meta/snap.yaml create mode 100644 tests/main/interfaces-account-control/task.yaml create mode 100644 tests/main/interfaces-accounts-service/task.yaml create mode 100644 tests/main/interfaces-adb-support/task.yaml create mode 100755 tests/main/interfaces-adb-support/test-snapd-adb-support/bin/sh create mode 100644 tests/main/interfaces-adb-support/test-snapd-adb-support/meta/snap.yaml create mode 100644 tests/main/interfaces-alsa/task.yaml create mode 100644 tests/main/interfaces-appstream-metadata/task.yaml create mode 100644 tests/main/interfaces-audio-playback-record/task.yaml create mode 100644 tests/main/interfaces-autopilot-introspection/task.yaml create mode 100644 tests/main/interfaces-avahi-observe/task.yaml create mode 100644 tests/main/interfaces-bluetooth-control/task.yaml create mode 100644 tests/main/interfaces-bluez/task.yaml create mode 100644 tests/main/interfaces-broadcom-asic-control/task.yaml create mode 100755 tests/main/interfaces-broadcom-asic-control/test-snapd-sh/bin/sh create mode 100644 tests/main/interfaces-broadcom-asic-control/test-snapd-sh/meta/snap.yaml create mode 100755 tests/main/interfaces-browser-support/browser-support-consumer/bin/cmd create mode 100644 tests/main/interfaces-browser-support/browser-support-consumer/meta/snap.yaml.in create mode 100644 tests/main/interfaces-browser-support/task.yaml create mode 100644 tests/main/interfaces-calendar-service/task.yaml create mode 100644 tests/main/interfaces-classic-content-slot/task.yaml create mode 100644 tests/main/interfaces-cli/task.yaml create mode 100644 tests/main/interfaces-contacts-service/task.yaml create mode 100644 tests/main/interfaces-content-circular/task.yaml create mode 100755 tests/main/interfaces-content-circular/test-snapd-content-circular1/bin/content-plug create mode 100644 tests/main/interfaces-content-circular/test-snapd-content-circular1/import/.placeholder create mode 100644 tests/main/interfaces-content-circular/test-snapd-content-circular1/meta/snap.yaml create mode 100755 tests/main/interfaces-content-circular/test-snapd-content-circular2/bin/content-plug create mode 100644 tests/main/interfaces-content-circular/test-snapd-content-circular2/import/.placeholder create mode 100644 tests/main/interfaces-content-circular/test-snapd-content-circular2/meta/snap.yaml create mode 100644 tests/main/interfaces-content-default-provider/task.yaml create mode 100644 tests/main/interfaces-content-empty-content-attr/task.yaml create mode 100755 tests/main/interfaces-content-empty-content-attr/test-snapd-content-plug-no-content-attr/bin/content-plug create mode 100644 tests/main/interfaces-content-empty-content-attr/test-snapd-content-plug-no-content-attr/import/.placeholder create mode 100644 tests/main/interfaces-content-empty-content-attr/test-snapd-content-plug-no-content-attr/meta/snap.yaml create mode 100644 tests/main/interfaces-content-empty-content-attr/test-snapd-content-slot-no-content-attr/meta/snap.yaml create mode 100644 tests/main/interfaces-content-empty-content-attr/test-snapd-content-slot-no-content-attr/shared-content create mode 100644 tests/main/interfaces-content-mimic/task.yaml create mode 100755 tests/main/interfaces-content-mimic/test-snapd-content-mimic-plug/bin/sh create mode 100644 tests/main/interfaces-content-mimic/test-snapd-content-mimic-plug/dir/stuff-in-dir create mode 100644 tests/main/interfaces-content-mimic/test-snapd-content-mimic-plug/file create mode 100644 tests/main/interfaces-content-mimic/test-snapd-content-mimic-plug/meta/snap.yaml create mode 120000 tests/main/interfaces-content-mimic/test-snapd-content-mimic-plug/symlink create mode 100644 tests/main/interfaces-content-mimic/test-snapd-content-mimic-plug/symlink-target create mode 100755 tests/main/interfaces-content-mimic/test-snapd-content-mimic-slot/bin/sh create mode 100644 tests/main/interfaces-content-mimic/test-snapd-content-mimic-slot/meta/snap.yaml create mode 100644 tests/main/interfaces-content-mimic/test-snapd-content-mimic-slot/source/canary create mode 100644 tests/main/interfaces-content-mkdir-writable/task.yaml create mode 100644 tests/main/interfaces-content/task.yaml create mode 100644 tests/main/interfaces-cups-control-autoconnect/cups-consumer/meta/snap.yaml create mode 100644 tests/main/interfaces-cups-control-autoconnect/cups-provider/meta/snap.yaml create mode 100644 tests/main/interfaces-cups-control-autoconnect/task.yaml create mode 100644 tests/main/interfaces-cups-control/task.yaml create mode 100644 tests/main/interfaces-cups/task.yaml create mode 100755 tests/main/interfaces-custom-device-app-slot/device-app/bin/cmd create mode 100644 tests/main/interfaces-custom-device-app-slot/device-app/meta/snap.yaml create mode 100644 tests/main/interfaces-custom-device-app-slot/task.yaml create mode 100644 tests/main/interfaces-daemon-notify/task.yaml create mode 100644 tests/main/interfaces-dbus/task.yaml create mode 100644 tests/main/interfaces-desktop-document-portal/task.yaml create mode 100644 tests/main/interfaces-desktop-host-fonts-core/desktop-provider/meta/snap.yaml create mode 100644 tests/main/interfaces-desktop-host-fonts-core/task.yaml create mode 100644 tests/main/interfaces-desktop-host-fonts/task.yaml create mode 100644 tests/main/interfaces-desktop-launch/task.yaml create mode 100755 tests/main/interfaces-desktop-launch/test-app/bin/app.sh create mode 100644 tests/main/interfaces-desktop-launch/test-app/meta/gui/test-app.desktop create mode 100644 tests/main/interfaces-desktop-launch/test-app/meta/snap.yaml create mode 100755 tests/main/interfaces-desktop-launch/test-launcher/bin/launcher.sh create mode 100644 tests/main/interfaces-desktop-launch/test-launcher/meta/snap.yaml create mode 100644 tests/main/interfaces-desktop/task.yaml create mode 100644 tests/main/interfaces-device-buttons/task.yaml create mode 100755 tests/main/interfaces-device-buttons/test-snapd-sh/bin/sh create mode 100644 tests/main/interfaces-device-buttons/test-snapd-sh/meta/snap.yaml create mode 100644 tests/main/interfaces-dvb/task.yaml create mode 100755 tests/main/interfaces-dvb/test-snapd-sh/bin/sh create mode 100644 tests/main/interfaces-dvb/test-snapd-sh/meta/snap.yaml create mode 100755 tests/main/interfaces-firewall-control/firewall-control-consumer/bin/consumer create mode 100644 tests/main/interfaces-firewall-control/firewall-control-consumer/meta/snap.yaml create mode 100644 tests/main/interfaces-firewall-control/task.yaml create mode 100644 tests/main/interfaces-framebuffer/task.yaml create mode 100755 tests/main/interfaces-framebuffer/test-snapd-framebuffer/bin/read create mode 100755 tests/main/interfaces-framebuffer/test-snapd-framebuffer/bin/write create mode 100644 tests/main/interfaces-framebuffer/test-snapd-framebuffer/meta/snap.yaml create mode 100644 tests/main/interfaces-fuse-support/task.yaml create mode 100644 tests/main/interfaces-fwupd-classic/task.yaml create mode 100755 tests/main/interfaces-fwupd-classic/test-snapd-fwupd/bin/get-version.sh create mode 100644 tests/main/interfaces-fwupd-classic/test-snapd-fwupd/meta/snap.yaml create mode 100644 tests/main/interfaces-gpg-keys/task.yaml create mode 100755 tests/main/interfaces-gpg-keys/test-snapd-sh/bin/sh create mode 100644 tests/main/interfaces-gpg-keys/test-snapd-sh/meta/snap.yaml create mode 100644 tests/main/interfaces-gpg-public-keys/task.yaml create mode 100755 tests/main/interfaces-gpg-public-keys/test-snapd-sh/bin/sh create mode 100644 tests/main/interfaces-gpg-public-keys/test-snapd-sh/meta/snap.yaml create mode 100644 tests/main/interfaces-gpio-memory-control/task.yaml create mode 100755 tests/main/interfaces-hardware-observe/hardware-observe-consumer/bin/consumer create mode 100644 tests/main/interfaces-hardware-observe/hardware-observe-consumer/meta/snap.yaml create mode 100644 tests/main/interfaces-hardware-observe/task.yaml create mode 100644 tests/main/interfaces-hardware-random-control/task.yaml create mode 100755 tests/main/interfaces-hardware-random-control/test-snapd-hardware-random-control/bin/check create mode 100644 tests/main/interfaces-hardware-random-control/test-snapd-hardware-random-control/meta/snap.yaml create mode 100644 tests/main/interfaces-hardware-random-observe/task.yaml create mode 100755 tests/main/interfaces-hardware-random-observe/test-snapd-hardware-random-observe/bin/check create mode 100644 tests/main/interfaces-hardware-random-observe/test-snapd-hardware-random-observe/meta/snap.yaml create mode 100644 tests/main/interfaces-home/task.yaml create mode 100644 tests/main/interfaces-hooks-plug-with-number/task.yaml create mode 100755 tests/main/interfaces-hooks-plug-with-number/test-snap/meta/hooks/connect-plug-consumer0 create mode 100755 tests/main/interfaces-hooks-plug-with-number/test-snap/meta/hooks/prepare-plug-consumer0 create mode 100644 tests/main/interfaces-hooks-plug-with-number/test-snap/meta/snap.yaml create mode 100755 tests/main/interfaces-hooks/basic-iface-hooks-consumer/meta/hooks/configure create mode 100755 tests/main/interfaces-hooks/basic-iface-hooks-consumer/meta/hooks/connect-plug-consumer create mode 100755 tests/main/interfaces-hooks/basic-iface-hooks-consumer/meta/hooks/disconnect-plug-consumer create mode 100755 tests/main/interfaces-hooks/basic-iface-hooks-consumer/meta/hooks/prepare-plug-consumer create mode 100755 tests/main/interfaces-hooks/basic-iface-hooks-consumer/meta/hooks/unprepare-plug-consumer create mode 100644 tests/main/interfaces-hooks/basic-iface-hooks-consumer/meta/snap.yaml create mode 100755 tests/main/interfaces-hooks/basic-iface-hooks-producer/meta/hooks/configure create mode 100755 tests/main/interfaces-hooks/basic-iface-hooks-producer/meta/hooks/connect-slot-producer create mode 100755 tests/main/interfaces-hooks/basic-iface-hooks-producer/meta/hooks/disconnect-slot-producer create mode 100755 tests/main/interfaces-hooks/basic-iface-hooks-producer/meta/hooks/prepare-slot-producer create mode 100755 tests/main/interfaces-hooks/basic-iface-hooks-producer/meta/hooks/unprepare-slot-producer create mode 100644 tests/main/interfaces-hooks/basic-iface-hooks-producer/meta/snap.yaml create mode 100644 tests/main/interfaces-hooks/task.yaml create mode 100644 tests/main/interfaces-hostname-control/task.yaml create mode 100755 tests/main/interfaces-hostname-control/test-snapd-sh/bin/sh create mode 100644 tests/main/interfaces-hostname-control/test-snapd-sh/meta/snap.yaml create mode 100644 tests/main/interfaces-input/task.yaml create mode 100755 tests/main/interfaces-input/test-snapd-sh/bin/sh create mode 100644 tests/main/interfaces-input/test-snapd-sh/meta/snap.yaml create mode 100644 tests/main/interfaces-joystick/task.yaml create mode 100755 tests/main/interfaces-joystick/test-snapd-sh/bin/sh create mode 100644 tests/main/interfaces-joystick/test-snapd-sh/meta/snap.yaml create mode 100644 tests/main/interfaces-juju-client-observe/task.yaml create mode 100755 tests/main/interfaces-juju-client-observe/test-snapd-sh/bin/sh create mode 100644 tests/main/interfaces-juju-client-observe/test-snapd-sh/meta/snap.yaml create mode 100644 tests/main/interfaces-kernel-module-control/task.yaml create mode 100644 tests/main/interfaces-kernel-module-load/task.yaml create mode 100755 tests/main/interfaces-kernel-module-load/test-snapd-kernel-module-load/bin/cmd create mode 100644 tests/main/interfaces-kernel-module-load/test-snapd-kernel-module-load/meta/snap.yaml create mode 100644 tests/main/interfaces-kvm/task.yaml create mode 100644 tests/main/interfaces-libvirt/task.yaml create mode 100755 tests/main/interfaces-locale-control/locale-control-consumer/bin/get create mode 100755 tests/main/interfaces-locale-control/locale-control-consumer/bin/set create mode 100644 tests/main/interfaces-locale-control/locale-control-consumer/meta/snap.yaml create mode 100644 tests/main/interfaces-locale-control/task.yaml create mode 100644 tests/main/interfaces-location-control/task.yaml create mode 100644 tests/main/interfaces-log-observe/task.yaml create mode 100644 tests/main/interfaces-many-core-provided/task.yaml create mode 100644 tests/main/interfaces-many-snap-provided/task.yaml create mode 100755 tests/main/interfaces-many-snap-provided/test-snapd-policy-app-provider-classic/bin/run create mode 100644 tests/main/interfaces-many-snap-provided/test-snapd-policy-app-provider-classic/meta/snap.yaml create mode 100755 tests/main/interfaces-many-snap-provided/test-snapd-policy-app-provider-core/bin/run create mode 100644 tests/main/interfaces-many-snap-provided/test-snapd-policy-app-provider-core/meta/snap.yaml create mode 100644 tests/main/interfaces-microstack-support/task.yaml create mode 100755 tests/main/interfaces-microstack-support/test-snapd-sh/bin/inspect create mode 100644 tests/main/interfaces-microstack-support/test-snapd-sh/meta/snap.yaml create mode 100644 tests/main/interfaces-mount-control/task.yaml create mode 100755 tests/main/interfaces-mount-control/test-mount-control-invalid/bin/cmd create mode 100644 tests/main/interfaces-mount-control/test-mount-control-invalid/meta/snap.yaml create mode 100755 tests/main/interfaces-mount-observe/mount-observe-consumer/bin/consumer create mode 100644 tests/main/interfaces-mount-observe/mount-observe-consumer/meta/snap.yaml create mode 100644 tests/main/interfaces-mount-observe/task.yaml create mode 100644 tests/main/interfaces-netlink-audit/task.yaml create mode 100755 tests/main/interfaces-netlink-audit/test-snapd-netlink-audit/bin/bind create mode 100644 tests/main/interfaces-netlink-audit/test-snapd-netlink-audit/meta/snap.yaml create mode 100644 tests/main/interfaces-netlink-connector/task.yaml create mode 100755 tests/main/interfaces-netlink-connector/test-snapd-netlink-connector/bin/bind create mode 100644 tests/main/interfaces-netlink-connector/test-snapd-netlink-connector/meta/snap.yaml create mode 100755 tests/main/interfaces-network-bind/network-bind-consumer/bin/consumer create mode 100644 tests/main/interfaces-network-bind/network-bind-consumer/meta/snap.yaml create mode 100644 tests/main/interfaces-network-bind/task.yaml create mode 100644 tests/main/interfaces-network-control-ip-netns/task.yaml create mode 100644 tests/main/interfaces-network-control-tuntap/task.yaml create mode 100755 tests/main/interfaces-network-control-tuntap/test-snapd-tuntap/bin/tuntap.py create mode 100644 tests/main/interfaces-network-control-tuntap/test-snapd-tuntap/meta/snap.yaml create mode 100755 tests/main/interfaces-network-control/network-control-consumer/bin/cmd create mode 100644 tests/main/interfaces-network-control/network-control-consumer/meta/snap.yaml create mode 100644 tests/main/interfaces-network-control/task.yaml create mode 100644 tests/main/interfaces-network-manager/task.yaml create mode 100755 tests/main/interfaces-network-observe/network-observe-consumer/bin/consumer create mode 100644 tests/main/interfaces-network-observe/network-observe-consumer/meta/snap.yaml create mode 100644 tests/main/interfaces-network-observe/task.yaml create mode 100644 tests/main/interfaces-network-setup-control/task.yaml create mode 100755 tests/main/interfaces-network-setup-control/test-snapd-sh/bin/sh create mode 100644 tests/main/interfaces-network-setup-control/test-snapd-sh/meta/snap.yaml create mode 100644 tests/main/interfaces-network-setup-observe/task.yaml create mode 100755 tests/main/interfaces-network-setup-observe/test-snapd-sh/bin/sh create mode 100644 tests/main/interfaces-network-setup-observe/test-snapd-sh/meta/snap.yaml create mode 100755 tests/main/interfaces-network-status-classic/fake-portal-network-monitor.py create mode 100644 tests/main/interfaces-network-status-classic/task.yaml create mode 100755 tests/main/interfaces-network-status-classic/test-snapd-network-status-client/bin/get-connectivity.sh create mode 100644 tests/main/interfaces-network-status-classic/test-snapd-network-status-client/meta/snap.yaml create mode 100644 tests/main/interfaces-network/task.yaml create mode 100644 tests/main/interfaces-nvidia-drivers-support/task.yaml create mode 100755 tests/main/interfaces-nvidia-drivers-support/test-snapd-nvidia-drivers-support/bin/check create mode 100644 tests/main/interfaces-nvidia-drivers-support/test-snapd-nvidia-drivers-support/meta/snap.yaml create mode 100755 tests/main/interfaces-opengl-nvidia/gl-core16/bin/run create mode 100644 tests/main/interfaces-opengl-nvidia/gl-core16/meta/snap.yaml create mode 100755 tests/main/interfaces-opengl-nvidia/gl-core20/bin/run create mode 100644 tests/main/interfaces-opengl-nvidia/gl-core20/meta/snap.yaml create mode 100644 tests/main/interfaces-opengl-nvidia/task.yaml create mode 100644 tests/main/interfaces-packagekit-control/task.yaml create mode 100644 tests/main/interfaces-password-manager-service/task.yaml create mode 100644 tests/main/interfaces-personal-files/task.yaml create mode 100755 tests/main/interfaces-personal-files/test-snapd-sh/bin/sh create mode 100644 tests/main/interfaces-personal-files/test-snapd-sh/meta/snap.yaml create mode 100644 tests/main/interfaces-physical-memory-observe/task.yaml create mode 100755 tests/main/interfaces-physical-memory-observe/test-snapd-physical-memory-observe/bin/head-mem create mode 100644 tests/main/interfaces-physical-memory-observe/test-snapd-physical-memory-observe/meta/snap.yaml create mode 100644 tests/main/interfaces-polkit/task.yaml create mode 100755 tests/main/interfaces-polkit/test-snapd-pk-service/bin/check-pid.sh create mode 100644 tests/main/interfaces-polkit/test-snapd-pk-service/meta/polkit/polkit.foo.policy create mode 100644 tests/main/interfaces-polkit/test-snapd-pk-service/meta/snap.yaml create mode 100755 tests/main/interfaces-process-control/process-control-consumer/bin/signal create mode 100644 tests/main/interfaces-process-control/process-control-consumer/meta/snap.yaml create mode 100644 tests/main/interfaces-process-control/task.yaml create mode 100644 tests/main/interfaces-pulseaudio/task.yaml create mode 100644 tests/main/interfaces-raw-usb/task.yaml create mode 100755 tests/main/interfaces-raw-usb/test-snapd-sh/bin/sh create mode 100644 tests/main/interfaces-raw-usb/test-snapd-sh/meta/snap.yaml create mode 100644 tests/main/interfaces-removable-media/task.yaml create mode 100755 tests/main/interfaces-removable-media/test-snapd-sh/bin/sh create mode 100644 tests/main/interfaces-removable-media/test-snapd-sh/meta/snap.yaml create mode 100644 tests/main/interfaces-ros-opt-data/task.yaml create mode 100755 tests/main/interfaces-ros-opt-data/test-snapd-sh/bin/sh create mode 100644 tests/main/interfaces-ros-opt-data/test-snapd-sh/meta/snap.yaml create mode 100755 tests/main/interfaces-shared-memory-private/shm-private/bin/cmd create mode 100644 tests/main/interfaces-shared-memory-private/shm-private/meta/snap.yaml create mode 100644 tests/main/interfaces-shared-memory-private/task.yaml create mode 100755 tests/main/interfaces-shared-memory/shm-plug/bin/cmd create mode 100644 tests/main/interfaces-shared-memory/shm-plug/meta/snap.yaml create mode 100755 tests/main/interfaces-shared-memory/shm-slot/bin/cmd create mode 100644 tests/main/interfaces-shared-memory/shm-slot/meta/snap.yaml create mode 100644 tests/main/interfaces-shared-memory/task.yaml create mode 100755 tests/main/interfaces-shutdown-introspection/shutdown-introspection-consumer/bin/consumer create mode 100644 tests/main/interfaces-shutdown-introspection/shutdown-introspection-consumer/meta/snap.yaml create mode 100644 tests/main/interfaces-shutdown-introspection/task.yaml create mode 100755 tests/main/interfaces-snap-refresh-observe/api-client/bin/api-client.py create mode 100644 tests/main/interfaces-snap-refresh-observe/api-client/meta/snap.yaml create mode 100644 tests/main/interfaces-snap-refresh-observe/task.yaml create mode 100644 tests/main/interfaces-snapd-control-with-manage/task.yaml create mode 100644 tests/main/interfaces-snapd-control/task.yaml create mode 100644 tests/main/interfaces-ssh-keys/task.yaml create mode 100755 tests/main/interfaces-ssh-keys/test-snapd-sh/bin/sh create mode 100644 tests/main/interfaces-ssh-keys/test-snapd-sh/meta/snap.yaml create mode 100644 tests/main/interfaces-ssh-public-keys/task.yaml create mode 100755 tests/main/interfaces-ssh-public-keys/test-snapd-sh/bin/sh create mode 100644 tests/main/interfaces-ssh-public-keys/test-snapd-sh/meta/snap.yaml create mode 100644 tests/main/interfaces-system-dbus/task.yaml create mode 100644 tests/main/interfaces-system-files/task.yaml create mode 100755 tests/main/interfaces-system-files/test-snapd-sh/bin/sh create mode 100644 tests/main/interfaces-system-files/test-snapd-sh/meta/snap.yaml create mode 100644 tests/main/interfaces-system-observe/task.yaml create mode 100755 tests/main/interfaces-system-observe/testsnap/bin/cmd create mode 100644 tests/main/interfaces-system-observe/testsnap/meta/snap.yaml create mode 100644 tests/main/interfaces-system-packages-doc/task.yaml create mode 100755 tests/main/interfaces-system-packages-doc/test-snapd-app/bin/sh create mode 100644 tests/main/interfaces-system-packages-doc/test-snapd-app/meta/snap.yaml create mode 100644 tests/main/interfaces-time-control/task.yaml create mode 100644 tests/main/interfaces-timeserver-control/task.yaml create mode 100644 tests/main/interfaces-timezone-control/task.yaml create mode 100755 tests/main/interfaces-udev/modem-manager-consumer/bin/consumer create mode 100644 tests/main/interfaces-udev/modem-manager-consumer/meta/snap.yaml create mode 100644 tests/main/interfaces-udev/task.yaml create mode 100644 tests/main/interfaces-udisks2/task.yaml create mode 100644 tests/main/interfaces-uhid/task.yaml create mode 100644 tests/main/interfaces-upower-observe/task.yaml create mode 100644 tests/main/interfaces-userns/task.yaml create mode 100644 tests/main/interfaces-userns/unshare.c create mode 100644 tests/main/interfaces-wayland/task.yaml create mode 100644 tests/main/interfaces-x11-unix-socket/task.yaml create mode 100755 tests/main/interfaces-x11-unix-socket/x11-client/bin/rm.sh create mode 100755 tests/main/interfaces-x11-unix-socket/x11-client/bin/xclient.sh create mode 100644 tests/main/interfaces-x11-unix-socket/x11-client/meta/snap.yaml create mode 100755 tests/main/interfaces-x11-unix-socket/x11-server/bin/xserver.sh create mode 100644 tests/main/interfaces-x11-unix-socket/x11-server/meta/snap.yaml create mode 100644 tests/main/known-remote/task.yaml create mode 100644 tests/main/known/task.yaml create mode 100644 tests/main/layout-change/task.yaml create mode 100644 tests/main/layout-remove/task.yaml create mode 100755 tests/main/layout-remove/test-layout-v1/bin/test create mode 100644 tests/main/layout-remove/test-layout-v1/meta/snap.yaml create mode 100755 tests/main/layout-remove/test-layout-v2/bin/test create mode 100644 tests/main/layout-remove/test-layout-v2/meta/snap.yaml create mode 100755 tests/main/layout-symlink-bind-revert/app.v1/bin/app create mode 100644 tests/main/layout-symlink-bind-revert/app.v1/meta/snap.yaml create mode 100644 tests/main/layout-symlink-bind-revert/app.v1/runtime/.keep create mode 100755 tests/main/layout-symlink-bind-revert/app.v2/bin/app create mode 100644 tests/main/layout-symlink-bind-revert/app.v2/meta/snap.yaml create mode 100644 tests/main/layout-symlink-bind-revert/app.v2/runtime/.keep create mode 100644 tests/main/layout-symlink-bind-revert/runtime/meta/snap.yaml create mode 100755 tests/main/layout-symlink-bind-revert/runtime/opt/runtime/runner create mode 100644 tests/main/layout-symlink-bind-revert/task.yaml create mode 100644 tests/main/layout/task.yaml create mode 100644 tests/main/listing/task.yaml create mode 100644 tests/main/local-install-w-metadata/digest.go create mode 100644 tests/main/local-install-w-metadata/task.yaml create mode 100644 tests/main/login/missing_email_error.exp create mode 100644 tests/main/login/task.yaml create mode 100644 tests/main/login/unsuccessful_login.exp create mode 100644 tests/main/lxd-mount-units/task.yaml create mode 100644 tests/main/lxd-no-fuse/task.yaml create mode 100755 tests/main/lxd-postrm-purge/prep-snapd-in-lxd.sh create mode 100644 tests/main/lxd-postrm-purge/task.yaml create mode 100644 tests/main/lxd-services-smoke/task.yaml create mode 100644 tests/main/lxd-snapfuse/task.yaml create mode 100644 tests/main/lxd-try/task.yaml create mode 100755 tests/main/lxd/prep-snapd-in-lxd.sh create mode 100644 tests/main/lxd/task.yaml create mode 100644 tests/main/manpages/task.yaml create mode 100644 tests/main/media-sharing/task.yaml create mode 100644 tests/main/microk8s-smoke/task.yaml create mode 100644 tests/main/mkimage-uc22/task.yaml create mode 100644 tests/main/mount-ns/google.ubuntu-16.04-64/HOST.expected.txt create mode 100644 tests/main/mount-ns/google.ubuntu-16.04-64/PER-SNAP-16.expected.txt create mode 100644 tests/main/mount-ns/google.ubuntu-16.04-64/PER-SNAP-18.expected.txt create mode 100644 tests/main/mount-ns/google.ubuntu-16.04-64/PER-SNAP-C7.expected.txt create mode 100644 tests/main/mount-ns/google.ubuntu-16.04-64/PER-USER-16.expected.txt create mode 100644 tests/main/mount-ns/google.ubuntu-16.04-64/PER-USER-18.expected.txt create mode 100644 tests/main/mount-ns/google.ubuntu-16.04-64/PER-USER-C7.expected.txt create mode 100644 tests/main/mount-ns/google.ubuntu-18.04-64/HOST.expected.txt create mode 100644 tests/main/mount-ns/google.ubuntu-18.04-64/PER-SNAP-16.expected.txt create mode 100644 tests/main/mount-ns/google.ubuntu-18.04-64/PER-SNAP-18.expected.txt create mode 100644 tests/main/mount-ns/google.ubuntu-18.04-64/PER-SNAP-C7.expected.txt create mode 100644 tests/main/mount-ns/google.ubuntu-18.04-64/PER-USER-16.expected.txt create mode 100644 tests/main/mount-ns/google.ubuntu-18.04-64/PER-USER-18.expected.txt create mode 100644 tests/main/mount-ns/google.ubuntu-18.04-64/PER-USER-C7.expected.txt create mode 100644 tests/main/mount-ns/google.ubuntu-core-16-64/HOST.expected.txt create mode 100644 tests/main/mount-ns/google.ubuntu-core-16-64/PER-SNAP-16.expected.txt create mode 100644 tests/main/mount-ns/google.ubuntu-core-16-64/PER-SNAP-18.expected.txt create mode 100644 tests/main/mount-ns/google.ubuntu-core-16-64/PER-USER-16.expected.txt create mode 100644 tests/main/mount-ns/google.ubuntu-core-16-64/PER-USER-18.expected.txt create mode 100644 tests/main/mount-ns/google.ubuntu-core-18-64/HOST.expected.txt create mode 100644 tests/main/mount-ns/google.ubuntu-core-18-64/PER-SNAP-16.expected.txt create mode 100644 tests/main/mount-ns/google.ubuntu-core-18-64/PER-SNAP-18.expected.txt create mode 100644 tests/main/mount-ns/google.ubuntu-core-18-64/PER-USER-16.expected.txt create mode 100644 tests/main/mount-ns/google.ubuntu-core-18-64/PER-USER-18.expected.txt create mode 100644 tests/main/mount-ns/google.ubuntu-core-20-64/HOST.expected.txt create mode 100644 tests/main/mount-ns/google.ubuntu-core-20-64/PER-SNAP-16.expected.txt create mode 100644 tests/main/mount-ns/google.ubuntu-core-20-64/PER-SNAP-18.expected.txt create mode 100644 tests/main/mount-ns/google.ubuntu-core-20-64/PER-USER-16.expected.txt create mode 100644 tests/main/mount-ns/google.ubuntu-core-20-64/PER-USER-18.expected.txt create mode 100644 tests/main/mount-ns/task.yaml create mode 100755 tests/main/mount-ns/test-snapd-mountinfo-classic/bin/mountinfo create mode 100644 tests/main/mount-ns/test-snapd-mountinfo-classic/meta/snap.yaml create mode 100755 tests/main/mount-ns/test-snapd-mountinfo-core16/bin/mountinfo create mode 100644 tests/main/mount-ns/test-snapd-mountinfo-core16/meta/snap.yaml create mode 100755 tests/main/mount-ns/test-snapd-mountinfo-core18/bin/mountinfo create mode 100644 tests/main/mount-ns/test-snapd-mountinfo-core18/meta/snap.yaml create mode 100644 tests/main/mount-protocol-error/task.yaml create mode 100644 tests/main/mounts-persist-refresh-content-snap/task.yaml create mode 100755 tests/main/mounts-persist-refresh-content-snap/test-snapd-desktop-layout-with-content/bin.sh create mode 100644 tests/main/mounts-persist-refresh-content-snap/test-snapd-desktop-layout-with-content/meta/snap.yaml create mode 100644 tests/main/network-retry/task.yaml create mode 100644 tests/main/nfs-support/task.yaml create mode 100755 tests/main/nfs-support/test-snapd-sh/bin/sh create mode 100644 tests/main/nfs-support/test-snapd-sh/meta/snap.yaml create mode 100644 tests/main/no-snap-repair-classic/task.yaml create mode 100644 tests/main/non-home/task.yaml create mode 100644 tests/main/op-install-failed-undone/task.yaml create mode 100644 tests/main/op-remove-retry/task.yaml create mode 100644 tests/main/op-remove/task.yaml create mode 100644 tests/main/parallel-install-aliases/task.yaml create mode 100644 tests/main/parallel-install-auto-aliases/task.yaml create mode 100644 tests/main/parallel-install-basic/task.yaml create mode 100644 tests/main/parallel-install-classic/task.yaml create mode 100644 tests/main/parallel-install-common-dirs-undo/task.yaml create mode 100644 tests/main/parallel-install-common-dirs/task.yaml create mode 100644 tests/main/parallel-install-desktop/task.yaml create mode 100644 tests/main/parallel-install-interfaces-content/task.yaml create mode 100644 tests/main/parallel-install-interfaces/task.yaml create mode 100644 tests/main/parallel-install-layout/task.yaml create mode 100644 tests/main/parallel-install-local/task.yaml create mode 100644 tests/main/parallel-install-remove-after/task.yaml create mode 100644 tests/main/parallel-install-services/task.yaml create mode 100644 tests/main/parallel-install-snap-icons/task.yaml create mode 100644 tests/main/parallel-install-store/task.yaml create mode 100644 tests/main/postrm-purge/task.yaml create mode 100644 tests/main/prefer/task.yaml create mode 100644 tests/main/prepare-image-check-arch/task.yaml create mode 100644 tests/main/prepare-image-classic/task.yaml create mode 100644 tests/main/prepare-image-gating/task.yaml create mode 100644 tests/main/prepare-image-grub-core18/task.yaml create mode 100644 tests/main/prepare-image-grub/task.yaml create mode 100644 tests/main/prepare-image-reproducible/task.yaml create mode 100644 tests/main/prepare-image-uboot-uc20/task.yaml create mode 100644 tests/main/prepare-image-uboot/task.yaml create mode 100644 tests/main/prepare-image-validation-sets/asserts/core-20.json create mode 100644 tests/main/prepare-image-validation-sets/asserts/vs1.json create mode 100644 tests/main/prepare-image-validation-sets/asserts/vs2.json create mode 100644 tests/main/prepare-image-validation-sets/task.yaml create mode 100755 tests/main/preseed-core20/systemusers-snap/foo.sh create mode 100644 tests/main/preseed-core20/systemusers-snap/meta/snap.yaml create mode 100644 tests/main/preseed-core20/task.yaml create mode 100644 tests/main/preseed-lxd/metadata.yaml create mode 100755 tests/main/preseed-lxd/preseed-prepare.sh create mode 100644 tests/main/preseed-lxd/task.yaml create mode 100644 tests/main/preseed-reset/task.yaml create mode 100644 tests/main/preseed/task.yaml create mode 100644 tests/main/proxy-no-core/task.yaml create mode 100644 tests/main/proxy/task.yaml create mode 100644 tests/main/quota-groups-systemd-accounting/task.yaml create mode 100644 tests/main/refresh-all-undo/task.yaml create mode 100644 tests/main/refresh-all/task.yaml create mode 100644 tests/main/refresh-amend/task.yaml create mode 100644 tests/main/refresh-app-awareness-notify/task.yaml create mode 100644 tests/main/refresh-app-awareness/task.yaml create mode 100755 tests/main/refresh-app-awareness/test-snapd-refresh.v1/bin/sh create mode 100755 tests/main/refresh-app-awareness/test-snapd-refresh.v1/bin/version create mode 100644 tests/main/refresh-app-awareness/test-snapd-refresh.v1/meta/snap.yaml.in create mode 100755 tests/main/refresh-app-awareness/test-snapd-refresh.v2/bin/version create mode 100644 tests/main/refresh-app-awareness/test-snapd-refresh.v2/meta/snap.yaml.in create mode 100644 tests/main/refresh-classic/task.yaml create mode 100644 tests/main/refresh-delta-from-core/task.yaml create mode 100644 tests/main/refresh-delta/task.yaml create mode 100644 tests/main/refresh-devmode/task.yaml create mode 100644 tests/main/refresh-hold/task.yaml create mode 100644 tests/main/refresh-many-transactional-undo/task.yaml create mode 100644 tests/main/refresh-many-transactional/task.yaml create mode 100644 tests/main/refresh-mode-ignore-running/task.yaml create mode 100755 tests/main/refresh-mode-ignore-running/test-snapd-refresh/bin/sh create mode 100644 tests/main/refresh-mode-ignore-running/test-snapd-refresh/meta/snap.yaml create mode 100644 tests/main/refresh-undo/task.yaml create mode 100644 tests/main/refresh-with-epoch-bump/task.yaml create mode 100644 tests/main/refresh/task.yaml create mode 100644 tests/main/regression-home-snap-root-owned/task.yaml create mode 100644 tests/main/remove-auto-connections/simplesnap.v1/meta/snap.yaml create mode 100644 tests/main/remove-auto-connections/simplesnap.v2/meta/snap.yaml create mode 100644 tests/main/remove-auto-connections/task.yaml create mode 100644 tests/main/remove-core/task.yaml create mode 100644 tests/main/remove-errors/task.yaml create mode 100644 tests/main/retry-network/detect-retry.go create mode 100644 tests/main/retry-network/task.yaml create mode 100644 tests/main/retryable-error/task.yaml create mode 100755 tests/main/retryable-error/test-snapd-sleep-install/meta/hooks/configure create mode 100644 tests/main/retryable-error/test-snapd-sleep-install/meta/snap.yaml create mode 100644 tests/main/revert-devmode/task.yaml create mode 100644 tests/main/revert-sideload/task.yaml create mode 100644 tests/main/revert/task.yaml create mode 100644 tests/main/searching/task.yaml create mode 100644 tests/main/seccomp-statx/task.yaml create mode 100755 tests/main/seccomp-statx/test-snapd-statx/bin/statx.py create mode 100644 tests/main/seccomp-statx/test-snapd-statx/meta/snap.yaml create mode 100644 tests/main/security-apparmor/task.yaml create mode 100644 tests/main/security-dev-input-event-denied/task.yaml create mode 100755 tests/main/security-dev-input-event-denied/test-snapd-event/bin/read-evdev-device create mode 100644 tests/main/security-dev-input-event-denied/test-snapd-event/meta/snap.yaml create mode 100644 tests/main/security-device-cgroups-classic/task.yaml create mode 100755 tests/main/security-device-cgroups-classic/test-classic-cgroup/bin/read-fb create mode 100755 tests/main/security-device-cgroups-classic/test-classic-cgroup/bin/read-kmsg create mode 100644 tests/main/security-device-cgroups-classic/test-classic-cgroup/meta/snap.yaml create mode 100644 tests/main/security-device-cgroups-devmode/task.yaml create mode 100644 tests/main/security-device-cgroups-helper/task.yaml create mode 100755 tests/main/security-device-cgroups-helper/test-strict-cgroup-helper/bin/sh create mode 100644 tests/main/security-device-cgroups-helper/test-strict-cgroup-helper/meta/snap.yaml create mode 100644 tests/main/security-device-cgroups-jailmode/task.yaml create mode 100644 tests/main/security-device-cgroups-required-or-optional/task.yaml create mode 100755 tests/main/security-device-cgroups-self-manage/container-mgr-snap/bin/sh create mode 100644 tests/main/security-device-cgroups-self-manage/container-mgr-snap/meta/snap.yaml create mode 100644 tests/main/security-device-cgroups-self-manage/task.yaml create mode 100644 tests/main/security-device-cgroups-serial-port/task.yaml create mode 100644 tests/main/security-device-cgroups-strict-enforced/task.yaml create mode 100755 tests/main/security-device-cgroups-strict-enforced/test-strict-cgroup/bin/sh create mode 100644 tests/main/security-device-cgroups-strict-enforced/test-strict-cgroup/meta/snap.yaml create mode 100644 tests/main/security-device-cgroups-strict/task.yaml create mode 100755 tests/main/security-device-cgroups-strict/test-strict-cgroup/bin/read-dev create mode 100644 tests/main/security-device-cgroups-strict/test-strict-cgroup/meta/snap.yaml create mode 100644 tests/main/security-device-cgroups/task.yaml create mode 100644 tests/main/security-devpts/task.yaml create mode 100755 tests/main/security-devpts/test-snapd-devpts/bin/openpty create mode 100755 tests/main/security-devpts/test-snapd-devpts/bin/useptmx create mode 100644 tests/main/security-devpts/test-snapd-devpts/meta/snap.yaml create mode 100644 tests/main/security-private-tmp/task.yaml create mode 100644 tests/main/security-private-tmp/tmp-create.exp create mode 100644 tests/main/security-profiles/task.yaml create mode 100644 tests/main/security-seccomp/task.yaml create mode 100644 tests/main/security-setuid-root/task.yaml create mode 100644 tests/main/security-udev-input-subsystem/task.yaml create mode 100755 tests/main/security-udev-input-subsystem/test-snapd-udev-input-subsystem/bin/read-evdev-kbd create mode 100644 tests/main/security-udev-input-subsystem/test-snapd-udev-input-subsystem/meta/snap.yaml create mode 100644 tests/main/selinux-classic-confinement/task.yaml create mode 100755 tests/main/selinux-classic-confinement/test-snapd-classic-service-hooks/bin/service create mode 100755 tests/main/selinux-classic-confinement/test-snapd-classic-service-hooks/meta/hooks/configure create mode 100755 tests/main/selinux-classic-confinement/test-snapd-classic-service-hooks/meta/hooks/install create mode 100644 tests/main/selinux-classic-confinement/test-snapd-classic-service-hooks/meta/snap.yaml create mode 100644 tests/main/selinux-clean/task.yaml create mode 100644 tests/main/selinux-data-context/task.yaml create mode 100755 tests/main/selinux-data-context/test-snapd-service-writer/bin/start create mode 100755 tests/main/selinux-data-context/test-snapd-service-writer/meta/hooks/configure create mode 100644 tests/main/selinux-data-context/test-snapd-service-writer/meta/snap.yaml create mode 100644 tests/main/selinux-lxd/task.yaml create mode 100644 tests/main/selinux-snap-restorecon/task.yaml create mode 100644 tests/main/server-snap/task.yaml create mode 100644 tests/main/services-after-before-install/task.yaml create mode 100644 tests/main/services-after-before/task.yaml create mode 100644 tests/main/services-disable-install-hook/task.yaml create mode 100755 tests/main/services-disable-install-hook/test-snapd-svcs-disable-install-hook/bin/forking.sh create mode 100755 tests/main/services-disable-install-hook/test-snapd-svcs-disable-install-hook/bin/simple.sh create mode 100755 tests/main/services-disable-install-hook/test-snapd-svcs-disable-install-hook/meta/hooks/install create mode 100644 tests/main/services-disable-install-hook/test-snapd-svcs-disable-install-hook/meta/snap.yaml create mode 100644 tests/main/services-disable-refresh-hook/task.yaml create mode 100755 tests/main/services-disable-refresh-hook/test-snapd-svcs-disable-refresh-hook/bin/forking.sh create mode 100755 tests/main/services-disable-refresh-hook/test-snapd-svcs-disable-refresh-hook/bin/simple.sh create mode 100755 tests/main/services-disable-refresh-hook/test-snapd-svcs-disable-refresh-hook/meta/hooks/post-refresh create mode 100644 tests/main/services-disable-refresh-hook/test-snapd-svcs-disable-refresh-hook/meta/snap.yaml create mode 100644 tests/main/services-disabled-kept-happy/task.yaml create mode 100644 tests/main/services-disabled-kept-unhappy/task.yaml create mode 100644 tests/main/services-install-hook-can-run-svcs/task.yaml create mode 100755 tests/main/services-install-hook-can-run-svcs/test-snapd-install-hook-runs-svc/bin/svc.sh create mode 100755 tests/main/services-install-hook-can-run-svcs/test-snapd-install-hook-runs-svc/meta/hooks/install.in create mode 100644 tests/main/services-install-hook-can-run-svcs/test-snapd-install-hook-runs-svc/meta/snap.yaml create mode 100644 tests/main/services-multi-service-failing/task.yaml create mode 100755 tests/main/services-multi-service-failing/test-snapd-multi-service/bin/start create mode 100644 tests/main/services-multi-service-failing/test-snapd-multi-service/meta/snap.yaml create mode 100644 tests/main/services-refresh-mode/task.yaml create mode 100644 tests/main/services-restart/task.yaml create mode 100644 tests/main/services-snapctl/task.yaml create mode 100644 tests/main/services-socket-activation/task.yaml create mode 100644 tests/main/services-start-timeout/task.yaml create mode 100755 tests/main/services-start-timeout/test-snapd-service-start-timeout/forking.sh create mode 100644 tests/main/services-start-timeout/test-snapd-service-start-timeout/meta/snap.yaml create mode 100644 tests/main/services-stop-mode-sigkill/task.yaml create mode 100644 tests/main/services-stop-mode/task.yaml create mode 100644 tests/main/services-stop-timeout/task.yaml create mode 100755 tests/main/services-stop-timeout/test-snapd-service-stop-timeout/forking.sh create mode 100644 tests/main/services-stop-timeout/test-snapd-service-stop-timeout/meta/snap.yaml create mode 100755 tests/main/services-stop-timeout/test-snapd-service-stop-timeout/staaap.sh create mode 100644 tests/main/services-stress/task.yaml create mode 100644 tests/main/services-timer/task.yaml create mode 100644 tests/main/services-user/task.yaml create mode 100755 tests/main/services-user/test-snapd-user-service/bin/start create mode 100644 tests/main/services-user/test-snapd-user-service/meta/snap.yaml create mode 100644 tests/main/services-watchdog/task.yaml create mode 100755 tests/main/services-watchdog/test-snapd-service-watchdog/bin/direct create mode 100644 tests/main/services-watchdog/test-snapd-service-watchdog/meta/snap.yaml create mode 100644 tests/main/set-proxy-store/task.yaml create mode 100644 tests/main/snap-advise-command/task.yaml create mode 100644 tests/main/snap-cli-no-managers/task.yaml create mode 100644 tests/main/snap-confine-drops-sys-admin/has-sys-admin.c create mode 100644 tests/main/snap-confine-drops-sys-admin/task.yaml create mode 100644 tests/main/snap-confine-from-core/task.yaml create mode 100644 tests/main/snap-confine-privs/task.yaml create mode 100644 tests/main/snap-confine-privs/uids-and-gids.c create mode 100644 tests/main/snap-confine-tmp-mount/task.yaml create mode 100644 tests/main/snap-confine-undesired-mode-group/task.yaml create mode 100755 tests/main/snap-confine-undesired-mode-group/test-snapd-app/bin/sh create mode 100644 tests/main/snap-confine-undesired-mode-group/test-snapd-app/meta/snap.yaml create mode 100644 tests/main/snap-confine-unexpected-path/task.yaml create mode 100644 tests/main/snap-confine/task.yaml create mode 100644 tests/main/snap-connect/task.yaml create mode 100644 tests/main/snap-connections/task.yaml create mode 100644 tests/main/snap-connections/test-snap.v1/meta/snap.yaml create mode 100644 tests/main/snap-connections/test-snap.v2/meta/snap.yaml create mode 100644 tests/main/snap-connectivity-check/task.yaml create mode 100644 tests/main/snap-debug-get-base-declaration/task.yaml create mode 100644 tests/main/snap-debug-stacktrace/task.yaml create mode 100644 tests/main/snap-debug-state/task.yaml create mode 100644 tests/main/snap-debug-timings/task.yaml create mode 100644 tests/main/snap-discard-ns/mount.py create mode 100755 tests/main/snap-discard-ns/mount.sh create mode 100644 tests/main/snap-discard-ns/task.yaml create mode 100644 tests/main/snap-disconnect/task.yaml create mode 100644 tests/main/snap-disconnect/test-snap-consumer.v1/meta/snap.yaml create mode 100644 tests/main/snap-disconnect/test-snap-consumer.v2/meta/snap.yaml create mode 100644 tests/main/snap-disconnect/test-snap-producer/meta/snap.yaml create mode 100644 tests/main/snap-download/task.yaml create mode 100644 tests/main/snap-env/task.yaml create mode 100644 tests/main/snap-get/task.yaml create mode 100644 tests/main/snap-handle-link/task.yaml create mode 100644 tests/main/snap-icons/task.yaml create mode 100644 tests/main/snap-info/check.py create mode 100644 tests/main/snap-info/task.yaml create mode 100644 tests/main/snap-interface/snap-interface-network-core.yaml create mode 100644 tests/main/snap-interface/snap-interface-network-snapd.yaml create mode 100644 tests/main/snap-interface/task.yaml create mode 100644 tests/main/snap-logs-journal/task.yaml create mode 100644 tests/main/snap-logs/task.yaml create mode 100644 tests/main/snap-mgmt/task.yaml create mode 100644 tests/main/snap-model/task.yaml create mode 100644 tests/main/snap-network-errors/task.yaml create mode 100755 tests/main/snap-ns-forward-compat/setup_mount_namespace.sh create mode 100644 tests/main/snap-ns-forward-compat/task.yaml create mode 100755 tests/main/snap-ns-forward-compat/testsnap/bin/cmd create mode 100644 tests/main/snap-ns-forward-compat/testsnap/meta/snap.yaml create mode 100644 tests/main/snap-pack-integrity/task.yaml create mode 100644 tests/main/snap-pack/task.yaml create mode 100644 tests/main/snap-quota-cpu/task.yaml create mode 100644 tests/main/snap-quota-install/task.yaml create mode 100644 tests/main/snap-quota-journal/task.yaml create mode 100644 tests/main/snap-quota-memory/task.yaml create mode 100644 tests/main/snap-quota-services/task.yaml create mode 100644 tests/main/snap-quota-thread/task.yaml create mode 100644 tests/main/snap-quota/task.yaml create mode 100644 tests/main/snap-readme/task.yaml create mode 100644 tests/main/snap-refresh-enforce/refresh-enforce-set.yaml create mode 100644 tests/main/snap-refresh-enforce/task.yaml create mode 100644 tests/main/snap-refresh-hold/task.yaml create mode 100644 tests/main/snap-remove-not-mounted/task.yaml create mode 100644 tests/main/snap-routine-file-access/task.yaml create mode 100755 tests/main/snap-routine-file-access/test-snapd-file-access/bin/sh create mode 100644 tests/main/snap-routine-file-access/test-snapd-file-access/meta/snap.yaml create mode 100644 tests/main/snap-routine-portal-info/task.yaml create mode 100644 tests/main/snap-run-alias/task.yaml create mode 100644 tests/main/snap-run-devmode-classic/task.yaml create mode 100644 tests/main/snap-run-gdbserver/task.yaml create mode 100644 tests/main/snap-run-hook/task.yaml create mode 100755 tests/main/snap-run-inhibition-flow/api-client/bin/api-client.py create mode 100644 tests/main/snap-run-inhibition-flow/api-client/meta/snap.yaml create mode 100644 tests/main/snap-run-inhibition-flow/task.yaml create mode 100644 tests/main/snap-run-symlink-error/task.yaml create mode 100644 tests/main/snap-run-symlink/task.yaml create mode 100644 tests/main/snap-run-userdata-current/task.yaml create mode 100755 tests/main/snap-run/basic-run/bin/echo create mode 100644 tests/main/snap-run/basic-run/meta/snap.yaml create mode 100644 tests/main/snap-run/task.yaml create mode 100644 tests/main/snap-seccomp-blocks-tty-injection/task.yaml create mode 100644 tests/main/snap-seccomp-blocks-tty-injection/test-tioclinux.c create mode 100644 tests/main/snap-seccomp-blocks-tty-injection/test-tiocsti.c create mode 100644 tests/main/snap-seccomp-syscalls/listcalls.go create mode 100644 tests/main/snap-seccomp-syscalls/task.yaml create mode 100644 tests/main/snap-seccomp/task.yaml create mode 100755 tests/main/snap-service-install-mode/svc.v1/meta/hooks/install create mode 100644 tests/main/snap-service-install-mode/svc.v1/meta/snap.yaml create mode 100755 tests/main/snap-service-install-mode/svc.v1/sleep create mode 100644 tests/main/snap-service-install-mode/svc.v2/meta/snap.yaml create mode 100755 tests/main/snap-service-install-mode/svc.v2/sleep create mode 100644 tests/main/snap-service-install-mode/task.yaml create mode 100644 tests/main/snap-service/task.yaml create mode 100644 tests/main/snap-services/task.yaml create mode 100644 tests/main/snap-session-agent-service-control/task.yaml create mode 100644 tests/main/snap-session-agent-socket-activation/task.yaml create mode 100644 tests/main/snap-session-agent-unavailable-to-snaps/task.yaml create mode 100644 tests/main/snap-set-core-w-no-core/task.yaml create mode 100755 tests/main/snap-set/failing-config-hooks/meta/hooks/configure create mode 100644 tests/main/snap-set/failing-config-hooks/meta/snap.yaml create mode 100644 tests/main/snap-set/task.yaml create mode 100644 tests/main/snap-sign/create-key.exp create mode 100644 tests/main/snap-sign/sign-model.exp create mode 100644 tests/main/snap-sign/task.yaml create mode 100644 tests/main/snap-switch/task.yaml create mode 100644 tests/main/snap-system-env/task.yaml create mode 100644 tests/main/snap-system-key/task.yaml create mode 100644 tests/main/snap-unset/task.yaml create mode 100644 tests/main/snap-update-ns/task.yaml create mode 100644 tests/main/snap-user-dir-perms-fixed/task.yaml create mode 100644 tests/main/snap-user-service-restart-on-upgrade/task.yaml create mode 100644 tests/main/snap-user-service-socket-activation/task.yaml create mode 100644 tests/main/snap-user-service-start-on-install/task.yaml create mode 100644 tests/main/snap-user-service-upgrade-failure/task.yaml create mode 100644 tests/main/snap-user-service/task.yaml create mode 100644 tests/main/snap-userd-desktop-app-autostart/task.yaml create mode 100755 tests/main/snap-userd-desktop-app-autostart/test-snapd-xdg-autostart/bin/foobar create mode 100644 tests/main/snap-userd-desktop-app-autostart/test-snapd-xdg-autostart/meta/snap.yaml create mode 100644 tests/main/snap-userd-reexec/task.yaml create mode 100644 tests/main/snap-validate-basic/task.yaml create mode 100644 tests/main/snap-validate-basic/vs1.assert create mode 100644 tests/main/snap-validate-basic/vs1.json create mode 100644 tests/main/snap-validate-enforce/task.yaml create mode 100644 tests/main/snap-validate-enforce/testenforce1-seq1.yaml create mode 100644 tests/main/snap-validate-enforce/testenforce1-seq2.yaml create mode 100644 tests/main/snap-validate-enforce/testenfroce2-seq1.yaml create mode 100644 tests/main/snap-validate-with-store/task.yaml create mode 100644 tests/main/snap-validate-with-store/testset1-seq1.yaml create mode 100644 tests/main/snap-validate-with-store/testset1-seq2.yaml create mode 100644 tests/main/snap-wait/task.yaml create mode 100755 tests/main/snapctl-from-snap/snapctl-from-snap-core18/bin/snapctl-get create mode 100755 tests/main/snapctl-from-snap/snapctl-from-snap-core18/bin/snapctl-set create mode 100755 tests/main/snapctl-from-snap/snapctl-from-snap-core18/meta/hooks/configure create mode 100644 tests/main/snapctl-from-snap/snapctl-from-snap-core18/meta/snap.yaml create mode 100755 tests/main/snapctl-from-snap/snapctl-from-snap/bin/snapctl-get create mode 100755 tests/main/snapctl-from-snap/snapctl-from-snap/bin/snapctl-set create mode 100755 tests/main/snapctl-from-snap/snapctl-from-snap/meta/hooks/configure create mode 100644 tests/main/snapctl-from-snap/snapctl-from-snap/meta/snap.yaml create mode 100644 tests/main/snapctl-from-snap/task.yaml create mode 100644 tests/main/snapctl-is-connected-list/task.yaml create mode 100755 tests/main/snapctl-is-connected-list/test-snap/bin/listconn create mode 100644 tests/main/snapctl-is-connected-list/test-snap/meta/snap.yaml create mode 100644 tests/main/snapctl-is-connected-pid/task.yaml create mode 100755 tests/main/snapctl-is-connected-pid/test-snap-classic/bin/service.sh create mode 100644 tests/main/snapctl-is-connected-pid/test-snap-classic/meta/snap.yaml create mode 100755 tests/main/snapctl-is-connected-pid/test-snap1/bin/service.sh create mode 100644 tests/main/snapctl-is-connected-pid/test-snap1/meta/snap.yaml create mode 100755 tests/main/snapctl-is-connected-pid/test-snap2/bin/run-snapctl.sh create mode 100644 tests/main/snapctl-is-connected-pid/test-snap2/meta/snap.yaml create mode 100644 tests/main/snapctl-is-connected/task.yaml create mode 100755 tests/main/snapctl-is-connected/test-snap/bin/checkconn create mode 100644 tests/main/snapctl-is-connected/test-snap/meta/snap.yaml create mode 100644 tests/main/snapctl/task.yaml create mode 100644 tests/main/snapd-apparmor/task.yaml create mode 100644 tests/main/snapd-certs/task.yaml create mode 100644 tests/main/snapd-go-socket-activated/task.yaml create mode 100644 tests/main/snapd-homedirs-vendored/task.yaml create mode 100755 tests/main/snapd-homedirs-vendored/test-snapd-sh/bin/sh create mode 100644 tests/main/snapd-homedirs-vendored/test-snapd-sh/meta/snap.yaml create mode 100644 tests/main/snapd-homedirs/task.yaml create mode 100644 tests/main/snapd-notify/task.yaml create mode 100644 tests/main/snapd-reexec-snapd-snap/task.yaml create mode 100644 tests/main/snapd-reexec/task.yaml create mode 100644 tests/main/snapd-sigterm/task.yaml create mode 100644 tests/main/snapd-slow-startup/task.yaml create mode 100644 tests/main/snapd-snap-auto-install/task.yaml create mode 100644 tests/main/snapd-snap-removal/task.yaml create mode 100644 tests/main/snapd-snap-transition/task.yaml create mode 100644 tests/main/snapd-snap/task.yaml create mode 100644 tests/main/snapd-state/task.yaml create mode 100644 tests/main/snapd-update-services/task.yaml create mode 100644 tests/main/snapd-without-core/task.yaml create mode 100644 tests/main/snaps-state/task.yaml create mode 100644 tests/main/snapshot-basic/task.yaml create mode 100755 tests/main/snapshot-basic/test-snap/bin/sh create mode 100755 tests/main/snapshot-basic/test-snap/meta/hooks/configure create mode 100644 tests/main/snapshot-basic/test-snap/meta/snap.yaml create mode 100644 tests/main/snapshot-cross-revno/task.yaml create mode 100644 tests/main/snapshot-exclusions-dynamic/task.yaml create mode 100755 tests/main/snapshot-exclusions-dynamic/test-snap/bin/sh create mode 100644 tests/main/snapshot-exclusions-dynamic/test-snap/meta/snap.yaml create mode 100644 tests/main/snapshot-exclusions-dynamic/test-snap/meta/snapshots.yaml create mode 100644 tests/main/snapshot-exclusions-static/task.yaml create mode 100755 tests/main/snapshot-exclusions-static/test-snap/bin/sh create mode 100644 tests/main/snapshot-exclusions-static/test-snap/meta/snap.yaml create mode 100644 tests/main/snapshot-exclusions-static/test-snap/meta/snapshots.yaml create mode 100644 tests/main/snapshot-users/task.yaml create mode 100644 tests/main/special-home-can-run-classic-snaps/task.yaml create mode 100644 tests/main/squashfs-precondition-check/task.yaml create mode 100644 tests/main/stale-base-snap/task.yaml create mode 100644 tests/main/static/task.yaml create mode 100755 tests/main/store-state/snap/bin/sh create mode 100644 tests/main/store-state/snap/meta/snap.yaml.in create mode 100644 tests/main/store-state/task.yaml create mode 100644 tests/main/sudo-env/task.yaml create mode 100644 tests/main/system-core-alias/task.yaml create mode 100644 tests/main/system-usernames-illegal/task.yaml create mode 100755 tests/main/system-usernames-illegal/test-snapd-illegal-system-username/bin/sh create mode 100644 tests/main/system-usernames-illegal/test-snapd-illegal-system-username/meta/snap.yaml create mode 100644 tests/main/system-usernames-install-twice/task.yaml create mode 100644 tests/main/system-usernames-missing-user/task.yaml create mode 100755 tests/main/system-usernames-snap-scoped/snap/bin/sh create mode 100644 tests/main/system-usernames-snap-scoped/snap/meta/snap.yaml.in create mode 100644 tests/main/system-usernames-snap-scoped/task.yaml create mode 100644 tests/main/system-usernames/task.yaml create mode 100755 tests/main/system-users-are-created/daemon-user/bin/sh create mode 100644 tests/main/system-users-are-created/daemon-user/meta/snap.yaml.in create mode 100644 tests/main/system-users-are-created/task.yaml create mode 100644 tests/main/systemd-service/task.yaml create mode 100755 tests/main/theme-install/api-client/bin/api-client.py create mode 100644 tests/main/theme-install/api-client/meta/snap.yaml create mode 100644 tests/main/theme-install/task.yaml create mode 100644 tests/main/try-non-fatal/task.yaml create mode 100644 tests/main/try-snap-goes-away/task.yaml create mode 100644 tests/main/try-snap-is-optional/task.yaml create mode 100644 tests/main/try-twice-with-daemon/task.yaml create mode 100755 tests/main/try-twice-with-daemon/test-snapd-service-try-v1/bin/service create mode 100644 tests/main/try-twice-with-daemon/test-snapd-service-try-v1/meta/snap.yaml create mode 100755 tests/main/try-twice-with-daemon/test-snapd-service-try-v2/bin/service create mode 100644 tests/main/try-twice-with-daemon/test-snapd-service-try-v2/meta/snap.yaml create mode 100644 tests/main/try-with-hooks/task.yaml create mode 100644 tests/main/try/task.yaml create mode 100644 tests/main/uc20-create-partitions-encrypt/task.yaml create mode 100644 tests/main/uc20-create-partitions-reinstall/task.yaml create mode 100644 tests/main/uc20-create-partitions/task.yaml create mode 100644 tests/main/umask/task.yaml create mode 100755 tests/main/unclash-mount-entries/fwupd-client/bin/cmd create mode 100644 tests/main/unclash-mount-entries/fwupd-client/meta/snap.yaml create mode 100644 tests/main/unclash-mount-entries/task.yaml create mode 100755 tests/main/unclash-mount-entries/testsnap/bin/cmd create mode 100644 tests/main/unclash-mount-entries/testsnap/meta/snap.yaml create mode 100644 tests/main/unhandled-task/task.yaml create mode 100644 tests/main/upgrade-from-2.15/task.yaml create mode 100644 tests/main/upgrade-from-release/task.yaml create mode 100644 tests/main/user-data-handling/task.yaml create mode 100644 tests/main/user-libnss/findid.go create mode 100644 tests/main/user-libnss/task.yaml create mode 100644 tests/main/user-mounts/task.yaml create mode 100644 tests/main/user-session-env/task.yaml create mode 100644 tests/main/validate-container-failures/task.yaml create mode 100644 tests/main/validate-container-failures/test-snapd-validate-container-failures/bin/bar create mode 100755 tests/main/validate-container-failures/test-snapd-validate-container-failures/bin/foo create mode 100644 tests/main/validate-container-failures/test-snapd-validate-container-failures/comp.sh create mode 100644 tests/main/validate-container-failures/test-snapd-validate-container-failures/meta/hooks/what create mode 100644 tests/main/validate-container-failures/test-snapd-validate-container-failures/meta/snap.yaml create mode 100644 tests/main/validate-container-failures/test-snapd-validate-container-failures/meta/unreadable create mode 100644 tests/main/validate-container-happy/task.yaml create mode 100755 tests/main/validate-container-happy/test-snapd-validate-container-happy/bin/validate-container create mode 100644 tests/main/validate-container-happy/test-snapd-validate-container-happy/hell/hell.tar create mode 100644 tests/main/validate-container-happy/test-snapd-validate-container-happy/meta/snap.yaml create mode 100644 tests/main/vitality/task.yaml create mode 100644 tests/main/whoami/task.yaml create mode 100755 tests/main/writable-areas/data-writer/bin/write-data create mode 100644 tests/main/writable-areas/data-writer/meta/snap.yaml create mode 100644 tests/main/writable-areas/task.yaml create mode 100644 tests/main/xauth-migration/task.yaml create mode 100644 tests/main/xdg-open-compat/task.yaml create mode 100755 tests/main/xdg-open-portal/editor.sh create mode 100644 tests/main/xdg-open-portal/task.yaml create mode 100755 tests/main/xdg-open-portal/web-browser.sh create mode 100644 tests/main/xdg-open/task.yaml create mode 100644 tests/main/xdg-settings/task.yaml create mode 100755 tests/main/xdg-settings/test-snapd-xdg-settings/bin/browser create mode 100755 tests/main/xdg-settings/test-snapd-xdg-settings/bin/xdg-settings-wrapper create mode 100644 tests/main/xdg-settings/test-snapd-xdg-settings/meta/gui/browser.desktop create mode 100644 tests/main/xdg-settings/test-snapd-xdg-settings/meta/snap.yaml create mode 100644 tests/manual-tests.md create mode 100644 tests/nested/classic/hotplug/task.yaml create mode 100644 tests/nested/classic/snapshots-with-core-refresh-revert/task.yaml create mode 100644 tests/nested/core/bad-try-kernel-no-reboot/task.yaml create mode 100644 tests/nested/core/base-revert-after-boot/task.yaml create mode 100644 tests/nested/core/connected-after-reboot-revert/task.yaml create mode 100644 tests/nested/core/core-gadget-mounted/task.yaml create mode 100644 tests/nested/core/core-revert/task.yaml create mode 100644 tests/nested/core/core-snap-refresh-on-core/task.yaml create mode 100644 tests/nested/core/core20-basic/task.yaml create mode 100644 tests/nested/core/core20-create-recovery/task.yaml create mode 100644 tests/nested/core/core20-degraded/task.yaml create mode 100644 tests/nested/core/core20-factory-reset/task.yaml create mode 100644 tests/nested/core/core20-fault-inject/task.yaml create mode 100755 tests/nested/core/core20-gadget-reseal/manip_gadget.py create mode 100644 tests/nested/core/core20-gadget-reseal/task.yaml create mode 100644 tests/nested/core/core20-kernel-failover/task.yaml create mode 100644 tests/nested/core/core20-kernel-reseal/task.yaml create mode 100644 tests/nested/core/core20-reinstall-partitions/task.yaml create mode 100644 tests/nested/core/core20-tpm/task.yaml create mode 100644 tests/nested/core/core22-basic/task.yaml create mode 100644 tests/nested/core/coreconfig-services/task.yaml create mode 100644 tests/nested/core/hotplug/task.yaml create mode 100644 tests/nested/core/image-build/task.yaml create mode 100755 tests/nested/core/interfaces-custom-devices/devices-plug/bin/cmd create mode 100644 tests/nested/core/interfaces-custom-devices/devices-plug/meta/snap.yaml create mode 100644 tests/nested/core/interfaces-custom-devices/task.yaml create mode 100644 tests/nested/core/kernel-revert-after-boot/task.yaml create mode 100644 tests/nested/core/save-data/task.yaml create mode 100644 tests/nested/manual/cloud-init-never-used-not-vuln/task.yaml create mode 100644 tests/nested/manual/cloud-init-nocloud-not-vuln/task.yaml create mode 100644 tests/nested/manual/cmdline-option/cloud.conf create mode 100644 tests/nested/manual/cmdline-option/defaults.yaml create mode 100755 tests/nested/manual/cmdline-option/prepare-device create mode 100644 tests/nested/manual/cmdline-option/task.yaml create mode 100644 tests/nested/manual/cmdline-remove-append/task.yaml create mode 100644 tests/nested/manual/core-early-config/defaults.yaml create mode 100755 tests/nested/manual/core-early-config/install create mode 100644 tests/nested/manual/core-early-config/task.yaml create mode 100644 tests/nested/manual/core-seeding-devmode/task.yaml create mode 100644 tests/nested/manual/core20-4k-sector-size/task.yaml create mode 100644 tests/nested/manual/core20-auto-remove-user/defaults.yaml create mode 100755 tests/nested/manual/core20-auto-remove-user/prepare-device create mode 100644 tests/nested/manual/core20-auto-remove-user/task.yaml create mode 100644 tests/nested/manual/core20-auto-remove-user/user2-2.json create mode 100644 tests/nested/manual/core20-auto-remove-user/user2.json create mode 100644 tests/nested/manual/core20-auto-remove-user/user3.json create mode 100644 tests/nested/manual/core20-boot-config-update/task.yaml create mode 100644 tests/nested/manual/core20-cloud-init-maas-signed-seed-data/50-cloudconfig-gce-unsupported-config.cfg create mode 100755 tests/nested/manual/core20-cloud-init-maas-signed-seed-data/50-cloudconfig-maas-cloud-config.cfg create mode 100755 tests/nested/manual/core20-cloud-init-maas-signed-seed-data/50-cloudconfig-maas-datasource.cfg create mode 100755 tests/nested/manual/core20-cloud-init-maas-signed-seed-data/50-cloudconfig-maas-reporting.cfg create mode 100755 tests/nested/manual/core20-cloud-init-maas-signed-seed-data/50-cloudconfig-maas-ubuntu-sso.cfg create mode 100755 tests/nested/manual/core20-cloud-init-maas-signed-seed-data/50-curtin-networking.cfg create mode 100644 tests/nested/manual/core20-cloud-init-maas-signed-seed-data/defaults.yaml create mode 100644 tests/nested/manual/core20-cloud-init-maas-signed-seed-data/gadget-says-maas.conf create mode 100644 tests/nested/manual/core20-cloud-init-maas-signed-seed-data/gadget-says-none.conf create mode 100755 tests/nested/manual/core20-cloud-init-maas-signed-seed-data/prepare-device create mode 100644 tests/nested/manual/core20-cloud-init-maas-signed-seed-data/task.yaml create mode 100644 tests/nested/manual/core20-custom-kernel-commandline/task.yaml create mode 100644 tests/nested/manual/core20-da-lockout/getdalockout.go create mode 100644 tests/nested/manual/core20-da-lockout/getdalockout_nosecboot.go create mode 100644 tests/nested/manual/core20-da-lockout/task.yaml create mode 100644 tests/nested/manual/core20-early-config/defaults.yaml create mode 100755 tests/nested/manual/core20-early-config/install create mode 100644 tests/nested/manual/core20-early-config/task.yaml create mode 100644 tests/nested/manual/core20-factory-reset-install-device-hook/defaults.yaml create mode 100755 tests/nested/manual/core20-factory-reset-install-device-hook/install-device create mode 100644 tests/nested/manual/core20-factory-reset-install-device-hook/pc-snap-decl-extras.json create mode 100755 tests/nested/manual/core20-factory-reset-install-device-hook/prepare-device create mode 100644 tests/nested/manual/core20-factory-reset-install-device-hook/snap-yaml-extras.yaml create mode 100644 tests/nested/manual/core20-factory-reset-install-device-hook/task.yaml create mode 100644 tests/nested/manual/core20-gadget-cloud-conf/cloud.conf create mode 100644 tests/nested/manual/core20-gadget-cloud-conf/defaults.yaml create mode 100755 tests/nested/manual/core20-gadget-cloud-conf/prepare-device create mode 100644 tests/nested/manual/core20-gadget-cloud-conf/task.yaml create mode 100644 tests/nested/manual/core20-grade-signed-above-testkeys-boot/defaults.yaml create mode 100755 tests/nested/manual/core20-grade-signed-above-testkeys-boot/prepare-device create mode 100644 tests/nested/manual/core20-grade-signed-above-testkeys-boot/task.yaml create mode 100644 tests/nested/manual/core20-grade-signed-cloud-init-testkeys/defaults.yaml create mode 100755 tests/nested/manual/core20-grade-signed-cloud-init-testkeys/prepare-device create mode 100644 tests/nested/manual/core20-grade-signed-cloud-init-testkeys/task.yaml create mode 100644 tests/nested/manual/core20-initramfs-time-moves-forward/task.yaml create mode 100644 tests/nested/manual/core20-install-device-file-install-ubuntu-save-via-hook/defaults.yaml create mode 100755 tests/nested/manual/core20-install-device-file-install-ubuntu-save-via-hook/install-device create mode 100755 tests/nested/manual/core20-install-device-file-install-ubuntu-save-via-hook/prepare-device create mode 100644 tests/nested/manual/core20-install-device-file-install-ubuntu-save-via-hook/snap-yaml-extras.yaml create mode 100644 tests/nested/manual/core20-install-device-file-install-ubuntu-save-via-hook/task.yaml create mode 100644 tests/nested/manual/core20-install-device-file-install-via-hook-hack/defaults.yaml create mode 100755 tests/nested/manual/core20-install-device-file-install-via-hook-hack/install-device create mode 100644 tests/nested/manual/core20-install-device-file-install-via-hook-hack/pc-snap-decl-extras.json create mode 100755 tests/nested/manual/core20-install-device-file-install-via-hook-hack/prepare-device create mode 100644 tests/nested/manual/core20-install-device-file-install-via-hook-hack/snap-yaml-extras.yaml create mode 100644 tests/nested/manual/core20-install-device-file-install-via-hook-hack/task.yaml create mode 100755 tests/nested/manual/core20-install-mode-shutdown-via-hook/install-device create mode 100644 tests/nested/manual/core20-install-mode-shutdown-via-hook/task.yaml create mode 100644 tests/nested/manual/core20-new-snapd-does-not-break-old-initrd/task.yaml create mode 100644 tests/nested/manual/core20-preseed/task.yaml create mode 100644 tests/nested/manual/core20-remodel/task.yaml create mode 100644 tests/nested/manual/core20-save/task.yaml create mode 100644 tests/nested/manual/core20-to-core22/task.yaml create mode 100644 tests/nested/manual/core20-validation-sets/asserts/bar-vs.json create mode 100644 tests/nested/manual/core20-validation-sets/asserts/core-20-model.json create mode 100644 tests/nested/manual/core20-validation-sets/task.yaml create mode 100644 tests/nested/manual/devmode-snap-seeded-dangerous/task.yaml create mode 100644 tests/nested/manual/devmode-snap-seeded-dangerous/uc20-devmode/meta/snap.yaml create mode 100755 tests/nested/manual/devmode-snap-seeded-dangerous/uc20-devmode/true create mode 100644 tests/nested/manual/devmode-snap-seeded-dangerous/uc22-devmode/meta/snap.yaml create mode 100755 tests/nested/manual/devmode-snap-seeded-dangerous/uc22-devmode/true create mode 100644 tests/nested/manual/devmode-snaps-can-run-other-snaps/task.yaml create mode 100644 tests/nested/manual/fde-on-classic/classic-model.assert create mode 100644 tests/nested/manual/fde-on-classic/classic-model.json create mode 100755 tests/nested/manual/fde-on-classic/mk-image.sh create mode 100644 tests/nested/manual/fde-on-classic/model-etc create mode 100755 tests/nested/manual/fde-on-classic/replace-image-files.sh create mode 100644 tests/nested/manual/fde-on-classic/task.yaml create mode 100644 tests/nested/manual/fde-on-classic/tweak-gadget.py create mode 100644 tests/nested/manual/gadget-connections/task.yaml create mode 100755 tests/nested/manual/gadget-connections/test-snapd-connections/bin/test create mode 100644 tests/nested/manual/gadget-connections/test-snapd-connections/meta/snap.yaml create mode 100644 tests/nested/manual/hybrid-remodel/task.yaml create mode 100644 tests/nested/manual/install-min-size/task.yaml create mode 100644 tests/nested/manual/minimal-smoke/task.yaml create mode 100644 tests/nested/manual/muinstaller-core/task.yaml create mode 100644 tests/nested/manual/muinstaller-real/gadget-partial.yaml create mode 100644 tests/nested/manual/muinstaller-real/task.yaml create mode 100644 tests/nested/manual/muinstaller/task.yaml create mode 100644 tests/nested/manual/preseed/task.yaml create mode 100644 tests/nested/manual/recovery-system-offline/task.yaml create mode 100644 tests/nested/manual/recovery-system-reboot/task.yaml create mode 100644 tests/nested/manual/recovery-system/task.yaml create mode 100644 tests/nested/manual/refresh-revert-fundamentals/task.yaml create mode 100644 tests/nested/manual/remodel-cross-store/task.yaml create mode 100644 tests/nested/manual/remodel-min-size/task.yaml create mode 100644 tests/nested/manual/remodel-offline/task.yaml create mode 100644 tests/nested/manual/remodel-simple/task.yaml create mode 100644 tests/nested/manual/remodel-target-base-installed/task.yaml create mode 100644 tests/nested/manual/remodel-uc20-to-uc22/task.yaml create mode 100644 tests/nested/manual/remodel-validation-sets-downgrade/assets/validation-set.yaml create mode 100644 tests/nested/manual/remodel-validation-sets-downgrade/task.yaml create mode 100644 tests/nested/manual/remodel-validation-sets-invalid/assets/validation-set.yaml create mode 100644 tests/nested/manual/remodel-validation-sets-invalid/task.yaml create mode 100644 tests/nested/manual/run-spread/task.yaml create mode 100644 tests/nested/manual/snapd-refresh-from-old/task.yaml create mode 100644 tests/nested/manual/snapd-removes-vulnerable-snap-confine-revs/task.yaml create mode 100644 tests/nested/manual/uc-grub-boot-chains/modify-gadget.py create mode 100644 tests/nested/manual/uc-grub-boot-chains/task.yaml create mode 100644 tests/nested/manual/uc-update-assets-secure-add-sbat/task.yaml create mode 100644 tests/nested/manual/uc-update-assets-secure/generate_vendor_cert_section.py create mode 100644 tests/nested/manual/uc-update-assets-secure/task.yaml create mode 100644 tests/nested/manual/uc-update-command-line-secure/task.yaml create mode 100644 tests/nested/manual/uc20-fde-hooks-ice/task.yaml create mode 100644 tests/nested/manual/uc20-fde-hooks-v1/task.yaml create mode 100644 tests/nested/manual/uc20-fde-hooks/task.yaml create mode 100644 tests/nested/manual/uc20-install-in-initrd/task.yaml create mode 100644 tests/nested/manual/uc20-storage-safety/task.yaml create mode 100644 tests/nightly/install-snaps/task.yaml create mode 100644 tests/nightly/interfaces-openvswitch/task.yaml create mode 100644 tests/nightly/sbuild/task.yaml create mode 100644 tests/nightly/upload-snapd-to-gce/task.yaml create mode 100644 tests/perf/main/install-many-snaps-no-wait/task.yaml create mode 100644 tests/perf/main/install-many-snaps/task.yaml create mode 100644 tests/perf/main/interfaces-core-provided/task.yaml create mode 100644 tests/perf/main/interfaces-snap-provided/task.yaml create mode 100755 tests/perf/main/interfaces-snap-provided/test-snapd-policy-app-provider-classic/bin/run create mode 100644 tests/perf/main/interfaces-snap-provided/test-snapd-policy-app-provider-classic/meta/snap.yaml create mode 100755 tests/perf/main/interfaces-snap-provided/test-snapd-policy-app-provider-core/bin/run create mode 100644 tests/perf/main/interfaces-snap-provided/test-snapd-policy-app-provider-core/meta/snap.yaml create mode 100644 tests/perf/main/parallel-installs/task.yaml create mode 100644 tests/perf/nested/install-many-snaps-no-wait/task.yaml create mode 100644 tests/perf/nested/install-many-snaps/task.yaml create mode 100644 tests/perf/nested/interfaces-many/task.yaml create mode 100644 tests/perf/nested/parallel-installs/task.yaml create mode 100644 tests/regression/exploding-namespace/task.yaml create mode 100644 tests/regression/lp-1595444/task.yaml create mode 100644 tests/regression/lp-1597839/task.yaml create mode 100644 tests/regression/lp-1597842/task.yaml create mode 100644 tests/regression/lp-1599891/task.yaml create mode 100644 tests/regression/lp-1606277/task.yaml create mode 100644 tests/regression/lp-1607796/task.yaml create mode 100644 tests/regression/lp-1615113/task.yaml create mode 100644 tests/regression/lp-1618683/task.yaml create mode 100644 tests/regression/lp-1630479/task.yaml create mode 100644 tests/regression/lp-1641885/task.yaml create mode 100644 tests/regression/lp-1644439/task.yaml create mode 100644 tests/regression/lp-1665004/task.yaml create mode 100644 tests/regression/lp-1667385/task.yaml create mode 100644 tests/regression/lp-1693042/task.yaml create mode 100755 tests/regression/lp-1704860/snap-env-query.sh create mode 100644 tests/regression/lp-1704860/task.yaml create mode 100644 tests/regression/lp-1732555/task.yaml create mode 100755 tests/regression/lp-1732555/test-snapd-unknown-interfaces/bin/sh create mode 100644 tests/regression/lp-1732555/test-snapd-unknown-interfaces/meta/snap.yaml create mode 100644 tests/regression/lp-1764977/task.yaml create mode 100644 tests/regression/lp-1797556/task.yaml create mode 100755 tests/regression/lp-1797556/test-snapd-sh/bin/sh create mode 100644 tests/regression/lp-1797556/test-snapd-sh/meta/snap.yaml create mode 100644 tests/regression/lp-1800004/task.yaml create mode 100644 tests/regression/lp-1801955/task.yaml create mode 100644 tests/regression/lp-1802581/task.yaml create mode 100644 tests/regression/lp-1803535/task.yaml create mode 100755 tests/regression/lp-1803535/test-snapd-lp-1803535/bin/sh create mode 100644 tests/regression/lp-1803535/test-snapd-lp-1803535/etc/OpenCL/vendors/foo.icd create mode 100644 tests/regression/lp-1803535/test-snapd-lp-1803535/meta/snap.yaml create mode 100644 tests/regression/lp-1803542/task.yaml create mode 100644 tests/regression/lp-1805485/task.yaml create mode 100644 tests/regression/lp-1805838/task.yaml create mode 100755 tests/regression/lp-1808821/task.sh create mode 100644 tests/regression/lp-1808821/task.yaml create mode 100755 tests/regression/lp-1808821/test-snapd-app/bin/sh create mode 100644 tests/regression/lp-1808821/test-snapd-app/meta/snap.yaml create mode 100644 tests/regression/lp-1808821/test-snapd-app/stub.txt create mode 100644 tests/regression/lp-1812973/Makefile create mode 100644 tests/regression/lp-1812973/lp-1812973.c create mode 100644 tests/regression/lp-1812973/task.yaml create mode 100644 tests/regression/lp-1812973/test-snapd-lp-1812973/meta/snap.yaml create mode 100755 tests/regression/lp-1813365/helper create mode 100755 tests/regression/lp-1813365/logger create mode 100644 tests/regression/lp-1813365/task.yaml create mode 100644 tests/regression/lp-1813963/task.yaml create mode 100644 tests/regression/lp-1815722/task.yaml create mode 100644 tests/regression/lp-1815869/hello.py create mode 100644 tests/regression/lp-1815869/task.yaml create mode 100644 tests/regression/lp-1819728/task.yaml create mode 100644 tests/regression/lp-1825883/task.yaml create mode 100755 tests/regression/lp-1825883/test-snapd-app/bin/sh create mode 100644 tests/regression/lp-1825883/test-snapd-app/meta/snap.yaml create mode 100644 tests/regression/lp-1825883/test-snapd-app/things/README create mode 100644 tests/regression/lp-1825883/test-snapd-content.v1/meta/snap.yaml create mode 100644 tests/regression/lp-1825883/test-snapd-content.v1/things/a/thing create mode 100644 tests/regression/lp-1825883/test-snapd-content.v1/things/b/thing create mode 100644 tests/regression/lp-1825883/test-snapd-content.v2/meta/snap.yaml create mode 100644 tests/regression/lp-1825883/test-snapd-content.v2/things/a/thing create mode 100644 tests/regression/lp-1825883/test-snapd-content.v2/things/b/thing create mode 100644 tests/regression/lp-1825883/test-snapd-content.v2/things/c/thing create mode 100644 tests/regression/lp-1831010/task.yaml create mode 100644 tests/regression/lp-1831010/test-snapd-layout/a/.keep create mode 100644 tests/regression/lp-1831010/test-snapd-layout/b/c/.keep create mode 100755 tests/regression/lp-1831010/test-snapd-layout/bin/sh create mode 100644 tests/regression/lp-1831010/test-snapd-layout/d/.keep create mode 100644 tests/regression/lp-1831010/test-snapd-layout/meta/snap.yaml create mode 100644 tests/regression/lp-1844496/task.yaml create mode 100755 tests/regression/lp-1844496/test-snapd-layout/bin/sh create mode 100644 tests/regression/lp-1844496/test-snapd-layout/meta/snap.yaml create mode 100644 tests/regression/lp-1844496/test-snapd-layout/usr/lib/x86_64-linux-gnu/wpe-webkit-1.0/canary create mode 100644 tests/regression/lp-1844496/test-snapd-layout/usr/wpe-webkit-1.0/canary create mode 100644 tests/regression/lp-1848567/task.yaml create mode 100755 tests/regression/lp-1848567/test-snapd-app/bin/sh create mode 100644 tests/regression/lp-1848567/test-snapd-app/data-dirs/icons/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-app/data-dirs/sounds/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-app/data-dirs/themes/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-app/gnome-platform/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-app/meta/snap.yaml create mode 100644 tests/regression/lp-1848567/test-snapd-gnome-3-28-1804/meta/snap.yaml create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/meta/snap.yaml create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/gtk2/Adwaita-dark/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/gtk2/Adwaita/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/gtk2/Ambiance/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/gtk2/Ambiant-MATE-Dark/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/gtk2/Ambiant-MATE/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/gtk2/Arc-Dark/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/gtk2/Arc-Darker/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/gtk2/Arc/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/gtk2/Breeze-Dark/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/gtk2/Breeze/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/gtk2/Communitheme-dark/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/gtk2/Communitheme-light/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/gtk2/Communitheme/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/gtk2/HighContrast/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/gtk2/Matcha-aliz/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/gtk2/Matcha-azul/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/gtk2/Matcha-dark-aliz/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/gtk2/Matcha-dark-azul/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/gtk2/Matcha-dark-sea/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/gtk2/Matcha-sea/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/gtk2/Radiance/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/gtk2/Radiant-MATE/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/gtk2/Yaru-dark/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/gtk2/Yaru-light/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/gtk2/Yaru/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/gtk2/elementary/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/icons/Adwaita/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/icons/Ambiant-MATE/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/icons/DMZ-Black/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/icons/DMZ-White/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/icons/HighContrast/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/icons/Humanity-Dark/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/icons/Humanity/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/icons/Papirus-Adapta-Maia/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/icons/Papirus-Adapta-Nokto-Maia/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/icons/Papirus-Dark-Maia/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/icons/Papirus-Light-Maia/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/icons/Papirus-Maia/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/icons/Radiant-MATE/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/icons/Suru/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/icons/Yaru/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/icons/communitheme/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/icons/elementary/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/icons/hicolor/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/icons/ubuntu-mono-dark/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/icons/ubuntu-mono-light/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/sounds/Yaru/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/sounds/communitheme/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/themes/Adwaita-dark/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/themes/Adwaita/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/themes/Ambiance/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/themes/Ambiant-MATE-Dark/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/themes/Ambiant-MATE/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/themes/Arc-Dark/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/themes/Arc-Darker/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/themes/Arc/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/themes/Breeze-Dark/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/themes/Breeze/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/themes/Communitheme-dark/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/themes/Communitheme-light/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/themes/Communitheme/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/themes/HighContrast/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/themes/Matcha-aliz/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/themes/Matcha-azul/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/themes/Matcha-dark-aliz/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/themes/Matcha-dark-azul/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/themes/Matcha-dark-sea/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/themes/Matcha-sea/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/themes/Radiance/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/themes/Radiant-MATE/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/themes/Yaru-dark/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/themes/Yaru-light/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/themes/Yaru/.keep create mode 100644 tests/regression/lp-1848567/test-snapd-gtk-common-themes/share/themes/elementary/.keep create mode 100644 tests/regression/lp-1849845/task.yaml create mode 100755 tests/regression/lp-1849845/test-snapd-app/bin/sh create mode 100644 tests/regression/lp-1849845/test-snapd-app/meta/snap.yaml create mode 100755 tests/regression/lp-1849845/test-snapd-assets-bar/meta/hooks/install create mode 100644 tests/regression/lp-1849845/test-snapd-assets-bar/meta/snap.yaml create mode 100755 tests/regression/lp-1849845/test-snapd-assets-foo/meta/hooks/install create mode 100644 tests/regression/lp-1849845/test-snapd-assets-foo/meta/snap.yaml create mode 100644 tests/regression/lp-1852361/task.yaml create mode 100755 tests/regression/lp-1852361/test-snapd-layout/bin/sh create mode 100644 tests/regression/lp-1852361/test-snapd-layout/etc/vtpath.ini create mode 100644 tests/regression/lp-1852361/test-snapd-layout/meta/snap.yaml create mode 100644 tests/regression/lp-1852361/test-snapd-layout/usr/lib/x86_64-linux-gnu/alsa-lib/.keep create mode 100644 tests/regression/lp-1852361/test-snapd-layout/usr/share/pico/.keep create mode 100644 tests/regression/lp-1852361/test-snapd-layout/usr/share/snips/.keep create mode 100644 tests/regression/lp-1852361/test-snapd-layout/usr/vt/.keep create mode 100644 tests/regression/lp-1862637/task.yaml create mode 100755 tests/regression/lp-1862637/test-snapd-app/bin/sh create mode 100644 tests/regression/lp-1862637/test-snapd-app/meta/snap.yaml create mode 100644 tests/regression/lp-1866095/task.yaml create mode 100755 tests/regression/lp-1866095/test-snapd-service/bin/test-snapd-service create mode 100644 tests/regression/lp-1866095/test-snapd-service/meta/snap.yaml create mode 100644 tests/regression/lp-1867193/task.yaml create mode 100644 tests/regression/lp-1867752/task.yaml create mode 100755 tests/regression/lp-1871652/systemctl create mode 100644 tests/regression/lp-1871652/task.yaml create mode 100644 tests/regression/lp-1884849/task.yaml create mode 100644 tests/regression/lp-1886786/task.yaml create mode 100644 tests/regression/lp-1886786/test-snapd-app-with-test-name/meta/snap.yaml create mode 100755 tests/regression/lp-1886786/test-snapd-app-with-test-name/test.sh create mode 100644 tests/regression/lp-1891371/task.yaml create mode 100755 tests/regression/lp-1891371/test-snapd-app/bin/keep-foo-open create mode 100644 tests/regression/lp-1891371/test-snapd-app/extra-content/README.txt create mode 100644 tests/regression/lp-1891371/test-snapd-app/foo create mode 100644 tests/regression/lp-1891371/test-snapd-app/meta/snap.yaml create mode 100644 tests/regression/lp-1891371/test-snapd-extra-content/meta/snap.yaml create mode 100644 tests/regression/lp-1898038/task.yaml create mode 100755 tests/regression/lp-1898038/test-snapd-docker-support-app/bin/test-snapd-docker-support create mode 100644 tests/regression/lp-1898038/test-snapd-docker-support-app/meta/snap.yaml create mode 100644 tests/regression/lp-1898038/test-snapd-docker-support-app/test-snapd-docker-support.profile create mode 100755 tests/regression/lp-1898038/test-snapd-multipass-support-app/bin/test-snapd-multipass-support create mode 100644 tests/regression/lp-1898038/test-snapd-multipass-support-app/meta/snap.yaml create mode 100644 tests/regression/lp-1898038/test-snapd-multipass-support-app/test-snapd-multipass-support.profile create mode 100644 tests/regression/lp-1899664/task.yaml create mode 100644 tests/regression/lp-1906821/task.yaml create mode 100755 tests/regression/lp-1906821/test-snapd-complex-layout/bin/sh create mode 100644 tests/regression/lp-1906821/test-snapd-complex-layout/meta/snap.yaml create mode 100644 tests/regression/lp-1906821/test-snapd-complex-layout/node/bin/node create mode 100644 tests/regression/lp-1906821/test-snapd-complex-layout/usr/bin/python2.7 create mode 100644 tests/regression/lp-1906821/test-snapd-complex-layout/usr/bin/python3.8 create mode 100644 tests/regression/lp-1906821/test-snapd-complex-layout/usr/lib/jvm/jre/bin/java create mode 100644 tests/regression/lp-1906821/test-snapd-complex-layout/wrapper-scripts/exec-node.sh create mode 100755 tests/regression/lp-1910456/container-mgr-snap/bin/simple.sh create mode 100644 tests/regression/lp-1910456/container-mgr-snap/meta/snap.yaml create mode 100644 tests/regression/lp-1910456/task.yaml create mode 100644 tests/regression/lp-1942266/task.yaml create mode 100755 tests/regression/lp-1942266/test-system-files-conn-snap/bin.sh create mode 100644 tests/regression/lp-1942266/test-system-files-conn-snap/meta/snap.yaml.in create mode 100644 tests/regression/lp-1943853/task.yaml create mode 100644 tests/regression/lp-1949368/bad-layout/meta/snap.yaml create mode 100755 tests/regression/lp-1949368/content-consumer/bin/sh create mode 100644 tests/regression/lp-1949368/content-consumer/meta/snap.yaml create mode 100644 tests/regression/lp-1949368/content-provider/meta/snap.yaml create mode 100644 tests/regression/lp-1949368/task.yaml create mode 100644 tests/regression/lp-1996090/task.yaml create mode 100644 tests/regression/lp-2011485/task.yaml create mode 100755 tests/regression/lp-2011485/test-snapd-docker-support-core22-app/bin/test-snapd-docker-support create mode 100644 tests/regression/lp-2011485/test-snapd-docker-support-core22-app/meta/snap.yaml create mode 100644 tests/regression/lp-2044335/task.yaml create mode 100644 tests/regression/mount-order-regression/task.yaml create mode 100755 tests/regression/mount-order-regression/test-content-layout-consumer-amd64/bin/cmd create mode 100644 tests/regression/mount-order-regression/test-content-layout-consumer-amd64/meta/snap.yaml create mode 100755 tests/regression/mount-order-regression/test-content-layout-consumer-arm64/bin/cmd create mode 100644 tests/regression/mount-order-regression/test-content-layout-consumer-arm64/meta/snap.yaml create mode 100755 tests/regression/mount-order-regression/test-content-layout-consumer-armhf/bin/cmd create mode 100644 tests/regression/mount-order-regression/test-content-layout-consumer-armhf/meta/snap.yaml create mode 100644 tests/regression/rhbz-1584461/task.yaml create mode 100644 tests/regression/rhbz-1708991/task.yaml create mode 100644 tests/regression/vitality-rank-uc18-required-snapd-snap/task.yaml create mode 100644 tests/smoke/find-info/task.yaml create mode 100644 tests/smoke/install/task.yaml create mode 100644 tests/smoke/remove/task.yaml create mode 100644 tests/smoke/sandbox/task.yaml create mode 100755 tests/smoke/sandbox/test-snapd-sandbox/bin/sh create mode 100644 tests/smoke/sandbox/test-snapd-sandbox/meta/snap.yaml create mode 100644 tests/smoke/versioning/task.yaml create mode 100644 tests/snapd-state.md create mode 100644 tests/unit/c-unit-tests-clang/task.yaml create mode 100644 tests/unit/c-unit-tests-gcc/task.yaml create mode 100644 tests/unit/go/task.yaml create mode 100755 tests/unit/shell-traps/set-e-pipe-chain-with-negation.sh create mode 100755 tests/unit/shell-traps/set-e-pipe-chain-with-not.sh create mode 100755 tests/unit/shell-traps/set-e-simple-cmd-with-negation.sh create mode 100755 tests/unit/shell-traps/set-e-simple-cmd-with-not.sh create mode 100644 tests/unit/shell-traps/task.yaml create mode 100644 tests/upgrade/basic/task.yaml create mode 100644 tests/upgrade/selinux-relabel/task.yaml create mode 100644 tests/upgrade/snapd-xdg-open/task.yaml create mode 100644 tests/upgrade/sudoers-conffile-removal/task.yaml create mode 100755 tests/utils/benchmark.sh create mode 100644 testutil/base.go create mode 100644 testutil/containschecker.go create mode 100644 testutil/containschecker_test.go create mode 100644 testutil/dbustest.go create mode 100644 testutil/dbustest_test.go create mode 100644 testutil/errorischecker.go create mode 100644 testutil/errorischecker_test.go create mode 100644 testutil/exec.go create mode 100644 testutil/exec_test.go create mode 100644 testutil/export_test.go create mode 100644 testutil/filecontentchecker.go create mode 100644 testutil/filecontentchecker_test.go create mode 100644 testutil/filepresencechecker.go create mode 100644 testutil/filepresencechecker_test.go create mode 100644 testutil/intcheckers.go create mode 100644 testutil/intcheckers_test.go create mode 100644 testutil/interfacenilchecker.go create mode 100644 testutil/interfacenilchecker_test.go create mode 100644 testutil/jsonchecker.go create mode 100644 testutil/jsonchecker_test.go create mode 100644 testutil/lowlevel.go create mode 100644 testutil/lowlevel_test.go create mode 100644 testutil/mocking_test.go create mode 100644 testutil/paddedchecker.go create mode 100644 testutil/paddedchecker_test.go create mode 100644 testutil/symlinktargetchecker.go create mode 100644 testutil/symlinktargetchecker_test.go create mode 100644 testutil/syscallschecker.go create mode 100644 testutil/syscallschecker_test.go create mode 100644 testutil/testutil_test.go create mode 100644 testutil/timeouts.go create mode 100644 testutil/timeouts_test.go create mode 100644 timeout/timeout.go create mode 100644 timeout/timeout_test.go create mode 100644 timeutil/export_test.go create mode 100644 timeutil/human.go create mode 100644 timeutil/human_test.go create mode 100644 timeutil/schedule.go create mode 100644 timeutil/schedule_test.go create mode 100644 timeutil/synchronized.go create mode 100644 timeutil/synchronized_test.go create mode 100644 timings/export_test.go create mode 100644 timings/helpers.go create mode 100644 timings/state.go create mode 100644 timings/timings.go create mode 100644 timings/timings_test.go create mode 100755 update-pot create mode 100644 usersession/agent/export_test.go create mode 100644 usersession/agent/response.go create mode 100644 usersession/agent/rest_api.go create mode 100644 usersession/agent/rest_api_test.go create mode 100644 usersession/agent/session_agent.go create mode 100644 usersession/agent/session_agent_test.go create mode 100644 usersession/autostart/autostart.go create mode 100644 usersession/autostart/autostart_test.go create mode 100644 usersession/autostart/export_test.go create mode 100644 usersession/client/client.go create mode 100644 usersession/client/client_test.go create mode 100644 usersession/userd/export_test.go create mode 100644 usersession/userd/helpers.go create mode 100644 usersession/userd/launcher.go create mode 100644 usersession/userd/launcher_test.go create mode 100644 usersession/userd/privileged_desktop_launcher.go create mode 100644 usersession/userd/privileged_desktop_launcher_internal_test.go create mode 100644 usersession/userd/privileged_desktop_launcher_test.go create mode 100644 usersession/userd/settings.go create mode 100644 usersession/userd/settings_test.go create mode 100644 usersession/userd/ui/kdialog.go create mode 100644 usersession/userd/ui/kdialog_test.go create mode 100644 usersession/userd/ui/ui.go create mode 100644 usersession/userd/ui/zenity.go create mode 100644 usersession/userd/ui/zenity_test.go create mode 100644 usersession/userd/userd.go create mode 100644 usersession/xdgopenproxy/export_test.go create mode 100644 usersession/xdgopenproxy/portal_launcher.go create mode 100644 usersession/xdgopenproxy/userd_launcher.go create mode 100644 usersession/xdgopenproxy/userd_launcher_test.go create mode 100644 usersession/xdgopenproxy/xdgopenproxy.go create mode 100644 usersession/xdgopenproxy/xdgopenproxy_test.go create mode 100644 wrappers/binaries.go create mode 100644 wrappers/binaries_test.go create mode 100644 wrappers/core18.go create mode 100644 wrappers/core18_test.go create mode 100644 wrappers/dbus.go create mode 100644 wrappers/dbus_test.go create mode 100644 wrappers/desktop.go create mode 100644 wrappers/desktop_test.go create mode 100644 wrappers/export_test.go create mode 100644 wrappers/icons.go create mode 100644 wrappers/icons_test.go create mode 100644 wrappers/internal/export_test.go create mode 100644 wrappers/internal/journal_conf_gen.go create mode 100644 wrappers/internal/service_slice_gen.go create mode 100644 wrappers/internal/service_socket_gen.go create mode 100644 wrappers/internal/service_socket_gen_test.go create mode 100644 wrappers/internal/service_status.go create mode 100644 wrappers/internal/service_status_test.go create mode 100644 wrappers/internal/service_timer_gen.go create mode 100644 wrappers/internal/service_timer_gen_test.go create mode 100644 wrappers/internal/service_unit_gen.go create mode 100644 wrappers/internal/service_unit_gen_test.go create mode 100644 wrappers/services.go create mode 100644 wrappers/services_test.go create mode 100644 x11/xauth.go create mode 100644 x11/xauth_test.go diff --git a/.clang-format b/.clang-format new file mode 100644 index 00000000..8f1d5e19 --- /dev/null +++ b/.clang-format @@ -0,0 +1,4 @@ +BasedOnStyle: Google +IndentWidth: 4 +ColumnLimit: 120 +IncludeBlocks: Preserve diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 00000000..a08c77bd --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,16 @@ +# 18.02.2021: temporarily disabled as labeler keeps removing the labels +# possible related issues: +# - https://github.com/actions/labeler/issues/112 +# - https://github.com/actions/labeler/issues/104 + +# Add 'Run nested -auto-' label to either any change on nested lib or nested test +Run nested -auto-: + - tests/lib/nested.sh + - tests/nested/**/* + +# Add 'Needs Documentation -auto-' label to indicate a change needs changes in the docs +Needs Documentation -auto-: + - cmd/snap/**/*" + - daemon/**/* + - overlord/hookstate/ctlcmd/**/* + - overlord/configstate/configcore/**/* diff --git a/.github/spread-problem-matcher.json b/.github/spread-problem-matcher.json new file mode 100644 index 00000000..87326ee5 --- /dev/null +++ b/.github/spread-problem-matcher.json @@ -0,0 +1,14 @@ +{ + "problemMatcher": [ + { + "owner": "spread-error", + "pattern": [ + { + "regexp": "^\\d+-\\d+-\\d+ \\d+:\\d+:\\d+ ((Error) (preparing|executing|restoring) .+) : .*$", + "message": 1, + "severity": 2 + } + ] + } + ] +} diff --git a/.github/workflows/cla-check.yaml b/.github/workflows/cla-check.yaml new file mode 100644 index 00000000..5c857550 --- /dev/null +++ b/.github/workflows/cla-check.yaml @@ -0,0 +1,16 @@ +name: cla-check +on: + # Only run when a pull request get opened; run in the context of the base + # repository, not the fork so that comments can be posted + pull_request_target: + branches: [ "master", "release/**" ] + +jobs: + cla-check: + runs-on: ubuntu-latest + steps: + - name: Check if CLA signed + uses: canonical/has-signed-canonical-cla@v1 + with: + accept-existing-contributors: true + exempted-bots: 'Launchpad Translations on behalf of snappy-dev,dependabot' diff --git a/.github/workflows/labeler.yaml b/.github/workflows/labeler.yaml new file mode 100644 index 00000000..6d907972 --- /dev/null +++ b/.github/workflows/labeler.yaml @@ -0,0 +1,15 @@ +name: "Pull Request Labeler" +on: + - pull_request_target + +jobs: + triage: + permissions: + contents: read + pull-requests: write + runs-on: ubuntu-latest + steps: + - uses: actions/labeler@v4 + with: + repo-token: "${{ secrets.GITHUB_TOKEN }}" + sync-labels: "true" diff --git a/.github/workflows/macos-quick.yaml b/.github/workflows/macos-quick.yaml new file mode 100644 index 00000000..acbf3157 --- /dev/null +++ b/.github/workflows/macos-quick.yaml @@ -0,0 +1,34 @@ +name: MacOS quick checks +on: + # Only run on pull requests: not pushes + pull_request: + branches: ["master", "release/**"] + +jobs: + macos-quick: + runs-on: macos-latest + steps: + - uses: actions/setup-go@v3 + with: + go-version: "1.18.x" + + - name: Checkout code + uses: actions/checkout@v3 + + - name: Install squashfs from homebrew + run: | + brew install squashfs + + - name: Build quick checks + run: | + go mod vendor + ./mkversion.sh + go build -tags nosecboot -o /tmp/snp ./cmd/snap + + - name: Runtime quick checks + run: | + /tmp/snp download hello + /tmp/snp version + if command -v mksquashfs; then + /tmp/snp pack tests/lib/snaps/test-snapd-tools/ /tmp + fi diff --git a/.github/workflows/naming.yml b/.github/workflows/naming.yml new file mode 100644 index 00000000..63f644fc --- /dev/null +++ b/.github/workflows/naming.yml @@ -0,0 +1,23 @@ +name: Inclusive naming PR check +on: pull_request + +jobs: + inclusive-naming-check: + name: Inclusive-naming-check + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - uses: tj-actions/changed-files@v41.0.0 + id: files + + - name: woke + uses: get-woke/woke-action-reviewdog@v0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + reporter: github-pr-check + fail-on-error: true + woke-args: ${{ steps.files.outputs.all_changed_files }} diff --git a/.github/workflows/nightly.yaml b/.github/workflows/nightly.yaml new file mode 100644 index 00000000..bb6cb50f --- /dev/null +++ b/.github/workflows/nightly.yaml @@ -0,0 +1,94 @@ +name: Nightly static code analysis + +on: + workflow_dispatch: + schedule: + - cron: '30 0 * * *' + +jobs: + + tics: + runs-on: ubuntu-22.04 + env: + GOPATH: ${{ github.workspace }} + # Set PATH to ignore the load of magic binaries from /usr/local/bin and + # to use the go snap automatically. Note that we install go from the + # snap in a step below. Without this we get the GitHub-controlled latest + # version of go. + PATH: /snap/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:${{ github.workspace }}/bin + GOROOT: "" + strategy: + matrix: + gochannel: + - 1.18 + unit-scenario: + - normal + + steps: + - name: Checkout code + uses: actions/checkout@v3 + with: + # needed for git commit history + fetch-depth: 0 + # NOTE: checkout the code in a fixed location, even for forks, as this + # is relevant for go's import system. + path: ./src/github.com/snapcore/snapd + + - name: Download Debian dependencies + run: | + sudo apt clean + sudo apt update + sudo apt build-dep -y "${{ github.workspace }}/src/github.com/snapcore/snapd" + + - name: Install the go snap + run: | + sudo snap install --classic --channel="${{ matrix.gochannel }}" go + + - name: Get deps + run: | + cd "${{ github.workspace }}/src/github.com/snapcore/snapd" + ./get-deps.sh + + - name: Build C + run: | + cd "${{ github.workspace }}/src/github.com/snapcore/snapd/cmd" + ./autogen.sh + make -j$(nproc) + + - name: Build Go + run: | + go build github.com/snapcore/snapd/... + + - name: Test C + run: | + cd "${{ github.workspace }}/src/github.com/snapcore/snapd/cmd" + make check + + - name: Reset code coverage data + run: | + rm -rf "${{ github.workspace }}/src/github.com/snapcore/snapd/.coverage" + + - name: Test Go with coverage + run: | + go install github.com/boumenot/gocover-cobertura@latest + + cd "${{ github.workspace }}/src/github.com/snapcore/snapd" + COVERAGE_OUT=.coverage/coverage.txt ./run-checks --unit + gocover-cobertura < .coverage/coverage.txt > .coverage/coverage.xml + + - name: TICS scan + run: | + set -x + export TICSAUTHTOKEN="${{ secrets.TICSAUTHTOKEN }}" + + # Install the TICS + curl --silent --show-error "https://canonical.tiobe.com/tiobeweb/TICS/api/public/v1/fapi/installtics/Script?cfg=default&platform=linux&url=https://canonical.tiobe.com/tiobeweb/TICS/" > install_tics.sh + . ./install_tics.sh + + TICSQServer -project snapd -tmpdir /tmp/tics -branchdir "${{ github.workspace }}/src/github.com/snapcore/snapd" + + - name: Uploading TICS logs + uses: actions/upload-artifact@v4 + with: + name: tics-logs.tar.gz + path: tics-logs.tar.gz diff --git a/.github/workflows/riscv64-builds.yml b/.github/workflows/riscv64-builds.yml new file mode 100644 index 00000000..aa00758c --- /dev/null +++ b/.github/workflows/riscv64-builds.yml @@ -0,0 +1,19 @@ +name: lp-snap-request-build-riscv64 + +on: + schedule: + - cron: "0 0 * * *" + workflow_dispatch: + +jobs: + lp-snap-request-build: + runs-on: ubuntu-latest + steps: + - name: trigger-lp-snap-request-build + env: + SNAPD_RISCV64_BOT_BASE64: ${{ secrets.SNAPD_RISCV64_BOT_BASE64 }} + if: env.SNAPD_RISCV64_BOT_BASE64 != null + run: | + sudo apt install -y lptools + echo $SNAPD_RISCV64_BOT_BASE64 | base64 --decode > SNAPD_RISCV64_BOT.cred + lp-shell --credentials-file=SNAPD_RISCV64_BOT.cred -c 'snap=lp.load("~snappy-dev-riscv64/+snap/snapd-master-riscv64"); snap.requestBuilds(archive=snap.auto_build_archive_link, channels=snap.auto_build_channels, pocket=snap.auto_build_pocket)' diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 00000000..2b39d136 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,804 @@ +name: Tests +on: + pull_request: + branches: [ "master", "release/**" ] + push: + # we trigger runs on master branch, but we do not run spread on master + # branch, the master branch runs are just for unit tests + codecov.io + branches: [ "master","release/**" ] + + # XXX we suspect that the whenever the labeler workflow executes successfully + # it will trigger another workflow of tests on master, temporarily disable to + # see if that improves the situation + # workflow_run: + # workflows: ["Pull Request Labeler"] + # types: + # - completed + +concurrency: + group: ${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + snap-builds: + runs-on: ubuntu-20.04 + # only build the snap for pull requests, it's not needed on release branches + # or on master since we have launchpad build recipes which do this already + if: ${{ github.event_name == 'pull_request' }} + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Build snapd snap + uses: snapcore/action-build@v1 + with: + snapcraft-channel: 4.x/candidate + + - name: Check built artifact + run: | + unsquashfs snapd*.snap meta/snap.yaml usr/lib/snapd/ + if cat squashfs-root/meta/snap.yaml | grep -q "version:.*dirty.*"; then + echo "PR produces dirty snapd snap version" + cat squashfs-root/usr/lib/snapd/dirty-git-tree-info.txt + exit 1 + elif cat squashfs-root/usr/lib/snapd/info | grep -q "VERSION=.*dirty.*"; then + echo "PR produces dirty internal snapd info version" + cat squashfs-root/usr/lib/snapd/info + cat squashfs-root/usr/lib/snapd/dirty-git-tree-info.txt + exit 1 + fi + + - name: Uploading snapd snap artifact + uses: actions/upload-artifact@v3 + with: + name: snap-files + path: "*.snap" + + cache-build-deps: + runs-on: ubuntu-20.04 + steps: + - name: Checkout code + uses: actions/checkout@v3 + with: + # needed for git commit history + fetch-depth: 0 + # NOTE: checkout the code in a fixed location, even for forks, as this + # is relevant for go's import system. + path: ./src/github.com/snapcore/snapd + + # Fetch base ref, needed for golangci-lint + - name: Fetching base ref ${{ github.base_ref }} + run: | + cd ${{ github.workspace }}/src/github.com/snapcore/snapd + git fetch origin ${{ github.base_ref }}:${{ github.base_ref }} + # golang latest ensures things work on the edge + + - name: Download Debian dependencies + run: | + sudo apt clean + sudo apt update + sudo apt build-dep -d -y ${{ github.workspace }}/src/github.com/snapcore/snapd + # for indent + sudo apt install texinfo autopoint + + - name: Copy dependencies + run: | + sudo tar cvf cached-apt.tar /var/cache/apt + + - name: upload Debian dependencies + uses: actions/upload-artifact@v3 + with: + name: debian-dependencies + path: ./cached-apt.tar + + static-checks: + runs-on: ubuntu-latest + needs: [cache-build-deps] + env: + GOPATH: ${{ github.workspace }} + # Set PATH to ignore the load of magic binaries from /usr/local/bin And + # to use the go snap automatically. Note that we install go from the + # snap in a step below. Without this we get the GitHub-controlled latest + # version of go. + PATH: /snap/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:${{ github.workspace }}/bin + GOROOT: "" + GITHUB_PULL_REQUEST: ${{ github.event.number }} + + strategy: + # we cache successful runs so it's fine to keep going + fail-fast: false + matrix: + gochannel: + - 1.18 + - latest/stable + + steps: + - name: Checkout code + uses: actions/checkout@v3 + with: + # needed for git commit history + fetch-depth: 0 + # NOTE: checkout the code in a fixed location, even for forks, as this + # is relevant for go's import system. + path: ./src/github.com/snapcore/snapd + + # Fetch base ref, needed for golangci-lint + - name: Fetching base ref ${{ github.base_ref }} + run: | + cd ${{ github.workspace }}/src/github.com/snapcore/snapd + git fetch origin ${{ github.base_ref }}:${{ github.base_ref }} + + - name: Download Debian dependencies + uses: actions/download-artifact@v3 + with: + name: debian-dependencies + path: ./debian-deps/ + + - name: Copy dependencies + run: | + test -f ./debian-deps/cached-apt.tar + sudo tar xvf ./debian-deps/cached-apt.tar -C / + + - name: Install Debian dependencies + run: | + sudo apt update + sudo apt build-dep -y ${{ github.workspace }}/src/github.com/snapcore/snapd + + # golang latest ensures things work on the edge + - name: Install the go snap + run: | + sudo snap install --classic --channel=${{ matrix.gochannel }} go + + - name: Install ShellCheck as a snap + run: | + sudo apt-get remove --purge shellcheck + sudo snap install shellcheck + + - name: Get C vendoring + run: | + cd ${{ github.workspace }}/src/github.com/snapcore/snapd/c-vendor && ./vendor.sh + + - name: golangci-lint + if: ${{ matrix.gochannel == 'latest/stable' }} + uses: golangci/golangci-lint-action@v3 + with: + # version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` + # to use the latest version + version: v1.55.2 + working-directory: ./src/github.com/snapcore/snapd + # show only new issues + # use empty path prefix to make annotations work + args: --new-from-rev=${{ github.base_ref }} --path-prefix= + # skip all additional steps + skip-pkg-cache: true + skip-build-cache: true + # XXX: does no work with working-directory + # only-new-issues: true + + - name: Get changed files + id: changed-files + uses: tj-actions/changed-files@v41.0.0 + with: + path: ./src/github.com/snapcore/snapd + + - name: Save changes files + run: | + CHANGED_FILES="${{ steps.changed-files.outputs.all_changed_files }}" + echo "CHANGED_FILES=$CHANGED_FILES" >> $GITHUB_ENV + echo "The changed files found are: $CHANGED_FILES" + + - name: Run static checks + run: | + cd ${{ github.workspace }}/src/github.com/snapcore/snapd || exit 1 + # run gofmt checks only with Go 1.18 + if [ "${{ matrix.gochannel }}" != "1.18" ]; then + export SKIP_GOFMT=1 + echo "Formatting checks will be skipped due to the use of Go version ${{ matrix.gochannel }}" + fi + sudo apt-get install -y python3-yamlordereddictloader + ./run-checks --static + + - name: Cache prebuilt indent + id: cache-indent-bin + uses: actions/cache@v3 + with: + path: indent-bin + key: ${{ runner.os }}-indent-2.2.13 + + # build indent 2.2.13 which has this patch + # https://git.savannah.gnu.org/cgit/indent.git/commit/?id=22b83d68e9a8b429590f42920e9f473a236123cf + - name: Build indent 2.2.13 + if: steps.cache-indent-bin.outputs.cache-hit != 'true' + run: | + sudo apt install texinfo autopoint + curl -O https://ftp.gnu.org/gnu/indent/indent-2.2.13.tar.xz + tar xvf indent-2.2.13.tar.xz + cd indent-2.2.13 + autoreconf -if + # set prefix in case we want to pack to tar/extract into system + ./configure --prefix=/opt/indent + make -j + make install DESTDIR=${{ github.workspace }}/indent-bin + find ${{ github.workspace }}/indent-bin -ls + + - name: Check C source code formatting + run: | + set -x + cd ${{ github.workspace }}/src/github.com/snapcore/snapd/cmd/ + ./autogen.sh + # apply formatting + PATH=${{ github.workspace }}/indent-bin/opt/indent/bin:$PATH make fmt + set +x + if [ -n "$(git diff --stat)" ]; then + git diff + echo "C files are not fomratted correctly, run 'make fmt'" + echo "make sure to have clang-format and indent 2.2.13+ installed" + exit 1 + fi + + branch-static-checks: + runs-on: ubuntu-latest + needs: [cache-build-deps] + if: github.ref != 'refs/heads/master' + steps: + + - name: Checkout code + uses: actions/checkout@v3 + with: + # needed for git commit history + fetch-depth: 0 + + - name: check-branch-ubuntu-daily-spread + run: | + # Compare the daily system in master and in the current branch + wget -q -O test_master.yaml https://raw.githubusercontent.com/snapcore/snapd/master/.github/workflows/test.yaml + system_daily="$(yq '.jobs.spread.strategy.matrix.include.[] | select(.group == "ubuntu-daily") | .systems' test_master.yaml)" + current_daily="$(yq '.jobs.spread.strategy.matrix.include.[] | select(.group == "ubuntu-daily") | .systems' .github/workflows/test.yaml)" + test "$system_daily" == "$current_daily" + shell: bash + + unit-tests: + needs: [static-checks] + runs-on: ubuntu-22.04 + env: + GOPATH: ${{ github.workspace }} + # Set PATH to ignore the load of magic binaries from /usr/local/bin And + # to use the go snap automatically. Note that we install go from the + # snap in a step below. Without this we get the GitHub-controlled latest + # version of go. + PATH: /snap/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:${{ github.workspace }}/bin + GOROOT: "" + GITHUB_PULL_REQUEST: ${{ github.event.number }} + strategy: + # we cache successful runs so it's fine to keep going + fail-fast: false + matrix: + gochannel: + - 1.18 + - latest/stable + unit-scenario: + - normal + - snapd_debug + - withbootassetstesting + - nosecboot + - faultinject + + steps: + - name: Checkout code + uses: actions/checkout@v3 + with: + # needed for git commit history + fetch-depth: 0 + # NOTE: checkout the code in a fixed location, even for forks, as this + # is relevant for go's import system. + path: ./src/github.com/snapcore/snapd + + # Fetch base ref, needed for golangci-lint + - name: Fetching base ref ${{ github.base_ref }} + run: | + cd ${{ github.workspace }}/src/github.com/snapcore/snapd + git fetch origin ${{ github.base_ref }}:${{ github.base_ref }} + + - name: Download Debian dependencies + uses: actions/download-artifact@v3 + with: + name: debian-dependencies + path: ./debian-deps/ + + - name: Copy dependencies + run: | + test -f ./debian-deps/cached-apt.tar + sudo tar xvf ./debian-deps/cached-apt.tar -C / + + - name: Install Debian dependencies + run: | + sudo apt update + sudo apt build-dep -y ${{ github.workspace }}/src/github.com/snapcore/snapd + + # golang latest ensures things work on the edge + - name: Install the go snap + run: | + sudo snap install --classic --channel=${{ matrix.gochannel }} go + + - name: Get deps + run: | + cd ${{ github.workspace }}/src/github.com/snapcore/snapd/ && ./get-deps.sh + + - name: Build C + run: | + cd ${{ github.workspace }}/src/github.com/snapcore/snapd/cmd/ + ./autogen.sh + make -j$(nproc) + + - name: Build Go + run: | + go build github.com/snapcore/snapd/... + + - name: Test C + run: | + cd ${{ github.workspace }}/src/github.com/snapcore/snapd/cmd/ && make check + + - name: Reset code coverage data + run: | + rm -rf ${{ github.workspace }}/.coverage/ + + - name: Test Go + if: ${{ matrix.unit-scenario == 'normal' }} + run: | + cd ${{ github.workspace }}/src/github.com/snapcore/snapd || exit 1 + ./run-checks --unit + + - name: Test Go (SNAPD_DEBUG=1) + if: ${{ matrix.unit-scenario == 'snapd_debug' }} + run: | + cd ${{ github.workspace }}/src/github.com/snapcore/snapd || exit 1 + SKIP_DIRTY_CHECK=1 SNAPD_DEBUG=1 ./run-checks --unit + + - name: Test Go (withbootassetstesting) + if: ${{ matrix.unit-scenario == 'withbootassetstesting' }} + run: | + cd ${{ github.workspace }}/src/github.com/snapcore/snapd || exit 1 + SKIP_DIRTY_CHECK=1 GO_BUILD_TAGS=withbootassetstesting ./run-checks --unit + + - name: Test Go (nosecboot) + if: ${{ matrix.unit-scenario == 'nosecboot' }} + run: | + cd ${{ github.workspace }}/src/github.com/snapcore/snapd || exit 1 + echo "Dropping github.com/snapcore/secboot" + # use govendor remove so that a subsequent govendor sync does not + # install secboot again + # ${{ github.workspace }}/bin/govendor remove github.com/snapcore/secboot + # ${{ github.workspace }}/bin/govendor remove +unused + SKIP_DIRTY_CHECK=1 GO_BUILD_TAGS=nosecboot ./run-checks --unit + + - name: Test Go (faultinject) + if: ${{ matrix.unit-scenario == 'faultinject' }} + run: | + cd ${{ github.workspace }}/src/github.com/snapcore/snapd || exit 1 + SKIP_DIRTY_CHECK=1 GO_BUILD_TAGS=faultinject ./run-checks --unit + + - name: Upload the coverage results + if: ${{ matrix.gochannel != 'latest/stable' }} + uses: actions/upload-artifact@v3 + with: + name: coverage-files + path: "${{ github.workspace }}/src/github.com/snapcore/snapd/.coverage/coverage*.cov" + + code-coverage: + needs: [unit-tests] + runs-on: ubuntu-20.04 + env: + GOPATH: ${{ github.workspace }} + # Set PATH to ignore the load of magic binaries from /usr/local/bin And + # to use the go snap automatically. Note that we install go from the + # snap in a step below. Without this we get the GitHub-controlled latest + # version of go. + PATH: /snap/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:${{ github.workspace }}/bin + GOROOT: "" + GITHUB_PULL_REQUEST: ${{ github.event.number }} + steps: + - name: Download the coverage files + uses: actions/download-artifact@v3 + with: + name: coverage-files + path: .coverage/ + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + # uploading to codecov occasionally fails, so continue running the test + # workflow regardless of the upload + continue-on-error: true + with: + fail_ci_if_error: true + flags: unittests + name: codecov-umbrella + files: .coverage/coverage-*.cov + verbose: true + + spread: + needs: [unit-tests] + # have spread jobs run on master on PRs only, but on both PRs and pushes to + # release branches + if: ${{ github.event_name != 'push' || github.ref != 'refs/heads/master' }} + name: ${{ matrix.group }} + runs-on: self-hosted + strategy: + # FIXME: enable fail-fast mode once spread can cancel an executing job. + # Disable fail-fast mode as it doesn't function with spread. It seems + # that cancelling tasks requires short, interruptible actions and + # interrupting spread, notably, does not work today. As such disable + # fail-fast while we tackle that problem upstream. + fail-fast: false + matrix: + include: + - group: amazon-linux + backend: google-central + systems: 'amazon-linux-2-64 amazon-linux-2023-64' + - group: arch-linux + backend: google-central + systems: 'arch-linux-64' + - group: centos + backend: google-central + systems: 'centos-7-64 centos-8-64 centos-9-64' + - group: debian-req + backend: google-central + systems: 'debian-11-64' + - group: debian-no-req + backend: google-central + systems: 'debian-12-64 debian-sid-64' + - group: fedora + backend: google-central + systems: 'fedora-38-64 fedora-39-64' + - group: opensuse + backend: google-central + systems: 'opensuse-15.5-64 opensuse-tumbleweed-64' + - group: ubuntu-trusty-xenial + backend: google + systems: 'ubuntu-14.04-64 ubuntu-16.04-64' + - group: ubuntu-bionic + backend: google + systems: 'ubuntu-18.04-32 ubuntu-18.04-64' + - group: ubuntu-focal-jammy + backend: google + systems: 'ubuntu-20.04-64 ubuntu-22.04-64' + - group: ubuntu-no-lts + backend: google + systems: 'ubuntu-23.10-64' + - group: ubuntu-daily + backend: google + systems: 'ubuntu-24.04-64' + - group: ubuntu-core-16 + backend: google + systems: 'ubuntu-core-16-64' + - group: ubuntu-core-18 + backend: google + systems: 'ubuntu-core-18-64' + - group: ubuntu-core-20 + backend: google + systems: 'ubuntu-core-20-64' + - group: ubuntu-core-22 + backend: google + systems: 'ubuntu-core-22-64' + - group: ubuntu-core-24 + backend: google + systems: 'ubuntu-core-24-64' + - group: ubuntu-arm + backend: google-arm + systems: 'ubuntu-20.04-arm-64 ubuntu-core-22-arm-64' + - group: ubuntu-secboot + backend: google + systems: 'ubuntu-secboot-20.04-64' + steps: + - name: Cleanup job workspace + id: cleanup-job-workspace + run: | + rm -rf "${{ github.workspace }}" + mkdir "${{ github.workspace }}" + + - name: Checkout code + uses: actions/checkout@v3 + with: + # spread uses tags as delta reference + fetch-depth: 0 + + - name: Get previous attempt + id: get-previous-attempt + run: | + echo "previous_attempt=$(( ${{ github.run_attempt }} - 1 ))" >> $GITHUB_OUTPUT + shell: bash + + - name: Get previous cache + uses: actions/cache@v3 + with: + path: "${{ github.workspace }}/.test-results" + key: "${{ github.job }}-results-${{ github.run_id }}-${{ matrix.group }}-${{ steps.get-previous-attempt.outputs.previous_attempt }}" + + - name: Prepare test results env and vars + id: prepare-test-results-env + run: | + # Create test results directories and save vars + TEST_RESULTS_DIR="${{ github.workspace }}/.test-results" + echo "TEST_RESULTS_DIR=$TEST_RESULTS_DIR" >> $GITHUB_ENV + + # Save the var with the failed tests file + echo "FAILED_TESTS_FILE=$TEST_RESULTS_DIR/failed-tests" >> $GITHUB_ENV + + # Make sure the test results dirs are created + # This step has to be after the cache is restored + mkdir -p "$TEST_RESULTS_DIR" + + - name: Check failed tests to run + if: "!contains(github.event.pull_request.labels.*.name, 'Run all')" + run: | + # Save previous failed test results in FAILED_TESTS env var + FAILED_TESTS="" + if [ -f "$FAILED_TESTS_FILE" ]; then + echo "Failed tests file found" + FAILED_TESTS="$(cat $FAILED_TESTS_FILE)" + if [ -n "$FAILED_TESTS" ]; then + echo "Failed tests to run: $FAILED_TESTS" + echo "FAILED_TESTS=$FAILED_TESTS" >> $GITHUB_ENV + fi + fi + + - name: Run spread tests + if: "!contains(github.event.pull_request.labels.*.name, 'Skip spread')" + env: + SPREAD_GOOGLE_KEY: ${{ secrets.SPREAD_GOOGLE_KEY }} + run: | + # Register a problem matcher to highlight spread failures + echo "::add-matcher::.github/spread-problem-matcher.json" + + set -x + + SPREAD=spread + if [[ "${{ matrix.systems }}" =~ -arm- ]]; then + SPREAD=spread-arm + fi + + if [[ "${{ matrix.systems }}" =~ amazon-linux-2023 ]]; then + # Amazon Linux 2023 has no xdelta, however we cannot disable + # xdelta on a per-target basis as it's used in the repack section + # of spread.yaml, which is shared by all targets, so all systems + # in this batch will not use delta for transferring project data + echo "Disabling xdelta support" + export NO_DELTA=1 + fi + + RUN_TESTS="" + # Save previous failed test results in FAILED_TESTS env var + if [ -n "$FAILED_TESTS" ]; then + RUN_TESTS="$FAILED_TESTS" + else + for SYSTEM in ${{ matrix.systems }}; do + RUN_TESTS="$RUN_TESTS ${{ matrix.backend }}:$SYSTEM:tests/..." + done + fi + # Run spread tests + # "pipefail" ensures that a non-zero status from the spread is + # propagated; and we use a subshell as this option could trigger + # undesired changes elsewhere + echo "Running command: $SPREAD $RUN_TESTS" + (set -o pipefail; $SPREAD $RUN_TESTS | tee spread.log) + + - name: Discard spread workers + if: always() + run: | + shopt -s nullglob; + for r in .spread-reuse.*.yaml; do + spread -discard -reuse-pid="$(echo "$r" | grep -o -E '[0-9]+')"; + done + + - name: report spread errors + if: always() + run: | + if [ -e spread.log ]; then + echo "Running spread log analyzer" + issues_metadata='{"source_url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"}' + ./tests/lib/external/snapd-testing-tools/utils/log-parser spread.log --print-detail error-debug --output spread-results.json --cut 100 + while IFS= read -r line; do + if [ ! -z "$line" ]; then + echo "Reporting spread error..." + ./tests/lib/tools/report-mongodb --db-name snapd --db-collection spread_errors --metadata "$issues_metadata" "$line" + fi + done <<< $(jq -cr '.[] | select( .type == "info") | select( (.info_type == "Error") or (.info_type == "Debug"))' spread-results.json) + else + echo "No spread log found, skipping errors reporting" + fi + + - name: analyze spread test results + if: always() + run: | + if [ -f spread.log ]; then + echo "Running spread log parser" + ./tests/lib/external/snapd-testing-tools/utils/log-parser spread.log --output spread-results.json + + echo "Determining which tests were executed" + RUN_TESTS="" + for SYSTEM in ${{ matrix.systems }}; do + RUN_TESTS="$RUN_TESTS ${{ matrix.backend }}:$SYSTEM:tests/..." + done + if [ -n "$FAILED_TESTS" ]; then + RUN_TESTS="$FAILED_TESTS" + fi + + echo "Running spread log analyzer" + ./tests/lib/external/snapd-testing-tools/utils/log-analyzer list-reexecute-tasks "$RUN_TESTS" spread-results.json > "$FAILED_TESTS_FILE" + + echo "List of failed tests saved" + cat "$FAILED_TESTS_FILE" + else + echo "No spread log found, saving empty list of failed tests" + touch "$FAILED_TESTS_FILE" + fi + + - name: save spread test results to cache + if: always() + uses: actions/cache/save@v3 + with: + path: "${{ github.workspace }}/.test-results" + key: "${{ github.job }}-results-${{ github.run_id }}-${{ matrix.group }}-${{ github.run_attempt }}" + + spread-nested: + needs: [unit-tests] + # have spread jobs run on master on PRs only, but on both PRs and pushes to + # release branches + if: ${{ github.event_name != 'push' || github.ref != 'refs/heads/master' }} + runs-on: self-hosted + strategy: + # FIXME: enable fail-fast mode once spread can cancel an executing job. + # Disable fail-fast mode as it doesn't function with spread. It seems + # that cancelling tasks requires short, interruptible actions and + # interrupting spread, notably, does not work today. As such disable + # fail-fast while we tackle that problem upstream. + fail-fast: false + matrix: + system: + - ubuntu-16.04-64 + - ubuntu-18.04-64 + - ubuntu-20.04-64 + - ubuntu-22.04-64 + steps: + - name: Cleanup job workspace + id: cleanup-job-workspace + run: | + rm -rf "${{ github.workspace }}" + mkdir "${{ github.workspace }}" + + - name: Checkout code + uses: actions/checkout@v3 + + - name: Get previous attempt + id: get-previous-attempt + run: | + echo "previous_attempt=$(( ${{ github.run_attempt }} - 1 ))" >> $GITHUB_OUTPUT + shell: bash + + - name: Get previous cache + uses: actions/cache@v3 + with: + path: "${{ github.workspace }}/.test-results" + key: "${{ github.job }}-results-${{ github.run_id }}-${{ matrix.system }}-${{ steps.get-previous-attempt.outputs.previous_attempt }}" + + - name: Prepare test results env and vars + id: prepare-test-results-env + run: | + # Create test results directories and save vars + TEST_RESULTS_DIR="${{ github.workspace }}/.test-results" + echo "TEST_RESULTS_DIR=$TEST_RESULTS_DIR" >> $GITHUB_ENV + + # Save the var with the failed tests file + echo "FAILED_TESTS_FILE=$TEST_RESULTS_DIR/failed-tests" >> $GITHUB_ENV + + # Make sure the test results dirs are created + # This step has to be after the cache is restored + mkdir -p "$TEST_RESULTS_DIR" + + - name: Check failed tests to run + if: "!contains(github.event.pull_request.labels.*.name, 'Run all')" + run: | + # Save previous failed test results in FAILED_TESTS env var + FAILED_TESTS="" + if [ -f "$FAILED_TESTS_FILE" ]; then + echo "Failed tests file found" + FAILED_TESTS="$(cat $FAILED_TESTS_FILE)" + if [ -n "$FAILED_TESTS" ]; then + echo "Failed tests to run: $FAILED_TESTS" + echo "FAILED_TESTS=$FAILED_TESTS" >> $GITHUB_ENV + fi + fi + + - name: Collect PR labels + id: collect-pr-labels + env: + GH_TOKEN: ${{ github.token }} + run: | + LABELS="$(gh api -H 'Accept: application/vnd.github+json' /repos/snapcore/snapd/issues/${{ github.event.pull_request.number }}/labels | jq '[.[].name] | join(",")')" + echo "labels=$LABELS" >> $GITHUB_OUTPUT + echo "Collected labels: $LABELS" + shell: bash + + - name: Run spread tests + # run if the commit is pushed to the release/* branch or there is a 'Run + # nested' label set on the PR + if: "contains(steps.collect-pr-labels.outputs.labels, 'Run nested') || contains(github.ref, 'refs/heads/release/')" + env: + SPREAD_GOOGLE_KEY: ${{ secrets.SPREAD_GOOGLE_KEY }} + run: | + # Register a problem matcher to highlight spread failures + echo "::add-matcher::.github/spread-problem-matcher.json" + export NESTED_BUILD_SNAPD_FROM_CURRENT=true + export NESTED_ENABLE_KVM=true + + BACKEND=google-nested + SPREAD=spread + if [[ "${{ matrix.system }}" =~ -arm- ]]; then + BACKEND=google-nested-arm + SPREAD=spread-arm + fi + + RUN_TESTS="$BACKEND:${{ matrix.system }}:tests/nested/..." + if [ -n "$FAILED_TESTS" ]; then + RUN_TESTS="$FAILED_TESTS" + fi + + # Run spread tests + # "pipefail" ensures that a non-zero status from the spread is + # propagated; and we use a subshell as this option could trigger + # undesired changes elsewhere + (set -o pipefail; spread $RUN_TESTS | tee spread.log) + + - name: Discard spread workers + if: always() + run: | + shopt -s nullglob; + for r in .spread-reuse.*.yaml; do + spread -discard -reuse-pid="$(echo "$r" | grep -o -E '[0-9]+')"; + done + + - name: report spread errors + if: always() + run: | + if [ -e spread.log ]; then + echo "Running spread log analyzer" + issues_metadata='{"source_url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"}' + ./tests/lib/external/snapd-testing-tools/utils/log-parser spread.log --print-detail error --output spread-results.json --cut 100 + while IFS= read -r line; do + if [ ! -z "$line" ]; then + echo "Reporting spread error..." + ./tests/lib/tools/report-mongodb --db-name snapd --db-collection spread_errors --metadata "$issues_metadata" "$line" + fi + done <<< $(jq -cr '.[] | select( .type == "info") | select( .info_type == "Error")' spread-results.json) + else + echo "No spread log found, skipping errors reporting" + fi + + - name: analyze spread test results + if: always() + run: | + if [ -f spread.log ]; then + echo "Running spread log parser" + ./tests/lib/external/snapd-testing-tools/utils/log-parser spread.log --output spread-results.json + + echo "Determining which tests were executed" + RUN_TESTS="google-nested:${{ matrix.system }}:tests/nested/..." + if [ -n "$FAILED_TESTS" ]; then + RUN_TESTS="$FAILED_TESTS" + fi + + echo "Running spread log analyzer" + ./tests/lib/external/snapd-testing-tools/utils/log-analyzer list-reexecute-tasks "$RUN_TESTS" spread-results.json > "$FAILED_TESTS_FILE" + + echo "List of failed tests saved" + cat "$FAILED_TESTS_FILE" + else + echo "No spread log found, saving empty list of failed tests" + touch "$FAILED_TESTS_FILE" + fi + + - name: save spread test results to cache + if: always() + uses: actions/cache/save@v3 + with: + path: "${{ github.workspace }}/.test-results" + key: "${{ github.job }}-results-${{ github.run_id }}-${{ matrix.system }}-${{ github.run_attempt }}" diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..4d8b34ad --- /dev/null +++ b/.gitignore @@ -0,0 +1,94 @@ +./share +tags +.coverage +snapdtool/version_generated.go +cmd/version_generated.go +cmd/VERSION +*~ +*.swp +.vscode/ +.idea/ +vendor/*/ +vendor/modules.txt +c-vendor/*/ +.spread-reuse*.yaml +po/snappy.pot + +# snap-confine bits +*.a +*.o +*~ +.*.swp +.*.swp +.dirstamp +# Locally built binaries +cmd/decode-mount-opts/decode-mount-opts +cmd/libsnap-confine-private/unit-tests +cmd/snap-confine/snap-confine +cmd/snap-confine/snap-confine-debug +cmd/snap-confine/snap-confine.apparmor +cmd/snap-confine/unit-tests +cmd/snap-device-helper/snap-device-helper +cmd/snap-device-helper/unit-tests +cmd/snap-discard-ns/snap-discard-ns +cmd/snap-gdb-shim/snap-gdb-shim +cmd/snap-gdb-shim/snap-gdbserver-shim +cmd/snap-mgmt/snap-mgmt +cmd/snap-seccomp/snap-seccomp +cmd/snap-update-ns/snap-update-ns +cmd/snap-update-ns/unit-tests +cmd/snapd-apparmor/snapd-apparmor +cmd/snapd-env-generator/snapd-env-generator +cmd/snapd-generator/snapd-generator +cmd/system-shutdown/system-shutdown +cmd/system-shutdown/unit-tests +cmd/snap/snap +cmd/snapd/snapd + +# manual pages +cmd/*/*.[1-9] + +# auto-generated systemd units +data/systemd/*.service + +# auto-generated dbus services +data/dbus/*.service + +data/info +data/env/snapd.sh + +# test-driver +*.log +*.trs + +# Automake for the cmd/ parts +cmd/Makefile +cmd/Makefile.in +snap-confine-*.tar.gz +.deps + +# Autoconf +cmd/aclocal.m4 +cmd/autom4te.cache +cmd/compile +cmd/config.guess +cmd/config.h +cmd/config.h.in +cmd/config.status +cmd/config.sub +cmd/configure +cmd/depcomp +cmd/install-sh +cmd/missing +cmd/stamp-h1 +cmd/test-driver + +# Mypy +.mypy +.mypy_cache + +# Snap files +*.snap + +# Image files +*.img diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 00000000..33ff97d4 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,323 @@ +run: + # default concurrency is a available CPU number + concurrency: 4 + + # timeout for analysis, e.g. 30s, 5m, default is 1m + timeout: 5m + + # exit code when at least one issue was found, default is 1 + # issues-exit-code: 1 + + # include test files or not, default is true + tests: true + + # list of build tags, all linters use it. Default is empty list. + #build-tags: + # - mytag + + # which dirs to skip: issues from them won't be reported; + # can use regexp here: generated.*, regexp is applied on full path; + # default value is empty list, but default dirs are skipped independently + # from this option's value (see skip-dirs-use-default). + # "/" will be replaced by current OS file path separator to properly work + # on Windows. + #skip-dirs: + # - src/external_libs + # - autogenerated_by_my_lib + + # default is true. Enables skipping of directories: + # vendor$, third_party$, testdata$, examples$, Godeps$, builtin$ + skip-dirs-use-default: true + + # which files to skip: they will be analyzed, but issues from them + # won't be reported. Default value is empty list, but there is + # no need to include all autogenerated files, we confidently recognize + # autogenerated files. If it's not please let us know. + # "/" will be replaced by current OS file path separator to properly work + # on Windows. + # skip-files: + # - export_test.go + + # by default isn't set. If set we pass it to "go list -mod={option}". From "go help modules": + # If invoked with -mod=readonly, the go command is disallowed from the implicit + # automatic updating of go.mod described above. Instead, it fails when any changes + # to go.mod are needed. This setting is most useful to check that go.mod does + # not need updates, such as in a continuous integration and testing system. + # If invoked with -mod=vendor, the go command assumes that the vendor + # directory holds the correct copies of dependencies and ignores + # the dependency descriptions in go.mod. + # modules-download-mode: readonly|release|vendor + + # Allow multiple parallel golangci-lint instances running. + # If false (default) - golangci-lint acquires file lock on start. + allow-parallel-runners: false + +# output configuration options +output: + # colored-line-number|line-number|json|tab|checkstyle|code-climate, default is "colored-line-number" + # format: colored-line-number + + # print lines of code with issue, default is true + # print-issued-lines: true + + # print linter name in the end of issue text, default is true + # print-linter-name: true + + # make issues output unique by line, default is true + # uniq-by-line: true + + # add a prefix to the output file references; default is no prefix + # path-prefix: "" + +# all available settings of specific linters +linters-settings: + errcheck: + # report about not checking of errors in type assertions: `a := b.(MyStruct)`; + # default is false: such cases aren't reported by default. + check-type-assertions: false + + # report about assignment of errors to blank identifier: `num, _ := strconv.Atoi(numStr)`; + # default is false: such cases aren't reported by default. + check-blank: false + + # [deprecated] comma-separated list of pairs of the form pkg:regex + # the regex is used to ignore names within pkg. (default "fmt:.*"). + # see https://github.com/kisielk/errcheck#the-deprecated-method for details + ignore: fmt:.* + + # path to a file containing a list of functions to exclude from checking + # see https://github.com/kisielk/errcheck#excluding-functions for details + # exclude: /path/to/file.txt + + gci: + # put imports beginning with prefix after 3rd-party packages; + # only support one prefix + # if not set, use goimports.local-prefixes + local-prefixes: github.com/snapcore/snapd + govet: + # report about shadowed variables + check-shadowing: false + golint: + # minimal confidence for issues, default is 0.8 + min-confidence: 0.8 + gofmt: + # simplify code: gofmt with `-s` option, true by default + simplify: true + goimports: + # put imports beginning with prefix after 3rd-party packages; + # it's a comma-separated list of prefixes + local-prefixes: github.com/snapcore/snapd + gocyclo: + # minimal code complexity to report, 30 by default (but we recommend 10-20) + min-complexity: 10 + maligned: + # print struct with more effective memory layout or not, false by default + suggest-new: true + dupl: + # tokens count to trigger issue, 150 by default + threshold: 100 + goconst: + # minimal length of string constant, 3 by default + min-len: 3 + # minimal occurrences count to trigger, 3 by default + min-occurrences: 3 + # depguard: + # list-type: blacklist + # include-go-root: false + # packages: + # - github.com/davecgh/go-spew/spew + misspell: + # Correct spellings using locale preferences for US or UK. + # Default is to use a neutral variety of English. + # Setting locale to US will correct the British spelling of 'colour' to 'color'. + #locale: US + ignore-words: + - someword + - auther + - PROCES + - PROCESSS + - proces + - processs + - exportfs + lll: + # max line length, lines longer will be reported. Default is 120. + # '\t' is counted as 1 character by default, and can be changed with the tab-width option + line-length: 120 + # tab width in spaces. Default to 1. + tab-width: 1 + unused: + # treat code as a program (not a library) and report unused exported identifiers; default is false. + # XXX: if you enable this setting, unused will report a lot of false-positives in text editors: + # if it's called for subdir of a project it can't find funcs usages. All text editor integrations + # with golangci-lint call it on a directory with the changed file. + check-exported: false + unparam: + # Inspect exported functions, default is false. Set to true if no external program/library imports your code. + # XXX: if you enable this setting, unparam will report a lot of false-positives in text editors: + # if it's called for subdir of a project it can't find external interfaces. All text editor integrations + # with golangci-lint call it on a directory with the changed file. + check-exported: false + nakedret: + # make an issue if func has more lines of code than this setting and it has naked returns; default is 30 + max-func-lines: 5 + prealloc: + # XXX: we don't recommend using this linter before doing performance profiling. + # For most programs usage of prealloc will be a premature optimization. + + # Report preallocation suggestions only on simple loops that have no returns/breaks/continues/gotos in them. + # True by default. + simple: true + range-loops: true # Report preallocation suggestions on range loops, true by default + for-loops: false # Report preallocation suggestions on for loops, false by default + +linters: + # enable the following linters + enable: + - govet + - misspell + - unused + # gosimple may suggest patterns that work only with more recent Go versions + # - gosimple + - nakedret + # formatting is disabled until we move to Go 1.13 + # - gofmt + - ineffassign + # disabling until https://github.com/daixiang0/gci/issues/54 is fixed + # - gci + - testpackage + # disable everything else + disable-all: true + + +issues: + # List of regexps of issue texts to exclude, empty list by default. + # But independently from this option we use default exclude patterns, + # it can be disabled by `exclude-use-default: false`. To list all + # excluded by default patterns execute `golangci-lint run --help` + # exclude: + # - abcdef + + # Excluding configuration per-path, per-linter, per-text and per-source + exclude-rules: + # Exclude some linters from running on tests files. + - path: _test\.go + linters: + - gocyclo + - errcheck + - dupl + - gosec + + - path: export.*_test\.go + linters: + - deadcode + - testpackage + + - path: osutil/.*_test.go + linters: + - testpackage + + # an external dependency that was imported + - path: udev/netlink/.*.go + linters: + - testpackage + - nakedret + + - path: udev/crawler/.*.go + linters: + - testpackage + - nakedret + + # test files that do live in corresponding xxx_test packages, they will + # need over time to be moved to *_internal_test.go or fixed + - path: arch/arch_test.go + linters: + - testpackage + - path: asserts/findwildcard_test.go + linters: + - testpackage + - path: cmd/snapctl/main_test.go + linters: + - testpackage + - path: cmd/snap-update-ns/sorting_test.go + linters: + - testpackage + - path: daemon/daemon_test.go + linters: + - testpackage + - path: daemon/ucrednet_test.go + linters: + - testpackage + - path: i18n/i18n_test.go + linters: + - testpackage + - path: i18n/xgettext-go/main_test.go + linters: + - testpackage + - path: interfaces/hotplug/deviceinfo_test.go + linters: + - testpackage + - path: interfaces/hotplug/proposed_slot_test.go + linters: + - testpackage + - path: interfaces/hotplug/udevadm_test.go + linters: + - testpackage + - path: overlord/hookstate/context_test.go + linters: + - testpackage + - path: overlord/hookstate/repository_test.go + linters: + - testpackage + - path: overlord/snapstate/cookies_test.go + linters: + - testpackage + - path: overlord/snapstate/progress_test.go + linters: + - testpackage + - path: polkit/pid_start_time_test.go + linters: + - testpackage + - path: snap/snapenv/snapenv_test.go + linters: + - testpackage + - path: snap/types_test.go + linters: + - testpackage + - path: store/details_v2_test.go + linters: + - testpackage + - path: store/stringlist_test.go + linters: + - testpackage + - path: strutil/shlex/shlex_test.go + linters: + - testpackage + - path: tests/lib/fakestore/store/store_test.go + linters: + - testpackage + - path: testutil/exec_test.go + linters: + - testpackage + - path: timeout/timeout_test.go + linters: + - testpackage + + # Independently from option `exclude` we use default exclude patterns, + # it can be disabled by this option. To list all + # excluded by default patterns execute `golangci-lint run --help`. + # Default value for this option is true. + exclude-use-default: false + + # Maximum issues count per one linter. Set to 0 to disable. Default is 50. + max-per-linter: 0 + + # Maximum count of issues with the same text. Set to 0 to disable. Default is 3. + max-same-issues: 0 + + # Show only new issues: if there are unstaged changes or untracked files, + # only those changes are analyzed, else only changes in HEAD~ are analyzed. + # It's a super-useful option for integration of golangci-lint into existing + # large codebase. It's not practical to fix all existing issues at the moment + # of integration: much better don't allow issues in new code. + # Default is false. + new: false diff --git a/.mailmap b/.mailmap new file mode 100644 index 00000000..997184e5 --- /dev/null +++ b/.mailmap @@ -0,0 +1,3 @@ +Sergio Cazzolato sergio-j-cazzolato +John R. Lenton John Lenton +Zygmunt Krynicki Zygmunt Krynicki diff --git a/.woke.yaml b/.woke.yaml new file mode 100644 index 00000000..c60290e3 --- /dev/null +++ b/.woke.yaml @@ -0,0 +1,5 @@ +ignore_files: + - packaging/ubuntu-14.04/changelog + - packaging/ubuntu-16.04/changelog + - packaging/debian-sid/changelog + - packaging/fedora/snapd.spec diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..eae850e8 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,76 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, sex characteristics, gender identity and expression, +level of experience, education, socio-economic status, nationality, personal +appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or + advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at snap-advocacy@canonical.com. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see +https://www.contributor-covenant.org/faq diff --git a/CODING.md b/CODING.md new file mode 100644 index 00000000..7d9b5dc3 --- /dev/null +++ b/CODING.md @@ -0,0 +1,236 @@ +# Coding/Review checklist + +## Why reviews? + +* Reviews can give input on whether the proposed code is seemingly correct and reasonable in the context of project practices, and whether it seems sufficiently tested. + +* Code can have a long lifetime; the effort to maintain and adapt it in the future can be much larger than the original effort to produce the first version of it. Reviews from other team members should therefore focus on: + * Is the new code readable and understandable, alongside other attributes that can help future maintainability? + * Could the code be simplified? + +## Naming conventions + +To a large extent we follow [golang naming conventions](https://go.dev/doc/effective_go#names): + +* Names should strike a balance between concision and clarity, where for a local variable more weight might be put on concision while for an exported name clarity might have a larger weight. + +* Consistency is important in a somewhat large and long lived project, it is always a good idea to check whether there are similar entities or concepts in the code from which to borrow terminology or naming patterns, especially in the neighbourhood of the new code. For example when using a verb in a method name, it is good to check whether the verb is used for similar behaviour in other names or some other verb is more common for the usage. + +* Regarding concision, golang is a typed language so a slightly more concise name might still work because purpose is clarified by the parameter types of the to-be-named function or the type of the to-be-named field. + +* One should remember that unexported symbols are scoped to a whole package, not their code file, so they should be named accordingly. Even for unexported helpers and symbols clarity is important - prefer something specific in their name over very generic names: `func findRev(needle snap.Revision, haystack []snap.Revision) bool` vs `find` or `findStuff`. + +## Comments + +Ideally all exported names should have doc comments for them following [golang conventions](https://go.dev/doc/comment). + +We sometimes also use long code comments or separate markdown README files for higher-level descriptions of mechanisms or concepts. + +Inline code comments should usually address non-obvious or unexpected parts of the code. Repeating what the code does is not usually very informative: + +* Code comments should either address the why something is done +* Or clarify the more abstract impact of the low-level manipulation in the code + +It might be appropriate and useful also to give proper doc comments even to complex unexported helpers. + +## Function signatures + +Example: in `overlord/snapstate` + +``` +Install(ctx context.Context, st *state.State, name string, opts *RevisionOptions, userID int, flags Flags) (*state.TaskSet, error) +``` + +* We try to follow this kind of ordering for parameters of functions and methods: + * `context.Context` if provided + * Long lived/ambient objects like `state.State` + * The main entities the function or method operates on + * Any optional and ancillary parameters in some order of relevance +* For return parameters, they should be in some order of importance with error last as per golang conventions. +* Consistency is important, so parallel/similar functions/methods should try to have the same/any shared parameters in the same order. +* For exported functions, generally try to avoid asking callers to pass values that can be computed by the called methods/functions anyway. Sometimes some optimisation pattern might make this worthwhile but consider if that is really the case. Even then things should always be organised in ways that avoid breaking/confusing responsibility boundaries. + +## Error and error messages + +* We tend not to introduce **Error* structs until we know of caller code that will need to inspect them. + +* We use `fmt.Errorf` and `errors.New` as much as possible. + +* Error messages start with lowercase and not end with a period, as they often end up embedded in one another + +* Error messages should be formulated as *"cannot …"* whenever possible, so avoid *"failed to …"* for example. + +* OTOH as error messages often end up being embedded in one another/chained. It is also important to pay attention so that the final messages do not have too much repetition, when possible, to avoid things like *"cannot …: cannot …: cannot …"* for example. Tests for the error paths can help find those repetitions. + +* Error messages should be clear, and when possible, actionable. They should also use concepts and terminology familiar to the user instead of internal-only concepts unless they are really unexpected internal errors. + +* Prefixing errors with *"internal error: …"* should be used for programming errors or other unexpected internal inconsistencies but preferably not for situations where external state that is not completely under snapd control is involved. + +## Other style points + +* We rely on and apply `go fmt` consistently. +* Our PR CI static checks run [`golangci-lint`](https://github.com/golangci/golangci-lint), for details see our `.golangci-lint.yml` config. +* We tend to avoid naked returns, we run the `nakedret` test with an accepted function length of at most 5. +* `run-checks –static` runs also various linting plus some project specific checks: + * For example, in the face of mixed usage in the end we agreed to use numbers directly instead and avoid `http.Status*` constants. This is checked by `run-checks -–static`. + +## Code structuring + +* Packages should have a clear responsibility, and present an exported interface with a relatively consistent level of abstraction. As an exception, there might be a few higher-level convenience functions or lower level ones. Generally if a responsibility is split across multiple packages, with the aim to produce focused and readable code, it should be split by having packages with growing application-specific levels of abstraction, instead of splitting the same level of abstraction across multiple packages. As current examples of this in the code base, consider (in higher-level to lower-level abstraction level): + * overlord/snapstate -> overlord/snapstate/backend -> boot -> bootloader + * overlord/snapstate -> overlord/snapstate/backend -> wrappers -> systemd + * overlord/servicestate -> wrappers -> systemd + +* Symmetry should always be applied to structure code when it is useful. Code is easier to reason about and to review if do and undo code paths, save and restore paths, etc. are written in obvious structurally symmetric ways when possible. Managers' tasks being defined with do and undo handlers tries to guide and facilitate this for example. + +* When trying to keep functions and methods readable by introducing helpers, trying to aim for a mostly consistent level of abstraction inside each function could be useful. + +* *Do not repeat yourself* is a balancing act. Complex behaviour should ideally be encoded only once in the code base when possible. When extracting and deciding how to extract some behaviour it is important to consider the readability of both the now encapsulated code and its consumers. For example, if it's hard to give the extracted code a good name and signature it might show that a different approach should be looked at. For simpler helpers, it might be worth seeing a couple of usages before creating them as local helpers, and a bit more before creating an exported helper that can be imported from all the used places. When creating helpers and avoiding repetition the aim should also be first to improve maintainability and readability. If the consumer code is less readable then maybe the extraction in this case might not be a good idea in the end. + +* See [Tests](#tests) for consideration about repetition and reuse specifically in test code. + +* Given that golang does not support mutual/circular imports we have a few patterns and rules: + * Across overlord state managers' packages: + * `snapstate` can and is imported by other managers but cannot import them directly + * `assertstate` and `hookstate` should also be mostly consumed and not consume other managers + * When unavoidable, we break circular import issues by using exported function hook variables from the normally imported package. These are assigned to in the package that normally imports and uses the first one, either directly or indirectly. + * These variables need to be assigned in `init` code or `Manager` constructor functions. + * Examples are: + * `snapstate.ValidateRefreshes` assigned from a `assertstate.delayedCrossMgrInit` called by `assertsstate.Manager` + * The hooks in `snapstate` assigned from a `hookstate init` + * `boot.HasFDESetupHook` assigned from `devicestate.Manager` + * When applicable, we might also use hook registration mechanisms. Examples: + * `snapstate.AddCheckSnapCallback` + * `snapstate.RegisterAffectedSnapsByAttr` + * `*util` packages should not import not `*util` packages, and whenever possible just use standard library packages and as few as strictly necessary other `*util` packages + +* Most packages can be imported as needed by any snapd tool and service with the exception of most code under overlord and the state managers' packages. This code is meant to implement only the snapd daemon itself and not be imported by any other tool, as it will also grow the size of the latter significantly. (As an exception, a subset of `overlord/configstate/configcore` is consumed and built into tools outside of the snapd daemon. This subset is kept under control via the `nomanagers` build tag). + +## System properties + +* snapd should complete initiated operations even in the case of snapd restarts or system reboots. In the case of failure, it should try to bring back the system to a known good state. +* snapd should avoid or minimise cases and time windows where the external state of the system can cause unexpected errors for users and the rest of the system. + +## Tests + +Tests should help with these aspects: + +* Illustrate (and help clarify while writing the code) the intent and behaviour of code. +* Anchor the correctness of the code, to the extent possible, from this POV while we try to keep a high coverage without overdoing. One should keep in mind that line coverage might itself not be enough. Lines of code can be covered without being tested as only part of a predicate might be triggered, or some code could be removed and no test breaks unless tests were targeting it. So when writing tests it is always important to take on an expectations and behaviours mindset. +* Help later to have some confidence when refactoring. + +We do not mandate TDD, but it's always a good idea when possible to start at least from happy path tests for new code/behaviour. + +Bug fixes should be covered by new tests, for which is important to verify that they do not pass and fail as expected prior to the fix. + +Coverage of error handling is important as well: + +* Complex error handling should be tested +* Undo/restore behaviour on error, if any, should be tested +* Error generation that produces errors that are expected/inspected by callers should be tested +* Simple unexpected/give up error paths may not necessarily need to be tested +* The return paths in code like: + + ``` + if […;] err != nil { + return err + } + ``` + + might not need to be tested if it's too cumbersome to trigger them, but the other considerations need to be taken into account. Reviewers should keep under consideration that golang error handling conventions are followed. + +We use [gocheck](https://labix.org/gocheck) and our own testutil package for snapd tests, to complement what is provided by go and golang standard library. + +We definitely prefer to write tests in dedicated `_test` packages, this means that tests should mainly explore the exported interface of the tested packages. There might be helpers and unexported details that sometimes warrant testing, in which case we use re-assignment or type aliasing in conventional `export_test.go` or `export_foo_test.go` files in the package under test, to get access to what we need to test. This is usually needed if there is algorithmic complexity or error handling behaviour that is hard to explore through the exported API, or is important to illustrate the chosen behaviour of the helper in itself. + +There are varying opinions on this, but mocking is definitely a double-edged sword. Our pattern for mocking is `Mock*` functions defined in `export_test.go` returning a parameter-less restore function. These usually change some package global variable through which original values or functions are indirectly accessed and therefore can be replaced. + +``` +var timeNow = time.Now // close to usages in package code + +func MockTimeNow(f func() time.Time) (restore func()) { // in export_test.go + restore = testutil.Backup(&timeNow) + timeNow = f + return restore +} +``` + +If something cannot avoid being mocked across package boundaries, we sometimes have `Mock*` functions or constructors exported in the API of packages. + +Because of this complexity with mocking, and because mock-heavy tests might risk needing large rewrites when refactoring (which goes against their confidence enhancement use), we are not very strict about unit tests testing exactly single functions and structs. We should do that whenever possible without mocking, but otherwise it is not atypical for snapd tests to concentrate on mocking points of interaction with the actual external system and state, as it might require less overall mocking support and it might be easier to reason on expectations for effects. For example, we have support to mock our systemd interactions and observe the involved `systemctl` invocations (`systemd.MockSystemctl`). + +So, many of our unit tests might end up testing more than one package, and test instead across two levels (rarely more) of packages in our architecture; a package of lower-level primitives, for example, and a more high-level behaviour one using the former. + +Full direct mocking might still make perfect sense when the API of the consumed packages is very complex but its details should be fully or largely transparent to the consumer. This is mostly the case, for example, when testing API functionality in the `daemon` package vs the API offered by the [overlord state managers](https://github.com/snapcore/snapd/blob/master/overlord/README.md). + +The cost of our approach is sometimes a complex fixture setup. To help mitigate this, and in other cases when it makes sense for a package to offer test-dedicated helpers related to it, we can introduce matching `test` packages one level deeper than `` (e.g. `asserts/assertstest` or `overlord/devicestate/devicestatetest`). + +Related to tests in and for overlord state manager packages `overlord/state`, we have a few rules: + +* Ideally they should limit themselves to test the manager defined by the package +* If that's not possible they should limit themselves to as few managers as possible +* If what needs to be tested is the full interaction across many or all managers then we have or can write tests for this in `overlord/managers_test.go`. Fixture setup in these cases is very costly but they are still easier to iterate on and can be useful to probe behaviour in more internal details than functional/integration tests. + +We do not have strong policies against repetition in test code, as usual the important consideration is readability. This area is mostly left to the personal judgement of developers. If any general advice can be given is that: + +* Investing in clear helpers to setup complex fixtures is often valuable, while compressing actual ad hoc testing and checking code less so, as it might result in if-trees that might be hard to follow. +* Wherever applicable, tabular tests (where cases are expressed as a slice of anonymous structs) should be used. For example, they are often appropriate when testing error cases of functions. + +## Functional/integration tests + +We write them using [spread](https://github.com/snapcore/spread). Generally all externally visible features and behaviour should have spread tests. It's also important to have them for robustness and fallback behaviour as related to system properties. + +In order to keep the integration testing harness easy to read and consistent, there are some rules about the order and the existence of the different spread tests sections. + +This is the ordered sections list to follow: + 1. summary (required) + 1. details (required) + 1. backends + 1. systems + 1. manual + 1. priority + 1. warn-timeout + 1. kill-timeout + 1. environment + 1. prepare + 1. restore + 1. debug + 1. execute (required) + +The CI tooling will check and enforce the order and required sections when a spread test is created or updated. + +## PRs and refactorings + +* PR should ideally have diffs of around 500 lines or less. There might be exceptions when size is due to large repetitive tests, but not for the production code. Experience indicates that smaller PRs are easier to review, while it is hard to do careful and punctual reviews for very large diffs. + +* It is fair for reviewers to ask for large PRs to be split. It is also fair to ask for discussion on best strategies to do this with colleagues and architects. + +* Whenever reasonable, avoid spurious differences between the code in master and the new code. + +* Large mechanical refactoring and changes should be done as separate PRs. Try to separate behaviour changes and refactoring into different PRs and not mix the two. + +* Large moving of code around and changes to code placement might also be better done separately. + +* PR summaries and the first line of commit messages are expected to be of this form: + * *`affected full packages: short summary in lowercase`* + * When too many packages are involved, many can be used instead, or sometimes package names can be abbreviated by using single letters for the top-level package, when non ambiguous combined with the subpackage. + * Examples: + * `overlord/devicestate: add test to check connect hooks don't break anything` + * `gadget,image: remove LayoutConstraints struct` + * `o/snapstate: add helpers to get user and gating holds` + * `many: correct struct fields and output key` + * When no golang code is involved, the context prefix before the colon can refer to directories or top-level files instead. + * `build-aux,.github/workflows: limit make processes with nproc` + +* Merging + * Only use `Squash and Merge` or `Rebase and Merge`, never `Create a merge commit` + * `Squash and Merge`: Preferred method because it simplifies cherry-picking of PR content + * Also for single commits + * This merge will use the title as commit message so double check that it is accurate and concise + * `Rebase and Merge`: Required when it is important to be able to distinguish different parts of a solution in the future + * Keep commits to a minimum + * Squash uninteresting commits such as review improvements after review approval + +## Further readings + +* [Notes on state and changes](https://github.com/snapcore/snapd/blob/master/overlord/README.md) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..10f30911 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,179 @@ +# Contributing to snapd + +We are an open source project and welcome community contributions, suggestions, +fixes and constructive feedback. + +If you'd like to contribute, you will first need to sign the Canonical +contributor agreement. This is the easiest way for you to give us permission to +use your contributions. In effect, you’re giving us a licence, but you still +own the copyright — so you retain the right to modify your code and use it in +other projects. + +The agreement can be found, and signed, here: +https://ubuntu.com/legal/contributors + +If you have any questions, please reach out to us on our forum: +https://forum.snapcraft.io/c/snapd/5 + +## Contributor guidelines + +Contributors can help us by observing the following guidelines: + +- Commit messages should be well structured. +- Commit emails should not include non-ASCII characters. +- Several smaller PRs are better than one large PR. +- Try not to mix potentially controversial and trivial changes together. + (Proposing trivial changes separately makes landing them easier and + makes reviewing controversial changes simpler) +- Do not [force push][git-force] a PR after it has received reviews. It is + acceptable to force push when a PR is ready to merge, however. +- Try to write tests to cover the contributed changes (see below) + +For further details on our coding conventions, including how to format a PR, +see [CODING.md](CODING.md). + +## Pull requests and tests + +Before merging any changes into the snapd codebase, we need to verify that the +proposed functionality and code quality does not degrade the functionality and +quality requirement we've set for the project. + +For each PR, we run checks in three different groups: static, unit and spread. + +Static tests use several code analysis tools present in the golang ecosystem +(go vet, go lint and go fmt) to make sure that the code always aligns with +the standards. They also check the markdown format of documentation files. + +All the existing unit tests are also executed, and the coverage info is +reported to coveralls. + +We use [spread](https://github.com/snapcore/spread) to verify the +integrity of the product, exercising it as a whole, both from an end user +standpoint (eg. all kinds of interactions with the snap tool from the command +line) and from a more systemic approach (testing upgrades, for instance). + +Spread and unit tests are not strictly a requirement for a PR to be submitted, +but we do strongly encourage contributors to include them. We rarely merge code +without tests although we may occasionally write them ourselves on behalf of +a contributor. + +Unit tests help us understand expected behaviour, verified through the tests +and review process, which ensures we're building on the solid base of a tested +and working system. + +If any tests need to be added for a PR to be merged it will be denoted +during the review process. + +See [Testing](HACKING.md#user-content-testing) for further details on running +tests. + +## Pull request guidelines + +Contributions are submitted through a [pull request][pull-request] created from +a [fork][fork] of the `snapd` repository (under your GitHub account). + +GitHub's documentation outlines the [process][github-pr], but for a more +concise and informative version try [this GitHub gist][pr-gist]. + +### Linear git history + +We strive to keep a [linear git history][linear-git]. This makes it easier to +inspect the history, keep related commits next to each other, and make tools +like [git bisect][git-bisect] work intuitively. + +### Labels + +We add [GitHub labels][github-labels] to a PR for both organisational purposes +and to alter specific CI behaviour. Only project maintainers can add labels. + +The following labels are commonly used: + +- `Simple 🙂`: informs potential reviewers the PR can be reviewed quickly. +- `Test robustness`: either fixes tests, adds tests, or otherwise improves our + test suite. +- `Documentation`: is used to denote a PR that requires typically small + documentation changes, either internally (to this repository) or externally. +- `Needs documentation`: not to be confused with the above. This label needs to + be added when a PR introduces new features which need to be documented for + our users, or if the PR changes the behaviour of already documented + features (though this should almost never happen). + * Our user-facing documentation can be found here: https://snapcraft.io/docs + * The PR description must explain any required documentation changes. + * For internal documentation in this repository, it's expected that + documentation changes are delivered in the same branch. + Please don't abuse this tag. +- `Needs Samuele review`: Samuele (@pedronis) is our architect, and this label + will summon his attention. Do not use it unless you want @pedronis to review + your branch. If making big or deep changes, then ping Samuele in advance. The + tag will then be added if necessary. When requesting a quick high-level green + light about a chosen approach use a [draft PR][github-draft] to avoid the risk + of other reviewers wasting time on something that has not been agreed upon. +- `Needs security review`: similar to above, but with a security focus. If your + changes touch code in snap-confine or code related to AppArmor, Seccomp, + Cgroup management, then someone from the security team will be alerted and + will review your code. +- `Run nested`: instructs our CI system to run our container-based + [nested tests][nested-tests]. These tests are usually skipped to save time, + but they're useful to test a PR against certain operating system traits that + might otherwise be missed. +- `Skip spread`: instructs our CI system to not run any spread tests. Only unit + tests will be executed. Use this when a PR only changes code in the unit tests. + Do not use this flag if any production code changes. + +### Pull request updates + +Feel free to [rebase][github-rebase], rework commits, and [force +push][git-force] to your branch while a PR is waiting for its first review. + +However, if you are still making significant changes during this waiting +phase, it's a good idea to keep the PR as a [draft][github-draft]. This stops +reviewers from looking at code you may not be confident about. Set the PR as +"Ready for review" when you do feel confident. + +During the review process, reviewers will point out defects or suggest +alternative implementations. + +After the first review, please treat your already pushed commits as immutable +and submit any requested changes as additional commits. This helps reviewers to +see exactly what has changed since the last review without requesting them to +review all the changes. + +Two approvals are required for a PR to be merged. A PR can then be merged into the main branch. + +After approval, you can rework the branch history as you see fit. Consider +squashing commits from the original PR with those made during the review +process, for example. Commit messages should follow the format described in +[CODING.md](CODING.md). A [force push][git-force] will be required if you +rework the history. + +Start a [rebase][github-rebase] from the original parent commit of your first +commit. Ensure you do not rebase on top of the current main as this means +changes from the _main_ branch will be shown in the GitHub UI as part of your +changes, making the verification more confusing. + +Merge using Github's [Squash and Merge][github-squash-merge] or [Rebase and merge][github-rebase-merge], +never [Create a merge commit][github-merge-commit]. +* [Squash and Merge][github-squash-merge] is preferred because it simplifies cherry-picking of PR +content. + * Also for single commits + * This merge will use the title as commit message so double check that it is accurate and concise +* [Rebase and merge][github-rebase-merge] is required when it is important to be able to distinguish +different parts of a solution in the future. + * Keep commits to a minimum + * Squash uninteresting commits such as review improvements after review approval + +[1]: http://www.ubuntu.com/legal/contributors +[pull-request]: https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request-from-a-fork +[fork]: https://docs.github.com/en/get-started/quickstart/fork-a-repo#forking-a-repository +[github-pr]: https://docs.github.com/en/github/collaborating-with-pull-requests +[pr-gist]: https://gist.github.com/Chaser324/ce0505fbed06b947d962 +[linear-git]: https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/defining-the-mergeability-of-pull-requests/about-protected-branches#require-linear-history +[git-bisect]: https://git-scm.com/docs/git-bisect +[github-draft]: https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-pull-requests#draft-pull-requests +[github-labels]: https://docs.github.com/en/issues/using-labels-and-milestones-to-track-work/managing-labels +[nested-tests]: https://github.com/snapcore/snapd/tree/master/tests/nested +[github-rebase]: https://docs.github.com/en/get-started/using-git/about-git-rebase +[git-force]: https://git-scm.com/docs/git-push#Documentation/git-push.txt---force +[github-rebase-merge]: https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/incorporating-changes-from-a-pull-request/about-pull-request-merges#rebase-and-merge-your-commits +[github-squash-merge]: https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/incorporating-changes-from-a-pull-request/about-pull-request-merges#squash-and-merge-your-commits +[github-merge-commit]: https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/incorporating-changes-from-a-pull-request/about-pull-request-merges#merge-your-commits diff --git a/COPYING b/COPYING new file mode 100644 index 00000000..94a9ed02 --- /dev/null +++ b/COPYING @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/HACKING.md b/HACKING.md new file mode 100644 index 00000000..ebb5eb0b --- /dev/null +++ b/HACKING.md @@ -0,0 +1,543 @@ +# Hacking on snapd + +Hacking on `snapd` is fun and straightforward. The code is extensively unit +tested and we use the [spread](https://github.com/snapcore/spread) +integration test framework for the integration/system level tests. + +For non-technical details on contributing to the project, including how to +approach a pull request, see [Contributing to snapd](./CONTRIBUTING.md). + +## Setting up + +### Supported Ubuntu distributions + +Ubuntu 18.04 LTS or later is recommended for `snapd` development. +Usually, the latest LTS would be the best choice. + +> If you want to build or test on older versions of Ubuntu, additional steps +may be required when installing dependencies. + +### Supported Go version + +Go 1.18 (or later) is required to build `snapd`. + +> If you need to build older versions of snapd, please have a look at the file +[debian/control](debian/control) to find out what dependencies were needed at the time +(including which version of the Go compiler). + +### Getting the snapd sources + +The easiest way to get the source for `snapd` is to clone the GitHub repository +in a directory where you have read-write permissions, such as your home +directory. + + cd ~/ + git clone https://github.com/snapcore/snapd.git + cd snapd + +This will allow you to build and test `snapd`. If you wish to contribute to +the `snapd` project, please see [Contributing to snapd](./CONTRIBUTING.md). + +> For more details about source-code structure of `snapd` please read about +[Managing module source](https://go.dev/doc/modules/managing-source) in Go. + +### Installing the build dependencies + +Build dependencies can automatically be resolved using `build-dep` on Ubuntu: + + cd ~/snapd + sudo apt-get build-dep . + +Package build dependencies for other distributions can be found under the +[./packaging/](./packaging/) directory. + +Source dependencies are automatically retrieved at build time. +Sometimes, it might be useful to pull them without building: + +``` +cd ~/snapd +go get ./... && ./get-deps.sh +``` + +## Building + +### Building the snap with snapcraft + +The easiest (though not the most efficient) way to test changes to snapd is to +build the snapd snap using _snapcraft_ and then install that snapd snap. The +[snapcraft.yaml](./build-aux/snapcraft.yaml) for the snapd snap is located at +[./build-aux/](./build-aux/), and +can be built using snapcraft either in a LXD container or a multipass VM (or +natively with `--destructive-mode` on a Ubuntu 16.04 host). + +> Currently, snapcraft's default track of 5.x does not support building the +snapd snap, since the snapd snap uses `build-base: core`. Building with a +`build-base` of core uses Ubuntu 16.04 as the base operating system (and thus +root filesystem) for building and Ubuntu 16.04 is now in Extended Security +Maintenance (ESM, see +[Ubuntu 16.04 LTS ESM](https://ubuntu.com/blog/ubuntu-16-04-lts-transitions-to-extended-security-maintenance-esm)), +and as such only is buildable using snapcraft's 4.x channel. At some point in the future, +the snapd snap should be moved to a newer `build-base`, but until then `4.x` +needs to be used. + +Install snapcraft from the 4.x channel: + +``` +sudo snap install snapcraft --channel=4.x +``` + +Then run snapcraft: + +``` +snapcraft +``` + +Now the snapd snap that was just built can be installed with: + +``` +snap install --dangerous snapd_*.snap +``` + +To go back to using snapd from the store instead of the custom version we +installed (since it will not get updates as it was installed dangerously), you +can either use `snap revert snapd`, or you can refresh directly with +`snap refresh snapd --stable --amend`. + +#### Building for other architectures with snapcraft + +It is also sometimes useful to use snapcraft to build the snapd snap for +other architectures using the `remote-build` feature. In order to build +remotely with snapcraft, make sure you have at least version `6.x` installed: +if the command `snap info snapcraft` shows that you are running an older +version, upgrade with: + +``` +snap refresh snapcraft --channel=latest/stable +``` + +Now you can use remote-build with snapcraft on the snapd tree for any desired +architectures: + +``` +snapcraft remote-build --build-for=armhf,s390x,arm64 +``` + +And to go back to building the snapd snap locally, just revert the channel back +to 4.x: + +``` +snap refresh snapcraft --channel=4.x/stable +``` + +#### Splicing the snapd snap into the core snap + +Sometimes while developing you may need to build a version of the _core_ snap +with a custom snapd version. +The `snapcraft.yaml` for the [core snap](https://github.com/snapcore/core/) +currently is complex in that it assumes it is built inside Launchpad with the +[ppa:snappy-dev/image](https://launchpad.net/~snappy-dev/+archive/ubuntu/image/) +enabled, so it is difficult to inject a custom version of +snapd into this by rebuilding the core snap directly, so an easier way is to +actually first build the snapd snap and inject the binaries from the snapd snap +into the core snap. This currently works since both the snapd snap and the core +snap have the same `build-base` of Ubuntu 16.04. However, at some point in time +this trick will stop working when the snapd snap starts using a `build-base` other +than Ubuntu 16.04, but until then, you can use the following trick to more +easily get a custom version of snapd inside a core snap. + +First follow the steps above to build a full snapd snap. Then, extract the core +snap you wish to splice the custom snapd snap into: + +``` +sudo unsquashfs -d custom-core core_.snap +``` + +`sudo` is important as the core snap has special permissions on various +directories and files that must be preserved as it is a boot base snap. + +Now, extract the snapd snap, again with sudo because there are `suid` binaries +which must retain their permission bits: + +``` +sudo unsquashfs -d custom-snapd snapd-custom.snap +``` + +Now, copy the meta directory from the core snap outside to keep it and prevent +it from being lost when we replace the files from the snapd snap: + +``` +sudo cp ./custom-core/meta meta-core-backup +``` + +Then copy all the files from the snapd snap into the core snap, and delete the +meta directory so we don't use any of the meta files from the snapd snap: + +``` +sudo cp -r ./custom-snapd/* ./custom-core/ +sudo rm -r ./custom-core/meta/ +sudo cp ./meta-core-backup ./custom-core/ +``` + +Now we can repack the core snap: + +``` +sudo snap pack custom-core +``` + +Sometimes it is helpful to modify the snap version in +`./custom-core/meta/snap.yaml` before repacking with `snap pack` so it is easy +to identify which snap file is which. + +### Building natively + +To build the `snap` command line client: + +``` +cd ~/snapd +mkdir -p /tmp/build +go build -o /tmp/build/snap ./cmd/snap +``` + +To build the `snapd` REST API daemon: + +``` +cd ~/snapd +mkdir -p /tmp/build +go build -o /tmp/build/snapd ./cmd/snapd +``` + +To build all the `snapd` Go components: + +``` +cd ~/snapd +mkdir -p /tmp/build +go build -o /tmp/build ./... +``` + +### Building with cross-compilation (_example: ARM v7 target_) + +Install a suitable cross-compiler for the target architecture. + +``` +sudo apt-get install gcc-arm-linux-gnueabihf +``` + +Verify the default architecture version of your GCC cross-compiler. + +``` +arm-linux-gnueabihf-gcc -v +: +--with-arch=armv7-a +--with-fpu=vfpv3-d16 +--with-float=hard +--with-mode=thumb +``` + +Verify the supported Go cross-compile ARM targets [here]( +https://github.com/golang/go/wiki/GoArm). + +`Snapd` depends on [libseccomp](https://github.com/seccomp/libseccomp#readme) +v2.3 or later. The following instructions can be +used to cross-compile the library: + +``` +cd ~/ +git clone https://github.com/seccomp/libseccomp +cd libseccomp +./autogen.sh +./configure --host=arm-linux-gnueabihf --prefix=${HOME}/libseccomp/build +make && make install +``` + +Setup the Go environment for cross-compiling. + +```sh +export CC=arm-linux-gnueabihf-gcc +export CGO_ENABLED=1 +export CGO_LDFLAGS="-L${HOME}/libseccomp/build/lib" +export GOOS=linux +export GOARCH=arm +export GOARM=7 +``` + +The Go environment variables are now explicitly set to target the ARM v7 +architecture. + +Run the same build commands from the Building (natively) section above. + +Verify the target architecture by looking at the application ELF header. + +``` +readelf -h /tmp/build/snapd +: +Class: ELF32 +OS/ABI: UNIX - System V +Machine: ARM +``` + +CGO produced ELF binaries contain additional architecture attributes that +reflect the exact ARM architecture we targeted. + +``` +readelf -A /tmp/build/snap-seccomp +: +File Attributes + Tag_CPU_name: "7-A" + Tag_CPU_arch: v7 + Tag_FP_arch: VFPv3-D16 +``` + +## Testing + +We value good tests, so when you fix a bug or add a new feature we highly +encourage you to add tests. + +Install the following package(s) to satisfy test dependencies. + +``` +sudo apt-get install python3-yamlordereddictloader dbus-x11 +``` + +### Running unit-tests + +To run the various tests that we have to ensure a high quality source just run: + + ./run-checks + +This will check if the source format is consistent, that it builds, all tests +work as expected and that "go vet" has nothing to complain about. + +The source format follows the `gofmt -s` formating. Please run this on your +source files if `run-checks` complains about the format. + +You can run an individual test for a sub-package by changing into that +directory and: + +``` +go test -check.f $testname +``` + +If a test hangs, you can enable verbose mode: + +``` +go test -v -check.vv +``` + +Or, try just `-check.v` for a less verbose output. + +> Some unit tests are known to fail on locales other than `C.UTF-8`. +If you have unit tests failing, try setting `LANG=C.UTF-8` when running +`go test`. See [issue #1960131](https://bugs.launchpad.net/snapd/+bug/1960131) for more details. + +There is more to read about the testing framework on the [website](https://labix.org/gocheck) + +### Running integration tests + +#### Downloading spread framework + +To run the integration tests locally via QEMU, you need the latest version of +the [spread](https://github.com/snapcore/spread) framework. +You can get spread, QEMU, and the build tools to build QEMU images with: + + $ sudo apt update && sudo apt install -y qemu-kvm autopkgtest + $ curl https://storage.googleapis.com/snapd-spread-tests/spread/spread-amd64.tar.gz | tar -xz -C + +> `` can be any directory that is listed in `$PATH`, +as it is assumed further in the guidelines of this document. +You may consider creating a dedicated directory and adding it to `$PATH`, +or you may choose to use one of the conventional Linux directories (e.g. `/usr/local/bin`) + +#### Building spread VM images + +To run the spread tests via QEMU you need to create VM images in the +`~/.spread/qemu` directory: + + $ mkdir -p ~/.spread/qemu + $ cd ~/.spread/qemu + +Assuming you are building on Ubuntu 18.04 LTS ([Bionic Beaver](https://releases.ubuntu.com/18.04/)) +(or later), run the following to build a 64-bit Ubuntu 16.04 LTS (or later): + + $ autopkgtest-buildvm-ubuntu-cloud -r + $ mv autopkgtest--amd64.img ubuntu--64.img + +For the correct values of `` and ``, please refer +to the official list of [Ubuntu releases](https://wiki.ubuntu.com/Releases). + +> `` is the first word in the release's full name, +e.g. for "Bionic Beaver" it is `bionic`. + +To build an Ubuntu 14.04 (Trusty Tahr) based VM, use: + + $ autopkgtest-buildvm-ubuntu-cloud -r trusty --post-command='sudo apt-get install -y --install-recommends linux-generic-lts-xenial && update-grub' + $ mv autopkgtest-trusty-amd64.img ubuntu-14.04-64.img + +> This is because we need at least 4.4+ kernel for snapd to run on Ubuntu 14.04 +LTS, which is available through the `linux-generic-lts-xenial` package. + +If you are running Ubuntu 16.04 LTS, use +`adt-buildvm-ubuntu-cloud` instead of `autopkgtest-buildvm-ubuntu-cloud` (the +latter replaced the former in 18.04): + + $ adt-buildvm-ubuntu-cloud -r xenial + $ mv adt--amd64-cloud.img ubuntu--64.img + +#### Downloading spread VM images + +Alternatively, instead of building the QEMU images manually, you can download +pre-built and somewhat maintained images from +[spread.zygoon.pl](https://spread.zygoon.pl/). The images will need to be extracted +with `gunzip` and placed into `~/.spread/qemu` as above. + +> An image for Ubuntu Core 20 that is pre-built for KVM can be downloaded from +[here](https://cdimage.ubuntu.com/ubuntu-core/20/stable/current/ubuntu-core-20-amd64.img.xz). + +#### Running spread with QEMU + +Finally, you can run the spread tests for Ubuntu 18.04 LTS 64-bit with: + + $ spread -v qemu:ubuntu-18.04-64 + +>To run for a different system, replace `ubuntu-18.04-64` with a different system +name, which should be a basename of the [built](#building-spread-vm-images) or +[downloaded](#downloading-spread-vm-images) Ubuntu image file. + +For quick reuse you can use: + + $ spread -reuse qemu:ubuntu-18.04-64 + +It will print how to reuse the systems. Make sure to use +`export REUSE_PROJECT=1` in your environment too. + +> Spread tests can be exercised on Ubuntu Core 20, but need UEFI. +UEFI support with QEMU backend of spread requires a BIOS from the +[OVMF](https://wiki.ubuntu.com/UEFI/OVMF) package, +which can be installed with `sudo apt install ovmf`. + +### Testing the snapd daemon + +To test the `snapd` REST API daemon on a snappy system you need to +transfer it to the snappy system and then run: + + sudo systemctl stop snapd.service snapd.socket + sudo SNAPD_DEBUG=1 SNAPD_DEBUG_HTTP=3 ./snapd + +To debug interaction with the snap store, you can set `SNAPD_DEBUG_HTTP`. +It is a bitfield: dump requests: 1, dump responses: 2, dump bodies: 4. + +Similarly, to debug the interaction between the `snap` command-line tool and the +snapd REST API, you can set `SNAP_CLIENT_DEBUG_HTTP`. It is also a bitfield, +with the same values and behaviour as `SNAPD_DEBUG_HTTP`. +> In case you get some security profiles errors, when trying to install or refresh a snap, +maybe you need to replace system installed snap-seccomp with the one aligned to the snapd that +you are testing. To do this, simply backup `/usr/lib/snapd/snap-seccomp` and overwrite it with +the testing one. Don't forget to roll back to the original, after you finish testing. + +### Testing the snap userd agent + +To test the `snap userd --agent` command, you must first stop the current process, if it is +running, and then stop the dbus activation part. To do so, just run: + + systemctl --user disable snapd.session-agent.socket + systemctl --user stop snapd.session-agent.socket + +After that, it's now possible to launch the daemon with `snapd userd --agent` from a command +line. + +To re-enable the dbus activation, kill that process and run: + + systemctl --user enable snapd.session-agent.socket + +### Running nested tests + +Nested tests are used to validate features that cannot be tested with the regular tests. + +The nested test suites work differently from the other test suites in snapd. In +this case each test runs in a new image which is created following the rules +defined for the test. + +The nested tests are executed using the [spread framework](#downloading-spread-framework). +See the following examples using the QEMU and Google backends. + +- _QEMU_: `spread qemu-nested:ubuntu-20.04-64:tests/nested/core20/tpm` +- _Google_: `spread google-nested:ubuntu-20.04-64:tests/nested/core20/tpm` + +The nested system in all the cases is selected based on the host system. The following lines +show the relation between host and nested `systemd` (same applies to the classic nested tests): + +- ubuntu-16.04-64 => ubuntu-core-16-64 +- ubuntu-18.04-64 => ubuntu-core-18-64 +- ubuntu-20.04-64 => ubuntu-core-20-64 + +The tools used for creating and hosting the nested VMs are: + +- _ubuntu-image snap_ is used to build the images +- _QEMU_ is used for the virtualization (with [_KVM_](https://www.linux-kvm.org/page/Main_Page) acceleration) + +Nested test suite is composed by the following 4 suites: + +- _classic_: the nested suite contains an image of a classic system downloaded from cloud-images.ubuntu.com +- _core_: it tests a core nested system, and the images are generated with _ubuntu-image snap_ +- _core20_: this is similar to the _core_ suite, but these tests are focused on UC20 +- _manual_: tests on this suite create a non generic image with specific conditions + +The nested suites use some environment variables to configure the suite +and the tests inside it. The most important ones are described below: + +- `NESTED_WORK_DIR`: path to the directory where all the nested assets and images are stored +- `NESTED_TYPE`: use core for Ubuntu Core nested systems or classic instead. +- `NESTED_CORE_CHANNEL`: the images are created using _ubuntu-image snap_, use it to define the default branch +- `NESTED_CORE_REFRESH_CHANNEL`: the images can be refreshed to a specific channel; use it to specify the channel +- `NESTED_USE_CLOUD_INIT`: use cloud init to make initial system configuration instead of user assertion +- `NESTED_ENABLE_KVM`: enable KVM in the QEMU command line +- `NESTED_ENABLE_TPM`: re-boot in the nested VM in case it is supported (just supported on UC20) +- `NESTED_ENABLE_SECURE_BOOT`: enable secure boot in the nested VM in case it is supported (supported just on UC20) +- `NESTED_BUILD_SNAPD_FROM_CURRENT`: build and use either core or `snapd` from the current branch +- `NESTED_CUSTOM_IMAGE_URL`: download and use an custom image from this URL +- `NESTED_SNAPD_DEBUG_TO_SERIAL`: add snapd debug and log to nested vm serial console +- `NESTED_EXTRA_CMDLINE`: add any extra cmd line parameter to the nested vm + +# Quick intro to hacking on snap-confine + +Hey, welcome to the nice, low-level world of snap-confine + +## Building the code locally + +To get started from a pristine tree you want to do this: + +``` +./mkversion.sh +cd cmd/ +autoreconf -i -f +./configure --prefix=/usr --libexecdir=/usr/lib/snapd --enable-nvidia-multiarch --with-host-arch-triplet="$(dpkg-architecture -qDEB_HOST_MULTIARCH)" +``` + +This will drop makefiles and let you build stuff. You may find the `make hack` +target, available in [./cmd/](./cmd/) handy `(cd cmd; make hack)`. It installs the locally built +version on your system and reloads the [AppArmor](https://apparmor.net/) profile. + +>The above configure options assume you are on Ubuntu and are generally +necessary to run/test graphical applications with your local version of +snap-confine. The `--with-host-arch-triplet` option sets your specific +architecture and `--enable-nvidia-multiarch` allows the host's graphics drivers +and libraries to be shared with snaps. If you are on a distro other than +Ubuntu, try `--enable-nvidia-biarch` (though you'll likely need to add further +system-specific options too). + +## Testing your changes locally + +After building the code locally as explained in the previous section, you can run the +test suite available for snap-confine (among other low-level tools) by running the +`make check` target available in [./cmd]((./cmd/)). + +## Submitting patches + +Please run `(cd cmd; make fmt)` before sending your patches for the "C" part of +the source code. + + diff --git a/NEWS.md b/NEWS.md new file mode 100644 index 00000000..d4626fb9 --- /dev/null +++ b/NEWS.md @@ -0,0 +1,245 @@ +# New in snapd 2.63: +* Support for snap services to show the current status of user services (experimental) +* Refresh app awareness: record snap-run-inhibit notice when starting app from snap that is busy with refresh (experimental) +* Refresh app awareness: use warnings as fallback for desktop notifications (experimental) +* Aspect based configuration: make request fields in the aspect-bundle's rules optional (experimental) +* Aspect based configuration: make map keys conform to the same format as path sub-keys (experimental) +* Aspect based configuration: make unset and set behaviour similar to configuration options (experimental) +* Aspect based configuration: limit nesting level for setting value (experimental) +* Components: use symlinks to point active snap component revisions +* Components: add model assertion support for components +* Components: fix to ensure local component installation always gets a new revision number +* Add basic support for a CIFS remote filesystem-based home directory +* Add support for AppArmor profile kill mode to avoid snap-confine error +* Allow more than one interface to grant access to the same API endpoint or notice type +* Allow all snapd service's control group processes to send systemd notifications to prevent warnings flooding the log +* Enable not preseeded single boot install +* Update secboot to handle new sbatlevel +* Fix to not use cgroup for non-strict confined snaps (devmode, classic) +* Fix two race conditions relating to freedesktop notifications +* Fix missing tunables in snap-update-ns AppArmor template +* Fix rejection of snapd snap udev command line by older host snap-device-helper +* Rework seccomp allow/deny list +* Clean up files removed by gadgets +* Remove non-viable boot chains to avoid secboot failure +* posix_mq interface: add support for missing time64 mqueue syscalls mq_timedreceive_time64 and mq_timedsend_time64 +* password-manager-service interface: allow kwalletd version 6 +* kubernetes-support interface: allow SOCK_SEQPACKET sockets +* system-observe interface: allow listing systemd units and their properties +* opengl interface: enable use of nvidia container toolkit CDI config generation + +# New in snapd 2.62: +* Aspects based configuration schema support (experimental) +* Refresh app awareness support for UI (experimental) +* Support for user daemons by introducing new control switches --user/--system/--users for service start/stop/restart (experimental) +* Add AppArmor prompting experimental flag (feature currently unsupported) +* Installation of local snap components of type test +* Packaging of components with snap pack +* Expose experimental features supported/enabled in snapd REST API endpoint /v2/system-info +* Support creating and removing recovery systems for use by factory reset +* Enable API route for creating and removing recovery systems using /v2/systems with action create and /v2/systems/{label} with action remove +* Lift requirements for fde-setup hook for single boot install +* Enable single reboot gadget update for UC20+ +* Allow core to be removed on classic systems +* Support for remodeling on hybrid systems +* Install desktop files on Ubuntu Core and update after snapd upgrade +* Upgrade sandbox features to account for cgroup v2 device filtering +* Support snaps to manage their own cgroups +* Add support for AppArmor 4.0 unconfined profile mode +* Add AppArmor based read access to /etc/default/keyboard +* Upgrade to squashfuse 0.5.0 +* Support useradd utility to enable removing Perl dependency for UC24+ +* Support for recovery-chooser to use console-conf snap +* Add support for --uid/--gid using strace-static +* Add support for notices (from pebble) and expose via the snapd REST API endpoints /v2/notices and /v2/notice +* Add polkit authentication for snapd REST API endpoints /v2/snaps/{snap}/conf and /v2/apps +* Add refresh-inhibit field to snapd REST API endpoint /v2/snaps +* Add refresh-inhibited select query to REST API endpoint /v2/snaps +* Take into account validation sets during remodeling +* Improve offline remodeling to use installed revisions of snaps to fulfill the remodel revision requirement +* Add rpi configuration option sdtv_mode +* When snapd snap is not installed, pin policy ABI to 4.0 or 3.0 if present on host +* Fix gadget zero-sized disk mapping caused by not ignoring zero sized storage traits +* Fix gadget install case where size of existing partition was not correctly taken into account +* Fix trying to unmount early kernel mount if it does not exist +* Fix restarting mount units on snapd start +* Fix call to udev in preseed mode +* Fix to ensure always setting up the device cgroup for base bare and core24+ +* Fix not copying data from newly set homedirs on revision change +* Fix leaving behind empty snap home directories after snap is removed (resulting in broken symlink) +* Fix to avoid using libzstd from host by adding to snapd snap +* Fix autorefresh to correctly handle forever refresh hold +* Fix username regex allowed for system-user assertion to not allow '+' +* Fix incorrect application icon for notification after autorefresh completion +* Fix to restart mount units when changed +* Fix to support AppArmor running under incus +* Fix case of snap-update-ns dropping synthetic mounts due to failure to match desired mount dependencies +* Fix parsing of base snap version to enable pre-seeding of Ubuntu Core Desktop +* Fix packaging and tests for various distributions +* Add remoteproc interface to allow developers to interact with Remote Processor Framework which enables snaps to load firmware to ARM Cortex microcontrollers +* Add kernel-control interface to enable controlling the kernel firmware search path +* Add nfs-mount interface to allow mounting of NFS shares +* Add ros-opt-data interface to allow snaps to access the host /opt/ros/ paths +* Add snap-refresh-observe interface that provides refresh-app-awareness clients access to relevant snapd API endpoints +* steam-support interface: generalize Pressure Vessel root paths and allow access to driver information, features and container versions +* steam-support interface: make implicit on Ubuntu Core Desktop +* desktop interface: improved support for Ubuntu Core Desktop and limit autoconnection to implicit slots +* cups-control interface: make autoconnect depend on presence of cupsd on host to ensure it works on classic systems +* opengl interface: allow read access to /usr/share/nvidia +* personal-files interface: extend to support automatic creation of missing parent directories in write paths +* network-control interface: allow creating /run/resolveconf +* network-setup-control and network-setup-observe interfaces: allow busctl bind as required for systemd 254+ +* libvirt interface: allow r/w access to /run/libvirt/libvirt-sock-ro and read access to /var/lib/libvirt/dnsmasq/** +* fwupd interface: allow access to IMPI devices (including locking of device nodes), sysfs attributes needed by amdgpu and the COD capsule update directory +* uio interface: allow configuring UIO drivers from userspace libraries +* serial-port interface: add support for NXP Layerscape SoC +* lxd-support interface: add attribute enable-unconfined-mode to require LXD to opt-in to run unconfined +* block-devices interface: add support for ZFS volumes +* system-packages-doc interface: add support for reading jquery and sphinx documentation +* system-packages-doc interface: workaround to prevent autoconnect failure for snaps using base bare +* microceph-support interface: allow more types of block devices to be added as an OSD +* mount-observe interface: allow read access to /proc/{pid}/task/{tid}/mounts and proc/{pid}/task/{tid}/mountinfo +* polkit interface: changed to not be implicit on core because installing policy files is not possible +* upower-observe interface: allow stats refresh +* gpg-public-keys interface: allow creating lock file for certain gpg operations +* shutdown interface: allow access to SetRebootParameter method +* media-control interface: allow device file locking +* u2f-devices interface: support for Trustkey G310H, JaCarta U2F, Kensington VeriMark Guard, RSA DS100, Google Titan v2 + +# New in snapd 2.61.3: +* Install systemd files in correct location for 24.04 + +# New in snapd 2.61.2: +* Fix to enable plug/slot sanitization for prepare-image +* Fix panic when device-service.access=offline +* Support offline remodeling +* Allow offline update only remodels without serial +* Fail early when remodeling to old model revision +* Fix to enable plug/slot sanitization for validate-seed +* Allow removal of core snap on classic systems +* Fix network-control interface denial for file lock on /run/netns +* Add well-known core24 snap-id +* Fix remodel snap installation order +* Prevent remodeling from UC18+ to UC16 +* Fix cups auto-connect on classic with cups snap installed +* u2f-devices interface support for GoTrust Idem Key with USB-C +* Fix to restore services after unlink failure +* Add libcudnn.so to Nvidia libraries +* Fix skipping base snap download due to false snapd downgrade conflict + +# New in snapd 2.61.1: +* Stop requiring default provider snaps on image building and first boot if alternative providers are included and available +* Fix auth.json access for login as non-root group ID +* Fix incorrect remodelling conflict when changing track to older snapd version +* Improved check-rerefresh message +* Fix UC16/18 kernel/gadget update failure due volume mismatch with installed disk +* Stop auto-import of assertions during install modes +* Desktop interface exposes GetIdletime +* Polkit interface support for new polkit versions +* Fix not applying snapd snap changes in tracked channel when remodelling + +# New in snapd 2.61: +* Fix control of activated services in 'snap start' and 'snap stop' +* Correctly reflect activated services in 'snap services' +* Disabled services are no longer enabled again when snap is refreshed +* interfaces/builtin: added support for Token2 U2F keys +* interfaces/u2f-devices: add Swissbit iShield Key +* interfaces/builtin: update gpio apparmor to match pattern that contains multiple subdirectories under /sys/devices/platform +* interfaces: add a polkit-agent interface +* interfaces: add pcscd interface +* Kernel command-line can now be edited in the gadget.yaml +* Only track validation-sets in run-mode, fixes validation-set issues on first boot. +* Added support for using store.access to disable access to snap store +* Support for fat16 partition in gadget +* Pre-seed authority delegation is now possible +* Support new system-user name _daemon_ +* Several bug fixes and improvements around remodelling +* Offline remodelling support + +# New in snapd 2.60.4: +* Switch to plug/slot in the "qualcomm-ipc-router" interface + but keeping backward compatibility +* Fix "custom-device" udev KERNEL values +* Allow firmware-updater snap to install user-daemons +* Allow loopback as a block device + +# NEW in snapd 2.60.3: +* Fix bug in the "private" plug attribute of the shared-memory + interface that can result in a crash when upgrading from an + old version of snapd. +* Fix missing integration of the /etc/apparmor.d/tunables/home.d/ + apparmor to support non-standard home directories + +# New in snapd 2.60.2: +* Performance improvements for apparmor_parser to compensate for + the slower `-O expr-simplify` default used. This should bring + the performance back to the 2.60 level and even increase it + for many use-cases. +* Bugfixes + +# New in snapd 2.60.1: +* Bugfixes +* Use "aes-cbc-essiv:sha256" in cryptsetup on arm 32bit devices + to increase speed on devices with CAAM support +* Stop using `-O no-expr-simplify` in apparmor_parser to avoid + potential exponential memory use. This can lead to slower + policy complication in some cases but it is much safer on + low memory devices. + +# New in snapd 2.60: +* Support for dynamic snapshot data exclusions +* Apparmor userspace is vendored inside the snapd snap +* Added a default-configure hook that exposes gadget default configuration + options to snaps during first install before services are started +* Allow install from initrd to speed up the initial installation for + systems that do not have a install-device hook +* New `snap sign --chain` flag that appends the account and account-key + assertions +* Support validation-sets in the model assertion +* Support new "min-size" field in gadget.yaml +* New interface: "userns" + +# New in snapd 2.59.5: +* Explicitly disallow the use of ioctl + TIOCLINUX + This fixes CVE-2023-1523. + +# New in snapd 2.59.4: +* Retry when looking for disk label on non-UEFI systems +* Fix remodel from UC20 to UC22 + +# New in snapd 2.59.3: +* Fix quiet boot +* Ignore case for vfat paritions when validating +* Restart always enabled units + +# New in snapd 2.59.2: +* Notify users when a user triggered auto refresh finished + +# New in snapd 2.59.1: + +* Add udev rules from steam-devices to steam-support interface +* Bugfixes for layout path checking, dm_crypt permissions, + mount-control interface parameter checking, kernel commandline + parsing, docker-support, refresh-app-awareness + +# New in snapd 2.59: + +* Support setting extra kernel command line parameters via snap + configuration and under a gadget allow-list +* Support for Full-Disk-Encryption using ICE +* Support for arbitrary home dir locations via snap configuration +* New nvidia-drivers-support interface +* Support for udisks2 snap +* Pre-download of snaps ready for refresh and automatic refresh of the + snap when all apps are closed +* New microovn interface +* Support uboot with `CONFIG_SYS_REDUNDAND_ENV=n` +* Make "snap-preseed --reset" re-exec when needed +* Update the fwupd interface to support fully confined fwupd +* The memory,cpu,thread quota options are no longer experimental +* Support debugging snap client requests via the `SNAPD_CLIENT_DEBUG_HTTP` + environment variable +* Support ssh listen-address via snap configuration +* Support for quotas on single services +* prepare-image now takes into account snapd versions going into the image, + including in the kernel initrd, to fetch supported assertion formats diff --git a/PULL_REQUEST_TEMPLATE.md b/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..f37c6d41 --- /dev/null +++ b/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,2 @@ +Thanks for helping us make a better snapd! +Have you signed the [license agreement](https://www.ubuntu.com/legal/contributors) and read the [contribution guide](https://github.com/snapcore/snapd/blob/master/CONTRIBUTING.md)? diff --git a/README.md b/README.md new file mode 100644 index 00000000..e844f31e --- /dev/null +++ b/README.md @@ -0,0 +1,72 @@ +[![Snapcraft](https://avatars2.githubusercontent.com/u/19532717?s=200)](https://snapcraft.io) + +# Welcome to snapd + +This is the code repository for **snapd**, the background service that manages +and maintains installed snaps. + +Snaps are app packages for desktop, cloud and IoT that update automatically, +are easy to install, secure, cross-platform and dependency-free. They're being +used on millions of Linux systems every day. + +Alongside its various service and management functions, snapd: +- provides the _snap_ command that's used to install and remove snaps and + interact with the wider snap ecosystem +- implements the confinement policies that isolate snaps from the base system + and from each other +- governs the interfaces that allow snaps to access specific system resources + outside of their confinement + +For general details, including +[installation](https://snapcraft.io/docs/installing-snapd) and [Getting +started](https://snapcraft.io/docs/getting-started) guides, head over to our +[Snap documentation](https://snapcraft.io/docs). If you're looking for +something to install, such as [Spotify](https://snapcraft.io/spotify) or +[Visual Studio Code](https://snapcraft.io/code), take a look at the [Snap +Store](https://snapcraft.io/store). And if you want to build your own snaps, +start with our [Creating a snap](https://snapcraft.io/docs/creating-a-snap) +documentation. + +## Get involved + +This is an [open source](COPYING) project and we warmly welcome community +contributions, suggestions, and constructive feedback. If you're interested in +contributing, please take a look at our [Code of Conduct](CODE_OF_CONDUCT.md) +first. + +- to report an issue, please file [a bug + report](https://bugs.launchpad.net/snapd/+filebug) on our [Launchpad issue +tracker](https://bugs.launchpad.net/snapd/) +- for suggestions and constructive feedback, create a post on the [Snapcraft + forum](https://forum.snapcraft.io/c/snapd) +- to build snapd manually, or to get started with snapd development, see + [HACKING.md](HACKING.md) + +## Get in touch + +We're friendly! We have a community forum at +[https://forum.snapcraft.io](https://forum.snapcraft.io) where we discuss +feature plans, development news, issues, updates and troubleshooting. You can +chat in realtime with the snapd team and our wider community on the +[#snappy](https://web.libera.chat?channel=#snappy) IRC channel on +[libera chat](https://libera.chat/). + +For news and updates, follow us on [Twitter](https://twitter.com/snapcraftio) +and on [Facebook](https://www.facebook.com/snapcraftio). + +## Project status + +| Service | Status | +|-----|:---| +| [Github Actions](https://github.com/actions/) | [![Build Status][actions-image]][actions-url] | +| [GoReport](https://goreportcard.com/) | [![Go Report Card][goreportcard-image]][goreportcard-url] | +| [Codecov](https://codecov.io/) | [![codecov][codecov-image]][codecov-url] | + +[actions-image]: https://github.com/snapcore/snapd/actions/workflows/test.yaml/badge.svg?branch=master +[actions-url]: https://github.com/snapcore/snapd/actions?query=branch%3Amaster+event%3Apush + +[goreportcard-image]: https://goreportcard.com/badge/github.com/snapcore/snapd +[goreportcard-url]: https://goreportcard.com/report/github.com/snapcore/snapd + +[codecov-url]: https://codecov.io/gh/snapcore/snapd +[codecov-image]: https://codecov.io/gh/snapcore/snapd/branch/master/graph/badge.svg diff --git a/advisor/backend_bolt.go b/advisor/backend_bolt.go new file mode 100644 index 00000000..301fb29c --- /dev/null +++ b/advisor/backend_bolt.go @@ -0,0 +1,288 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2018-2024 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package advisor + +import ( + "encoding/json" + "os" + "path/filepath" + "time" + + "go.etcd.io/bbolt" + + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/randutil" +) + +var ( + cmdBucketKey = []byte("Commands") + pkgBucketKey = []byte("Snaps") +) + +type writer struct { + fn string + db *bbolt.DB + tx *bbolt.Tx + cmdBucket *bbolt.Bucket + pkgBucket *bbolt.Bucket +} + +// Create opens the commands database for writing, and starts a +// transaction that drops and recreates the buckets. You should then +// call AddSnap with each snap you wish to add, and them Commit the +// results to make the changes live, or Rollback to abort; either of +// these closes the database again. +func Create() (CommandDB, error) { + var err error + t := &writer{ + fn: dirs.SnapCommandsDB + "." + randutil.RandomString(12) + "~", + } + + t.db, err = bbolt.Open(t.fn, 0644, &bbolt.Options{Timeout: 1 * time.Second}) + if err != nil { + return nil, err + } + + t.tx, err = t.db.Begin(true) + if err == nil { + t.cmdBucket, err = t.tx.CreateBucket(cmdBucketKey) + if err == nil { + t.pkgBucket, err = t.tx.CreateBucket(pkgBucketKey) + } + + if err != nil { + t.tx.Rollback() + } + } + + if err != nil { + t.db.Close() + return nil, err + } + + return t, nil +} + +func (t *writer) AddSnap(snapName, version, summary string, commands []string) error { + for _, cmd := range commands { + var sil []Package + + bcmd := []byte(cmd) + row := t.cmdBucket.Get(bcmd) + if row != nil { + if err := json.Unmarshal(row, &sil); err != nil { + return err + } + } + // For the mapping of command->snap we do not need the summary, nothing is using that. + sil = append(sil, Package{Snap: snapName, Version: version}) + row, err := json.Marshal(sil) + if err != nil { + return err + } + if err := t.cmdBucket.Put(bcmd, row); err != nil { + return err + } + } + + // TODO: use json here as well and put the version information here + bj, err := json.Marshal(Package{ + Snap: snapName, + Version: version, + Summary: summary, + }) + if err != nil { + return err + } + if err := t.pkgBucket.Put([]byte(snapName), bj); err != nil { + return err + } + + return nil +} + +func (t *writer) Commit() error { + // either everything worked, and therefore this will fail, or something + // will fail, and that error is more important than this one if this one + // then fails as well. So, ignore the error. + defer os.Remove(t.fn) + + if err := t.done(true); err != nil { + return err + } + + dir, err := os.Open(filepath.Dir(dirs.SnapCommandsDB)) + if err != nil { + return err + } + defer dir.Close() + + if err := os.Rename(t.fn, dirs.SnapCommandsDB); err != nil { + return err + } + + return dir.Sync() +} + +func (t *writer) Rollback() error { + e1 := t.done(false) + e2 := os.Remove(t.fn) + if e1 == nil { + return e2 + } + return e1 +} + +func (t *writer) done(commit bool) error { + var e1, e2 error + + t.cmdBucket = nil + t.pkgBucket = nil + if t.tx != nil { + if commit { + e1 = t.tx.Commit() + } else { + e1 = t.tx.Rollback() + } + t.tx = nil + } + if t.db != nil { + e2 = t.db.Close() + t.db = nil + } + if e1 == nil { + return e2 + } + return e1 +} + +// DumpCommands returns the whole database as a map. For use in +// testing and debugging. +func DumpCommands() (map[string]string, error) { + db, err := bbolt.Open(dirs.SnapCommandsDB, 0644, &bbolt.Options{ + ReadOnly: true, + Timeout: 1 * time.Second, + }) + if err != nil { + return nil, err + } + defer db.Close() + + tx, err := db.Begin(false) + if err != nil { + return nil, err + } + defer tx.Rollback() + + b := tx.Bucket(cmdBucketKey) + if b == nil { + return nil, nil + } + + m := map[string]string{} + c := b.Cursor() + for k, v := c.First(); k != nil; k, v = c.Next() { + m[string(k)] = string(v) + } + + return m, nil +} + +type boltFinder struct { + *bbolt.DB +} + +// Open the database for reading. +func Open() (Finder, error) { + // Check for missing file manually to workaround bug in bolt. + // bolt.Open() is using os.OpenFile(.., os.O_RDONLY | + // os.O_CREATE) even if ReadOnly mode is used. So we would get + // a misleading "permission denied" error without this check. + if !osutil.FileExists(dirs.SnapCommandsDB) { + return nil, os.ErrNotExist + } + db, err := bbolt.Open(dirs.SnapCommandsDB, 0644, &bbolt.Options{ + ReadOnly: true, + Timeout: 1 * time.Second, + }) + if err != nil { + return nil, err + } + + return &boltFinder{db}, nil +} + +func (f *boltFinder) FindCommand(command string) ([]Command, error) { + tx, err := f.Begin(false) + if err != nil { + return nil, err + } + defer tx.Rollback() + + b := tx.Bucket(cmdBucketKey) + if b == nil { + return nil, nil + } + + buf := b.Get([]byte(command)) + if buf == nil { + return nil, nil + } + var sil []Package + if err := json.Unmarshal(buf, &sil); err != nil { + return nil, err + } + cmds := make([]Command, len(sil)) + for i, si := range sil { + cmds[i] = Command{ + Snap: si.Snap, + Version: si.Version, + Command: command, + } + } + + return cmds, nil +} + +func (f *boltFinder) FindPackage(pkgName string) (*Package, error) { + tx, err := f.Begin(false) + if err != nil { + return nil, err + } + defer tx.Rollback() + + b := tx.Bucket(pkgBucketKey) + if b == nil { + return nil, nil + } + + bj := b.Get([]byte(pkgName)) + if bj == nil { + return nil, nil + } + var si Package + err = json.Unmarshal(bj, &si) + if err != nil { + return nil, err + } + + return &Package{Snap: pkgName, Version: si.Version, Summary: si.Summary}, nil +} diff --git a/advisor/backend_common.go b/advisor/backend_common.go new file mode 100644 index 00000000..5655e0cd --- /dev/null +++ b/advisor/backend_common.go @@ -0,0 +1,37 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2018-2024 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package advisor + +import "errors" + +type CommandDB interface { + // AddSnap adds the entries for commands pointing to the given + // snap name to the commands database. + AddSnap(snapName, version, summary string, commands []string) error + // Commit persist the changes, and closes the database. If the + // database has already been committed/rollbacked, does nothing. + Commit() error + // Rollback aborts the changes, and closes the database. If the + // database has already been committed/rollbacked, does nothing. + Rollback() error +} + +// ErrNotSupported indicates that advisor is not supported. +var ErrNotSupported = errors.New("advisor is not supported") diff --git a/advisor/backend_test.go b/advisor/backend_test.go new file mode 100644 index 00000000..25b35ea6 --- /dev/null +++ b/advisor/backend_test.go @@ -0,0 +1,76 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2023 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package advisor_test + +import ( + "os" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/advisor" + "github.com/snapcore/snapd/dirs" +) + +type backendSuite struct{} + +var _ = Suite(&backendSuite{}) + +func (s *backendSuite) SetUpTest(c *C) { + dirs.SetRootDir(c.MkDir()) + c.Assert(os.MkdirAll(dirs.SnapCacheDir, 0755), IsNil) + + // create an empty DB + db, err := advisor.Create() + c.Assert(err, IsNil) + err = db.Commit() + c.Assert(err, IsNil) +} + +func dumpCommands(c *C) map[string]string { + cmds, err := advisor.DumpCommands() + c.Assert(err, IsNil) + return cmds +} + +func (s *backendSuite) TestCreateCommit(c *C) { + expectedCommands := map[string]string{ + "meh": `[{"snap":"foo","version":"1.0"}]`, + "foo": `[{"snap":"foo","version":"1.0"}]`, + } + + db, err := advisor.Create() + c.Assert(err, IsNil) + c.Assert(db.AddSnap("foo", "1.0", "foo summary", []string{"foo", "meh"}), IsNil) + // adding does not change the DB + c.Check(dumpCommands(c), DeepEquals, map[string]string{}) + // but commit does + c.Assert(db.Commit(), IsNil) + c.Check(dumpCommands(c), DeepEquals, expectedCommands) +} + +func (s *backendSuite) TestCreateRollback(c *C) { + db, err := advisor.Create() + c.Assert(err, IsNil) + // adding does not change the DB + c.Assert(db.AddSnap("foo", "1.0", "foo summary", []string{"foo", "meh"}), IsNil) + // and rollback ensures any change is reverted + c.Assert(db.Rollback(), IsNil) + c.Check(dumpCommands(c), DeepEquals, map[string]string{}) +} diff --git a/advisor/cmdfinder.go b/advisor/cmdfinder.go new file mode 100644 index 00000000..7cfadb5c --- /dev/null +++ b/advisor/cmdfinder.go @@ -0,0 +1,110 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2018 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package advisor + +import ( + "os" +) + +type Command struct { + Snap string + Version string `json:"Version,omitempty"` + Command string +} + +func FindCommand(command string) ([]Command, error) { + finder, err := newFinder() + if err != nil && os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + defer finder.Close() + + return finder.FindCommand(command) +} + +const ( + minLen = 3 + maxLen = 256 +) + +// based on CommandNotFound.py:similar_words.py +func similarWords(word string) []string { + const alphabet = "abcdefghijklmnopqrstuvwxyz-_0123456789" + similar := make(map[string]bool, 2*len(word)+2*len(word)*len(alphabet)) + + // deletes + for i := range word { + similar[word[:i]+word[i+1:]] = true + } + // transpose + for i := 0; i < len(word)-1; i++ { + similar[word[:i]+word[i+1:i+2]+word[i:i+1]+word[i+2:]] = true + } + // replaces + for i := range word { + for _, r := range alphabet { + similar[word[:i]+string(r)+word[i+1:]] = true + } + } + // inserts + for i := range word { + for _, r := range alphabet { + similar[word[:i]+string(r)+word[i:]] = true + } + } + + // convert for output + ret := make([]string, 0, len(similar)) + for w := range similar { + ret = append(ret, w) + } + + return ret +} + +func FindMisspelledCommand(command string) ([]Command, error) { + if len(command) < minLen || len(command) > maxLen { + return nil, nil + } + finder, err := newFinder() + if err != nil && os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + defer finder.Close() + + alternatives := make([]Command, 0, 32) + for _, w := range similarWords(command) { + res, err := finder.FindCommand(w) + if err != nil { + return nil, err + } + if len(res) > 0 { + alternatives = append(alternatives, res...) + } + } + + return alternatives, nil +} diff --git a/advisor/cmdfinder_test.go b/advisor/cmdfinder_test.go new file mode 100644 index 00000000..5a83e540 --- /dev/null +++ b/advisor/cmdfinder_test.go @@ -0,0 +1,153 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2018 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package advisor_test + +import ( + "os" + "sort" + "testing" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/advisor" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/testutil" +) + +// Hook up check.v1 into the "go test" runner +func Test(t *testing.T) { TestingT(t) } + +type cmdfinderSuite struct{} + +var _ = Suite(&cmdfinderSuite{}) + +func (s *cmdfinderSuite) SetUpTest(c *C) { + dirs.SetRootDir(c.MkDir()) + c.Assert(os.MkdirAll(dirs.SnapCacheDir, 0755), IsNil) + + db, err := advisor.Create() + c.Assert(err, IsNil) + c.Assert(db.AddSnap("foo", "1.0", "foo summary", []string{"foo", "meh"}), IsNil) + c.Assert(db.AddSnap("bar", "2.0", "bar summary", []string{"bar", "meh"}), IsNil) + c.Assert(db.Commit(), IsNil) +} + +func (s *cmdfinderSuite) TestFindSimilarWordsCnf(c *C) { + words := advisor.SimilarWords("123") + sort.Strings(words) + c.Check(words, DeepEquals, []string{ + // calculated using CommandNotFound.py:similar_words("123") + "-123", "-23", "0123", "023", "1-23", "1-3", "1023", + "103", "1123", "113", "12", "12-", "12-3", "120", + "1203", "121", "1213", "122", "1223", "123", "1233", + "124", "1243", "125", "1253", "126", "1263", "127", + "1273", "128", "1283", "129", "1293", "12_", "12_3", + "12a", "12a3", "12b", "12b3", "12c", "12c3", "12d", + "12d3", "12e", "12e3", "12f", "12f3", "12g", "12g3", + "12h", "12h3", "12i", "12i3", "12j", "12j3", "12k", + "12k3", "12l", "12l3", "12m", "12m3", "12n", "12n3", + "12o", "12o3", "12p", "12p3", "12q", "12q3", "12r", + "12r3", "12s", "12s3", "12t", "12t3", "12u", "12u3", + "12v", "12v3", "12w", "12w3", "12x", "12x3", "12y", + "12y3", "12z", "12z3", "13", "132", "1323", "133", + "1423", "143", "1523", "153", "1623", "163", "1723", + "173", "1823", "183", "1923", "193", "1_23", "1_3", + "1a23", "1a3", "1b23", "1b3", "1c23", "1c3", "1d23", + "1d3", "1e23", "1e3", "1f23", "1f3", "1g23", "1g3", + "1h23", "1h3", "1i23", "1i3", "1j23", "1j3", "1k23", + "1k3", "1l23", "1l3", "1m23", "1m3", "1n23", "1n3", + "1o23", "1o3", "1p23", "1p3", "1q23", "1q3", "1r23", + "1r3", "1s23", "1s3", "1t23", "1t3", "1u23", "1u3", + "1v23", "1v3", "1w23", "1w3", "1x23", "1x3", "1y23", + "1y3", "1z23", "1z3", "2123", "213", "223", "23", + "3123", "323", "4123", "423", "5123", "523", "6123", + "623", "7123", "723", "8123", "823", "9123", "923", + "_123", "_23", "a123", "a23", "b123", "b23", "c123", + "c23", "d123", "d23", "e123", "e23", "f123", "f23", + "g123", "g23", "h123", "h23", "i123", "i23", "j123", + "j23", "k123", "k23", "l123", "l23", "m123", "m23", + "n123", "n23", "o123", "o23", "p123", "p23", "q123", + "q23", "r123", "r23", "s123", "s23", "t123", "t23", + "u123", "u23", "v123", "v23", "w123", "w23", "x123", + "x23", "y123", "y23", "z123", "z23", + }) +} + +func (s *cmdfinderSuite) TestFindSimilarWordsTrivial(c *C) { + words := advisor.SimilarWords("hella") + c.Check(words, testutil.Contains, "hello") +} + +func (s *cmdfinderSuite) TestFindCommandHit(c *C) { + cmds, err := advisor.FindCommand("meh") + c.Assert(err, IsNil) + c.Check(cmds, DeepEquals, []advisor.Command{ + {Snap: "foo", Version: "1.0", Command: "meh"}, + {Snap: "bar", Version: "2.0", Command: "meh"}, + }) +} + +func (s *cmdfinderSuite) TestFindCommandMiss(c *C) { + cmds, err := advisor.FindCommand("moh") + c.Assert(err, IsNil) + c.Check(cmds, HasLen, 0) +} + +func (s *cmdfinderSuite) TestFindMisspelledCommandHit(c *C) { + cmds, err := advisor.FindMisspelledCommand("moh") + c.Assert(err, IsNil) + c.Check(cmds, DeepEquals, []advisor.Command{ + {Snap: "foo", Version: "1.0", Command: "meh"}, + {Snap: "bar", Version: "2.0", Command: "meh"}, + }) +} + +func (s *cmdfinderSuite) TestFindMisspelledCommandMiss(c *C) { + cmds, err := advisor.FindMisspelledCommand("hello") + c.Assert(err, IsNil) + c.Check(cmds, HasLen, 0) +} + +func (s *cmdfinderSuite) TestDumpCommands(c *C) { + cmds, err := advisor.DumpCommands() + c.Assert(err, IsNil) + c.Check(cmds, DeepEquals, map[string]string{ + "foo": `[{"snap":"foo","version":"1.0"}]`, + "bar": `[{"snap":"bar","version":"2.0"}]`, + "meh": `[{"snap":"foo","version":"1.0"},{"snap":"bar","version":"2.0"}]`, + }) +} + +func (s *cmdfinderSuite) TestFindMissingCommandsDB(c *C) { + err := os.Remove(dirs.SnapCommandsDB) + c.Assert(err, IsNil) + + cmds, err := advisor.FindMisspelledCommand("hello") + c.Assert(err, IsNil) + c.Check(cmds, HasLen, 0) + + cmds, err = advisor.FindCommand("hello") + c.Assert(err, IsNil) + c.Check(cmds, HasLen, 0) + + pkg, err := advisor.FindPackage("hello") + c.Assert(err, IsNil) + c.Check(pkg, IsNil) +} diff --git a/advisor/export_test.go b/advisor/export_test.go new file mode 100644 index 00000000..20b4ce15 --- /dev/null +++ b/advisor/export_test.go @@ -0,0 +1,22 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2018 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package advisor + +var SimilarWords = similarWords diff --git a/advisor/finder.go b/advisor/finder.go new file mode 100644 index 00000000..ca3b9982 --- /dev/null +++ b/advisor/finder.go @@ -0,0 +1,36 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2018 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package advisor + +var newFinder = Open + +type Finder interface { + FindCommand(command string) ([]Command, error) + FindPackage(pkgName string) (*Package, error) + Close() error +} + +func ReplaceCommandsFinder(constructor func() (Finder, error)) (restore func()) { + old := newFinder + newFinder = constructor + return func() { + newFinder = old + } +} diff --git a/advisor/pkgfinder.go b/advisor/pkgfinder.go new file mode 100644 index 00000000..bae4f820 --- /dev/null +++ b/advisor/pkgfinder.go @@ -0,0 +1,43 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2018 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package advisor + +import ( + "os" +) + +type Package struct { + Snap string `json:"snap"` + Version string `json:"version"` + Summary string `json:"summary,omitempty"` +} + +func FindPackage(pkgName string) (*Package, error) { + finder, err := newFinder() + if err != nil && os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + defer finder.Close() + + return finder.FindPackage(pkgName) +} diff --git a/advisor/pkgfinder_test.go b/advisor/pkgfinder_test.go new file mode 100644 index 00000000..fd08017b --- /dev/null +++ b/advisor/pkgfinder_test.go @@ -0,0 +1,40 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2018 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package advisor_test + +import ( + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/advisor" +) + +func (s *cmdfinderSuite) TestFindPackageHit(c *C) { + pkg, err := advisor.FindPackage("foo") + c.Assert(err, IsNil) + c.Check(pkg, DeepEquals, &advisor.Package{ + Snap: "foo", Version: "1.0", Summary: "foo summary", + }) +} + +func (s *cmdfinderSuite) TestFindPackageMiss(c *C) { + pkg, err := advisor.FindPackage("moh") + c.Assert(err, IsNil) + c.Check(pkg, IsNil) +} diff --git a/arch/arch.go b/arch/arch.go new file mode 100644 index 00000000..f2d6ab55 --- /dev/null +++ b/arch/arch.go @@ -0,0 +1,133 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2014-2015 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package arch + +import ( + "log" + "runtime" + + "github.com/snapcore/snapd/osutil" +) + +// ArchitectureType is the type for a supported snappy architecture +type ArchitectureType string + +// arch is global to allow tools like ubuntu-device-flash to +// change the architecture. This is important to e.g. install +// armhf snaps onto a armhf image that is generated on an amd64 +// machine +var arch = ArchitectureType(dpkgArchFromGoArch(runtime.GOARCH)) + +// SetArchitecture allows overriding the auto detected Architecture +func SetArchitecture(newArch ArchitectureType) { + arch = newArch +} + +// DpkgArchitecture returns the debian equivalent architecture for the +// currently running architecture. +// +// If the architecture does not map any debian architecture, the +// GOARCH is returned. +func DpkgArchitecture() string { + return string(arch) +} + +// dpkgArchFromGoArch maps a go architecture string to the coresponding +// Debian equivalent architecture string. +// +// E.g. the go "386" architecture string maps to the ubuntu "i386" +// architecture. +func dpkgArchFromGoArch(goarch string) string { + goArchMapping := map[string]string{ + // go dpkg + "386": "i386", + "amd64": "amd64", + "arm": "armhf", + "arm64": "arm64", + "ppc": "powerpc", + "ppc64": "ppc64", // available in debian and other distros + "ppc64le": "ppc64el", + "riscv64": "riscv64", + "s390x": "s390x", + } + + // If we are running on an ARM platform we need to have a + // closer look if we are on armhf or armel. If we're not + // on a armv6 platform we can continue to use the Go + // arch mapping. The Go arch sadly doesn't map this out + // for us so we have to fallback to uname here. + if goarch == "arm" { + if osutil.MachineName() == "armv6l" { + return "armel" + } + } + + dpkgArch := goArchMapping[goarch] + if dpkgArch == "" { + log.Panicf("unknown goarch %q", goarch) + } + + return dpkgArch +} + +// DpkgKernelArchitecture returns the debian equivalent architecture +// for the current running kernel. This is usually the same as the +// DpkgArchitecture - however there maybe cases that you run e.g. +// a snapd:i386 on an amd64 kernel. +func DpkgKernelArchitecture() string { + return dpkgArchFromKernelArch(osutil.MachineName()) +} + +// dpkgArchFromkernelArch maps the kernel architecture as reported +// via uname() to the dpkg architecture +func dpkgArchFromKernelArch(utsMachine string) string { + kernelArchMapping := map[string]string{ + // kernel dpkg + "aarch64": "arm64", + "armv7l": "armhf", + "armv8l": "arm64", + "i686": "i386", + "ppc": "powerpc", + "ppc64": "ppc64", // available in debian and other distros + "ppc64le": "ppc64el", + "riscv64": "riscv64", + "s390x": "s390x", + "x86_64": "amd64", + } + + dpkgArch := kernelArchMapping[utsMachine] + if dpkgArch == "" { + log.Panicf("unknown kernel arch %q", utsMachine) + } + + return dpkgArch +} + +// IsSupportedArchitecture returns true if the system architecture is in the +// list of architectures. +func IsSupportedArchitecture(architectures []string) bool { + for _, a := range architectures { + if a == "all" || a == string(arch) { + return true + } + } + + return false +} diff --git a/arch/arch_test.go b/arch/arch_test.go new file mode 100644 index 00000000..9d38bbd4 --- /dev/null +++ b/arch/arch_test.go @@ -0,0 +1,64 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2014-2015 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package arch + +import ( + "testing" + + . "gopkg.in/check.v1" +) + +// Hook up check.v1 into the "go test" runner +func Test(t *testing.T) { TestingT(t) } + +var _ = Suite(&ArchTestSuite{}) + +type ArchTestSuite struct { +} + +func (ts *ArchTestSuite) TestArchDpkgArchitecture(c *C) { + c.Check(dpkgArchFromGoArch("386"), Equals, "i386") + c.Check(dpkgArchFromGoArch("amd64"), Equals, "amd64") + c.Check(dpkgArchFromGoArch("arm"), Equals, "armhf") + c.Check(dpkgArchFromGoArch("arm64"), Equals, "arm64") + c.Check(dpkgArchFromGoArch("ppc"), Equals, "powerpc") + c.Check(dpkgArchFromGoArch("ppc64"), Equals, "ppc64") + c.Check(dpkgArchFromGoArch("ppc64le"), Equals, "ppc64el") + c.Check(dpkgArchFromGoArch("riscv64"), Equals, "riscv64") + c.Check(dpkgArchFromGoArch("s390x"), Equals, "s390x") +} + +func (ts *ArchTestSuite) TestArchSetArchitecture(c *C) { + SetArchitecture("armhf") + c.Assert(DpkgArchitecture(), Equals, "armhf") +} + +func (ts *ArchTestSuite) TestArchSupportedArchitectures(c *C) { + arch = "armhf" + c.Check(IsSupportedArchitecture([]string{"all"}), Equals, true) + c.Check(IsSupportedArchitecture([]string{"amd64", "armhf", "powerpc"}), Equals, true) + c.Check(IsSupportedArchitecture([]string{"armhf"}), Equals, true) + c.Check(IsSupportedArchitecture([]string{"amd64", "powerpc"}), Equals, false) + + arch = "amd64" + c.Check(IsSupportedArchitecture([]string{"all"}), Equals, true) + c.Check(IsSupportedArchitecture([]string{"amd64", "armhf", "powerpc"}), Equals, true) + c.Check(IsSupportedArchitecture([]string{"powerpc"}), Equals, false) +} diff --git a/arch/archtest/archtest.go b/arch/archtest/archtest.go new file mode 100644 index 00000000..78d5dd51 --- /dev/null +++ b/arch/archtest/archtest.go @@ -0,0 +1,31 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016-2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package archtest + +import "github.com/snapcore/snapd/arch" + +// MockArchitecture mocks an architecture and returns a function to +// restore to the current value. +func MockArchitecture(newArch arch.ArchitectureType) (restore func()) { + currentArch := arch.DpkgArchitecture() + arch.SetArchitecture(newArch) + + return func() { arch.SetArchitecture(arch.ArchitectureType(currentArch)) } +} diff --git a/arch/endian.go b/arch/endian.go new file mode 100644 index 00000000..a6c8a1a4 --- /dev/null +++ b/arch/endian.go @@ -0,0 +1,40 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2023 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package arch + +import ( + "encoding/binary" + "fmt" + "runtime" +) + +var runtimeGOARCH = runtime.GOARCH + +// Endian will return the native endianness of the system +func Endian() binary.ByteOrder { + switch runtimeGOARCH { + case "ppc", "ppc64", "s390x": + return binary.BigEndian + case "386", "amd64", "arm", "arm64", "ppc64le", "riscv64": + return binary.LittleEndian + default: + panic(fmt.Sprintf("unknown architecture %s", runtimeGOARCH)) + } +} diff --git a/arch/endian_test.go b/arch/endian_test.go new file mode 100644 index 00000000..c35baea5 --- /dev/null +++ b/arch/endian_test.go @@ -0,0 +1,117 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2023 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package arch_test + +import ( + "encoding/binary" + "fmt" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/arch" +) + +type endianTestSuite struct{} + +var _ = Suite(&endianTestSuite{}) + +// back in 14.04/16.04 32bit powerpc was supported via gccgo +var knownGccGoArch = map[string]bool{ + "ppc": true, +} + +// copied from: +// https://github.com/golang/go/blob/release-branch.go1.20/src/go/build/syslist.go#L53 +// alternatively this could be done via "go tool dist list" but seems not +// worth the extra parsing +var knownArch = map[string]bool{ + "386": true, + "amd64": true, + "amd64p32": true, + "arm": true, + "armbe": true, + "arm64": true, + "arm64be": true, + "loong64": true, + "mips": true, + "mipsle": true, + "mips64": true, + "mips64le": true, + "mips64p32": true, + "mips64p32le": true, + "ppc": true, + "ppc64": true, + "ppc64le": true, + "riscv": true, + "riscv64": true, + "s390": true, + "s390x": true, + "sparc": true, + "sparc64": true, + "wasm": true, +} + +func knownGoArch(arch string) error { + // this knownGccGoArch map can be removed after 16.04 goes EOL + // in 2026 + if knownGccGoArch[arch] { + return nil + } + + if knownArch[arch] { + return nil + } + + return fmt.Errorf("cannot find %s in supported go arches", arch) +} + +func (s *endianTestSuite) TestKnownGoArch(c *C) { + c.Check(knownGoArch("not-supported-arch"), ErrorMatches, "cannot find not-supported-arch in supported go arches") +} + +func (s *endianTestSuite) TestEndian(c *C) { + for _, t := range []struct { + arch string + endian binary.ByteOrder + }{ + {"ppc", binary.BigEndian}, + {"ppc64", binary.BigEndian}, + {"s390x", binary.BigEndian}, + {"386", binary.LittleEndian}, + {"amd64", binary.LittleEndian}, + {"arm", binary.LittleEndian}, + {"arm64", binary.LittleEndian}, + {"ppc64le", binary.LittleEndian}, + {"riscv64", binary.LittleEndian}, + } { + restore := arch.MockRuntimeGOARCH(t.arch) + defer restore() + + c.Check(arch.Endian(), Equals, t.endian) + c.Check(knownGoArch(t.arch), IsNil) + } +} + +func (s *endianTestSuite) TestEndianErrors(c *C) { + restore := arch.MockRuntimeGOARCH("unknown-arch") + defer restore() + + c.Check(func() { arch.Endian() }, Panics, "unknown architecture unknown-arch") +} diff --git a/arch/export_test.go b/arch/export_test.go new file mode 100644 index 00000000..5eac9509 --- /dev/null +++ b/arch/export_test.go @@ -0,0 +1,30 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2023 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package arch + +import ( + "github.com/snapcore/snapd/testutil" +) + +func MockRuntimeGOARCH(arch string) (restore func()) { + restore = testutil.Backup(&runtimeGOARCH) + runtimeGOARCH = arch + return restore +} diff --git a/aspects/aspects.go b/aspects/aspects.go new file mode 100644 index 00000000..2187f099 --- /dev/null +++ b/aspects/aspects.go @@ -0,0 +1,1556 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package aspects + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "math" + "reflect" + "regexp" + "sort" + "strings" + + "github.com/snapcore/snapd/jsonutil" + "github.com/snapcore/snapd/strutil" +) + +type accessType int + +const ( + readWrite accessType = iota + read + write +) + +var accessTypeStrings = []string{"read-write", "read", "write"} + +func newAccessType(access string) (accessType, error) { + // default to read-write access + if access == "" { + access = "read-write" + } + + for i, accessStr := range accessTypeStrings { + if accessStr == access { + return accessType(i), nil + } + } + + return readWrite, fmt.Errorf("expected 'access' to be either %s or empty but was %q", strutil.Quoted(accessTypeStrings), access) +} + +type NotFoundError struct { + Account string + BundleName string + Aspect string + Operation string + Requests []string + Cause string +} + +func (e *NotFoundError) Error() string { + var reqStr string + if len(e.Requests) == 1 { + reqStr = fmt.Sprintf("%q", e.Requests[0]) + } else { + reqStr = strutil.Quoted(e.Requests) + } + return fmt.Sprintf("cannot %s %s in aspect %s/%s/%s: %s", e.Operation, reqStr, e.Account, e.BundleName, e.Aspect, e.Cause) +} + +func (e *NotFoundError) Is(err error) bool { + _, ok := err.(*NotFoundError) + return ok +} + +func notFoundErrorFrom(a *Aspect, op, request, errMsg string) *NotFoundError { + return &NotFoundError{ + Account: a.bundle.Account, + BundleName: a.bundle.Name, + Aspect: a.Name, + Operation: op, + Requests: []string{request}, + Cause: errMsg, + } +} + +type BadRequestError struct { + Account string + BundleName string + Aspect string + Operation string + Request string + Cause string +} + +func (e *BadRequestError) Error() string { + return fmt.Sprintf("cannot %s %q in aspect %s/%s/%s: %s", e.Operation, e.Request, e.Account, e.BundleName, e.Aspect, e.Cause) +} + +func (e *BadRequestError) Is(err error) bool { + _, ok := err.(*BadRequestError) + return ok +} + +func badRequestErrorFrom(a *Aspect, operation, request, errMsg string, v ...interface{}) *BadRequestError { + return &BadRequestError{ + Account: a.bundle.Account, + BundleName: a.bundle.Name, + Aspect: a.Name, + Operation: operation, + Request: request, + Cause: fmt.Sprintf(errMsg, v...), + } +} + +// DataBag controls access to the aspect data storage. +type DataBag interface { + Get(path string) (interface{}, error) + Set(path string, value interface{}) error + Unset(path string) error + Data() ([]byte, error) +} + +// Schema takes in data from the DataBag and validates that it's valid and could +// be committed. +type Schema interface { + Validate(data []byte) error + + // SchemaAt returns the schemas (e.g., string, int, etc) that may be at the + // provided path. If the path cannot be followed, an error is returned. + SchemaAt(path []string) ([]Schema, error) + + // Type returns the SchemaType corresponding to the Schema. + Type() SchemaType +} + +type SchemaType uint + +func (v SchemaType) String() string { + if int(v) >= len(typeStrings) { + panic("unknown schema type") + } + + return typeStrings[v] +} + +const ( + Int SchemaType = iota + Number + String + Bool + Map + Array + Any + Alt +) + +var typeStrings = [...]string{"int", "number", "string", "bool", "map", "array", "any", "alt"} + +// Bundle holds a series of related aspects. +type Bundle struct { + Account string + Name string + Schema Schema + aspects map[string]*Aspect +} + +// NewBundle returns a new aspect bundle with the specified aspects and their rules. +func NewBundle(account string, bundleName string, aspects map[string]interface{}, schema Schema) (*Bundle, error) { + if len(aspects) == 0 { + return nil, errors.New(`cannot define aspects bundle: no aspects`) + } + + aspectBundle := &Bundle{ + Account: account, + Name: bundleName, + Schema: schema, + aspects: make(map[string]*Aspect, len(aspects)), + } + + for name, v := range aspects { + aspectMap, ok := v.(map[string]interface{}) + if !ok || len(aspectMap) == 0 { + return nil, fmt.Errorf("cannot define aspect %q: aspect must be non-empty map", name) + } + + if summary, ok := aspectMap["summary"]; ok { + if _, ok = summary.(string); !ok { + return nil, fmt.Errorf("cannot define aspect %q: aspect summary must be a string but got %T", name, summary) + } + } + + rules, ok := aspectMap["rules"].([]interface{}) + if !ok || len(rules) == 0 { + return nil, fmt.Errorf("cannot define aspect %q: aspect rules must be non-empty list", name) + } + + aspect, err := newAspect(aspectBundle, name, rules) + if err != nil { + return nil, fmt.Errorf("cannot define aspect %q: %w", name, err) + } + + aspectBundle.aspects[name] = aspect + } + + return aspectBundle, nil +} + +func newAspect(bundle *Bundle, name string, aspectRules []interface{}) (*Aspect, error) { + aspect := &Aspect{ + Name: name, + rules: make([]*aspectRule, 0, len(aspectRules)), + bundle: bundle, + } + + for _, ruleRaw := range aspectRules { + rules, err := parseRule(nil, ruleRaw) + if err != nil { + return nil, err + } + + aspect.rules = append(aspect.rules, rules...) + } + + readRequests := make(map[string]bool) + for _, rule := range aspect.rules { + switch rule.access { + case read, readWrite: + if readRequests[rule.originalRequest] { + return nil, fmt.Errorf(`cannot have several reading rules with the same "request" field`) + } + + readRequests[rule.originalRequest] = true + } + } + + // check that the rules matching a given request can be satisfied with some + // data type (otherwise, no data can ever be written there) + pathToRules := make(map[string][]*aspectRule) + for _, rule := range aspect.rules { + // TODO: once the paths support list index placeholders, also add mapping + // for the prefixes of each path and their implied types (Map or Array) + path := rule.originalRequest + pathToRules[path] = append(pathToRules[path], rule) + } + + for _, rules := range pathToRules { + if err := checkSchemaMismatch(bundle.Schema, rules); err != nil { + return nil, err + } + } + + return aspect, nil +} + +func parseRule(parent *aspectRule, ruleRaw interface{}) ([]*aspectRule, error) { + ruleMap, ok := ruleRaw.(map[string]interface{}) + if !ok { + return nil, errors.New("each aspect rule should be a map") + } + + storageRaw, ok := ruleMap["storage"] + if !ok || storageRaw == "" { + return nil, errors.New(`aspect rules must have a "storage" field`) + } + + storage, ok := storageRaw.(string) + if !ok { + return nil, errors.New(`"storage" must be a string`) + } + + requestRaw, ok := ruleMap["request"] + if !ok { + // if omitted the "request" field defaults to the same as the "storage" + requestRaw = storage + } else if requestRaw == "" { + return nil, errors.New(`aspect rules' "request" field must be non-empty, if it exists`) + } + + request, ok := requestRaw.(string) + if !ok { + return nil, errors.New(`"request" must be a string`) + } + + if err := validateRequestStoragePair(request, storage); err != nil { + return nil, err + } + + accessRaw, ok := ruleMap["access"] + var access string + if ok { + access, ok = accessRaw.(string) + if !ok { + return nil, errors.New(`"access" must be a string`) + } + } + + if parent != nil { + request = parent.originalRequest + "." + request + storage = parent.originalStorage + "." + storage + } + + rule, err := newAspectRule(request, storage, access) + if err != nil { + return nil, err + } + + rules := []*aspectRule{rule} + if contentRaw, ok := ruleMap["content"]; ok { + contentRulesRaw, ok := contentRaw.([]interface{}) + if !ok || len(contentRulesRaw) == 0 { + return nil, fmt.Errorf(`"content" must be a non-empty list`) + } + + for _, contentRule := range contentRulesRaw { + nestedRules, err := parseRule(rule, contentRule) + if err != nil { + return nil, err + } + + rules = append(rules, nestedRules...) + } + } + + return rules, nil +} + +// validateRequestStoragePair checks that: +// - request and storage are composed of valid subkeys (see: validateAspectString) +// - all placeholders in a request are in the storage and vice-versa +func validateRequestStoragePair(request, storage string) error { + opts := &validationOptions{allowPlaceholder: true} + if err := validateAspectDottedPath(request, opts); err != nil { + return fmt.Errorf("invalid request %q: %w", request, err) + } + + if err := validateAspectDottedPath(storage, opts); err != nil { + return fmt.Errorf("invalid storage %q: %w", storage, err) + } + + reqPlaceholders, storagePlaceholders := getPlaceholders(request), getPlaceholders(storage) + if len(reqPlaceholders) != len(storagePlaceholders) { + return fmt.Errorf("request %q and storage %q have mismatched placeholders", request, storage) + } + + for placeholder := range reqPlaceholders { + if !storagePlaceholders[placeholder] { + return fmt.Errorf("placeholder %q from request %q is absent from storage %q", + placeholder, request, storage) + } + } + + return nil +} + +var ( + subkeyRegex = "(?:[a-z0-9]+-?)*[a-z](?:-?[a-z0-9])*" + validSubkey = regexp.MustCompile(fmt.Sprintf("^%s$", subkeyRegex)) + validPlaceholder = regexp.MustCompile(fmt.Sprintf("^{%s}$", subkeyRegex)) + // TODO: decide on what the format should be for aliases in schemas + validAliasName = validSubkey +) + +type validationOptions struct { + // allowPlaceholder means that placeholders are accepted when validating. + allowPlaceholder bool +} + +// validateAspectDottedPath validates that request/storage strings in an aspect definition are: +// - composed of non-empty, dot-separated subkeys with optional placeholders ("foo.{bar}"), +// if allowed by the validationOptions +// - non-placeholder subkeys are made up of lowercase alphanumeric ASCII characters, +// optionally with dashes between alphanumeric characters (e.g., "a-b-c") +// - placeholder subkeys are composed of non-placeholder subkeys wrapped in curly brackets +func validateAspectDottedPath(path string, opts *validationOptions) (err error) { + if opts == nil { + opts = &validationOptions{} + } + + subkeys := strings.Split(path, ".") + for _, subkey := range subkeys { + if subkey == "" { + return errors.New("cannot have empty subkeys") + } + + if !validSubkey.MatchString(subkey) && (!opts.allowPlaceholder || !validPlaceholder.MatchString(subkey)) { + return fmt.Errorf("invalid subkey %q", subkey) + } + } + + return nil +} + +// getPlaceholders returns the set of placeholders in the string or nil, if +// there is none. +func getPlaceholders(aspectStr string) map[string]bool { + var placeholders map[string]bool + + subkeys := strings.Split(aspectStr, ".") + for _, subkey := range subkeys { + if isPlaceholder(subkey) { + if placeholders == nil { + placeholders = make(map[string]bool) + } + + placeholders[subkey] = true + } + } + + return placeholders +} + +// Aspect returns an aspect from the aspect bundle. +func (d *Bundle) Aspect(aspect string) *Aspect { + return d.aspects[aspect] +} + +// Aspect carries access rules for a particular aspect in a bundle. +type Aspect struct { + Name string + rules []*aspectRule + bundle *Bundle +} + +type expandedMatch struct { + // storagePath is dot-separated storage path without unfilled placeholders. + storagePath string + + // request is the original request field that the request was matched with. + request string + + // value is the nested value obtained after removing the original values' outer + // layers that correspond to the unmatched suffix. + value interface{} +} + +// maxValueDepth is the limit on a value's nestedness. Creating a highly nested +// JSON value only requires a few bytes per level, but when recursively traversing +// such a value, each level requires about 2Kb stack. Prevent excessive stack +// usage by limiting the recursion depth. +var maxValueDepth = 64 + +// validateSetValue checks that map keys conform to the same format as path sub-keys. +func validateSetValue(v interface{}, depth int) error { + if depth > maxValueDepth { + return fmt.Errorf("value cannot have more than %d nested levels", maxValueDepth) + } + + var nestedVals []interface{} + switch typedVal := v.(type) { + case map[string]interface{}: + for k, v := range typedVal { + if !validSubkey.Match([]byte(k)) { + return fmt.Errorf(`key %q doesn't conform to required format: %s`, k, validSubkey.String()) + } + + nestedVals = append(nestedVals, v) + } + + case []interface{}: + nestedVals = typedVal + } + + for _, v := range nestedVals { + if v == nil { + // the value can be nil (used to unset values for compatibility w/ options) + continue + } + + if err := validateSetValue(v, depth+1); err != nil { + return err + } + } + + return nil +} + +// Set sets the named aspect to a specified non-nil value. +func (a *Aspect) Set(databag DataBag, request string, value interface{}) error { + if err := validateAspectDottedPath(request, nil); err != nil { + return badRequestErrorFrom(a, "set", request, err.Error()) + } + + depth := 1 + if err := validateSetValue(value, depth); err != nil { + return badRequestErrorFrom(a, "set", request, err.Error()) + } + + if value == nil { + return fmt.Errorf("internal error: Set value cannot be nil") + } + + matches, err := a.matchWriteRequest(request) + if err != nil { + return err + } + + if len(matches) == 0 { + return notFoundErrorFrom(a, "set", request, "no matching write rule") + } + + // sort less nested paths before more nested ones so that writes aren't overwritten + sort.Slice(matches, func(x, y int) bool { + return matches[x].storagePath < matches[y].storagePath + }) + + var expandedMatches []expandedMatch + suffixes := make(map[string]struct{}, len(matches)) + for _, match := range matches { + pathsToValues, err := getValuesThroughPaths(match.storagePath, match.suffixParts, value) + if err != nil { + return badRequestErrorFrom(a, "set", request, err.Error()) + } + + for path, val := range pathsToValues { + expandedMatches = append(expandedMatches, expandedMatch{ + storagePath: path, + request: match.request, + value: val, + }) + } + + // store the suffix in a map so we deduplicate them before checking if the + // value is used in its entirety + suffixes[strings.Join(match.suffixParts, ".")] = struct{}{} + } + + // check if value is entirely used. If not, we fail so this is consistent + // with doing the same write individually (one branch at a time) + if err := checkForUnusedBranches(value, suffixes); err != nil { + return badRequestErrorFrom(a, "set", request, err.Error()) + } + + for _, match := range expandedMatches { + if err := databag.Set(match.storagePath, match.value); err != nil { + return err + } + + data, err := databag.Data() + if err != nil { + return err + } + + // TODO: when using a transaction, the data only changes on commit so + // this is a bit of a waste. Maybe cache the result so we only do the first + // validation and then in aspectstate on Commit + if err := a.bundle.Schema.Validate(data); err != nil { + return fmt.Errorf(`cannot write data: %w`, err) + } + } + + return nil +} + +func (a *Aspect) Unset(databag DataBag, request string) error { + if err := validateAspectDottedPath(request, nil); err != nil { + return badRequestErrorFrom(a, "unset", request, err.Error()) + } + + matches, err := a.matchWriteRequest(request) + if err != nil { + return err + } + + if len(matches) == 0 { + return notFoundErrorFrom(a, "unset", request, "no matching write rule") + } + + for _, match := range matches { + if err := databag.Unset(match.storagePath); err != nil { + return err + } + + data, err := databag.Data() + if err != nil { + return err + } + + // TODO: when using a transaction, the data only changes on commit so + // this is a bit of a waste. Maybe cache the result so we only do the first + // validation and then in aspectstate on Commit + if err := a.bundle.Schema.Validate(data); err != nil { + return fmt.Errorf(`cannot unset data: %w`, err) + } + } + + return nil +} + +func (a *Aspect) matchWriteRequest(request string) ([]requestMatch, error) { + var matches []requestMatch + subkeys := strings.Split(request, ".") + for _, rule := range a.rules { + placeholders, suffixParts, ok := rule.match(subkeys) + if !ok { + continue + } + + if !rule.isWriteable() { + continue + } + + path, err := rule.storagePath(placeholders) + if err != nil { + return nil, err + } + + matches = append(matches, requestMatch{ + storagePath: path, + suffixParts: suffixParts, + request: rule.originalRequest, + }) + } + + return matches, nil +} + +// checkSchemaMismatch checks whether the rules accept compatible schema types. +// If not, then no data can satisfy these rules and the aspect should be rejected. +func checkSchemaMismatch(schema Schema, rules []*aspectRule) error { + pathTypes := make(map[string][]SchemaType) +out: + for _, rule := range rules { + path := rule.originalStorage + pathParts := strings.Split(path, ".") + schemas, err := schema.SchemaAt(pathParts) + if err != nil { + var serr *schemaAtError + if errors.As(err, &serr) { + parts := strings.Split(path, ".") + subParts := parts[:len(parts)-serr.left] + subPath := strings.Join(subParts, ".") + + return fmt.Errorf(`storage path %q for request %q is invalid after %q: %w`, + path, rule.originalRequest, subPath, serr.err) + } + + return fmt.Errorf(`internal error: unexpected error finding schema at %q: %w`, path, err) + } + + var newTypes []SchemaType + for _, schema := range schemas { + switch t := schema.Type(); t { + case Any: + // schema accepts "any" so it's never incompatible w/ other paths + continue out + case Alt: + // shouldn't happen except for programmer error because alternatives' + // SchemaAt should return the composing schemas, not itself + return fmt.Errorf(`internal error: unexpected Alt schema type along path`) + default: + newTypes = append(newTypes, t) + } + + } + + for oldPath, oldTypes := range pathTypes { + var pathMatch bool + pathMatching: + for _, newType := range newTypes { + // find a pair of types in the two paths that can accept the same data + for _, oldType := range oldTypes { + if newType == oldType || (newType == Number && oldType == Int) || (newType == Int && oldType == Number) { + // accept two different types of number since an int could apply to both + pathMatch = true + break pathMatching + } + } + } + + if !pathMatch { + oldSetStr, newSetStr := schemaTypesStr(oldTypes), schemaTypesStr(newTypes) + return fmt.Errorf(`storage paths %q and %q for request %q require incompatible types: %s != %s`, + oldPath, path, rule.originalRequest, oldSetStr, newSetStr) + } + } + + pathTypes[path] = newTypes + } + + return nil +} + +func schemaTypesStr(types []SchemaType) string { + if len(types) == 1 { + return types[0].String() + } + + var sb strings.Builder + sb.WriteRune('[') + for i, typ := range types { + if i > 0 { + sb.WriteString(", ") + } + sb.WriteString(typ.String()) + } + sb.WriteRune(']') + + return sb.String() +} + +// getValuesThroughPaths takes a match's storage path and unmatched request +// suffix and strips the outer layers of the value to be set so it can be used +// at the storage path. Parts of the suffix that are placeholders will be +// expanded based on what keys exist in the value at that point and the mapping +// will be used to complete the storage path. +var getValuesThroughPaths = getValuesThroughPathsImpl + +func getValuesThroughPathsImpl(storagePath string, reqSuffixParts []string, val interface{}) (map[string]interface{}, error) { + // use the non-placeholder parts of the suffix to find the value to write + var placeIndex int + for _, part := range reqSuffixParts { + if isPlaceholder(part) { + // there is a placeholder, we have to consider potentially many candidates + break + } + + mapVal, ok := val.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf(`expected map for unmatched request parts but got %T`, val) + } + + val, ok = mapVal[part] + if !ok { + return nil, fmt.Errorf(`cannot use unmatched part %q as key in %v`, part, mapVal) + } + + placeIndex++ + } + + // we reached the end of the suffix (there are no unmatched placeholders) so + // we have the full storage path and final value + if placeIndex == len(reqSuffixParts) { + return map[string]interface{}{storagePath: val}, nil + } + + mapVal, ok := val.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf(`expected map for unmatched request parts but got %T`, val) + } + + storagePathsToValues := make(map[string]interface{}) + // suffix has an unmatched placeholder, try all possible values to fill it and + // find the corresponding nested value. + for cand, candVal := range mapVal { + newStoragePath := replaceIn(storagePath, reqSuffixParts[placeIndex], cand) + pathsToValues, err := getValuesThroughPathsImpl(newStoragePath, reqSuffixParts[placeIndex+1:], candVal) + if err != nil { + return nil, err + } + + for path, val := range pathsToValues { + storagePathsToValues[path] = val + } + } + + return storagePathsToValues, nil +} + +func replaceIn(path, key, value string) string { + parts := strings.Split(path, ".") + for i, part := range parts { + if part == key { + parts[i] = value + } + } + + return strings.Join(parts, ".") +} + +// checkForUnusedBranches checks that the value is entirely covered by the paths. +func checkForUnusedBranches(value interface{}, paths map[string]struct{}) error { + // prune each path from the value. If anything is left at the end, the paths + // don't collectively cover the entire value + copyValue := deepCopy(value) + for path := range paths { + var err error + var pathParts []string + if path != "" { + pathParts = strings.Split(path, ".") + } + + copyValue, err = prunePathInValue(pathParts, copyValue) + if err != nil { + return err + } + } + + // after pruning each path the value is nil, so all of it is used + if copyValue == nil { + return nil + } + + var parts []string + for copyValue != nil { + mapVal, ok := copyValue.(map[string]interface{}) + if !ok { + break + } + + for k, v := range mapVal { + parts = append(parts, k) + copyValue = v + break + } + } + + return fmt.Errorf("value contains unused data under %q", strings.Join(parts, ".")) +} + +// deepCopy returns a deep copy of the value. Only supports the types that the +// API can take (so maps, slices and primitive types). +func deepCopy(value interface{}) interface{} { + switch typeVal := value.(type) { + case map[string]interface{}: + mapCopy := make(map[string]interface{}, len(typeVal)) + for k, v := range typeVal { + mapCopy[k] = deepCopy(v) + } + return mapCopy + + case []interface{}: + sliceCopy := make([]interface{}, 0, len(typeVal)) + for _, v := range typeVal { + sliceCopy = append(sliceCopy, deepCopy(v)) + } + return sliceCopy + + default: + return value + } +} + +func prunePathInValue(parts []string, val interface{}) (interface{}, error) { + if len(parts) == 0 { + return nil, nil + } else if val == nil { + return nil, nil + } + + mapVal, ok := val.(map[string]interface{}) + if !ok { + // shouldn't happen since we already checked this + return nil, fmt.Errorf(`expected map but got %T`, val) + } + + if isPlaceholder(parts[0]) { + nested := make(map[string]interface{}) + for k, v := range mapVal { + newVal, err := prunePathInValue(parts[1:], v) + if err != nil { + return nil, err + } + + if newVal != nil { + nested[k] = newVal + } + } + + if len(nested) == 0 { + return nil, nil + } + + return nested, nil + } + + nested, ok := mapVal[parts[0]] + if !ok { + // shouldn't happen since we already checked this + return nil, fmt.Errorf(`cannot use unmatched part %q as key in %v`, parts[0], nested) + } + + newValue, err := prunePathInValue(parts[1:], nested) + if err != nil { + return nil, err + } + + if newValue == nil { + delete(mapVal, parts[0]) + } else { + mapVal[parts[0]] = newValue + } + + if len(mapVal) == 0 { + return nil, nil + } + + return mapVal, nil +} + +// namespaceResult creates a nested namespace around the result that corresponds +// to the unmatched entry parts. Unmatched placeholders are filled in using maps +// of all the matching values in the databag. +func namespaceResult(res interface{}, suffixParts []string) (interface{}, error) { + if len(suffixParts) == 0 { + return res, nil + } + + // check if the part is an unmatched placeholder which should have been filled + // by the databag with all possible values + part := suffixParts[0] + if isPlaceholder(part) { + values, ok := res.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("internal error: expected databag to return map for unmatched placeholder") + } + + level := make(map[string]interface{}, len(values)) + for k, v := range values { + nested, err := namespaceResult(v, suffixParts[1:]) + if err != nil { + return nil, err + } + + level[k] = nested + } + + return level, nil + } + + nested, err := namespaceResult(res, suffixParts[1:]) + if err != nil { + return nil, err + } + + return map[string]interface{}{part: nested}, nil +} + +// Get returns the aspect value identified by the request. If either the named +// aspect or the corresponding value can't be found, a NotFoundError is returned. +func (a *Aspect) Get(databag DataBag, request string) (interface{}, error) { + if request != "" { + if err := validateAspectDottedPath(request, nil); err != nil { + return nil, badRequestErrorFrom(a, "get", request, err.Error()) + } + } + + matches, err := a.matchGetRequest(request) + if err != nil { + return nil, err + } + + var merged interface{} + for _, match := range matches { + val, err := databag.Get(match.storagePath) + if err != nil { + if errors.Is(err, PathError("")) { + continue + } + return nil, err + } + + // build a namespace around the result based on the unmatched suffix parts + val, err = namespaceResult(val, match.suffixParts) + if err != nil { + return nil, err + } + + // merge result with results from other matching rules + merged, err = mergeNamespaces(merged, val) + if err != nil { + return nil, err + } + } + + if merged == nil { + return nil, notFoundErrorFrom(a, "get", request, "matching rules don't map to any values") + } + + return merged, nil +} + +func mergeNamespaces(old, new interface{}) (interface{}, error) { + if old == nil { + return new, nil + } + + oldType, newType := reflect.TypeOf(old).Kind(), reflect.TypeOf(new).Kind() + if oldType != newType { + return nil, fmt.Errorf("cannot merge results of different types %T, %T", old, new) + } + + if oldType != reflect.Map { + // if the values are both scalars/lists, the new replaces the old value + return new, nil + } + + // if the values are maps, merge them recursively + oldMap, newMap := old.(map[string]interface{}), new.(map[string]interface{}) + for k, v := range newMap { + if storeVal, ok := oldMap[k]; ok { + merged, err := mergeNamespaces(storeVal, v) + if err != nil { + return nil, err + } + v = merged + } + + oldMap[k] = v + } + + return oldMap, nil +} + +type requestMatch struct { + // storagePath contains the storage path specified in the matching entry with + // any placeholders provided by the request filled in. + storagePath string + + // suffixParts contains the nested suffix of the entry's request that wasn't + // matched by the request. + suffixParts []string + + // request is the full request as it appears in the assertion's access rule. + request string +} + +// matchGetRequest either returns the first exact match for the request or, if +// no entry is an exact match, one or more entries that the request matches a +// prefix of. If no match is found, a NotFoundError is returned. +func (a *Aspect) matchGetRequest(request string) (matches []requestMatch, err error) { + var subkeys []string + if request != "" { + subkeys = strings.Split(request, ".") + } + + for _, rule := range a.rules { + placeholders, restSuffix, ok := rule.match(subkeys) + if !ok { + continue + } + + path, err := rule.storagePath(placeholders) + if err != nil { + return nil, err + } + + if !rule.isReadable() { + continue + } + + m := requestMatch{ + storagePath: path, + suffixParts: restSuffix, + request: rule.originalRequest, + } + matches = append(matches, m) + } + + if len(matches) == 0 { + return nil, notFoundErrorFrom(a, "get", request, "no matching read rule") + } + + // sort matches by namespace (unmatched suffix) to ensure that nested matches + // are read after + sort.Slice(matches, func(x, y int) bool { + xNamespace, yNamespace := matches[x].suffixParts, matches[y].suffixParts + + minLen := int(math.Min(float64(len(xNamespace)), float64(len(yNamespace)))) + for i := 0; i < minLen; i++ { + if xNamespace[i] == yNamespace[i] { + continue + } + return xNamespace[i] < yNamespace[i] + } + + return len(xNamespace) < len(yNamespace) + }) + + return matches, nil +} + +func newAspectRule(request, storage, accesstype string) (*aspectRule, error) { + accType, err := newAccessType(accesstype) + if err != nil { + return nil, fmt.Errorf("cannot create aspect rule: %w", err) + } + + requestSubkeys := strings.Split(request, ".") + requestMatchers := make([]requestMatcher, 0, len(requestSubkeys)) + for _, subkey := range requestSubkeys { + var patt requestMatcher + if isPlaceholder(subkey) { + patt = placeholder(subkey[1 : len(subkey)-1]) + } else { + patt = literal(subkey) + } + + requestMatchers = append(requestMatchers, patt) + } + + pathSubkeys := strings.Split(storage, ".") + pathWriters := make([]storageWriter, 0, len(pathSubkeys)) + for _, subkey := range pathSubkeys { + var patt storageWriter + if isPlaceholder(subkey) { + patt = placeholder(subkey[1 : len(subkey)-1]) + } else { + patt = literal(subkey) + } + + pathWriters = append(pathWriters, patt) + } + + return &aspectRule{ + originalRequest: request, + originalStorage: storage, + request: requestMatchers, + storage: pathWriters, + access: accType, + }, nil +} + +func isPlaceholder(part string) bool { + return part[0] == '{' && part[len(part)-1] == '}' +} + +// aspectRule represents an individual aspect rule. It can be used to match a +// request and map it into a corresponding storage path, potentially with +// placeholders filled in. +type aspectRule struct { + originalRequest string + originalStorage string + + request []requestMatcher + storage []storageWriter + access accessType +} + +// match returns true if the subkeys match the pattern exactly or as a prefix. +// If placeholders are "filled in" when matching, those are returned in a map. +// If the subkeys match as a prefix, the remaining suffix is returned. +func (p *aspectRule) match(reqSubkeys []string) (placeholders map[string]string, restSuffix []string, match bool) { + if len(p.request) < len(reqSubkeys) { + return nil, nil, false + } + + placeholders = make(map[string]string) + for i, subkey := range reqSubkeys { + // empty request matches everything + if len(reqSubkeys) != 0 && !p.request[i].match(subkey, placeholders) { + return nil, nil, false + } + } + + for _, key := range p.request[len(reqSubkeys):] { + restSuffix = append(restSuffix, key.String()) + } + + return placeholders, restSuffix, true +} + +// storagePath takes a map of placeholders to their values in the aspect name and +// returns the path with its placeholder values filled in with the map's values. +func (p *aspectRule) storagePath(placeholders map[string]string) (string, error) { + sb := &strings.Builder{} + + for _, subkey := range p.storage { + if sb.Len() > 0 { + if _, err := sb.WriteRune('.'); err != nil { + return "", err + } + } + + if err := subkey.write(sb, placeholders); err != nil { + return "", err + } + + } + + return sb.String(), nil +} + +func (p aspectRule) isReadable() bool { + return p.access == readWrite || p.access == read +} + +func (p aspectRule) isWriteable() bool { + return p.access == readWrite || p.access == write +} + +// pattern is an individual subkey of a dot-separated name or path pattern. It +// can be a literal value of a placeholder delineated by curly brackets. +type requestMatcher interface { + match(subkey string, placeholders map[string]string) bool + String() string +} + +type storageWriter interface { + write(sb *strings.Builder, placeholders map[string]string) error +} + +// placeholder represents a subkey of a name/path (e.g., "{foo}") that can match +// with any value and map it from the input name to the path. +type placeholder string + +// match adds a mapping to the placeholders map from this placeholder key to the +// supplied name subkey and returns true (a placeholder matches with any value). +func (p placeholder) match(subkey string, placeholders map[string]string) bool { + placeholders[string(p)] = subkey + return true +} + +// write writes the value from the placeholders map corresponding to this placeholder +// key into the strings.Builder. +func (p placeholder) write(sb *strings.Builder, placeholders map[string]string) error { + subkey, ok := placeholders[string(p)] + if !ok { + // placeholder wasn't matched, return the original key in brackets + subkey = fmt.Sprintf("{%s}", string(p)) + } + + _, err := sb.WriteString(subkey) + return err +} + +// String returns the placeholder as a string. +func (p placeholder) String() string { + return "{" + string(p) + "}" +} + +// literal is a non-placeholder name/path subkey. +type literal string + +// match returns true if the subkey is equal to the literal. +func (p literal) match(subkey string, _ map[string]string) bool { + return string(p) == subkey +} + +// write writes the literal subkey into the strings.Builder. +func (p literal) write(sb *strings.Builder, _ map[string]string) error { + _, err := sb.WriteString(string(p)) + return err +} + +// String returns the literal as a string. +func (p literal) String() string { + return string(p) +} + +type PathError string + +func (e PathError) Error() string { + return string(e) +} + +func (e PathError) Is(err error) bool { + _, ok := err.(PathError) + return ok +} + +func pathErrorf(str string, v ...interface{}) PathError { + return PathError(fmt.Sprintf(str, v...)) +} + +// JSONDataBag is a simple DataBag implementation that keeps JSON in-memory. +type JSONDataBag map[string]json.RawMessage + +// NewJSONDataBag returns a DataBag implementation that stores data in JSON. +// The top-level of the JSON structure is always a map. +func NewJSONDataBag() JSONDataBag { + return JSONDataBag(make(map[string]json.RawMessage)) +} + +// Get takes a path and a pointer to a variable into which the value referenced +// by the path is written. The path can be dotted. For each dot a JSON object +// is expected to exist (e.g., "a.b" is mapped to {"a": {"b": }}). +func (s JSONDataBag) Get(path string) (interface{}, error) { + // TODO: create this in the return below as well? + var value interface{} + subKeys := strings.Split(path, ".") + if err := get(subKeys, 0, s, &value); err != nil { + return nil, err + } + + return value, nil +} + +// get takes a dotted path split into sub-keys and uses it to traverse a JSON object. +// The path's sub-keys can be literals, in which case that value is used to +// traverse the tree, or a bracketed placeholder (e.g., "{foo}"). For placeholders, +// we take all sub-paths and try to match the remaining path. The results for +// any sub-path that matched the request path are then merged in a map and returned. +func get(subKeys []string, index int, node map[string]json.RawMessage, result *interface{}) error { + key := subKeys[index] + matchAll := isPlaceholder(key) + + rawLevel, ok := node[key] + if !matchAll && !ok { + pathPrefix := strings.Join(subKeys[:index+1], ".") + return pathErrorf("no value was found under path %q", pathPrefix) + } + + // read the final value + if index == len(subKeys)-1 { + if matchAll { + // request ends in placeholder so return map to all values (but unmarshal the rest first) + level := make(map[string]interface{}, len(node)) + for k, v := range node { + var deser interface{} + if err := json.Unmarshal(v, &deser); err != nil { + return fmt.Errorf(`internal error: %w`, err) + } + level[k] = deser + } + + *result = level + return nil + } + + if err := json.Unmarshal(rawLevel, result); err != nil { + return fmt.Errorf(`internal error: %w`, err) + } + + return nil + } + + if matchAll { + results := make(map[string]interface{}) + + for k, v := range node { + var level map[string]json.RawMessage + if err := jsonutil.DecodeWithNumber(bytes.NewReader(v), &level); err != nil { + if _, ok := err.(*json.UnmarshalTypeError); ok { + // we consider only the values for which the rest of the nested sub-keys + // can be fulfilled + continue + } + return err + } + + // walk the path under all possible values, only return an error if no value + // is found under any path + var res interface{} + if err := get(subKeys, index+1, level, &res); err != nil { + if errors.Is(err, PathError("")) { + continue + } + } + + if res != nil { + results[k] = res + } + } + + if len(results) == 0 { + pathPrefix := strings.Join(subKeys[:index+1], ".") + return pathErrorf("no value was found under path %q", pathPrefix) + } + + *result = results + return nil + } + + // decode the next map level + var level map[string]json.RawMessage + if err := jsonutil.DecodeWithNumber(bytes.NewReader(rawLevel), &level); err != nil { + if uErr, ok := err.(*json.UnmarshalTypeError); ok { + pathPrefix := strings.Join(subKeys[:index+1], ".") + return fmt.Errorf("cannot read path prefix %q: prefix maps to %s", pathPrefix, uErr.Value) + } + return err + } + + return get(subKeys, index+1, level, result) +} + +// Set takes a path to which the value will be written. The path can be dotted, +// in which case, a nested JSON object is created for each sub-key found after a dot. +// If the value is nil, the entry is deleted. +func (s JSONDataBag) Set(path string, value interface{}) error { + subKeys := strings.Split(path, ".") + + var err error + if value != nil { + _, err = set(subKeys, 0, s, value) + } else { + _, err = unset(subKeys, 0, s) + } + + return err +} + +func removeNilValues(value interface{}) interface{} { + level, ok := value.(map[string]interface{}) + if !ok { + return value + } + + for k, v := range level { + if v == nil { + delete(level, k) + continue + } + + level[k] = removeNilValues(v) + } + + return level +} + +func set(subKeys []string, index int, node map[string]json.RawMessage, value interface{}) (json.RawMessage, error) { + key := subKeys[index] + if index == len(subKeys)-1 { + // remove nil values that may be nested in the value + value = removeNilValues(value) + + data, err := json.Marshal(value) + if err != nil { + return nil, err + } + + node[key] = data + return json.Marshal(node) + } + + rawLevel, ok := node[key] + if !ok { + rawLevel = []byte("{}") + } + + var level map[string]json.RawMessage + err := jsonutil.DecodeWithNumber(bytes.NewReader(rawLevel), &level) + if err != nil { + var uerr *json.UnmarshalTypeError + if !errors.As(err, &uerr) { + return nil, err + } + } + + // stored valued wasn't map but new write expects one so overwrite value + if level == nil { + level = make(map[string]json.RawMessage) + } + + rawLevel, err = set(subKeys, index+1, level, value) + if err != nil { + return nil, err + } + + node[key] = rawLevel + return json.Marshal(node) +} + +func (s JSONDataBag) Unset(path string) error { + subKeys := strings.Split(path, ".") + _, err := unset(subKeys, 0, s) + return err +} + +func unset(subKeys []string, index int, node map[string]json.RawMessage) (json.RawMessage, error) { + key := subKeys[index] + matchAll := isPlaceholder(key) + + if index == len(subKeys)-1 { + if matchAll { + // remove entire level + return nil, nil + } + + delete(node, key) + return json.Marshal(node) + } + + unsetKey := func(level map[string]json.RawMessage, key string) error { + nextLevelRaw, ok := level[key] + if !ok { + return nil + } + + var nextLevel map[string]json.RawMessage + if err := jsonutil.DecodeWithNumber(bytes.NewReader(nextLevelRaw), &nextLevel); err != nil { + return err + } + + updated, err := unset(subKeys, index+1, nextLevel) + if err != nil { + return err + } + + // update the map with the sublevel which may have changed or been removed + if updated == nil { + delete(level, key) + } else { + level[key] = updated + } + + return nil + } + + if matchAll { + for k := range node { + if err := unsetKey(node, k); err != nil { + return nil, err + } + } + } else { + if err := unsetKey(node, key); err != nil { + return nil, err + } + } + + return json.Marshal(node) +} + +// Data returns all of the bag's data encoded in JSON. +func (s JSONDataBag) Data() ([]byte, error) { + return json.Marshal(s) +} + +// Copy returns a copy of the databag. +func (s JSONDataBag) Copy() JSONDataBag { + toplevel := map[string]json.RawMessage(s) + copy := make(map[string]json.RawMessage, len(toplevel)) + + for k, v := range toplevel { + copy[k] = v + } + + return JSONDataBag(copy) +} + +// JSONSchema is the Schema implementation corresponding to JSONDataBag and it's +// able to validate its data. +type JSONSchema struct{} + +// NewJSONSchema returns a Schema able to validate a JSONDataBag's data. +func NewJSONSchema() JSONSchema { + return JSONSchema{} +} + +// Validate validates that the specified data can be encoded into JSON. +func (s JSONSchema) Validate(jsonData []byte) error { + // the top-level is always an object + var data map[string]json.RawMessage + return json.Unmarshal(jsonData, &data) +} + +// SchemaAt always returns the JSONSchema. +func (v JSONSchema) SchemaAt(path []string) ([]Schema, error) { + return []Schema{v}, nil +} + +func (v JSONSchema) Type() SchemaType { + return Any +} diff --git a/aspects/aspects_test.go b/aspects/aspects_test.go new file mode 100644 index 00000000..ff4842fc --- /dev/null +++ b/aspects/aspects_test.go @@ -0,0 +1,2470 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package aspects_test + +import ( + "errors" + "fmt" + "testing" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/aspects" + "github.com/snapcore/snapd/testutil" +) + +type aspectSuite struct{} + +func Test(t *testing.T) { TestingT(t) } + +var _ = Suite(&aspectSuite{}) + +func (*aspectSuite) TestNewAspectBundle(c *C) { + type testcase struct { + bundle map[string]interface{} + err string + } + + tcs := []testcase{ + { + err: `cannot define aspects bundle: no aspects`, + }, + { + bundle: map[string]interface{}{"bar": "baz"}, + err: `cannot define aspect "bar": aspect must be non-empty map`, + }, + { + bundle: map[string]interface{}{"bar": map[string]interface{}{}}, + err: `cannot define aspect "bar": aspect must be non-empty map`, + }, + { + bundle: map[string]interface{}{"bar": map[string]interface{}{"rules": "bar"}}, + err: `cannot define aspect "bar": aspect rules must be non-empty list`, + }, + { + bundle: map[string]interface{}{"bar": map[string]interface{}{"rules": []interface{}{}}}, + err: `cannot define aspect "bar": aspect rules must be non-empty list`, + }, + { + bundle: map[string]interface{}{"bar": map[string]interface{}{"rules": []interface{}{"a"}}}, + err: `cannot define aspect "bar": each aspect rule should be a map`, + }, + { + bundle: map[string]interface{}{"bar": map[string]interface{}{"rules": []interface{}{map[string]interface{}{}}}}, + err: `cannot define aspect "bar": aspect rules must have a "storage" field`, + }, + + { + bundle: map[string]interface{}{"bar": map[string]interface{}{"rules": []interface{}{map[string]interface{}{"request": "foo", "storage": 1}}}}, + err: `cannot define aspect "bar": "storage" must be a string`, + }, + { + bundle: map[string]interface{}{"bar": map[string]interface{}{"rules": []interface{}{map[string]interface{}{"storage": "foo", "request": 1}}}}, + err: `cannot define aspect "bar": "request" must be a string`, + }, + { + bundle: map[string]interface{}{"bar": map[string]interface{}{"rules": []interface{}{map[string]interface{}{"storage": "foo", "request": ""}}}}, + err: `cannot define aspect "bar": aspect rules' "request" field must be non-empty, if it exists`, + }, + { + bundle: map[string]interface{}{"bar": map[string]interface{}{"rules": []interface{}{map[string]interface{}{"request": "foo", "storage": 1}}}}, + err: `cannot define aspect "bar": "storage" must be a string`, + }, + { + bundle: map[string]interface{}{ + "bar": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "a", "storage": "b"}, + map[string]interface{}{"request": "a", "storage": "c"}, + }, + }, + }, + err: `cannot define aspect "bar": cannot have several reading rules with the same "request" field`, + }, + { + bundle: map[string]interface{}{"bar": map[string]interface{}{"rules": []interface{}{map[string]interface{}{"request": "foo", "storage": "bar", "access": 1}}}}, + err: `cannot define aspect "bar": "access" must be a string`, + }, + { + bundle: map[string]interface{}{ + "bar": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "a", "storage": "c", "access": "write"}, + map[string]interface{}{"request": "a", "storage": "b"}, + }, + }, + }, + }, + } + + for i, tc := range tcs { + cmt := Commentf("test number %d", i+1) + aspectBundle, err := aspects.NewBundle("acc", "foo", tc.bundle, aspects.NewJSONSchema()) + if tc.err != "" { + c.Assert(err, ErrorMatches, tc.err, cmt) + } else { + c.Assert(err, IsNil, cmt) + c.Check(aspectBundle, Not(IsNil), cmt) + } + } +} + +func (s *aspectSuite) TestMissingRequestDefaultsToStorage(c *C) { + databag := aspects.NewJSONDataBag() + bundle := map[string]interface{}{ + "foo": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"storage": "a.b"}, + }, + }, + } + bun, err := aspects.NewBundle("acc", "foo", bundle, aspects.NewJSONSchema()) + c.Assert(err, IsNil) + + asp := bun.Aspect("foo") + c.Assert(asp, NotNil) + + err = asp.Set(databag, "a.b", "value") + c.Assert(err, IsNil) + + value, err := asp.Get(databag, "") + c.Assert(err, IsNil) + c.Assert(value, DeepEquals, map[string]interface{}{ + "a": map[string]interface{}{ + "b": "value", + }, + }) +} + +func (s *aspectSuite) TestBundleWithSample(c *C) { + bundle := map[string]interface{}{ + "wifi-setup": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "ssids", "storage": "wifi.ssids"}, + map[string]interface{}{"access": "read-write", "request": "ssid", "storage": "wifi.ssid"}, + map[string]interface{}{"access": "write", "request": "password", "storage": "wifi.psk"}, + map[string]interface{}{"access": "read", "request": "status", "storage": "wifi.status"}, + map[string]interface{}{"request": "private.{key}", "storage": "wifi.{key}"}, + }, + }, + } + _, err := aspects.NewBundle("acc", "foo", bundle, aspects.NewJSONSchema()) + c.Assert(err, IsNil) +} + +func (s *aspectSuite) TestAccessTypes(c *C) { + type testcase struct { + access string + err bool + } + + for _, t := range []testcase{ + { + access: "read", + }, + { + access: "write", + }, + { + access: "read-write", + }, + { + access: "", + }, + { + access: "invalid", + err: true, + }, + } { + aspectBundle, err := aspects.NewBundle("acc", "foo", map[string]interface{}{ + "bar": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "a", "storage": "b", "access": t.access}, + }, + }, + }, aspects.NewJSONSchema()) + + cmt := Commentf("\"%s access\" sub-test failed", t.access) + if t.err { + c.Assert(err, ErrorMatches, fmt.Sprintf(`.*expected 'access' to be either "read-write", "read", "write" or empty but was %q`, t.access), cmt) + c.Check(aspectBundle, IsNil, cmt) + } else { + c.Assert(err, IsNil, cmt) + c.Check(aspectBundle, Not(IsNil), cmt) + } + } +} + +func (*aspectSuite) TestGetAndSetAspects(c *C) { + databag := aspects.NewJSONDataBag() + aspectBundle, err := aspects.NewBundle("system", "network", map[string]interface{}{ + "wifi-setup": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "ssids", "storage": "wifi.ssids"}, + map[string]interface{}{"request": "ssid", "storage": "wifi.ssid"}, + map[string]interface{}{"request": "top-level", "storage": "top-level"}, + map[string]interface{}{"request": "dotted.path", "storage": "dotted"}, + }, + }, + }, aspects.NewJSONSchema()) + c.Assert(err, IsNil) + + wsAspect := aspectBundle.Aspect("wifi-setup") + + // nested string value + err = wsAspect.Set(databag, "ssid", "my-ssid") + c.Assert(err, IsNil) + + ssid, err := wsAspect.Get(databag, "ssid") + c.Assert(err, IsNil) + c.Check(ssid, DeepEquals, "my-ssid") + + // nested list value + err = wsAspect.Set(databag, "ssids", []string{"one", "two"}) + c.Assert(err, IsNil) + + ssids, err := wsAspect.Get(databag, "ssids") + c.Assert(err, IsNil) + c.Check(ssids, DeepEquals, []interface{}{"one", "two"}) + + // top-level string + err = wsAspect.Set(databag, "top-level", "randomValue") + c.Assert(err, IsNil) + + topLevel, err := wsAspect.Get(databag, "top-level") + c.Assert(err, IsNil) + c.Check(topLevel, DeepEquals, "randomValue") + + // dotted request paths are permitted + err = wsAspect.Set(databag, "dotted.path", 3) + c.Assert(err, IsNil) + + num, err := wsAspect.Get(databag, "dotted.path") + c.Assert(err, IsNil) + c.Check(num, DeepEquals, float64(3)) +} + +func (*aspectSuite) TestSetWithNilValueFail(c *C) { + databag := aspects.NewJSONDataBag() + aspectBundle, err := aspects.NewBundle("system", "test", map[string]interface{}{ + "test": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "foo", "storage": "foo"}, + }, + }, + }, aspects.NewJSONSchema()) + c.Assert(err, IsNil) + + wsAspect := aspectBundle.Aspect("test") + + err = wsAspect.Set(databag, "foo", "value") + c.Assert(err, IsNil) + + err = wsAspect.Set(databag, "foo", nil) + c.Assert(err, ErrorMatches, `internal error: Set value cannot be nil`) + + ssid, err := wsAspect.Get(databag, "foo") + c.Assert(err, IsNil) + c.Check(ssid, DeepEquals, "value") +} + +func (s *aspectSuite) TestAspectNotFound(c *C) { + databag := aspects.NewJSONDataBag() + aspectBundle, err := aspects.NewBundle("acc", "foo", map[string]interface{}{ + "bar": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "top-level", "storage": "top-level"}, + map[string]interface{}{"request": "nested", "storage": "top.nested-one"}, + map[string]interface{}{"request": "other-nested", "storage": "top.nested-two"}, + }, + }, + }, aspects.NewJSONSchema()) + c.Assert(err, IsNil) + + aspect := aspectBundle.Aspect("bar") + + _, err = aspect.Get(databag, "missing") + c.Assert(err, testutil.ErrorIs, &aspects.NotFoundError{}) + c.Assert(err, ErrorMatches, `cannot get "missing" in aspect acc/foo/bar: no matching read rule`) + + err = aspect.Set(databag, "missing", "thing") + c.Assert(err, testutil.ErrorIs, &aspects.NotFoundError{}) + c.Assert(err, ErrorMatches, `cannot set "missing" in aspect acc/foo/bar: no matching write rule`) + + err = aspect.Unset(databag, "missing") + c.Assert(err, testutil.ErrorIs, &aspects.NotFoundError{}) + c.Assert(err, ErrorMatches, `cannot unset "missing" in aspect acc/foo/bar: no matching write rule`) + + _, err = aspect.Get(databag, "top-level") + c.Assert(err, testutil.ErrorIs, &aspects.NotFoundError{}) + c.Assert(err, ErrorMatches, `cannot get "top-level" in aspect acc/foo/bar: matching rules don't map to any values`) + + err = aspect.Set(databag, "nested", "thing") + c.Assert(err, IsNil) + + err = aspect.Unset(databag, "nested") + c.Assert(err, IsNil) + + _, err = aspect.Get(databag, "other-nested") + c.Assert(err, testutil.ErrorIs, &aspects.NotFoundError{}) + c.Assert(err, ErrorMatches, `cannot get "other-nested" in aspect acc/foo/bar: matching rules don't map to any values`) +} + +func (s *aspectSuite) TestAspectBadRead(c *C) { + databag := aspects.NewJSONDataBag() + aspectBundle, err := aspects.NewBundle("acc", "foo", map[string]interface{}{ + "bar": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "one", "storage": "one"}, + map[string]interface{}{"request": "onetwo", "storage": "one.two"}, + }, + }, + }, aspects.NewJSONSchema()) + c.Assert(err, IsNil) + + aspect := aspectBundle.Aspect("bar") + err = aspect.Set(databag, "one", "foo") + c.Assert(err, IsNil) + + _, err = aspect.Get(databag, "onetwo") + c.Assert(err, ErrorMatches, `cannot read path prefix "one": prefix maps to string`) +} + +func (s *aspectSuite) TestAspectsAccessControl(c *C) { + for _, t := range []struct { + access string + getErr string + setErr string + }{ + { + access: "read-write", + }, + { + // defaults to "read-write" + access: "", + }, + { + access: "read", + // non-access control error, access ok + getErr: `cannot get "foo" in aspect acc/bundle/foo: matching rules don't map to any values`, + setErr: `cannot set "foo" in aspect acc/bundle/foo: no matching write rule`, + }, + { + access: "write", + getErr: `cannot get "foo" in aspect acc/bundle/foo: no matching read rule`, + }, + } { + cmt := Commentf("sub-test with %q access failed", t.access) + databag := aspects.NewJSONDataBag() + aspectBundle, err := aspects.NewBundle("acc", "bundle", map[string]interface{}{ + "foo": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "foo", "storage": "foo", "access": t.access}, + }, + }, + }, aspects.NewJSONSchema()) + c.Assert(err, IsNil) + + aspect := aspectBundle.Aspect("foo") + + err = aspect.Set(databag, "foo", "thing") + if t.setErr != "" { + c.Assert(err.Error(), Equals, t.setErr, cmt) + } else { + c.Assert(err, IsNil, cmt) + } + + _, err = aspect.Get(databag, "foo") + if t.getErr != "" { + c.Assert(err.Error(), Equals, t.getErr, cmt) + } else { + c.Assert(err, IsNil, cmt) + } + } +} + +type witnessDataBag struct { + bag aspects.DataBag + getPath, setPath string +} + +func newWitnessDataBag(bag aspects.DataBag) *witnessDataBag { + return &witnessDataBag{bag: bag} +} + +func (s *witnessDataBag) Get(path string) (interface{}, error) { + s.getPath = path + return s.bag.Get(path) +} + +func (s *witnessDataBag) Set(path string, value interface{}) error { + s.setPath = path + return s.bag.Set(path, value) +} + +func (s *witnessDataBag) Unset(path string) error { + s.setPath = path + return s.bag.Unset(path) +} + +func (s *witnessDataBag) Data() ([]byte, error) { + return s.bag.Data() +} + +// getLastPaths returns the last paths passed into Get and Set and resets them. +func (s *witnessDataBag) getLastPaths() (get, set string) { + get, set = s.getPath, s.setPath + s.getPath, s.setPath = "", "" + return get, set +} + +func (s *aspectSuite) TestAspectAssertionWithPlaceholder(c *C) { + for _, t := range []struct { + rule map[string]interface{} + testName string + request string + storage string + }{ + { + testName: "placeholder last to mid", + rule: map[string]interface{}{"request": "defaults.{foo}", "storage": "first.{foo}.last"}, + request: "defaults.abc", + storage: "first.abc.last", + }, + { + testName: "placeholder first to last", + rule: map[string]interface{}{"request": "{bar}.name", "storage": "first.{bar}"}, + request: "foo.name", + storage: "first.foo", + }, + { + testName: "placeholder mid to first", + rule: map[string]interface{}{"request": "first.{baz}.last", "storage": "{baz}.last"}, + request: "first.foo.last", + storage: "foo.last", + }, + { + testName: "two placeholders in order", + rule: map[string]interface{}{"request": "first.{foo}.{bar}", "storage": "{foo}.mid.{bar}"}, + request: "first.one.two", + storage: "one.mid.two", + }, + { + testName: "two placeholders out of order", + rule: map[string]interface{}{"request": "{foo}.mid1.{bar}", "storage": "{bar}.mid2.{foo}"}, + request: "first2.mid1.two2", + storage: "two2.mid2.first2", + }, + { + testName: "one placeholder mapping to several", + rule: map[string]interface{}{"request": "multi.{foo}", "storage": "{foo}.multi.{foo}"}, + request: "multi.firstlast", + storage: "firstlast.multi.firstlast", + }, + } { + cmt := Commentf("sub-test %q failed", t.testName) + + aspectBundle, err := aspects.NewBundle("acc", "bundle", map[string]interface{}{ + "foo": map[string]interface{}{ + "rules": []interface{}{t.rule}, + }, + }, aspects.NewJSONSchema()) + c.Assert(err, IsNil) + aspect := aspectBundle.Aspect("foo") + + databag := newWitnessDataBag(aspects.NewJSONDataBag()) + err = aspect.Set(databag, t.request, "expectedValue") + c.Assert(err, IsNil, cmt) + + value, err := aspect.Get(databag, t.request) + c.Assert(err, IsNil, cmt) + c.Assert(value, DeepEquals, "expectedValue", cmt) + + getPath, setPath := databag.getLastPaths() + c.Assert(getPath, Equals, t.storage, cmt) + c.Assert(setPath, Equals, t.storage, cmt) + } +} + +func (s *aspectSuite) TestAspectRequestAndStorageValidation(c *C) { + type testcase struct { + testName string + request string + storage string + err string + } + + for _, tc := range []testcase{ + { + testName: "empty subkeys in request", + request: "a..b", storage: "a.b", err: `invalid request "a..b": cannot have empty subkeys`, + }, + { + testName: "empty subkeys in path", + request: "a.b", storage: "c..b", err: `invalid storage "c..b": cannot have empty subkeys`, + }, + { + testName: "placeholder mismatch (same number)", + request: "bad.{foo}", storage: "bad.{bar}", err: `placeholder "{foo}" from request "bad.{foo}" is absent from storage "bad.{bar}"`, + }, + { + testName: "placeholder mismatch (different number)", + request: "{foo}", storage: "{foo}.bad.{bar}", err: `request "{foo}" and storage "{foo}.bad.{bar}" have mismatched placeholders`, + }, + { + testName: "invalid character in request: $", + request: "a.b$", storage: "bad", err: `invalid request "a.b$": invalid subkey "b$"`, + }, + { + testName: "invalid character in storage path: é", + request: "a.b", storage: "a.é", err: `invalid storage "a.é": invalid subkey "é"`, + }, + { + testName: "invalid character in request: _", + request: "a.b_c", storage: "a.b-c", err: `invalid request "a.b_c": invalid subkey "b_c"`, + }, + { + testName: "invalid leading dash", + request: "-a", storage: "a", err: `invalid request "-a": invalid subkey "-a"`, + }, + { + testName: "invalid trailing dash", + request: "a", storage: "a-", err: `invalid storage "a-": invalid subkey "a-"`, + }, + { + testName: "missing closing curly bracket", + request: "{a{", storage: "a", err: `invalid request "{a{": invalid subkey "{a{"`, + }, + { + testName: "missing opening curly bracket", + request: "a", storage: "}a}", err: `invalid storage "}a}": invalid subkey "}a}"`, + }, + { + testName: "curly brackets not wrapping subkey", + request: "a", storage: "a.b{a}c", err: `invalid storage "a.b{a}c": invalid subkey "b{a}c"`, + }, + { + testName: "invalid whitespace character", + request: "a. .c", storage: "a.b", err: `invalid request "a. .c": invalid subkey " "`, + }, + } { + _, err := aspects.NewBundle("acc", "foo", map[string]interface{}{ + "foo": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": tc.request, "storage": tc.storage}, + }, + }, + }, aspects.NewJSONSchema()) + + cmt := Commentf("sub-test %q failed", tc.testName) + c.Assert(err, Not(IsNil), cmt) + c.Assert(err.Error(), Equals, `cannot define aspect "foo": `+tc.err, cmt) + } +} + +func (s *aspectSuite) TestAspectUnsetTopLevelEntry(c *C) { + databag := aspects.NewJSONDataBag() + aspectBundle, err := aspects.NewBundle("acc", "foo", map[string]interface{}{ + "my-aspect": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "foo", "storage": "foo"}, + map[string]interface{}{"request": "bar", "storage": "bar"}, + }, + }, + }, aspects.NewJSONSchema()) + c.Assert(err, IsNil) + + aspect := aspectBundle.Aspect("my-aspect") + err = aspect.Set(databag, "foo", "fval") + c.Assert(err, IsNil) + + err = aspect.Set(databag, "bar", "bval") + c.Assert(err, IsNil) + + err = aspect.Unset(databag, "foo") + c.Assert(err, IsNil) + + _, err = aspect.Get(databag, "foo") + c.Assert(err, testutil.ErrorIs, &aspects.NotFoundError{}) + + value, err := aspect.Get(databag, "bar") + c.Assert(err, IsNil) + c.Assert(value, DeepEquals, "bval") +} + +func (s *aspectSuite) TestAspectUnsetLeafWithSiblings(c *C) { + databag := aspects.NewJSONDataBag() + aspectBundle, err := aspects.NewBundle("acc", "foo", map[string]interface{}{ + "my-aspect": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "bar", "storage": "foo.bar"}, + map[string]interface{}{"request": "baz", "storage": "foo.baz"}, + }, + }, + }, aspects.NewJSONSchema()) + c.Assert(err, IsNil) + + aspect := aspectBundle.Aspect("my-aspect") + err = aspect.Set(databag, "bar", "barVal") + c.Assert(err, IsNil) + + err = aspect.Set(databag, "baz", "bazVal") + c.Assert(err, IsNil) + + err = aspect.Unset(databag, "bar") + c.Assert(err, IsNil) + + _, err = aspect.Get(databag, "bar") + c.Assert(err, testutil.ErrorIs, &aspects.NotFoundError{}) + + // doesn't affect the other leaf entry under "foo" + value, err := aspect.Get(databag, "baz") + c.Assert(err, IsNil) + c.Assert(value, DeepEquals, "bazVal") +} + +func (s *aspectSuite) TestAspectUnsetWithNestedEntry(c *C) { + databag := aspects.NewJSONDataBag() + aspectBundle, err := aspects.NewBundle("acc", "foo", map[string]interface{}{ + "my-aspect": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "foo", "storage": "foo"}, + map[string]interface{}{"request": "bar", "storage": "foo.bar"}, + }, + }, + }, aspects.NewJSONSchema()) + c.Assert(err, IsNil) + + aspect := aspectBundle.Aspect("my-aspect") + err = aspect.Set(databag, "bar", "barVal") + c.Assert(err, IsNil) + + err = aspect.Unset(databag, "foo") + c.Assert(err, IsNil) + + _, err = aspect.Get(databag, "foo") + c.Assert(err, testutil.ErrorIs, &aspects.NotFoundError{}) + + _, err = aspect.Get(databag, "bar") + c.Assert(err, testutil.ErrorIs, &aspects.NotFoundError{}) +} + +func (s *aspectSuite) TestAspectUnsetLeafLeavesEmptyParent(c *C) { + databag := aspects.NewJSONDataBag() + aspectBundle, err := aspects.NewBundle("acc", "foo", map[string]interface{}{ + "my-aspect": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "foo", "storage": "foo"}, + map[string]interface{}{"request": "bar", "storage": "foo.bar"}, + }, + }, + }, aspects.NewJSONSchema()) + c.Assert(err, IsNil) + + aspect := aspectBundle.Aspect("my-aspect") + err = aspect.Set(databag, "bar", "val") + c.Assert(err, IsNil) + + value, err := aspect.Get(databag, "foo") + c.Assert(err, IsNil) + c.Assert(value, Not(HasLen), 0) + + err = aspect.Unset(databag, "bar") + c.Assert(err, IsNil) + + value, err = aspect.Get(databag, "foo") + c.Assert(err, IsNil) + c.Assert(value, DeepEquals, map[string]interface{}{}) +} + +func (s *aspectSuite) TestAspectUnsetAlreadyUnsetEntry(c *C) { + databag := aspects.NewJSONDataBag() + aspectBundle, err := aspects.NewBundle("acc", "foo", map[string]interface{}{ + "my-aspect": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "foo", "storage": "foo"}, + map[string]interface{}{"request": "bar", "storage": "one.bar"}, + }, + }, + }, aspects.NewJSONSchema()) + c.Assert(err, IsNil) + + aspect := aspectBundle.Aspect("my-aspect") + err = aspect.Unset(databag, "foo") + c.Assert(err, IsNil) + + err = aspect.Unset(databag, "bar") + c.Assert(err, IsNil) +} + +func (s *aspectSuite) TestJSONDataBagCopy(c *C) { + bag := aspects.NewJSONDataBag() + err := bag.Set("foo", "bar") + c.Assert(err, IsNil) + + // precondition check + data, err := bag.Data() + c.Assert(err, IsNil) + c.Assert(string(data), Equals, `{"foo":"bar"}`) + + bagCopy := bag.Copy() + data, err = bagCopy.Data() + c.Assert(err, IsNil) + c.Assert(string(data), Equals, `{"foo":"bar"}`) + + // changes in the copied bag don't affect the original + err = bagCopy.Set("foo", "baz") + c.Assert(err, IsNil) + + data, err = bag.Data() + c.Assert(err, IsNil) + c.Assert(string(data), Equals, `{"foo":"bar"}`) + + // and vice-versa + err = bag.Set("foo", "zab") + c.Assert(err, IsNil) + + data, err = bagCopy.Data() + c.Assert(err, IsNil) + c.Assert(string(data), Equals, `{"foo":"baz"}`) +} + +func (s *aspectSuite) TestAspectGetResultNamespaceMatchesRequest(c *C) { + databag := aspects.NewJSONDataBag() + aspectBundle, err := aspects.NewBundle("acc", "bundle", map[string]interface{}{ + "bar": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "one", "storage": "one"}, + map[string]interface{}{"request": "one.two", "storage": "one.two"}, + map[string]interface{}{"request": "onetwo", "storage": "one.two"}, + }, + }, + }, aspects.NewJSONSchema()) + c.Assert(err, IsNil) + + aspect := aspectBundle.Aspect("bar") + err = databag.Set("one", map[string]interface{}{"two": "value"}) + c.Assert(err, IsNil) + + value, err := aspect.Get(databag, "one.two") + c.Assert(err, IsNil) + c.Assert(value, DeepEquals, "value") + + value, err = aspect.Get(databag, "onetwo") + c.Assert(err, IsNil) + // the key matches the request, not the storage storage + c.Assert(value, DeepEquals, "value") + + value, err = aspect.Get(databag, "one") + c.Assert(err, IsNil) + c.Assert(value, DeepEquals, map[string]interface{}{"two": "value"}) +} + +func (s *aspectSuite) TestAspectGetMatchesOnPrefix(c *C) { + databag := aspects.NewJSONDataBag() + aspectBundle, err := aspects.NewBundle("acc", "bundle", map[string]interface{}{ + "statuses": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "snapd.status", "storage": "snaps.snapd.status"}, + map[string]interface{}{"request": "snaps", "storage": "snaps"}, + }, + }, + }, aspects.NewJSONSchema()) + c.Assert(err, IsNil) + + aspect := aspectBundle.Aspect("statuses") + err = aspect.Set(databag, "snaps", map[string]map[string]interface{}{ + "snapd": {"status": "active", "version": "1.0"}, + "firefox": {"status": "inactive", "version": "9.0"}, + }) + c.Assert(err, IsNil) + + value, err := aspect.Get(databag, "snapd.status") + c.Assert(err, IsNil) + c.Assert(value, DeepEquals, "active") + + value, err = aspect.Get(databag, "snapd") + c.Assert(err, IsNil) + c.Assert(value, DeepEquals, map[string]interface{}{"status": "active"}) +} + +func (s *aspectSuite) TestAspectUnsetValidates(c *C) { + databag := aspects.NewJSONDataBag() + aspectBundle, err := aspects.NewBundle("acc", "bundle", map[string]interface{}{ + "test": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "foo", "storage": "foo"}, + }, + }, + }, &failingSchema{err: errors.New("boom")}) + c.Assert(err, IsNil) + + aspect := aspectBundle.Aspect("test") + err = aspect.Unset(databag, "foo") + c.Assert(err, ErrorMatches, `cannot unset data: boom`) +} + +func (s *aspectSuite) TestAspectUnsetSkipsReadOnly(c *C) { + databag := aspects.NewJSONDataBag() + aspectBundle, err := aspects.NewBundle("acc", "bundle", map[string]interface{}{ + "test": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "foo", "storage": "foo", "access": "read"}, + }, + }, + }, aspects.NewJSONSchema()) + c.Assert(err, IsNil) + + aspect := aspectBundle.Aspect("test") + err = aspect.Unset(databag, "foo") + c.Assert(err, ErrorMatches, `cannot unset "foo" in aspect acc/bundle/test: no matching write rule`) +} + +func (s *aspectSuite) TestAspectGetNoMatchRequestLongerThanPattern(c *C) { + databag := aspects.NewJSONDataBag() + aspectBundle, err := aspects.NewBundle("acc", "bundle", map[string]interface{}{ + "statuses": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "snapd", "storage": "snaps.snapd"}, + }, + }, + }, aspects.NewJSONSchema()) + c.Assert(err, IsNil) + + aspect := aspectBundle.Aspect("statuses") + err = aspect.Set(databag, "snapd", map[string]interface{}{ + "status": "active", "version": "1.0", + }) + c.Assert(err, IsNil) + + _, err = aspect.Get(databag, "snapd.status") + c.Assert(err, testutil.ErrorIs, &aspects.NotFoundError{}) +} + +func (s *aspectSuite) TestAspectManyPrefixMatches(c *C) { + databag := aspects.NewJSONDataBag() + aspectBundle, err := aspects.NewBundle("acc", "bundle", map[string]interface{}{ + "statuses": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "status.firefox", "storage": "snaps.firefox.status"}, + map[string]interface{}{"request": "status.snapd", "storage": "snaps.snapd.status"}, + }, + }, + }, aspects.NewJSONSchema()) + c.Assert(err, IsNil) + + aspect := aspectBundle.Aspect("statuses") + err = aspect.Set(databag, "status.firefox", "active") + c.Assert(err, IsNil) + + err = aspect.Set(databag, "status.snapd", "disabled") + c.Assert(err, IsNil) + + value, err := aspect.Get(databag, "status") + c.Assert(err, IsNil) + c.Assert(value, DeepEquals, + map[string]interface{}{ + "snapd": "disabled", + "firefox": "active", + }) +} + +func (s *aspectSuite) TestAspectCombineNamespacesInPrefixMatches(c *C) { + databag := aspects.NewJSONDataBag() + aspectBundle, err := aspects.NewBundle("acc", "bundle", map[string]interface{}{ + "statuses": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "status.foo.bar.firefox", "storage": "snaps.firefox.status"}, + map[string]interface{}{"request": "status.foo.snapd", "storage": "snaps.snapd.status"}, + }, + }, + }, aspects.NewJSONSchema()) + c.Assert(err, IsNil) + + err = databag.Set("snaps", map[string]interface{}{ + "firefox": map[string]interface{}{ + "status": "active", + }, + "snapd": map[string]interface{}{ + "status": "disabled", + }, + }) + c.Assert(err, IsNil) + + aspect := aspectBundle.Aspect("statuses") + + value, err := aspect.Get(databag, "status") + c.Assert(err, IsNil) + c.Assert(value, DeepEquals, + map[string]interface{}{ + "foo": map[string]interface{}{ + "bar": map[string]interface{}{ + "firefox": "active", + }, + "snapd": "disabled", + }, + }) +} + +func (s *aspectSuite) TestGetScalarOverwritesLeafOfMapValue(c *C) { + databag := aspects.NewJSONDataBag() + aspectBundle, err := aspects.NewBundle("acc", "bundle", map[string]interface{}{ + "motors": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "motors.a.speed", "storage": "new-speed.a"}, + map[string]interface{}{"request": "motors", "storage": "motors"}, + }, + }, + }, aspects.NewJSONSchema()) + c.Assert(err, IsNil) + + err = databag.Set("motors", map[string]interface{}{ + "a": map[string]interface{}{ + "speed": 100, + }, + }) + c.Assert(err, IsNil) + + err = databag.Set("new-speed", map[string]interface{}{ + "a": 101.5, + }) + c.Assert(err, IsNil) + + aspect := aspectBundle.Aspect("motors") + + value, err := aspect.Get(databag, "motors") + c.Assert(err, IsNil) + c.Assert(value, DeepEquals, map[string]interface{}{"a": map[string]interface{}{"speed": 101.5}}) +} + +func (s *aspectSuite) TestGetSingleScalarOk(c *C) { + databag := aspects.NewJSONDataBag() + aspectBundle, err := aspects.NewBundle("acc", "bundle", map[string]interface{}{ + "foo": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "foo", "storage": "foo"}, + }, + }, + }, aspects.NewJSONSchema()) + c.Assert(err, IsNil) + + err = databag.Set("foo", "bar") + c.Assert(err, IsNil) + + aspect := aspectBundle.Aspect("foo") + + value, err := aspect.Get(databag, "foo") + c.Assert(err, IsNil) + c.Assert(value, DeepEquals, "bar") +} + +func (s *aspectSuite) TestGetMatchScalarAndMapError(c *C) { + databag := aspects.NewJSONDataBag() + aspectBundle, err := aspects.NewBundle("acc", "bundle", map[string]interface{}{ + "foo": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "foo", "storage": "bar"}, + map[string]interface{}{"request": "foo.baz", "storage": "baz"}, + }, + }, + }, aspects.NewJSONSchema()) + c.Assert(err, IsNil) + + err = databag.Set("bar", 1) + c.Assert(err, IsNil) + err = databag.Set("baz", 2) + c.Assert(err, IsNil) + + aspect := aspectBundle.Aspect("foo") + + _, err = aspect.Get(databag, "foo") + c.Assert(err, ErrorMatches, `cannot merge results of different types float64, map\[string\]interface {}`) +} + +func (s *aspectSuite) TestGetRulesAreSortedByParentage(c *C) { + databag := aspects.NewJSONDataBag() + aspectBundle, err := aspects.NewBundle("acc", "bundle", map[string]interface{}{ + "foo": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "foo.bar.baz", "storage": "third"}, + map[string]interface{}{"request": "foo", "storage": "first"}, + map[string]interface{}{"request": "foo.bar", "storage": "second"}, + }, + }, + }, aspects.NewJSONSchema()) + c.Assert(err, IsNil) + aspect := aspectBundle.Aspect("foo") + + err = databag.Set("first", map[string]interface{}{"bar": map[string]interface{}{"baz": "first"}}) + c.Assert(err, IsNil) + + value, err := aspect.Get(databag, "foo") + c.Assert(err, IsNil) + // returned the value read by entry "foo" + c.Assert(value, DeepEquals, map[string]interface{}{"bar": map[string]interface{}{"baz": "first"}}) + + err = databag.Set("second", map[string]interface{}{"baz": "second"}) + c.Assert(err, IsNil) + + value, err = aspect.Get(databag, "foo") + c.Assert(err, IsNil) + // the leaf is replaced by a value read from a rule that is nested + c.Assert(value, DeepEquals, map[string]interface{}{"bar": map[string]interface{}{"baz": "second"}}) + + err = databag.Set("third", "third") + c.Assert(err, IsNil) + + value, err = aspect.Get(databag, "foo") + c.Assert(err, IsNil) + // lastly, it reads the value from "foo.bar.baz" the most nested entry + c.Assert(value, DeepEquals, map[string]interface{}{"bar": map[string]interface{}{"baz": "third"}}) +} + +func (s *aspectSuite) TestGetUnmatchedPlaceholderReturnsAll(c *C) { + databag := aspects.NewJSONDataBag() + aspectBundle, err := aspects.NewBundle("acc", "bundle", map[string]interface{}{ + "snaps": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "snaps.{snap}", "storage": "snaps.{snap}"}, + }, + }, + }, aspects.NewJSONSchema()) + c.Assert(err, IsNil) + aspect := aspectBundle.Aspect("snaps") + c.Assert(aspect, NotNil) + + err = databag.Set("snaps", map[string]interface{}{ + "snapd": 1, + "foo": map[string]interface{}{ + "bar": 2, + }, + }) + c.Assert(err, IsNil) + + value, err := aspect.Get(databag, "snaps") + c.Assert(err, IsNil) + c.Assert(value, DeepEquals, map[string]interface{}{"snapd": float64(1), "foo": map[string]interface{}{"bar": float64(2)}}) +} + +func (s *aspectSuite) TestGetUnmatchedPlaceholdersWithNestedValues(c *C) { + databag := aspects.NewJSONDataBag() + aspectBundle, err := aspects.NewBundle("acc", "bundle", map[string]interface{}{ + "statuses": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "snaps.{snap}.status", "storage": "snaps.{snap}.status"}, + }, + }, + }, aspects.NewJSONSchema()) + c.Assert(err, IsNil) + asp := aspectBundle.Aspect("statuses") + c.Assert(asp, NotNil) + + err = databag.Set("snaps", map[string]interface{}{ + "snapd": map[string]interface{}{ + "status": "active", + }, + "foo": map[string]interface{}{ + "version": 2, + }, + }) + c.Assert(err, IsNil) + + value, err := asp.Get(databag, "snaps") + c.Assert(err, IsNil) + c.Assert(value, DeepEquals, map[string]interface{}{"snapd": map[string]interface{}{"status": "active"}}) +} + +func (s *aspectSuite) TestGetSeveralUnmatchedPlaceholders(c *C) { + databag := aspects.NewJSONDataBag() + aspectBundle, err := aspects.NewBundle("acc", "bundle", map[string]interface{}{ + "foo": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "a.{b}.c.{d}.e", "storage": "a.{b}.c.{d}.e"}, + }, + }, + }, aspects.NewJSONSchema()) + c.Assert(err, IsNil) + asp := aspectBundle.Aspect("foo") + c.Assert(asp, NotNil) + + err = databag.Set("a", map[string]interface{}{ + "b1": map[string]interface{}{ + "c": map[string]interface{}{ + // the request can be fulfilled here + "d1": map[string]interface{}{ + "e": "end", + "f": "not-included", + }, + "d2": "f", + }, + "x": 1, + }, + "b2": map[string]interface{}{ + "c": map[string]interface{}{ + // but not here + "d1": "e", + "d2": "f", + }, + "x": 1, + }, + }) + c.Assert(err, IsNil) + + value, err := asp.Get(databag, "a") + c.Assert(err, IsNil) + expected := map[string]interface{}{ + "b1": map[string]interface{}{ + "c": map[string]interface{}{ + "d1": map[string]interface{}{ + "e": "end", + }, + }, + }, + } + c.Assert(value, DeepEquals, expected) +} + +func (s *aspectSuite) TestGetMergeAtDifferentLevels(c *C) { + databag := aspects.NewJSONDataBag() + aspectBundle, err := aspects.NewBundle("acc", "bundle", map[string]interface{}{ + "foo": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "a.{b}.c.{d}.e", "storage": "a.{b}.c.{d}.e"}, + map[string]interface{}{"request": "a.{b}.c.{d}", "storage": "a.{b}.c.{d}"}, + map[string]interface{}{"request": "a.{b}", "storage": "a.{b}"}, + map[string]interface{}{"request": "a", "storage": "a"}, + }, + }, + }, aspects.NewJSONSchema()) + c.Assert(err, IsNil) + asp := aspectBundle.Aspect("foo") + c.Assert(asp, NotNil) + + err = databag.Set("a", map[string]interface{}{ + "b": map[string]interface{}{ + "c": map[string]interface{}{ + "d": map[string]interface{}{ + "e": "end", + }, + }, + }, + }) + c.Assert(err, IsNil) + + value, err := asp.Get(databag, "a") + c.Assert(err, IsNil) + expected := map[string]interface{}{ + "b": map[string]interface{}{ + "c": map[string]interface{}{ + "d": map[string]interface{}{ + "e": "end", + }, + }, + }, + } + c.Assert(value, DeepEquals, expected) +} + +func (s *aspectSuite) TestBadRequestPaths(c *C) { + databag := aspects.NewJSONDataBag() + aspectBundle, err := aspects.NewBundle("acc", "bundle", map[string]interface{}{ + "foo": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "a.{b}.c", "storage": "a.{b}.c"}, + }, + }, + }, aspects.NewJSONSchema()) + c.Assert(err, IsNil) + + asp := aspectBundle.Aspect("foo") + c.Assert(asp, NotNil) + + err = databag.Set("a", map[string]interface{}{ + "b": map[string]interface{}{ + "c": "value", + }, + }) + c.Assert(err, IsNil) + + type testcase struct { + request string + errMsg string + } + + tcs := []testcase{ + { + request: "a.", + errMsg: "cannot have empty subkeys", + }, + { + request: "a.b.", + errMsg: "cannot have empty subkeys", + }, + { + request: ".a", + errMsg: "cannot have empty subkeys", + }, + { + request: ".", + errMsg: "cannot have empty subkeys", + }, + { + request: "a..b", + errMsg: "cannot have empty subkeys", + }, + { + request: "a.{b}", + errMsg: `invalid subkey "{b}"`, + }, + { + request: "a.-b", + errMsg: `invalid subkey "-b"`, + }, + { + request: "a.b-", + errMsg: `invalid subkey "b-"`, + }, + } + + for _, tc := range tcs { + cmt := Commentf("test %q failed", tc.request) + err = asp.Set(databag, tc.request, "value") + c.Assert(err, NotNil, cmt) + c.Assert(err.Error(), Equals, fmt.Sprintf(`cannot set %q in aspect acc/bundle/foo: %s`, tc.request, tc.errMsg), cmt) + + _, err = asp.Get(databag, tc.request) + c.Assert(err, NotNil, cmt) + c.Assert(err.Error(), Equals, fmt.Sprintf(`cannot get %q in aspect acc/bundle/foo: %s`, tc.request, tc.errMsg), cmt) + + err = asp.Unset(databag, tc.request) + c.Assert(err, NotNil, cmt) + c.Assert(err.Error(), Equals, fmt.Sprintf(`cannot unset %q in aspect acc/bundle/foo: %s`, tc.request, tc.errMsg), cmt) + } +} + +func (s *aspectSuite) TestSetAllowedOnSameRequestButDifferentPaths(c *C) { + databag := aspects.NewJSONDataBag() + aspectBundle, err := aspects.NewBundle("acc", "bundle", map[string]interface{}{ + "foo": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "a.b.c", "storage": "new", "access": "write"}, + map[string]interface{}{"request": "a.b.c", "storage": "old", "access": "write"}, + }, + }, + }, aspects.NewJSONSchema()) + c.Assert(err, IsNil) + asp := aspectBundle.Aspect("foo") + c.Assert(asp, NotNil) + + err = asp.Set(databag, "a.b.c", "value") + c.Assert(err, IsNil) + + stored, err := databag.Get("old") + c.Assert(err, IsNil) + c.Assert(stored, Equals, "value") + + stored, err = databag.Get("new") + c.Assert(err, IsNil) + c.Assert(stored, Equals, "value") +} + +func (s *aspectSuite) TestSetWritesToMoreNestedLast(c *C) { + databag := aspects.NewJSONDataBag() + aspectBundle, err := aspects.NewBundle("acc", "bundle", map[string]interface{}{ + // purposefully unordered to check that Set doesn't depend on well-ordered entries in assertions + "foo": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "snaps.snapd.name", "storage": "snaps.snapd.name"}, + map[string]interface{}{"request": "snaps.snapd", "storage": "snaps.snapd"}, + }, + }, + }, aspects.NewJSONSchema()) + c.Assert(err, IsNil) + + asp := aspectBundle.Aspect("foo") + c.Assert(asp, NotNil) + + err = asp.Set(databag, "snaps.snapd", map[string]interface{}{ + "name": "snapd", + }) + c.Assert(err, IsNil) + + val, err := databag.Get("snaps") + c.Assert(err, IsNil) + + c.Assert(val, DeepEquals, map[string]interface{}{ + "snapd": map[string]interface{}{ + "name": "snapd", + }, + }) +} + +func (s *aspectSuite) TestReadWriteRead(c *C) { + databag := aspects.NewJSONDataBag() + aspectBundle, err := aspects.NewBundle("acc", "bundle", map[string]interface{}{ + "foo": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "a.b.c", "storage": "a.b.c"}, + }, + }, + }, aspects.NewJSONSchema()) + c.Assert(err, IsNil) + + asp := aspectBundle.Aspect("foo") + c.Assert(asp, NotNil) + + initData := map[string]interface{}{ + "b": map[string]interface{}{ + "c": "end", + }, + } + err = databag.Set("a", initData) + c.Assert(err, IsNil) + + data, err := asp.Get(databag, "a") + c.Assert(err, IsNil) + c.Assert(data, DeepEquals, initData) + + err = asp.Set(databag, "a", data) + c.Assert(err, IsNil) + + data, err = asp.Get(databag, "a") + c.Assert(err, IsNil) + c.Assert(data, DeepEquals, initData) +} + +func (s *aspectSuite) TestReadWriteSameDataAtDifferentLevels(c *C) { + databag := aspects.NewJSONDataBag() + aspectBundle, err := aspects.NewBundle("acc", "bundle", map[string]interface{}{ + "foo": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "a.b.c", "storage": "a.b.c"}, + }, + }, + }, aspects.NewJSONSchema()) + c.Assert(err, IsNil) + asp := aspectBundle.Aspect("foo") + c.Assert(asp, NotNil) + + initialData := map[string]interface{}{ + "b": map[string]interface{}{ + "c": "end", + }, + } + err = databag.Set("a", initialData) + c.Assert(err, IsNil) + + for _, req := range []string{"a", "a.b", "a.b.c"} { + val, err := asp.Get(databag, req) + c.Assert(err, IsNil) + + err = asp.Set(databag, req, val) + c.Assert(err, IsNil) + } + + data, err := databag.Get("a") + c.Assert(err, IsNil) + c.Assert(data, DeepEquals, initialData) +} + +func (s *aspectSuite) TestSetValueMissingNestedLevels(c *C) { + databag := aspects.NewJSONDataBag() + aspectBundle, err := aspects.NewBundle("acc", "bundle", map[string]interface{}{ + "foo": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "a.b", "storage": "a.b"}, + }, + }, + }, aspects.NewJSONSchema()) + c.Assert(err, IsNil) + asp := aspectBundle.Aspect("foo") + c.Assert(asp, NotNil) + + err = asp.Set(databag, "a", "foo") + c.Assert(err, ErrorMatches, `cannot set "a" in aspect acc/bundle/foo: expected map for unmatched request parts but got string`) + + err = asp.Set(databag, "a", map[string]interface{}{"c": "foo"}) + c.Assert(err, ErrorMatches, `cannot set "a" in aspect acc/bundle/foo: cannot use unmatched part "b" as key in map\[c:foo\]`) +} + +func (s *aspectSuite) TestGetReadsStorageLessNestedNamespaceBefore(c *C) { + // Get reads by order of namespace (not path) nestedness. This test explicitly + // tests for this and showcases why it matters. In Get we care about building + // a virtual document from locations in the storage that may evolve over time. + // In this example, the storage evolve to have version data in a different place + databag := aspects.NewJSONDataBag() + aspectBundle, err := aspects.NewBundle("acc", "bundle", map[string]interface{}{ + "foo": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "snaps.snapd", "storage": "snaps.snapd"}, + map[string]interface{}{"request": "snaps.snapd.version", "storage": "anewversion"}, + }, + }, + }, aspects.NewJSONSchema()) + c.Assert(err, IsNil) + + err = databag.Set("snaps", map[string]interface{}{ + "snapd": map[string]interface{}{ + "version": 1, + }, + }) + c.Assert(err, IsNil) + + err = databag.Set("anewversion", 2) + c.Assert(err, IsNil) + + asp := aspectBundle.Aspect("foo") + c.Assert(asp, NotNil) + + data, err := asp.Get(databag, "snaps") + c.Assert(err, IsNil) + c.Assert(data, DeepEquals, map[string]interface{}{ + "snapd": map[string]interface{}{ + "version": float64(2), + }, + }) +} + +func (s *aspectSuite) TestSetValidateError(c *C) { + databag := aspects.NewJSONDataBag() + aspectBundle, err := aspects.NewBundle("acc", "bundle", map[string]interface{}{ + "foo": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "bar", "storage": "bar"}, + }, + }, + }, &failingSchema{err: errors.New("expected error")}) + c.Assert(err, IsNil) + + asp := aspectBundle.Aspect("foo") + c.Assert(asp, NotNil) + + err = asp.Set(databag, "bar", "baz") + c.Assert(err, ErrorMatches, "cannot write data: expected error") +} + +func (s *aspectSuite) TestSetOverwriteValueWithNewLevel(c *C) { + databag := aspects.NewJSONDataBag() + err := databag.Set("foo", "bar") + c.Assert(err, IsNil) + + err = databag.Set("foo.bar", "baz") + c.Assert(err, IsNil) + + data, err := databag.Get("foo") + c.Assert(err, IsNil) + c.Assert(data, DeepEquals, map[string]interface{}{"bar": "baz"}) +} + +func (s *aspectSuite) TestSetValidatesDataWithSchemaPass(c *C) { + schema, err := aspects.ParseSchema([]byte(`{ + "aliases": { + "int-map": { + "type": "map", + "values": { + "type": "int", + "min": 0 + } + }, + "str-array": { + "type": "array", + "values": { + "type": "string" + } + } + }, + "schema": { + "foo": "$int-map", + "bar": "$str-array" + } +}`)) + c.Assert(err, IsNil) + + databag := aspects.NewJSONDataBag() + aspectBundle, err := aspects.NewBundle("acc", "bundle", map[string]interface{}{ + "foo": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "foo", "storage": "foo"}, + map[string]interface{}{"request": "bar", "storage": "bar"}, + }, + }, + }, schema) + c.Assert(err, IsNil) + + asp := aspectBundle.Aspect("foo") + c.Assert(asp, NotNil) + + err = asp.Set(databag, "foo", map[string]int{"a": 1, "b": 2}) + c.Assert(err, IsNil) + + err = asp.Set(databag, "bar", []string{"one", "two"}) + c.Assert(err, IsNil) +} + +func (s *aspectSuite) TestSetPreCheckValueFailsIncompatibleTypes(c *C) { + type schemaType struct { + schemaStr string + typ string + value interface{} + } + + types := []schemaType{ + { + schemaStr: `"int"`, + typ: "int", + value: int(0), + }, + { + schemaStr: `"number"`, + typ: "number", + value: float64(0), + }, + { + schemaStr: `"string"`, + typ: "string", + value: "foo", + }, + { + schemaStr: `"bool"`, + typ: "bool", + value: true, + }, + { + schemaStr: `{"type": "array", "values": "any"}`, + typ: "array", + value: []string{"foo"}, + }, + { + schemaStr: `{"type": "map", "values": "any"}`, + typ: "map", + value: map[string]string{"foo": "bar"}, + }, + } + + for _, one := range types { + for _, other := range types { + if one.typ == other.typ || (one.typ == "int" && other.typ == "number") || + (one.typ == "number" && other.typ == "int") { + continue + } + + schema, err := aspects.ParseSchema([]byte(fmt.Sprintf(`{ + "schema": { + "foo": %s, + "bar": %s + } +}`, one.schemaStr, other.schemaStr))) + c.Assert(err, IsNil) + + _, err = aspects.NewBundle("acc", "bundle", map[string]interface{}{ + "foo": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "foo", "storage": "foo", "access": "write"}, + map[string]interface{}{"request": "foo", "storage": "bar", "access": "write"}, + }, + }, + }, schema) + c.Assert(err, ErrorMatches, fmt.Sprintf(`.*storage paths "foo" and "bar" for request "foo" require incompatible types: %s != %s`, one.typ, other.typ)) + } + } +} + +func (s *aspectSuite) TestSetPreCheckValueAllowsIntNumberMismatch(c *C) { + schema, err := aspects.ParseSchema([]byte(`{ + "schema": { + "foo": "int", + "bar": "number" + } +}`)) + c.Assert(err, IsNil) + + databag := aspects.NewJSONDataBag() + aspectBundle, err := aspects.NewBundle("acc", "bundle", map[string]interface{}{ + "foo": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "foo", "storage": "foo", "access": "write"}, + map[string]interface{}{"request": "foo", "storage": "bar", "access": "write"}, + }, + }, + }, schema) + c.Assert(err, IsNil) + + asp := aspectBundle.Aspect("foo") + c.Assert(asp, NotNil) + + err = asp.Set(databag, "foo", 1) + c.Assert(err, IsNil) + + // the schema still checks the data at the end, so setting int schema to a float fails + err = asp.Set(databag, "foo", 1.1) + c.Assert(err, ErrorMatches, `.*cannot accept element in "foo": expected int type but value was number 1.1`) +} + +func (*aspectSuite) TestSetPreCheckMultipleAlternativeTypesFail(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": ["int", "bool"], + "bar": ["string", {"type": "array", "values": "string"}, {"schema": {"baz":"string"}}] + } +}`) + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + _, err = aspects.NewBundle("acc", "bundle", map[string]interface{}{ + "foo": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "foo", "storage": "foo", "access": "write"}, + map[string]interface{}{"request": "foo", "storage": "bar", "access": "write"}, + }, + }, + }, schema) + c.Assert(err, ErrorMatches, `.*storage paths "foo" and "bar" for request "foo" require incompatible types: \[int, bool\] != \[string, array, map\]`) +} + +func (*aspectSuite) TestAssertionRuleSchemaMismatch(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": "int", + "bar": { + "schema": { + "b": "string" + } + } + } +}`) + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + aspectBundle, err := aspects.NewBundle("acc", "bundle", map[string]interface{}{ + "foo": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "foo", "storage": "foo.b.c", "access": "write"}, + map[string]interface{}{"request": "foo", "storage": "bar.b.c", "access": "write"}, + }, + }, + }, schema) + c.Assert(err, ErrorMatches, `.*storage path "foo.b.c" for request "foo" is invalid after "foo": cannot follow path beyond "int" type`) + c.Assert(aspectBundle, IsNil) +} + +func (*aspectSuite) TestSchemaMismatchCheckDifferentLevelPaths(c *C) { + schemaStr := []byte(`{ + "schema": { + "snaps": { + "values": { + "schema": { + "status": { + "type": "string" + } + } + } + } + } +}`) + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + _, err = aspects.NewBundle("acc", "bundle", map[string]interface{}{ + "foo": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "snaps.{snap}", "storage": "snaps.{snap}"}, + map[string]interface{}{"request": "snaps.{snap}.status", "storage": "snaps.{snap}.status"}, + }, + }, + }, schema) + c.Assert(err, IsNil) +} + +func (*aspectSuite) TestSchemaMismatchCheckMultipleAlternativeTypesHappy(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": ["int", "bool"], + "bar": ["string", "bool"] + } +}`) + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + databag := aspects.NewJSONDataBag() + aspectBundle, err := aspects.NewBundle("acc", "bundle", map[string]interface{}{ + "foo": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "foo", "storage": "foo", "access": "write"}, + map[string]interface{}{"request": "foo", "storage": "bar", "access": "write"}, + }, + }, + }, schema) + c.Assert(err, IsNil) + + asp := aspectBundle.Aspect("foo") + c.Assert(asp, NotNil) + + err = asp.Set(databag, "foo", true) + c.Assert(err, IsNil) +} + +func (s *aspectSuite) TestSetUnmatchedPlaceholderLeaf(c *C) { + databag := aspects.NewJSONDataBag() + aspectBundle, err := aspects.NewBundle("acc", "bundle", map[string]interface{}{ + "foo": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "foo.{bar}", "storage": "foo.{bar}"}, + }, + }, + }, aspects.NewJSONSchema()) + c.Assert(err, IsNil) + + asp := aspectBundle.Aspect("foo") + c.Assert(asp, NotNil) + + err = asp.Set(databag, "foo", map[string]interface{}{ + "bar": "value", + "baz": "other", + }) + c.Assert(err, IsNil) + + data, err := asp.Get(databag, "foo") + c.Assert(err, IsNil) + c.Assert(data, DeepEquals, map[string]interface{}{ + "bar": "value", + "baz": "other", + }) +} + +func (s *aspectSuite) TestSetUnmatchedPlaceholderMidPath(c *C) { + databag := aspects.NewJSONDataBag() + aspectBundle, err := aspects.NewBundle("acc", "bundle", map[string]interface{}{ + "foo": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "foo.{bar}.nested", "storage": "foo.{bar}.nested"}, + }, + }, + }, aspects.NewJSONSchema()) + c.Assert(err, IsNil) + + asp := aspectBundle.Aspect("foo") + c.Assert(asp, NotNil) + + err = asp.Set(databag, "foo", map[string]interface{}{ + "bar": map[string]interface{}{"nested": "value"}, + "baz": map[string]interface{}{"nested": "other"}, + }) + c.Assert(err, IsNil) + + data, err := asp.Get(databag, "foo") + c.Assert(err, IsNil) + c.Assert(data, DeepEquals, map[string]interface{}{ + "bar": map[string]interface{}{"nested": "value"}, + "baz": map[string]interface{}{"nested": "other"}, + }) +} + +func (s *aspectSuite) TestSetManyUnmatchedPlaceholders(c *C) { + databag := aspects.NewJSONDataBag() + aspectBundle, err := aspects.NewBundle("acc", "bundle", map[string]interface{}{ + "foo": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "foo.{bar}.a.{baz}", "storage": "foo.{bar}.{baz}"}, + }, + }, + }, aspects.NewJSONSchema()) + c.Assert(err, IsNil) + + asp := aspectBundle.Aspect("foo") + c.Assert(asp, NotNil) + + err = asp.Set(databag, "foo", map[string]interface{}{ + "a": map[string]interface{}{"a": map[string]interface{}{ + "c": "value", + "d": "other", + }}, + "b": map[string]interface{}{"a": map[string]interface{}{ + "e": "value", + "f": "other", + }}, + }) + c.Assert(err, IsNil) + + data, err := asp.Get(databag, "foo") + c.Assert(err, IsNil) + c.Assert(data, DeepEquals, map[string]interface{}{ + "a": map[string]interface{}{"a": map[string]interface{}{ + "c": "value", + "d": "other", + }}, + "b": map[string]interface{}{"a": map[string]interface{}{ + "e": "value", + "f": "other", + }}, + }) +} + +func (s *aspectSuite) TestUnsetUnmatchedPlaceholderLast(c *C) { + databag := aspects.NewJSONDataBag() + aspectBundle, err := aspects.NewBundle("acc", "bundle", map[string]interface{}{ + "foo": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "foo.{bar}", "storage": "foo.{bar}"}, + }, + }, + }, aspects.NewJSONSchema()) + c.Assert(err, IsNil) + + asp := aspectBundle.Aspect("foo") + c.Assert(asp, NotNil) + + err = asp.Set(databag, "foo", map[string]interface{}{ + "bar": "value", + "baz": "other", + }) + c.Assert(err, IsNil) + + err = asp.Unset(databag, "foo") + c.Assert(err, IsNil) + + _, err = asp.Get(databag, "foo") + c.Assert(err, testutil.ErrorIs, &aspects.NotFoundError{}) + c.Assert(err, ErrorMatches, `cannot get "foo" in aspect acc/bundle/foo: matching rules don't map to any values`) +} + +func (s *aspectSuite) TestUnsetUnmatchedPlaceholderMid(c *C) { + databag := aspects.NewJSONDataBag() + aspectBundle, err := aspects.NewBundle("acc", "bundle", map[string]interface{}{ + "foo": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "all.{bar}", "storage": "foo.{bar}"}, + map[string]interface{}{"request": "one.{bar}", "storage": "foo.{bar}.one"}, + }, + }, + }, aspects.NewJSONSchema()) + c.Assert(err, IsNil) + + asp := aspectBundle.Aspect("foo") + c.Assert(asp, NotNil) + + err = asp.Set(databag, "all", map[string]interface{}{ + // should remove only the "one" path + "a": map[string]interface{}{ + "one": "value", + "two": "other", + }, + // the nested value should be removed, leaving an empty map + "b": map[string]interface{}{ + "one": "value", + }, + // should be untouched (no "one" path) + "c": map[string]interface{}{ + "two": "value", + }, + }) + c.Assert(err, IsNil) + + err = asp.Unset(databag, "one") + c.Assert(err, IsNil) + + val, err := asp.Get(databag, "all") + c.Assert(err, IsNil) + c.Assert(val, DeepEquals, map[string]interface{}{ + "a": map[string]interface{}{ + "two": "other", + }, + "b": map[string]interface{}{}, + "c": map[string]interface{}{ + "two": "value", + }, + }) +} + +func (s *aspectSuite) TestGetValuesThroughPaths(c *C) { + type testcase struct { + path string + suffix []string + value interface{} + expected map[string]interface{} + err string + } + + tcs := []testcase{ + { + path: "foo.bar", + suffix: nil, + value: "value", + expected: map[string]interface{}{"foo.bar": "value"}, + }, + { + path: "foo.{bar}", + suffix: []string{"{bar}"}, + value: map[string]interface{}{"a": "value", "b": "other"}, + expected: map[string]interface{}{"foo.a": "value", "foo.b": "other"}, + }, + { + path: "foo.{bar}.baz", + suffix: []string{"{bar}", "baz"}, + value: map[string]interface{}{ + "a": map[string]interface{}{"baz": "value"}, + "b": map[string]interface{}{"baz": "other"}, + }, + expected: map[string]interface{}{"foo.a.baz": "value", "foo.b.baz": "other"}, + }, + { + path: "foo.{bar}.{baz}.last", + suffix: []string{"{bar}", "{baz}"}, + value: map[string]interface{}{ + "a": map[string]interface{}{"b": "value"}, + "c": map[string]interface{}{"d": "other"}, + }, + expected: map[string]interface{}{"foo.a.b.last": "value", "foo.c.d.last": "other"}, + }, + + { + path: "foo.{bar}", + suffix: []string{"{bar}", "baz"}, + value: map[string]interface{}{ + "a": map[string]interface{}{"baz": "value", "ignore": 1}, + "b": map[string]interface{}{"baz": "other", "ignore": 1}, + }, + expected: map[string]interface{}{"foo.a": "value", "foo.b": "other"}, + }, + { + path: "foo.{bar}", + suffix: []string{"{bar}"}, + value: "a", + err: "expected map for unmatched request parts but got string", + }, + { + path: "foo.{bar}", + suffix: []string{"{bar}", "baz"}, + value: map[string]interface{}{ + "a": map[string]interface{}{"notbaz": 1}, + "b": map[string]interface{}{"notbaz": 1}, + }, + err: `cannot use unmatched part "baz" as key in map\[notbaz:1\]`, + }, + } + + for i, tc := range tcs { + cmt := Commentf("failed test number %d", i+1) + pathsToValues, err := aspects.GetValuesThroughPaths(tc.path, tc.suffix, tc.value) + + if tc.err != "" { + c.Check(err, ErrorMatches, tc.err, cmt) + c.Check(pathsToValues, IsNil, cmt) + } else { + c.Check(err, IsNil, cmt) + c.Check(pathsToValues, DeepEquals, tc.expected, cmt) + } + } +} + +func (s *aspectSuite) TestAspectSetErrorIfValueContainsUnusedParts(c *C) { + type testcase struct { + request string + value interface{} + err string + } + + tcs := []testcase{ + { + request: "a", + value: map[string]interface{}{ + "b": map[string]interface{}{"d": "value", "u": 1}, + }, + err: `cannot set "a" in aspect acc/bundle/foo: value contains unused data under "b.u"`, + }, + { + request: "a", + value: map[string]interface{}{ + "b": map[string]interface{}{"d": "value", "u": 1}, + "c": map[string]interface{}{"d": "value"}, + }, + err: `cannot set "a" in aspect acc/bundle/foo: value contains unused data under "b.u"`, + }, + { + request: "b", + value: map[string]interface{}{ + "e": []interface{}{"a"}, + "f": 1, + }, + err: `cannot set "b" in aspect acc/bundle/foo: value contains unused data under "e"`, + }, + { + request: "c", + value: map[string]interface{}{ + "d": map[string]interface{}{ + "e": map[string]interface{}{ + "f": "value", + }, + "f": 1, + }, + }, + err: `cannot set "c" in aspect acc/bundle/foo: value contains unused data under "d.f"`, + }, + } + + for i, tc := range tcs { + cmt := Commentf("failed test number %d", i+1) + databag := aspects.NewJSONDataBag() + aspectBundle, err := aspects.NewBundle("acc", "bundle", map[string]interface{}{ + "foo": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "a.{x}.d", "storage": "a.{x}"}, + map[string]interface{}{"request": "c.d.e.f", "storage": "d"}, + map[string]interface{}{"request": "b.f", "storage": "b.f"}, + }, + }, + }, aspects.NewJSONSchema()) + c.Assert(err, IsNil) + + asp := aspectBundle.Aspect("foo") + c.Assert(asp, NotNil) + + err = asp.Set(databag, tc.request, tc.value) + if tc.err != "" { + c.Check(err, ErrorMatches, tc.err, cmt) + } else { + c.Check(err, IsNil, cmt) + } + } +} + +func (*aspectSuite) TestAspectSummaryWrongType(c *C) { + for _, val := range []interface{}{ + 1, + true, + []interface{}{"foo"}, + map[string]interface{}{"foo": "bar"}, + } { + bundle, err := aspects.NewBundle("acc", "bundle", map[string]interface{}{ + "foo": map[string]interface{}{ + "summary": val, + "rules": []interface{}{ + map[string]interface{}{"request": "foo", "storage": "foo"}, + }, + }, + }, nil) + c.Check(err.Error(), Equals, fmt.Sprintf(`cannot define aspect "foo": aspect summary must be a string but got %T`, val)) + c.Check(bundle, IsNil) + } +} + +func (*aspectSuite) TestAspectSummary(c *C) { + bundle, err := aspects.NewBundle("acc", "bundle", map[string]interface{}{ + "foo": map[string]interface{}{ + "summary": "some summary of the aspect", + "rules": []interface{}{ + map[string]interface{}{"request": "foo", "storage": "foo"}, + }, + }, + }, aspects.NewJSONSchema()) + c.Assert(err, IsNil) + c.Assert(bundle, NotNil) +} + +func (s *aspectSuite) TestGetEntireAspect(c *C) { + databag := aspects.NewJSONDataBag() + aspectBundle, err := aspects.NewBundle("acc", "bundle", map[string]interface{}{ + "foo": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "foo.{bar}", "storage": "foo-path.{bar}"}, + map[string]interface{}{"request": "abc", "storage": "abc-path"}, + map[string]interface{}{"request": "write-only", "storage": "write-only", "access": "write"}, + }, + }, + }, aspects.NewJSONSchema()) + c.Assert(err, IsNil) + + asp := aspectBundle.Aspect("foo") + c.Assert(asp, NotNil) + + err = asp.Set(databag, "foo", map[string]interface{}{ + "bar": "value", + "baz": "other", + }) + c.Assert(err, IsNil) + + err = asp.Set(databag, "abc", "cba") + c.Assert(err, IsNil) + + err = asp.Set(databag, "write-only", "value") + c.Assert(err, IsNil) + + result, err := asp.Get(databag, "") + c.Assert(err, IsNil) + + c.Assert(result, DeepEquals, map[string]interface{}{ + "foo": map[string]interface{}{ + "bar": "value", + "baz": "other", + }, + "abc": "cba", + }) +} + +func (*aspectSuite) TestAspectContentRule(c *C) { + rules := map[string]interface{}{ + "bar": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{ + "request": "a", + "storage": "c", + "content": []interface{}{ + map[string]interface{}{ + "request": "b", + "storage": "d", + }, + }, + }, + }, + }, + } + + bundle, err := aspects.NewBundle("acc", "foo", rules, aspects.NewJSONSchema()) + c.Assert(err, IsNil) + + databag := aspects.NewJSONDataBag() + err = databag.Set("c.d", "value") + c.Assert(err, IsNil) + + asp := bundle.Aspect("bar") + val, err := asp.Get(databag, "a.b") + c.Assert(err, IsNil) + c.Assert(val, Equals, "value") + + err = asp.Set(databag, "a.b", "other") + c.Assert(err, IsNil) + + val, err = asp.Get(databag, "a.b") + c.Assert(err, IsNil) + c.Assert(val, Equals, "other") +} + +func (*aspectSuite) TestAspectWriteContentRuleNestedInRead(c *C) { + rules := map[string]interface{}{ + "bar": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{ + "request": "a", + "storage": "c", + "access": "read", + "content": []interface{}{ + map[string]interface{}{ + "request": "b", + "storage": "d", + "access": "write", + }, + }, + }, + }, + }, + } + + bundle, err := aspects.NewBundle("acc", "foo", rules, aspects.NewJSONSchema()) + c.Assert(err, IsNil) + + databag := aspects.NewJSONDataBag() + asp := bundle.Aspect("bar") + err = asp.Set(databag, "a.b", "value") + c.Assert(err, IsNil) + + _, err = asp.Get(databag, "a.b") + c.Assert(err, ErrorMatches, `.*: no matching read rule`) + + val, err := asp.Get(databag, "a") + c.Assert(err, IsNil) + c.Assert(val, DeepEquals, map[string]interface{}{"d": "value"}) +} + +func (*aspectSuite) TestAspectInvalidContentRules(c *C) { + type testcase struct { + content interface{} + err string + } + + tcs := []testcase{ + { + content: []interface{}{}, + err: `.*"content" must be a non-empty list`, + }, + { + content: map[string]interface{}{}, + err: `.*"content" must be a non-empty list`, + }, + { + content: []interface{}{map[string]interface{}{"request": "a"}}, + err: `.*aspect rules must have a "storage" field`, + }, + } + + for _, tc := range tcs { + rules := map[string]interface{}{ + "bar": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{ + "request": "a", + "storage": "c", + "content": tc.content, + }, + }, + }, + } + + _, err := aspects.NewBundle("acc", "foo", rules, aspects.NewJSONSchema()) + c.Assert(err, ErrorMatches, tc.err) + } +} + +func (*aspectSuite) TestAspectSeveralNestedContentRules(c *C) { + rules := map[string]interface{}{ + "bar": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{ + "request": "a", + "storage": "a", + "content": []interface{}{ + map[string]interface{}{ + "request": "b.c", + "storage": "b.c", + "content": []interface{}{ + map[string]interface{}{ + "request": "d", + "storage": "d", + }, + }, + }, + }, + }, + }, + }, + } + + bundle, err := aspects.NewBundle("acc", "foo", rules, aspects.NewJSONSchema()) + c.Assert(err, IsNil) + + databag := aspects.NewJSONDataBag() + asp := bundle.Aspect("bar") + err = asp.Set(databag, "a.b.c.d", "value") + c.Assert(err, IsNil) + + val, err := asp.Get(databag, "a.b.c.d") + c.Assert(err, IsNil) + c.Assert(val, Equals, "value") +} + +func (*aspectSuite) TestAspectInvalidMapKeys(c *C) { + bundle, err := aspects.NewBundle("acc", "foo", map[string]interface{}{ + "bar": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{ + "request": "foo", + "storage": "foo", + }, + }, + }, + }, aspects.NewJSONSchema()) + c.Assert(err, IsNil) + + databag := aspects.NewJSONDataBag() + asp := bundle.Aspect("bar") + + type testcase struct { + value interface{} + invalidKey string + } + + tcs := []testcase{ + { + value: map[string]interface{}{"-foo": 2}, + invalidKey: "-foo", + }, + { + value: map[string]interface{}{"foo--bar": 2}, + invalidKey: "foo--bar", + }, + { + value: map[string]interface{}{"foo-": 2}, + invalidKey: "foo-", + }, + { + value: map[string]interface{}{"foo": map[string]interface{}{"-bar": 2}}, + invalidKey: "-bar", + }, + { + value: map[string]interface{}{"foo": map[string]interface{}{"bar": map[string]interface{}{"baz-": 2}}}, + invalidKey: "baz-", + }, + { + value: []interface{}{map[string]interface{}{"foo": 2}, map[string]interface{}{"bar-": 2}}, + invalidKey: "bar-", + }, + { + value: []interface{}{nil, map[string]interface{}{"bar-": 2}}, + invalidKey: "bar-", + }, + { + value: map[string]interface{}{"foo": nil, "bar": map[string]interface{}{"-baz": 2}}, + invalidKey: "-baz", + }, + } + + for _, tc := range tcs { + cmt := Commentf("expected invalid key err for value: %v", tc.value) + err = asp.Set(databag, "foo", tc.value) + c.Assert(err, ErrorMatches, fmt.Sprintf("cannot set \"foo\" in aspect acc/foo/bar: key %q doesn't conform to required format: .*", tc.invalidKey), cmt) + } +} + +func (s *aspectSuite) TestSetUsingMapWithNilValuesAtLeaves(c *C) { + databag := aspects.NewJSONDataBag() + aspectBundle, err := aspects.NewBundle("acc", "bundle", map[string]interface{}{ + "foo": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "foo", "storage": "foo"}, + map[string]interface{}{"request": "foo.a", "storage": "foo.a"}, + map[string]interface{}{"request": "foo.b", "storage": "foo.b"}, + }, + }, + }, aspects.NewJSONSchema()) + c.Assert(err, IsNil) + + asp := aspectBundle.Aspect("foo") + c.Assert(asp, NotNil) + + err = asp.Set(databag, "foo", map[string]interface{}{ + "a": "value", + "b": "other", + }) + c.Assert(err, IsNil) + + err = asp.Set(databag, "foo", map[string]interface{}{ + "a": nil, + "b": nil, + }) + c.Assert(err, IsNil) + + value, err := asp.Get(databag, "foo") + c.Assert(err, IsNil) + c.Assert(value, DeepEquals, map[string]interface{}{}) +} + +func (s *aspectSuite) TestSetWithMultiplePathsNestedAtLeaves(c *C) { + databag := aspects.NewJSONDataBag() + aspectBundle, err := aspects.NewBundle("acc", "bundle", map[string]interface{}{ + "foo": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "foo.a", "storage": "foo.a"}, + map[string]interface{}{"request": "foo.b", "storage": "foo.b"}, + }, + }, + }, aspects.NewJSONSchema()) + c.Assert(err, IsNil) + + asp := aspectBundle.Aspect("foo") + c.Assert(asp, NotNil) + + err = asp.Set(databag, "foo", map[string]interface{}{ + "a": map[string]interface{}{ + "c": "value", + "d": "other", + }, + "b": "other", + }) + c.Assert(err, IsNil) + + err = asp.Set(databag, "foo", map[string]interface{}{ + "a": map[string]interface{}{ + "d": nil, + }, + "b": nil, + }) + c.Assert(err, IsNil) + + value, err := asp.Get(databag, "foo") + c.Assert(err, IsNil) + c.Assert(value, DeepEquals, map[string]interface{}{ + // consistent with the previous configuration mechanism + "a": map[string]interface{}{}, + }) +} + +func (s *aspectSuite) TestSetWithNilAndNonNilLeaves(c *C) { + databag := aspects.NewJSONDataBag() + aspectBundle, err := aspects.NewBundle("acc", "bundle", map[string]interface{}{ + "foo": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "foo", "storage": "foo"}, + }, + }, + }, aspects.NewJSONSchema()) + c.Assert(err, IsNil) + + asp := aspectBundle.Aspect("foo") + c.Assert(asp, NotNil) + + err = asp.Set(databag, "foo", map[string]interface{}{ + "a": "value", + "b": "other", + }) + c.Assert(err, IsNil) + + err = asp.Set(databag, "foo", map[string]interface{}{ + "a": nil, + "c": "value", + }) + c.Assert(err, IsNil) + + value, err := asp.Get(databag, "foo") + c.Assert(err, IsNil) + // nil values aren't stored but non-nil values are + c.Assert(value, DeepEquals, map[string]interface{}{ + "c": "value", + }) +} + +func (*aspectSuite) TestSetEnforcesNestednessLimit(c *C) { + restore := aspects.MockMaxValueDepth(2) + defer restore() + + bundle, err := aspects.NewBundle("acc", "foo", map[string]interface{}{ + "bar": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{ + "request": "foo", + "storage": "foo", + }, + }, + }, + }, aspects.NewJSONSchema()) + c.Assert(err, IsNil) + + databag := aspects.NewJSONDataBag() + asp := bundle.Aspect("bar") + + err = asp.Set(databag, "foo", map[string]interface{}{ + "bar": "baz", + }) + c.Assert(err, IsNil) + + err = asp.Set(databag, "foo", map[string]interface{}{ + "bar": map[string]interface{}{ + "baz": "value", + }, + }) + c.Assert(err, ErrorMatches, `cannot set "foo" in aspect acc/foo/bar: value cannot have more than 2 nested levels`) +} diff --git a/aspects/export_test.go b/aspects/export_test.go new file mode 100644 index 00000000..2caab57b --- /dev/null +++ b/aspects/export_test.go @@ -0,0 +1,30 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2024 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package aspects + +var GetValuesThroughPaths = getValuesThroughPaths + +func MockMaxValueDepth(newDepth int) (restore func()) { + oldDepth := maxValueDepth + maxValueDepth = newDepth + return func() { + maxValueDepth = oldDepth + } +} diff --git a/aspects/schema.go b/aspects/schema.go new file mode 100644 index 00000000..164db3c3 --- /dev/null +++ b/aspects/schema.go @@ -0,0 +1,1243 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2023 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package aspects + +import ( + "encoding/json" + "errors" + "fmt" + "regexp" + "strconv" + "strings" + + "github.com/snapcore/snapd/strutil" +) + +type parser interface { + Schema + + // expectsConstraints returns true if the parser must have a map definition + // with constraints or false, if it may have a simple name definition. + expectsConstraints() bool + + // parseConstraints parses constraints for a type defined as a JSON object. + // Shouldn't be used with non-object/map type definitions. + parseConstraints(map[string]json.RawMessage) error +} + +// ParseSchema parses a JSON aspect schema and returns a Schema that can be +// used to validate aspects. +func ParseSchema(raw []byte) (*StorageSchema, error) { + var schemaDef map[string]json.RawMessage + err := json.Unmarshal(raw, &schemaDef) + if err != nil { + return nil, fmt.Errorf("cannot parse top level schema as map: %w", err) + } + + if rawType, ok := schemaDef["type"]; ok { + var typ string + if err := json.Unmarshal(rawType, &typ); err != nil { + return nil, fmt.Errorf(`cannot parse top level schema's "type" entry: %w`, err) + } + + if typ != "map" { + return nil, fmt.Errorf(`cannot parse top level schema: unexpected declared type %q, should be "map" or omitted`, typ) + } + } + + if _, ok := schemaDef["schema"]; !ok { + return nil, fmt.Errorf(`cannot parse top level schema: must have a "schema" constraint`) + } + + schema := new(StorageSchema) + if aliasesRaw, ok := schemaDef["aliases"]; ok { + var aliases map[string]json.RawMessage + if err := json.Unmarshal(aliasesRaw, &aliases); err != nil { + return nil, fmt.Errorf(`cannot parse aliases map: %w`, err) + } + + // TODO: if we want to allow aliases to refer to others, this must be handled + // explicitly since the "aliases" map doesn't have any implicit order + schema.aliases = make(map[string]*aliasRefParser, len(aliases)) + for alias, typeDef := range aliases { + if !validAliasName.Match([]byte(alias)) { + return nil, fmt.Errorf(`cannot parse alias name %q: must match %s`, alias, validAliasName) + } + + aliasSchema, err := schema.parse(typeDef) + if err != nil { + return nil, fmt.Errorf(`cannot parse alias %q: %w`, alias, err) + } + + schema.aliases[alias] = newAliasRefParser(aliasSchema) + } + } + + schema.topLevel, err = schema.parse(raw) + if err != nil { + return nil, err + } + + return schema, nil +} + +// aliasRefParser parses references to aliases (e.g., $my-type). +type aliasRefParser struct { + Schema + + stringBased bool +} + +func newAliasRefParser(s Schema) *aliasRefParser { + _, ok := s.(*stringSchema) + return &aliasRefParser{ + Schema: s, + stringBased: ok, + } +} + +// expectsConstraints return false because a reference to an alias doesn't +// define constraints (these are defined under "aliases" at the top level). +func (*aliasRefParser) expectsConstraints() bool { + return false +} + +// parseConstraints is a no-op because type references can't define constraints. +func (v *aliasRefParser) parseConstraints(map[string]json.RawMessage) error { + return nil +} + +// isStringBased returns true if this reference's base type is a string. +func (u *aliasRefParser) isStringBased() bool { + return u.stringBased +} + +// StorageSchema represents an aspect schema and can be used to validate JSON +// aspects against it. +type StorageSchema struct { + // topLevel is the schema for the top level map. + topLevel Schema + + // aliases are schemas that can validate custom types defined by the user. + aliases map[string]*aliasRefParser +} + +// Validate validates the provided JSON object. +func (s *StorageSchema) Validate(raw []byte) error { + return s.topLevel.Validate(raw) +} + +// SchemaAt returns the types that may be stored at the specified path. +func (s *StorageSchema) SchemaAt(path []string) ([]Schema, error) { + return s.topLevel.SchemaAt(path) +} + +func (s *StorageSchema) Type() SchemaType { + return s.topLevel.Type() +} + +func (s *StorageSchema) parse(raw json.RawMessage) (Schema, error) { + jsonType, err := parseTypeDefinition(raw) + if err != nil { + return nil, fmt.Errorf(`cannot parse type definition: %w`, err) + } + + var typ string + var schemaDef map[string]json.RawMessage + switch typedVal := jsonType.(type) { + case string: + typ = typedVal + + case []json.RawMessage: + alts, err := s.parseAlternatives(typedVal) + if err != nil { + return nil, fmt.Errorf(`cannot parse alternative types: %w`, err) + } + return alts, nil + + case map[string]json.RawMessage: + schemaDef = typedVal + rawType, ok := schemaDef["type"] + if !ok { + typ = "map" + } else { + if err := json.Unmarshal(rawType, &typ); err != nil { + return nil, fmt.Errorf(`cannot parse "type" constraint in type definition: %w`, err) + } + } + + default: + // cannot happen save for programmer error + return nil, fmt.Errorf(`cannot parse schema definition of JSON type %T`, jsonType) + } + + schema, err := s.newTypeSchema(typ) + if err != nil { + return nil, err + } + + // only parse the schema if it's a schema definition w/ constraints + if schemaDef != nil { + if err := schema.parseConstraints(schemaDef); err != nil { + return nil, err + } + } else if schema.expectsConstraints() { + return nil, fmt.Errorf(`cannot parse %q: must be schema definition with constraints`, typ) + } + + return schema, nil +} + +// parseTypeDefinition tries to parse the raw JSON as a list, a map or a string +// (the accepted ways to express types). +func parseTypeDefinition(raw json.RawMessage) (interface{}, error) { + var typeErr *json.UnmarshalTypeError + + var l []json.RawMessage + if err := json.Unmarshal(raw, &l); err == nil { + return l, nil + } else if !errors.As(err, &typeErr) { + return nil, err + } + + var m map[string]json.RawMessage + if err := json.Unmarshal(raw, &m); err == nil { + return m, nil + } else if !errors.As(err, &typeErr) { + return nil, err + } + + var s string + if err := json.Unmarshal(raw, &s); err == nil { + return s, nil + } else { + return nil, fmt.Errorf(`type must be expressed as map, string or list: %w`, err) + } +} + +// parseAlternatives takes a list of alternative types, parses them and creates +// a schema that accepts values matching any alternative. +func (s *StorageSchema) parseAlternatives(alternatives []json.RawMessage) (*alternativesSchema, error) { + alt := &alternativesSchema{schemas: make([]Schema, 0, len(alternatives))} + for _, altRaw := range alternatives { + schema, err := s.parse(altRaw) + if err != nil { + return nil, err + } + + alt.schemas = append(alt.schemas, schema) + } + + if len(alt.schemas) == 0 { + return nil, fmt.Errorf(`alternative type list cannot be empty`) + } + + flatAlts := flattenAlternatives(alt) + alt.schemas = flatAlts + + return alt, nil +} + +// flattenAlternatives takes the schemas that comprise the alternative schema +// and flattens them into a single list. +func flattenAlternatives(alt *alternativesSchema) []Schema { + var flat []Schema + for _, schema := range alt.schemas { + if altSchema, ok := schema.(*alternativesSchema); ok { + nestedAlts := flattenAlternatives(altSchema) + flat = append(flat, nestedAlts...) + } else { + flat = append(flat, schema) + } + } + + return flat +} + +func (s *StorageSchema) newTypeSchema(typ string) (parser, error) { + switch typ { + case "map": + return &mapSchema{topSchema: s}, nil + case "string": + return &stringSchema{}, nil + case "int": + return &intSchema{}, nil + case "any": + return &anySchema{}, nil + case "number": + return &numberSchema{}, nil + case "bool": + return &booleanSchema{}, nil + case "array": + return &arraySchema{topSchema: s}, nil + default: + if typ != "" && typ[0] == '$' { + return s.getAlias(typ[1:]) + } + + return nil, fmt.Errorf("cannot parse unknown type %q", typ) + } +} + +func (s *StorageSchema) getAlias(ref string) (*aliasRefParser, error) { + if alias, ok := s.aliases[ref]; ok { + return alias, nil + } + + return nil, fmt.Errorf("cannot find alias %q", ref) +} + +type alternativesSchema struct { + // schemas holds schemas for the types allowed for the corresponding value. + schemas []Schema +} + +// Validate that raw matches at least one of the schemas in the alternative list. +func (v *alternativesSchema) Validate(raw []byte) error { + var errs []error + for _, schema := range v.schemas { + err := schema.Validate(raw) + if err == nil { + return nil + } + + errs = append(errs, err) + } + + var sb strings.Builder + sb.WriteString("no matching schema:") + for i, err := range errs { + sb.WriteString("\n\t") + if i > 0 { + sb.WriteString("or ") + } + + if verr, ok := err.(*ValidationError); ok { + err = verr.Err + + if len(verr.Path) != 0 { + sb.WriteString("...\"") + for i, part := range verr.Path { + switch v := part.(type) { + case string: + if i > 0 { + sb.WriteRune('.') + } + + sb.WriteString(v) + case int: + sb.WriteString(fmt.Sprintf("[%d]", v)) + default: + // can only happen due to bug + sb.WriteString(".") + } + } + sb.WriteString("\": ") + } + } + + sb.WriteString(err.Error()) + } + + return validationErrorf(sb.String()) +} + +// SchemaAt returns the list of schemas at the end of the path or an error if +// the path cannot be followed. +func (v *alternativesSchema) SchemaAt(path []string) ([]Schema, error) { + if len(path) == 0 { + return v.schemas, nil + } + + var types []Schema + var lastErr error + for _, alt := range v.schemas { + altTypes, err := alt.SchemaAt(path) + if err != nil { + // some schemas may permit the path + lastErr = err + continue + } + types = append(types, altTypes...) + } + + // TODO: find better way to combine errors + if len(types) == 0 { + return nil, lastErr + } + + return types, nil +} + +func (v *alternativesSchema) Type() SchemaType { + return Alt +} + +type mapSchema struct { + // topSchema is the schema for the top-level schema which contains the aliases. + topSchema *StorageSchema + + // entrySchemas maps keys to their expected types. Alternatively, the schema + // can constrain key and/or value types. + entrySchemas map[string]Schema + + // valueSchema validates that the map's values match a certain type. + valueSchema Schema + + // keySchema validates that the map's key match a certain type. + keySchema Schema + + // requiredCombs holds combinations of keys that an instance of the map is + // allowed to have. + requiredCombs [][]string +} + +// Validate that raw is a valid aspect map and meets the constraints set by the +// aspect schema. +func (v *mapSchema) Validate(raw []byte) error { + var mapValue map[string]json.RawMessage + if err := json.Unmarshal(raw, &mapValue); err != nil { + typeErr := &json.UnmarshalTypeError{} + if errors.As(err, &typeErr) { + return validationErrorf("expected map type but value was %s", typeErr.Value) + } + return validationErrorFrom(err) + } + + if mapValue == nil { + return validationErrorf(`cannot accept null value for "map" type`) + } + + if err := validMapKeys(mapValue); err != nil { + return validationErrorFrom(err) + } + + if v.entrySchemas != nil { + for key := range mapValue { + if _, ok := v.entrySchemas[key]; !ok { + return validationErrorf(`map contains unexpected key %q`, key) + } + } + } + + var missing bool + for _, required := range v.requiredCombs { + missing = false + for _, key := range required { + if _, ok := mapValue[key]; !ok { + missing = true + break + } + } + + if !missing { + // matched possible combination of required keys so we can stop + break + } + } + + if missing { + return validationErrorf(`cannot find required combinations of keys`) + } + + if v.entrySchemas != nil { + for key, val := range mapValue { + if validator, ok := v.entrySchemas[key]; ok { + if err := validator.Validate(val); err != nil { + var valErr *ValidationError + if errors.As(err, &valErr) { + valErr.Path = append([]interface{}{key}, valErr.Path...) + } + return err + } + } + } + + // all required entries are present and validated + return nil + } + + if v.keySchema != nil { + for k := range mapValue { + rawKey, err := json.Marshal(k) + if err != nil { + return fmt.Errorf("internal error: %w", err) + } + + if err := v.keySchema.Validate(rawKey); err != nil { + var valErr *ValidationError + if errors.As(err, &valErr) { + valErr.Path = append([]interface{}{k}, valErr.Path...) + } + return err + } + } + } + + if v.valueSchema != nil { + for k, val := range mapValue { + if err := v.valueSchema.Validate(val); err != nil { + var valErr *ValidationError + if errors.As(err, &valErr) { + valErr.Path = append([]interface{}{k}, valErr.Path...) + } + return err + } + } + } + + return nil +} + +func validMapKeys(v map[string]json.RawMessage) error { + for k := range v { + if !validSubkey.Match([]byte(k)) { + return fmt.Errorf(`key %q doesn't conform to required format: %s`, k, validSubkey.String()) + } + } + + return nil +} + +// SchemaAt returns the Map schema if this is the last path element. If not, it +// calls SchemaAt for the next path element's schema if the path is valid. +func (v *mapSchema) SchemaAt(path []string) ([]Schema, error) { + if len(path) == 0 { + return []Schema{v}, nil + } + + key := path[0] + if v.entrySchemas != nil { + valSchema, ok := v.entrySchemas[key] + if !ok { + return nil, schemaAtErrorf(path, `cannot use %q as key in map`, key) + } + + return valSchema.SchemaAt(path[1:]) + } + + return v.valueSchema.SchemaAt(path[1:]) +} + +// Type returns the Map type. +func (v *mapSchema) Type() SchemaType { + return Map +} + +func (v *mapSchema) parseConstraints(constraints map[string]json.RawMessage) error { + err := checkExclusiveMapConstraints(constraints) + if err != nil { + return fmt.Errorf(`cannot parse map: %w`, err) + } + + // maps can be "schemas" with types for specific entries and optional "required" constraints + if rawEntries, ok := constraints["schema"]; ok { + var entries map[string]json.RawMessage + if err := json.Unmarshal(rawEntries, &entries); err != nil { + return fmt.Errorf(`cannot parse map's "schema" constraint: %v`, err) + } + + if err := validMapKeys(entries); err != nil { + return fmt.Errorf(`cannot parse map: %w`, err) + } + + v.entrySchemas = make(map[string]Schema, len(entries)) + for key, value := range entries { + entrySchema, err := v.topSchema.parse(value) + if err != nil { + return err + } + + v.entrySchemas[key] = entrySchema + } + + // "required" can be a list of keys or many lists of alternative combinations + if rawRequired, ok := constraints["required"]; ok { + var requiredCombs [][]string + if err := json.Unmarshal(rawRequired, &requiredCombs); err != nil { + var typeErr *json.UnmarshalTypeError + if !errors.As(err, &typeErr) { + return fmt.Errorf(`cannot parse map's "required" constraint: %v`, err) + } + + var required []string + if err := json.Unmarshal(rawRequired, &required); err != nil { + return fmt.Errorf(`cannot parse map's "required" constraint: %v`, err) + } + + v.requiredCombs = [][]string{required} + } else { + v.requiredCombs = requiredCombs + } + + for _, requiredComb := range v.requiredCombs { + for _, required := range requiredComb { + if _, ok := v.entrySchemas[required]; !ok { + return fmt.Errorf(`cannot parse map's "required" constraint: required key %q must have schema entry`, required) + } + } + } + } + + return nil + } + + // map can not specify "schemas" and constrain the type of keys and values instead + rawKeyDef, ok := constraints["keys"] + if ok { + if v.keySchema, err = v.parseMapKeyType(rawKeyDef); err != nil { + return fmt.Errorf(`cannot parse "keys" constraint: %w`, err) + } + } + + rawValuesDef, ok := constraints["values"] + if ok { + v.valueSchema, err = v.topSchema.parse(rawValuesDef) + if err != nil { + return err + } + } + + if v.entrySchemas == nil && v.keySchema == nil && v.valueSchema == nil { + return fmt.Errorf(`cannot parse map: must have "schema" or "keys"/"values" constraint`) + } + + return nil +} + +// checkExclusiveMapConstraints checks if the map contains mutually exclusive constraints. +func checkExclusiveMapConstraints(obj map[string]json.RawMessage) error { + has := func(k string) bool { + _, ok := obj[k] + return ok + } + + if has("required") && !has("schema") { + return fmt.Errorf(`cannot use "required" without "schema" constraint`) + } + if has("schema") && has("keys") { + return fmt.Errorf(`cannot use "schema" and "keys" constraints simultaneously`) + } + if has("schema") && has("values") { + return fmt.Errorf(`cannot use "schema" and "values" constraints simultaneously`) + } + + return nil +} + +func (v *mapSchema) parseMapKeyType(raw json.RawMessage) (Schema, error) { + var typ string + if err := json.Unmarshal(raw, &typ); err != nil { + var typeErr *json.UnmarshalTypeError + if !errors.As(err, &typeErr) { + return nil, err + } + + var schemaDef map[string]json.RawMessage + if err := json.Unmarshal(raw, &schemaDef); err != nil { + return nil, err + } + + if rawType, ok := schemaDef["type"]; ok { + if err := json.Unmarshal(rawType, &typ); err != nil { + return nil, err + } + + if typ != "string" { + return nil, fmt.Errorf(`must be based on string but type was %s`, typ) + } + } + + schema := &stringSchema{} + if err := schema.parseConstraints(schemaDef); err != nil { + return nil, err + } + + return schema, nil + } + + if typ == "string" { + return &stringSchema{}, nil + } + + if typ != "" && typ[0] == '$' { + alias, err := v.topSchema.getAlias(typ[1:]) + if err != nil { + return nil, err + } + + if !alias.isStringBased() { + return nil, fmt.Errorf(`key type %q must be based on string`, typ[1:]) + } + + return alias, nil + } + + return nil, fmt.Errorf(`keys must be based on string but type was %s`, typ) +} + +func (v *mapSchema) expectsConstraints() bool { return true } + +type stringSchema struct { + // pattern is a regex pattern that the string must match. + pattern *regexp.Regexp + + // choices holds the possible values the string can take, if non-empty. + choices []string +} + +// Validate that raw is a valid aspect string and meets the schema's constraints. +func (v *stringSchema) Validate(raw []byte) (err error) { + defer func() { + if err != nil { + err = validationErrorFrom(err) + } + }() + + var value *string + if err := json.Unmarshal(raw, &value); err != nil { + typeErr := &json.UnmarshalTypeError{} + if errors.As(err, &typeErr) { + return fmt.Errorf("expected string type but value was %s", typeErr.Value) + } + return err + } + + if value == nil { + return fmt.Errorf(`cannot accept null value for "string" type`) + } + + if len(v.choices) != 0 && !strutil.ListContains(v.choices, *value) { + return fmt.Errorf(`string %q is not one of the allowed choices`, *value) + } + + if v.pattern != nil && !v.pattern.Match([]byte(*value)) { + return fmt.Errorf(`expected string matching %s but value was %q`, v.pattern.String(), *value) + } + + return nil +} + +// SchemaAt returns the string schema if the path terminates at this schema and +// an error if not. +func (v *stringSchema) SchemaAt(path []string) ([]Schema, error) { + if len(path) != 0 { + return nil, schemaAtErrorf(path, `cannot follow path beyond "string" type`) + } + + return []Schema{v}, nil +} + +func (v *stringSchema) Type() SchemaType { + return String +} + +func (v *stringSchema) parseConstraints(constraints map[string]json.RawMessage) error { + if rawChoices, ok := constraints["choices"]; ok { + var choices []string + if err := json.Unmarshal(rawChoices, &choices); err != nil { + return fmt.Errorf(`cannot parse "choices" constraint: %w`, err) + } + + if len(choices) == 0 { + return fmt.Errorf(`cannot have a "choices" constraint with an empty list`) + } + + v.choices = choices + } + + if rawPattern, ok := constraints["pattern"]; ok { + if v.choices != nil { + return fmt.Errorf(`cannot use "choices" and "pattern" constraints in same schema`) + } + + var patt string + err := json.Unmarshal(rawPattern, &patt) + if err != nil { + return fmt.Errorf(`cannot parse "pattern" constraint: %w`, err) + } + + if v.pattern, err = regexp.Compile(patt); err != nil { + return fmt.Errorf(`cannot parse "pattern" constraint: %w`, err) + } + } + + return nil +} + +func (v *stringSchema) expectsConstraints() bool { return false } + +type intSchema struct { + min *int64 + max *int64 + choices []int64 +} + +// Validate that raw is a valid integer and meets the schema's constraints. +func (v *intSchema) Validate(raw []byte) (err error) { + defer func() { + if err != nil { + err = validationErrorFrom(err) + } + }() + + var num *int64 + if err := json.Unmarshal(raw, &num); err != nil { + typeErr := &json.UnmarshalTypeError{} + if errors.As(err, &typeErr) { + return fmt.Errorf("expected int type but value was %s", typeErr.Value) + } + return err + } + + if num == nil { + return fmt.Errorf(`cannot accept null value for "int" type`) + } + + return validateNumber(*num, v.choices, v.min, v.max) +} + +// SchemaAt returns the int schema if the path terminates here and an error if +// not. +func (v *intSchema) SchemaAt(path []string) ([]Schema, error) { + if len(path) != 0 { + return nil, schemaAtErrorf(path, `cannot follow path beyond "int" type`) + } + + return []Schema{v}, nil +} + +// Type returns the Int schema type. +func (v *intSchema) Type() SchemaType { + return Int +} + +func (v *intSchema) parseConstraints(constraints map[string]json.RawMessage) error { + if rawChoices, ok := constraints["choices"]; ok { + var choices []int64 + err := json.Unmarshal(rawChoices, &choices) + if err != nil { + return fmt.Errorf(`cannot parse "choices" constraint: %v`, err) + } + + if len(choices) == 0 { + return fmt.Errorf(`cannot have "choices" constraint with empty list`) + } + + v.choices = choices + } + + if rawMin, ok := constraints["min"]; ok { + if v.choices != nil { + return fmt.Errorf(`cannot have "choices" and "min" constraints`) + } + + var min int64 + if err := json.Unmarshal(rawMin, &min); err != nil { + return fmt.Errorf(`cannot parse "min" constraint: %v`, err) + } + v.min = &min + } + + if rawMax, ok := constraints["max"]; ok { + if v.choices != nil { + return fmt.Errorf(`cannot have "choices" and "max" constraints`) + } + + var max int64 + if err := json.Unmarshal(rawMax, &max); err != nil { + return fmt.Errorf(`cannot parse "max" constraint: %v`, err) + } + v.max = &max + } + + if v.min != nil && v.max != nil && *v.min > *v.max { + return fmt.Errorf(`cannot have "min" constraint with value greater than "max"`) + } + + return nil +} + +func (v *intSchema) expectsConstraints() bool { return false } + +type anySchema struct{} + +func (v *anySchema) Validate(raw []byte) (err error) { + defer func() { + if err != nil { + err = validationErrorFrom(err) + } + }() + + var val interface{} + if err := json.Unmarshal(raw, &val); err != nil { + return err + } + + if val == nil { + return fmt.Errorf(`cannot accept null value for "any" type`) + } + return nil +} + +func (v *anySchema) parseConstraints(map[string]json.RawMessage) error { + // no error because we're not explicitly rejecting unsupported keywords (for now) + return nil +} + +// SchemaAt returns the "any" schema. +func (v *anySchema) SchemaAt([]string) ([]Schema, error) { + return []Schema{v}, nil +} + +// Type returns the Any schema type. +func (v *anySchema) Type() SchemaType { + return Any +} + +func (v *anySchema) expectsConstraints() bool { return false } + +type numberSchema struct { + min *float64 + max *float64 + choices []float64 +} + +// Validate that raw is a valid number and meets the schema's constraints. +func (v *numberSchema) Validate(raw []byte) (err error) { + defer func() { + if err != nil { + err = validationErrorFrom(err) + } + }() + + var num *float64 + if err := json.Unmarshal(raw, &num); err != nil { + typeErr := &json.UnmarshalTypeError{} + if errors.As(err, &typeErr) { + return fmt.Errorf("expected number type but value was %s", typeErr.Value) + } + return err + } + + if num == nil { + return fmt.Errorf(`cannot accept null value for "number" type`) + } + + return validateNumber(*num, v.choices, v.min, v.max) +} + +// SchemaAt returns the number schema if the path terminates here and an error if +// not. +func (v *numberSchema) SchemaAt(path []string) ([]Schema, error) { + if len(path) != 0 { + return nil, schemaAtErrorf(path, `cannot follow path beyond "number" type`) + } + + return []Schema{v}, nil +} + +// Type returns the Number schema type. +func (v *numberSchema) Type() SchemaType { + return Number +} + +func validateNumber[Num ~int64 | ~float64](num Num, choices []Num, min, max *Num) error { + if len(choices) != 0 { + var found bool + for _, choice := range choices { + if num == choice { + found = true + break + } + } + + if !found { + return fmt.Errorf(`%v is not one of the allowed choices`, num) + } + } + + // these comparisons are susceptible to floating-point errors but given that + // this won't be used for general storage it should be precise enough + if min != nil && num < *min { + return fmt.Errorf(`%v is less than the allowed minimum %v`, num, *min) + } + + if max != nil && num > *max { + return fmt.Errorf(`%v is greater than the allowed maximum %v`, num, *max) + } + + return nil +} + +func (v *numberSchema) parseConstraints(constraints map[string]json.RawMessage) error { + if rawChoices, ok := constraints["choices"]; ok { + var choices []float64 + err := json.Unmarshal(rawChoices, &choices) + if err != nil { + return fmt.Errorf(`cannot parse "choices" constraint: %v`, err) + } + + if len(choices) == 0 { + return fmt.Errorf(`cannot have "choices" constraint with empty list`) + } + + v.choices = choices + } + + if rawMin, ok := constraints["min"]; ok { + if v.choices != nil { + return fmt.Errorf(`cannot have "choices" and "min" constraints`) + } + + var min float64 + if err := json.Unmarshal(rawMin, &min); err != nil { + return fmt.Errorf(`cannot parse "min" constraint: %v`, err) + } + v.min = &min + } + + if rawMax, ok := constraints["max"]; ok { + if v.choices != nil { + return fmt.Errorf(`cannot have "choices" and "max" constraints`) + } + + var max float64 + if err := json.Unmarshal(rawMax, &max); err != nil { + return fmt.Errorf(`cannot parse "max" constraint: %v`, err) + } + v.max = &max + } + + if v.min != nil && v.max != nil && *v.min > *v.max { + return fmt.Errorf(`cannot have "min" constraint with value greater than "max"`) + } + + return nil +} + +func (v *numberSchema) expectsConstraints() bool { return false } + +type booleanSchema struct{} + +func (v *booleanSchema) Validate(raw []byte) (err error) { + defer func() { + if err != nil { + err = validationErrorFrom(err) + } + }() + + var val *bool + if err := json.Unmarshal(raw, &val); err != nil { + typeErr := &json.UnmarshalTypeError{} + if errors.As(err, &typeErr) { + return fmt.Errorf("expected bool type but value was %s", typeErr.Value) + } + return err + } + + if val == nil { + return fmt.Errorf(`cannot accept null value for "bool" type`) + } + + return nil +} + +// SchemaAt returns the boolean schema if the path terminates here and an error +// if not. +func (v *booleanSchema) SchemaAt(path []string) ([]Schema, error) { + if len(path) != 0 { + return nil, schemaAtErrorf(path, `cannot follow path beyond "bool" type`) + } + + return []Schema{v}, nil +} + +// Type return the Bool type. +func (v *booleanSchema) Type() SchemaType { + return Bool +} + +func (v *booleanSchema) parseConstraints(map[string]json.RawMessage) error { + // no error because we're not explicitly rejecting unsupported keywords (for now) + return nil +} + +func (v *booleanSchema) expectsConstraints() bool { return false } + +type arraySchema struct { + // topSchema is the schema for the top-level schema which contains the aliases. + topSchema *StorageSchema + + // elementType represents the type of the array's elements and can be used to + // validate them. + elementType Schema + + // unique is true if the array should not contain duplicates. + unique bool +} + +func (v *arraySchema) Validate(raw []byte) error { + var array *[]json.RawMessage + if err := json.Unmarshal(raw, &array); err != nil { + typeErr := &json.UnmarshalTypeError{} + if errors.As(err, &typeErr) { + return validationErrorf("expected array type but value was %s", typeErr.Value) + } + return validationErrorFrom(err) + } + + if array == nil { + return validationErrorf(`cannot accept null value for "array" type`) + } + + for e, val := range *array { + if err := v.elementType.Validate([]byte(val)); err != nil { + var vErr *ValidationError + if errors.As(err, &vErr) { + vErr.Path = append([]interface{}{e}, vErr.Path...) + } + return err + } + } + + if v.unique { + valSet := make(map[string]struct{}, len(*array)) + + for _, val := range *array { + encodedVal := string(val) + if _, ok := valSet[encodedVal]; ok { + return validationErrorf(`cannot accept duplicate values for array with "unique" constraint`) + } + valSet[encodedVal] = struct{}{} + } + } + + return nil +} + +// SchemaAt returns the array schema the path is empty. Otherwise, it calls SchemaAt +// for the next path element's schema if the path is valid. +func (v *arraySchema) SchemaAt(path []string) ([]Schema, error) { + if len(path) == 0 { + return []Schema{v}, nil + } + + key := path[0] + _, err := strconv.ParseUint(key, 10, 0) + if err != nil { + return nil, schemaAtErrorf(path, `key %q cannot be used to index array`, key) + } + + return v.elementType.SchemaAt(path[1:]) +} + +// Type returns the Array schema type. +func (v *arraySchema) Type() SchemaType { + return Array +} + +func (v *arraySchema) parseConstraints(constraints map[string]json.RawMessage) error { + rawValues, ok := constraints["values"] + if !ok { + return fmt.Errorf(`cannot parse "array": must have "values" constraint`) + } + + typ, err := v.topSchema.parse(rawValues) + if err != nil { + return fmt.Errorf(`cannot parse "array" values type: %v`, err) + } + + v.elementType = typ + + if rawUnique, ok := constraints["unique"]; ok { + var unique bool + if err := json.Unmarshal(rawUnique, &unique); err != nil { + return fmt.Errorf(`cannot parse array's "unique" constraint: %v`, err) + } + + v.unique = unique + } + + return nil +} + +func (v *arraySchema) expectsConstraints() bool { return true } + +// TODO: keep a list of expected types (to support alternatives), an actual type/value +// and then optional unmet constraints for the expected types. Then this could be used +// to have more concise errors when there are many possible types +// https://github.com/snapcore/snapd/pull/13502#discussion_r1463658230 +type ValidationError struct { + Path []interface{} + Err error +} + +func (v *ValidationError) Error() string { + var msg string + if len(v.Path) == 0 { + msg = "cannot accept top level element" + } else { + var sb strings.Builder + for i, part := range v.Path { + switch v := part.(type) { + case string: + if i > 0 { + sb.WriteRune('.') + } + + sb.WriteString(v) + case int: + sb.WriteString(fmt.Sprintf("[%d]", v)) + default: + // can only happen due to bug + sb.WriteString(".") + } + } + + msg = fmt.Sprintf("cannot accept element in %q", sb.String()) + } + + return fmt.Sprintf("%s: %v", msg, v.Err) +} + +func validationErrorFrom(err error) error { + return &ValidationError{Err: err} +} + +func validationErrorf(format string, v ...interface{}) error { + return &ValidationError{Err: fmt.Errorf(format, v...)} +} + +type schemaAtError struct { + left int + err error +} + +func (e *schemaAtError) Error() string { + return e.err.Error() +} + +func schemaAtErrorf(path []string, format string, v ...interface{}) error { + return &schemaAtError{ + left: len(path), + err: fmt.Errorf(format, v...), + } +} diff --git a/aspects/schema_test.go b/aspects/schema_test.go new file mode 100644 index 00000000..5bc466e5 --- /dev/null +++ b/aspects/schema_test.go @@ -0,0 +1,2341 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2023 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package aspects_test + +import ( + "fmt" + "math" + + "github.com/snapcore/snapd/aspects" + "github.com/snapcore/snapd/testutil" + . "gopkg.in/check.v1" +) + +type schemaSuite struct{} + +var _ = Suite(&schemaSuite{}) + +func (*schemaSuite) TestTopLevelFailsWithoutSchema(c *C) { + schemaStr := []byte(`{ + "keys": "string" +}`) + + _, err := aspects.ParseSchema(schemaStr) + c.Assert(err, ErrorMatches, `cannot parse top level schema: must have a "schema" constraint`) +} + +func (*schemaSuite) TestSchemaMustBeMap(c *C) { + schemaStr := []byte(`["foo"]`) + + _, err := aspects.ParseSchema(schemaStr) + c.Assert(err, ErrorMatches, `cannot parse top level schema as map: json: cannot unmarshal array.*`) +} + +func (*schemaSuite) TestTopLevelMustBeMapType(c *C) { + schemaStr := []byte(`{ + "type": "string" +}`) + + _, err := aspects.ParseSchema(schemaStr) + c.Assert(err, ErrorMatches, `cannot parse top level schema: unexpected declared type "string", should be "map" or omitted`) + + schemaStr = []byte(`{ + "type": "map", + "schema": { + + } +}`) + + _, err = aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) +} + +func (*schemaSuite) TestTopLevelTypeWrongFormat(c *C) { + schemaStr := []byte(`{ + "type": { + "type": "string" + } +}`) + + _, err := aspects.ParseSchema(schemaStr) + c.Assert(err, ErrorMatches, `cannot parse top level schema's "type" entry: .*`) +} + +func (*schemaSuite) TestMapWithSchemaConstraint(c *C) { + schemaStr := []byte(`{ + "schema": { + "snaps": { + "type": "map", + "schema": { + "foo": "string", + "bar": "string" + } + } + } +}`) + + input := []byte(`{ + "snaps": { + "foo": "abc", + "bar": "cba" + } +}`) + + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + err = schema.Validate(input) + c.Assert(err, IsNil) +} + +func (*schemaSuite) TestMapSchemasRequireConstraints(c *C) { + schemaStr := []byte(`{ + "schema": { + "snaps": { + "schema": { + "foo": "map" + } + } + } +}`) + + _, err := aspects.ParseSchema(schemaStr) + c.Assert(err, ErrorMatches, `cannot parse "map": must be schema definition with constraints`) +} + +func (*schemaSuite) TestTypeConstraintMustBeString(c *C) { + schemaStr := []byte(`{ + "schema": { + "snaps": { + "type": 1, + "schema": { + "foo": "string" + } + } + } +}`) + + _, err := aspects.ParseSchema(schemaStr) + c.Assert(err, ErrorMatches, `cannot parse "type" constraint in type definition: json: cannot unmarshal number into.*`) +} + +func (*schemaSuite) TestMapSchemasRequireSchemaOrKeyValues(c *C) { + schemaStr := []byte(`{ + "schema": { + "snaps": { + "type": "map" + } + } +}`) + + _, err := aspects.ParseSchema(schemaStr) + c.Assert(err, ErrorMatches, `cannot parse map: must have "schema" or "keys"/"values" constraint`) +} + +func (*schemaSuite) TestMapWithUnexpectedKey(c *C) { + schemaStr := []byte(`{ + "schema": { + "snaps": { + "schema": { + "foo": "string" + } + } + } +}`) + + input := []byte(`{ + "snaps": { + "foo": "abc", + "bar": "cba" + } +}`) + + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + err = schema.Validate(input) + c.Assert(err, ErrorMatches, `cannot accept element in "snaps": map contains unexpected key "bar"`) +} +func (*schemaSuite) TestMapWithKeysStringConstraintHappy(c *C) { + schemaStr := []byte(`{ + "schema": { + "snaps": { + "keys": "string" + } + } +}`) + + input := []byte(`{ + "snaps": { + "foo": "bar" + } +}`) + + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + err = schema.Validate(input) + c.Assert(err, IsNil) +} + +func (*schemaSuite) TestMapWithKeysConstraintAsMap(c *C) { + // the map constraining "keys" is assumed to be based on type string + schemaStr := []byte(`{ + "schema": { + "snaps": { + "keys": { + } + } + } +}`) + + input := []byte(`{ + "snaps": { + "foo": "bar" + } +}`) + + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + err = schema.Validate(input) + c.Assert(err, IsNil) +} + +func (*schemaSuite) TestMapKeysConstraintMustBeStringBased(c *C) { + schemaStr := []byte(`{ + "schema": { + "snaps": { + "keys": { + "type": "map" + } + } + } +}`) + + _, err := aspects.ParseSchema(schemaStr) + c.Assert(err, ErrorMatches, `cannot parse "keys" constraint: must be based on string but type was map`) + + schemaStr = []byte(`{ + "schema": { + "snaps": { + "keys": "int" + } + } +}`) + + _, err = aspects.ParseSchema(schemaStr) + c.Assert(err, ErrorMatches, `cannot parse "keys" constraint: keys must be based on string but type was int`) +} + +func (*schemaSuite) TestMapWithValuesStringConstraintHappy(c *C) { + schemaStr := []byte(`{ + "schema": { + "snaps": { + "values": "string" + } + } +}`) + + input := []byte(`{ + "snaps": { + "foo": "bar" + } +}`) + + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + err = schema.Validate(input) + c.Assert(err, IsNil) +} + +func (*schemaSuite) TestMapWithBadValuesConstraint(c *C) { + schemaStr := []byte(`{ + "schema": { + "snaps": { + "values": "foo" + } + } +}`) + + _, err := aspects.ParseSchema(schemaStr) + c.Assert(err, ErrorMatches, `cannot parse unknown type "foo"`) +} + +func (*schemaSuite) TestMapWithUnmetValuesConstraint(c *C) { + schemaStr := []byte(`{ + "schema": { + "snaps": { + "values": "string" + } + } +}`) + + input := []byte(`{ + "snaps": { + "foo": {} + } +}`) + + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + err = schema.Validate(input) + c.Assert(err, ErrorMatches, `cannot accept element in "snaps.foo": expected string type but value was object`) +} + +func (*schemaSuite) TestMapSchemaMetConstraintsWithMissingEntry(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": "string", + "bar": "string" + } +}`) + + input := []byte(`{ + "foo": "oof" +}`) + + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + err = schema.Validate(input) + c.Assert(err, IsNil) +} + +func (*schemaSuite) TestMapSchemaUnmetConstraint(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": "string", + "bar": "string" + } +}`) + + input := []byte(`{ + "foo": "oof", + "bar": { + "a": "b" + } +}`) + + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + err = schema.Validate(input) + c.Assert(err, ErrorMatches, `cannot accept element in "bar": expected string type but value was object`) +} + +func (*schemaSuite) TestMapSchemaWithMetRequiredConstraint(c *C) { + // single list of required entries + schemaStr := []byte(`{ + "schema": { + "foo": "string", + "bar": "string", + "baz": "int" + }, + "required": ["foo", "baz"] +}`) + + input := []byte(`{ + "foo": "oof", + "baz": 3 +}`) + + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + err = schema.Validate(input) + c.Assert(err, IsNil) +} + +func (*schemaSuite) TestMapSchemaWithUnmetRequiredConstraint(c *C) { + // single list of required entries + schemaStr := []byte(`{ + "schema": { + "foo": "string", + "bar": "string", + "baz": "int" + }, + "required": ["foo", "baz"] +}`) + + input := []byte(`{ + "foo": "oof", + "bar": "rab" +}`) + + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + err = schema.Validate(input) + c.Assert(err, ErrorMatches, `cannot accept top level element: cannot find required combinations of keys`) +} + +func (*schemaSuite) TestMapSchemaWithAlternativeOfRequiredEntries(c *C) { + // multiple alternative lists of required entries + schemaStr := []byte(`{ + "schema": { + "foo": "string", + "bar": "string", + "baz": "int" + }, + "required": [["foo"], ["bar"]] +}`) + + // accepts the 1st allowed combination "foo" + input := []byte(`{ + "foo": "oof" +}`) + + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + err = schema.Validate(input) + c.Assert(err, IsNil) + + // accepts the 2nd allowed combination "bar" + input = []byte(`{ + "bar": "rab" +}`) + + schema, err = aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + err = schema.Validate(input) + c.Assert(err, IsNil) +} + +func (*schemaSuite) TestMapSchemaWithUnmetAlternativeOfRequiredEntries(c *C) { + // multiple alternative lists of required entries + schemaStr := []byte(`{ + "schema": { + "foo": "string", + "bar": "string", + "baz": "int" + }, + "required": [["foo"], ["bar"]] +}`) + + input := []byte(`{ + "baz": 1 +}`) + + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + err = schema.Validate(input) + c.Assert(err, ErrorMatches, `cannot accept top level element: cannot find required combinations of keys`) +} + +func (*schemaSuite) TestMapSchemaRequiredNotInSchema(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": "string", + "bar": "string" + }, + "required": ["foo", "baz"] +}`) + + _, err := aspects.ParseSchema(schemaStr) + c.Assert(err, ErrorMatches, `cannot parse map's "required" constraint: required key "baz" must have schema entry`) +} + +func (*schemaSuite) TestMapSchemaWithInvalidKeyFormat(c *C) { + schemaStr := []byte(`{ + "schema": { + "-foo": "string" + } +}`) + + _, err := aspects.ParseSchema(schemaStr) + c.Assert(err, ErrorMatches, `cannot parse map: key "-foo" doesn't conform to required format: .*`) +} + +func (*schemaSuite) TestMapRejectsInputMapWithInvalidKeyFormat(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": "int" + } +}`) + + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + input := []byte(`{ + "-foo": 1 +}`) + err = schema.Validate(input) + c.Assert(err, ErrorMatches, `cannot accept top level element: key "-foo" doesn't conform to required format: .*`) +} + +func (*schemaSuite) TestMapInvalidConstraintCombos(c *C) { + type testcase struct { + name string + snippet string + err string + } + + tcs := []testcase{ + { + name: "schema and keys", + snippet: `{ + "schema": { "foo": "bar" }, + "keys": "string" +}`, + err: `cannot parse map: cannot use "schema" and "keys" constraints simultaneously`, + }, + { + name: "schema and values", + snippet: `{ + "schema": { "foo": "bar" }, + "values": "string" +}`, + err: `cannot parse map: cannot use "schema" and "values" constraints simultaneously`, + }, + { + name: "required w/o schema", + snippet: `{ + "required": ["foo"] +}`, + err: `cannot parse map: cannot use "required" without "schema" constraint`, + }, + } + + for _, tc := range tcs { + schemaStr := []byte(fmt.Sprintf(`{ + "schema": { + "top": %s + } +}`, tc.snippet)) + + _, err := aspects.ParseSchema(schemaStr) + cmt := Commentf("subtest %q", tc.name) + c.Assert(err, ErrorMatches, tc.err, cmt) + } +} + +func (*schemaSuite) TestSchemaWithUnknownType(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": "blarg" + } +}`) + + _, err := aspects.ParseSchema(schemaStr) + c.Assert(err, ErrorMatches, `cannot parse unknown type "blarg"`) +} + +func (*schemaSuite) TestStringsWithEmptyChoices(c *C) { + schemaStr := []byte(`{ + "schema": { + "snaps": { + "keys": { + "type": "string", + "choices": [] + } + } + } +}`) + + _, err := aspects.ParseSchema(schemaStr) + c.Assert(err, ErrorMatches, `cannot parse "keys" constraint: cannot have a "choices" constraint with an empty list`) +} + +// NOTE: this also serves as a test for the success case of checking map keys +func (*schemaSuite) TestStringsWithChoicesHappy(c *C) { + schemaStr := []byte(`{ + "schema": { + "snaps": { + "keys": { + "type": "string", + "choices": ["foo", "bar"] + } + } + } +}`) + + input := []byte(`{ + "snaps": { + "foo": "a", + "bar": "a" + } +}`) + + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + err = schema.Validate(input) + c.Assert(err, IsNil) +} + +// NOTE: this also serves as a test for the failure case of checking map keys +func (*schemaSuite) TestStringsWithChoicesFail(c *C) { + schemaStr := []byte(`{ + "schema": { + "snaps": { + "keys": { + "type": "string", + "choices": ["foo", "bar"] + } + } + } +}`) + + input := []byte(`{ + "snaps": { + "foo": "a", + "bar": "a", + "baz": "a" + } +}`) + + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + err = schema.Validate(input) + c.Assert(err, ErrorMatches, `cannot accept element in "snaps.baz": string "baz" is not one of the allowed choices`) +} + +func (*schemaSuite) TestStringChoicesAndPatternsFail(c *C) { + schemaStr := []byte(`{ + "schema": { + "snaps": { + "keys": { + "type": "string", + "pattern": "foo", + "choices": ["foo"] + } + } + } +}`) + + _, err := aspects.ParseSchema(schemaStr) + c.Assert(err, ErrorMatches, `.*cannot use "choices" and "pattern" constraints in same schema`) +} + +func (*schemaSuite) TestStringPatternHappy(c *C) { + schemaStr := []byte(`{ + "schema": { + "pattern": { + "keys": { + "type": "string", + "pattern": "[fb]oo" + } + } + } +}`) + + input := []byte(`{ + "pattern": { + "foo": "a", + "boo": "a" + } +}`) + + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + err = schema.Validate(input) + c.Assert(err, IsNil) +} + +func (*schemaSuite) TestStringPatternNoMatch(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": { + "type": "string", + "pattern": "[fb]00" + } + } +}`) + + input := []byte(`{ + "foo": "F00" +}`) + + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + err = schema.Validate(input) + c.Assert(err, ErrorMatches, `cannot accept element in "foo": expected string matching \[fb\]00 but value was "F00"`) +} + +func (*schemaSuite) TestStringPatternWrongFormat(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": { + "type": "string", + "pattern": "[fb00" + } + } +}`) + + _, err := aspects.ParseSchema(schemaStr) + c.Assert(err, ErrorMatches, `cannot parse "pattern" constraint: error parsing regexp.*`) + + schemaStr = []byte(`{ + "schema": { + "foo": { + "type": "string", + "pattern": 1 + } + } +}`) + + _, err = aspects.ParseSchema(schemaStr) + c.Assert(err, ErrorMatches, `cannot parse "pattern" constraint:.*`) +} + +func (*schemaSuite) TestStringChoicesWrongFormat(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": { + "type": "string", + "choices": "one-choice" + } + } +}`) + + _, err := aspects.ParseSchema(schemaStr) + c.Assert(err, ErrorMatches, `cannot parse "choices" constraint:.*`) +} + +func (*schemaSuite) TestStringBasedAlias(c *C) { + schemaStr := []byte(`{ + "aliases": { + "snap-name": { + "type": "string", + "pattern": "^[a-z0-9-]*[a-z][a-z0-9-]*$" + }, + "status": { + "type": "string", + "choices": ["active", "inactive"] + } + }, + "schema": { + "snaps": { + "keys": "$snap-name", + "values": { + "schema": { + "name": "$snap-name", + "version": "string", + "status": "$status" + } + } + } + } +}`) + + input := []byte(`{ + "snaps": { + "core20": { + "name": "core20", + "version": "20230503", + "status": "active" + }, + "snapd": { + "name": "snapd", + "version": "2.59.5+git948.gb447044", + "status": "inactive" + } + } +}`) + + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + err = schema.Validate(input) + c.Assert(err, IsNil) +} + +func (*schemaSuite) TestMapKeyMustBeStringAlias(c *C) { + schemaStr := []byte(`{ + "aliases": { + "key-type": { + "type": "map", + "schema": {} + } + }, + "schema": { + "snaps": { + "keys": "$key-type" + } + } +}`) + + _, err := aspects.ParseSchema(schemaStr) + c.Assert(err, ErrorMatches, `cannot parse "keys" constraint: key type "key-type" must be based on string`) +} + +func (*schemaSuite) TestAliasesWrongFormat(c *C) { + schemaStr := []byte(`{ + "aliases": ["foo"], + "schema": {} +}`) + + _, err := aspects.ParseSchema(schemaStr) + c.Assert(err, ErrorMatches, `cannot parse aliases map: json: cannot unmarshal.*`) +} + +func (*schemaSuite) TestBadAlias(c *C) { + schemaStr := []byte(`{ + "aliases": { + "mytype": { + "type": "bad-type" + } + }, + "schema": {} +}`) + + _, err := aspects.ParseSchema(schemaStr) + c.Assert(err, ErrorMatches, `cannot parse alias "mytype": cannot parse unknown type "bad-type"`) +} + +func (*schemaSuite) TestUnknownAlias(c *C) { + schemaStr := []byte(`{ + "schema": { + "snaps": { + "values": "$foo" + } + } +}`) + + _, err := aspects.ParseSchema(schemaStr) + c.Assert(err, ErrorMatches, `cannot find alias "foo"`) +} + +func (*schemaSuite) TestUnknownAliasInKeys(c *C) { + schemaStr := []byte(`{ + "schema": { + "snaps": { + "keys": "$foo" + } + } +}`) + + _, err := aspects.ParseSchema(schemaStr) + c.Assert(err, ErrorMatches, `cannot parse "keys" constraint: cannot find alias "foo"`) +} + +func (*schemaSuite) TestMapBasedAliasHappy(c *C) { + schemaStr := []byte(`{ + "aliases": { + "snap": { + "schema": { + "name": "string", + "status": "string" + } + } + }, + "schema": { + "snaps": { + "values": "$snap" + } + } +}`) + + input := []byte(`{ + "snaps": { + "core20": { + "name": "core20", + "status": "active" + }, + "snapd": { + "name": "snapd", + "status": "inactive" + } + } +}`) + + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + err = schema.Validate(input) + c.Assert(err, IsNil) +} + +func (*schemaSuite) TestAliasReferenceDoesntRequireConstraints(c *C) { + // references to aliases don't require need constraints + schemaStr := []byte(`{ + "aliases": { + "my-type": { + "schema": { + "foo": "string" + } + } + }, + "schema": { + "a": "$my-type" + } +}`) + + _, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + +} + +func (*schemaSuite) TestMapInAliasRequiresConstraints(c *C) { + // maps still require constraints even within aliases + schemaStr := []byte(`{ + "aliases": { + "my-type": "map" + }, + "schema": { + "a": "$my-type" + } +}`) + + _, err := aspects.ParseSchema(schemaStr) + c.Assert(err, ErrorMatches, `cannot parse alias "my-type": cannot parse "map": must be schema definition with constraints`) +} + +func (*schemaSuite) TestMapBasedAliasFail(c *C) { + schemaStr := []byte(`{ + "aliases": { + "snap": { + "schema": { + "name": "string", + "version": "string" + } + } + }, + "schema": { + "snaps": { + "values": "$snap" + } + } +}`) + + input := []byte(`{ + "snaps": { + "core20": { + "name": "core20", + "version": 123 + } + } +}`) + + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + err = schema.Validate(input) + c.Assert(err, ErrorMatches, `cannot accept element in "snaps.core20.version": expected string type but value was number`) +} + +func (*schemaSuite) TestBadAliasName(c *C) { + schemaStr := []byte(`{ + "aliases": { + "-foo": { + "schema": { + "name": "string", + "version": "string" + } + } + }, + "schema": { + "snaps": { + "values": "$-foo" + } + } +}`) + + _, err := aspects.ParseSchema(schemaStr) + c.Assert(err, NotNil) + c.Assert(err.Error(), Equals, `cannot parse alias name "-foo": must match ^(?:[a-z0-9]+-?)*[a-z](?:-?[a-z0-9])*$`) +} + +func (*schemaSuite) TestIntegerHappy(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": "int" + } +}`) + + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + input := []byte(`{ + "foo": 1 +}`) + err = schema.Validate(input) + c.Assert(err, IsNil) +} + +func (*schemaSuite) TestIntRejectsOtherValues(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": "int" + } +}`) + + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + for _, val := range []string{`1.2`, `"a"`, `false`, `[1]`} { + input := []byte(fmt.Sprintf(`{ + "foo": %s +}`, val)) + err = schema.Validate(input) + c.Check(err, ErrorMatches, `cannot accept element in "foo": expected int type but value was .*`) + } +} + +func (*schemaSuite) TestIntegerMustMatchChoices(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": { + "type": "int", + "choices": [1, 3] + } + } +}`) + + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + for _, num := range []int{0, 1, 2, 3, 4} { + input := []byte(fmt.Sprintf(`{ + "foo": %d +}`, num)) + + err := schema.Validate(input) + if num == 1 || num == 3 { + c.Assert(err, IsNil) + } else { + c.Assert(err, ErrorMatches, fmt.Sprintf(`cannot accept element in "foo": %d is not one of the allowed choices`, num)) + } + } +} + +func (*schemaSuite) TestIntegerMustMatchMinMax(c *C) { + min, max := 1, 3 + schemaStr := []byte(fmt.Sprintf(`{ + "schema": { + "foo": { + "type": "int", + "min": %d, + "max": %d + } + } +}`, min, max)) + + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + for _, num := range []int{0, 1, 2, 3, 4} { + input := []byte(fmt.Sprintf(`{ + "foo": %d +}`, num)) + + err := schema.Validate(input) + if num < min { + c.Assert(err, ErrorMatches, fmt.Sprintf(`cannot accept element in "foo": %d is less than the allowed minimum %d`, num, min)) + } else if num > max { + c.Assert(err, ErrorMatches, fmt.Sprintf(`cannot accept element in "foo": %d is greater than the allowed maximum %d`, num, max)) + } else { + c.Assert(err, IsNil) + } + } +} + +func (*schemaSuite) TestIntegerChoicesAndMinMaxFail(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": { + "type": "int", + "min": 0, + "choices": [0] + } + } +}`) + + _, err := aspects.ParseSchema(schemaStr) + c.Assert(err, ErrorMatches, `cannot have "choices" and "min" constraints`) + + schemaStr = []byte(`{ + "schema": { + "foo": { + "type": "int", + "max": 0, + "choices": [0] + } + } +}`) + + _, err = aspects.ParseSchema(schemaStr) + c.Assert(err, ErrorMatches, `cannot have "choices" and "max" constraints`) +} + +func (*schemaSuite) TestIntegerEmptyChoicesFail(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": { + "type": "int", + "choices": [] + } + } +}`) + + _, err := aspects.ParseSchema(schemaStr) + c.Assert(err, ErrorMatches, `cannot have "choices" constraint with empty list`) +} + +func (*schemaSuite) TestIntegerBadChoicesConstraint(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": { + "type": "int", + "choices": 5 + } + } +}`) + + _, err := aspects.ParseSchema(schemaStr) + c.Assert(err, ErrorMatches, `cannot parse "choices" constraint: json: cannot unmarshal number into Go value of type \[\]int64`) +} + +func (*schemaSuite) TestIntegerBadMinMaxConstraints(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": { + "type": "int", + "min": "5" + } + } +}`) + + _, err := aspects.ParseSchema(schemaStr) + c.Assert(err, ErrorMatches, `cannot parse "min" constraint: json: cannot unmarshal string into Go value of type int64`) + + schemaStr = []byte(`{ + "schema": { + "foo": { + "type": "int", + "max": "5" + } + } +}`) + + _, err = aspects.ParseSchema(schemaStr) + c.Assert(err, ErrorMatches, `cannot parse "max" constraint: json: cannot unmarshal string into Go value of type int64`) +} + +func (*schemaSuite) TestIntegerMinGreaterThanMaxConstraintFail(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": { + "type": "int", + "min": 5, + "max": 1 + } + } +}`) + + _, err := aspects.ParseSchema(schemaStr) + c.Assert(err, ErrorMatches, `cannot have "min" constraint with value greater than "max"`) +} + +func (*schemaSuite) TestIntegerMinMaxOver32Bits(c *C) { + schemaStr := []byte(fmt.Sprintf(`{ + "schema": { + "foo": { + "type": "int", + "min": %d, + "max": %d + } + } +}`, int64(math.MinInt64), int64(math.MaxInt64))) + + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + input := []byte(fmt.Sprintf(`{ + "foo": %d +}`, int64(math.MinInt64))) + + err = schema.Validate(input) + c.Assert(err, IsNil) +} + +func (*schemaSuite) TestIntegerChoicesOver32Bits(c *C) { + schemaStr := []byte(fmt.Sprintf(`{ + "schema": { + "foo": { + "type": "int", + "choices": [%d, %d] + } + } +}`, int64(math.MinInt64), int64(math.MaxInt64))) + + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + for _, num := range []int64{math.MinInt64, math.MaxInt64} { + input := []byte(fmt.Sprintf(`{ + "foo": %d +}`, num)) + + err = schema.Validate(input) + c.Assert(err, IsNil) + } +} + +func (*schemaSuite) TestAnyTypeAcceptsAllTypes(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": "any" + } +}`) + + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + for _, val := range []string{`"bar"`, `123`, `{ "a": 1, "b": 2 }`, `0.1`, `false`} { + input := []byte(fmt.Sprintf(`{ + "foo": %s + }`, val)) + + err = schema.Validate(input) + c.Assert(err, IsNil, Commentf(`"any" type didn't accept expected value: %s`, val)) + } +} + +func (*schemaSuite) TestAnyTypeWithMapDefinition(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": { + "type": "any" + } + } +}`) + + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + input := []byte(`{ + "foo": "string" + }`) + + err = schema.Validate(input) + c.Assert(err, IsNil) +} + +func (*schemaSuite) TestAnyTypeRejectsBadJSON(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": "any" + } +}`) + + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + input := []byte(`{ + "foo": . +}`) + err = schema.Validate(input) + c.Assert(err, ErrorMatches, `cannot accept top level element: invalid character .*`) +} + +func (*schemaSuite) TestNumberValidFloatAndInt(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": "number", + "bar": "number" + } +}`) + + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + input := []byte(`{ + "foo": 1.2, + "bar": 1 +}`) + + err = schema.Validate(input) + c.Assert(err, IsNil) +} + +func (*schemaSuite) TestNumberMustMatchChoices(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": { + "type": "number", + "choices": [1, 3.0] + } + } +}`) + + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + for _, num := range []float64{0, 1, 2, 3, 4} { + input := []byte(fmt.Sprintf(`{ + "foo": %f +}`, num)) + + err := schema.Validate(input) + if num == 1 || num == 3 { + c.Assert(err, IsNil) + } else { + c.Assert(err, ErrorMatches, fmt.Sprintf(`cannot accept element in "foo": %v is not one of the allowed choices`, num)) + } + } +} + +func (*schemaSuite) TestNumberMustMatchMinMax(c *C) { + min, max := float32(0.1), float32(3) + schemaStr := []byte(fmt.Sprintf(`{ + "schema": { + "foo": { + "type": "number", + "min": %.1f, + "max": %f + } + } +}`, min, max)) + + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + for _, num := range []float32{0, 0.1, 2, 3, 4} { + input := []byte(fmt.Sprintf(`{ + "foo": %.25f +}`, num)) + + err := schema.Validate(input) + if num < min { + c.Assert(err, ErrorMatches, fmt.Sprintf(`cannot accept element in "foo": %v is less than the allowed minimum %v`, num, min)) + } else if num > max { + c.Assert(err, ErrorMatches, fmt.Sprintf(`cannot accept element in "foo": %v is greater than the allowed maximum %v`, num, max)) + } else { + c.Assert(err, IsNil) + } + } +} + +func (*schemaSuite) TestNumberChoicesAndMinMaxFail(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": { + "type": "number", + "min": 0, + "choices": [0] + } + } +}`) + + _, err := aspects.ParseSchema(schemaStr) + c.Assert(err, ErrorMatches, `cannot have "choices" and "min" constraints`) + + schemaStr = []byte(`{ + "schema": { + "foo": { + "type": "number", + "max": 0, + "choices": [0] + } + } +}`) + + _, err = aspects.ParseSchema(schemaStr) + c.Assert(err, ErrorMatches, `cannot have "choices" and "max" constraints`) +} + +func (*schemaSuite) TestNumberEmptyChoicesFail(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": { + "type": "number", + "choices": [] + } + } +}`) + + _, err := aspects.ParseSchema(schemaStr) + c.Assert(err, ErrorMatches, `cannot have "choices" constraint with empty list`) +} + +func (*schemaSuite) TestNumberBadChoicesConstraint(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": { + "type": "number", + "choices": 5 + } + } +}`) + + _, err := aspects.ParseSchema(schemaStr) + c.Assert(err, ErrorMatches, `cannot parse "choices" constraint: json: cannot unmarshal number into Go value of type \[\]float64`) +} + +func (*schemaSuite) TestNumberBadMinMaxConstraints(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": { + "type": "number", + "min": "5" + } + } +}`) + + _, err := aspects.ParseSchema(schemaStr) + c.Assert(err, ErrorMatches, `cannot parse "min" constraint: json: cannot unmarshal string into Go value of type float64`) + + schemaStr = []byte(`{ + "schema": { + "foo": { + "type": "number", + "max": "5" + } + } +}`) + + _, err = aspects.ParseSchema(schemaStr) + c.Assert(err, ErrorMatches, `cannot parse "max" constraint: json: cannot unmarshal string into Go value of type float64`) +} + +func (*schemaSuite) TestNumberMinGreaterThanMaxConstraintFail(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": { + "type": "number", + "min": 5, + "max": 1 + } + } +}`) + + _, err := aspects.ParseSchema(schemaStr) + c.Assert(err, ErrorMatches, `cannot have "min" constraint with value greater than "max"`) +} + +func (*schemaSuite) TestSimpleTypesRejectNull(c *C) { + for _, typ := range []string{"string", "int", "any", "number", "bool"} { + schemaStr := []byte(fmt.Sprintf(`{ + "schema": { + "foo": %q + } +}`, typ)) + + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + err = schema.Validate([]byte(`{"foo": null}`)) + c.Assert(err, ErrorMatches, fmt.Sprintf(`cannot accept element in "foo": cannot accept null value for %q type`, typ)) + } +} + +func (*schemaSuite) TestMapTypeRejectsNull(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": { + "schema": { + "a": "int" + } + } + } +}`) + + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + err = schema.Validate([]byte(`{"foo": null}`)) + c.Assert(err, ErrorMatches, `cannot accept element in "foo": cannot accept null value for "map" type`) +} + +func (*schemaSuite) TestAliasRejectsNull(c *C) { + schemaStr := []byte(`{ + "aliases": { + "mytype": { + "type": "string" + } + }, + "schema": { + "foo": "$mytype" + } +}`) + + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + err = schema.Validate([]byte(`{"foo": null}`)) + c.Assert(err, ErrorMatches, `cannot accept element in "foo": cannot accept null value for "string" type`) +} + +func (*schemaSuite) TestArrayRejectsNull(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": { + "type": "array", + "values": "int" + } + } +}`) + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + input := []byte(`{"foo": null}`) + err = schema.Validate(input) + c.Assert(err, ErrorMatches, `cannot accept element in "foo": cannot accept null value for "array" type`) +} + +func (*schemaSuite) TestBooleanHappy(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": "bool", + "bar": "bool" + } +}`) + + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + input := []byte(`{ + "foo": true, + "bar": false +}`) + + err = schema.Validate(input) + c.Assert(err, IsNil) +} + +func (*schemaSuite) TestArrayHappy(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": { + "type": "array", + "values": "string" + } + } +}`) + + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + input := []byte(`{ + "foo": ["a", "b"] +}`) + + err = schema.Validate(input) + c.Assert(err, IsNil) +} + +func (*schemaSuite) TestArrayHappyWithAlias(c *C) { + schemaStr := []byte(`{ + "aliases": { + "my-type": "string" + }, + "schema": { + "foo": { + "type": "array", + "values": "$my-type" + } + } +}`) + + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + input := []byte(`{ + "foo": ["a", "b"] +}`) + + err = schema.Validate(input) + c.Assert(err, IsNil) +} + +func (*schemaSuite) TestArrayRequireConstraints(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": "array" + } +}`) + + _, err := aspects.ParseSchema(schemaStr) + c.Assert(err, ErrorMatches, `cannot parse "array": must be schema definition with constraints`) +} + +func (*schemaSuite) TestArrayRequireValueConstraint(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": { + "type": "array" + } + } +}`) + + _, err := aspects.ParseSchema(schemaStr) + c.Assert(err, ErrorMatches, `cannot parse "array": must have "values" constraint`) +} + +func (*schemaSuite) TestArrayFailsWithBadElementType(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": { + "type": "array", + "values": "foo" + } + } +}`) + + _, err := aspects.ParseSchema(schemaStr) + c.Assert(err, ErrorMatches, `cannot parse "array" values type: cannot parse unknown type "foo"`) +} + +func (*schemaSuite) TestArrayEnforcesOnlyOneType(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": { + "type": "array", + "values": "string" + } + } +}`) + + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + input := []byte(`{ + "foo": ["a", 1] +}`) + + err = schema.Validate(input) + c.Assert(err, ErrorMatches, `cannot accept element in "foo\[1\]": expected string type but value was number`) +} + +func (*schemaSuite) TestArrayWithUniqueRejectsDuplicates(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": { + "type": "array", + "values": "string", + "unique": true + } + } +}`) + + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + input := []byte(`{ + "foo": ["a", "a"] +}`) + + err = schema.Validate(input) + c.Assert(err, ErrorMatches, `cannot accept element in "foo": cannot accept duplicate values for array with "unique" constraint`) +} + +func (*schemaSuite) TestArrayWithoutUniqueAcceptsDuplicates(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": { + "type": "array", + "values": "string", + "unique": false + } + } +}`) + + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + input := []byte(`{ + "foo": ["a", "b"] +}`) + + err = schema.Validate(input) + c.Assert(err, IsNil) +} + +func (*schemaSuite) TestArrayFailsWithBadUniqueType(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": { + "type": "array", + "values": "string", + "unique": "true" + } + } +}`) + + _, err := aspects.ParseSchema(schemaStr) + c.Assert(err, ErrorMatches, `cannot parse array's "unique" constraint: json: cannot unmarshal string into Go value of type bool`) +} + +func (*schemaSuite) TestErrorContainsPathPrefixes(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": { + "schema": { + "bar": { + "schema": { + "baz": "string" + } + } + } + } + } +}`) + + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + type testcase struct { + name string + input []byte + err string + } + + testcases := []testcase{ + { + name: "top level", + input: []byte(`{"bar": 1}`), + err: `cannot accept top level element: map contains unexpected key "bar"`, + }, + { + name: "1 level of nesting", + input: []byte(`{"foo": {"baz": 1}}`), + err: `cannot accept element in "foo": map contains unexpected key "baz"`, + }, + { + name: "2 levels of nesting", + input: []byte(`{"foo": {"bar": {"boo": 1}}}`), + err: `cannot accept element in "foo.bar": map contains unexpected key "boo"`, + }, + } + + for _, tc := range testcases { + err = schema.Validate(tc.input) + c.Assert(err, ErrorMatches, tc.err, Commentf("test case %q failed", tc.name)) + } +} + +func (*schemaSuite) TestPathPrefixWithMapUnderUserType(c *C) { + schemaStr := []byte(`{ + "aliases": { + "my-type": { + "schema": { + "bar": { + "type": "int", + "min": 0 + } + } + } + }, + "schema": { + "foo": "$my-type" + } +}`) + + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + input := []byte(`{"foo": {"bar": -1}}`) + err = schema.Validate(input) + c.Assert(err, ErrorMatches, `cannot accept element in "foo.bar": -1 is less than the allowed minimum 0`) +} + +func (*schemaSuite) TestPathPrefixWithArrayUnderAlias(c *C) { + schemaStr := []byte(`{ + "aliases": { + "my-type": { + "type": "int", + "min": 0 + } + }, + "schema": { + "foo": { + "type": "array", + "values": "$my-type" + } + } +}`) + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + input := []byte(`{"foo": [-1]}`) + err = schema.Validate(input) + c.Assert(err, ErrorMatches, `cannot accept element in "foo\[0\]": -1 is less than the allowed minimum 0`) +} + +func (*schemaSuite) TestPathPrefixWithArrayUnderAliasWithAContainerElementType(c *C) { + schemaStr := []byte(`{ + "aliases": { + "my-type": { + "type": "array", + "values": { + "schema": { + "bar": { + "type": "int", + "min": 0 + } + } + } + } + }, + "schema": { + "foo": "$my-type" + } +}`) + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + input := []byte(`{"foo": [{"bar": 1}, {"bar": -1}]}`) + err = schema.Validate(input) + c.Assert(err, ErrorMatches, `cannot accept element in "foo\[1\].bar": -1 is less than the allowed minimum 0`) +} + +func (*schemaSuite) TestPathPrefixWithKeyOrValueConstraints(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": { + "type": "map", + "keys": { + "type": "string", + "choices": ["my-key"] + }, + "values": { + "type": "int", + "min": 0 + } + } + } +}`) + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + input := []byte(`{"foo": {"other-key": 1}}`) + err = schema.Validate(input) + c.Assert(err, ErrorMatches, `cannot accept element in "foo.other-key": string "other-key" is not one of the allowed choices`) + + input = []byte(`{"foo": {"my-key": -1}}`) + err = schema.Validate(input) + c.Assert(err, ErrorMatches, `cannot accept element in "foo.my-key": -1 is less than the allowed minimum 0`) +} + +func (*schemaSuite) TestPathManyUserDefinedTypeReferences(c *C) { + schemaStr := []byte(`{ + "aliases": { + "my-type": { + "type": "map", + "values": { + "type": "int", + "min": 0 + } + } + }, + "schema": { + "foo": "$my-type", + "bar": "$my-type" + } +}`) + + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + input := []byte(`{"foo": { "one": 1 }, "bar": { "two": -1 } }`) + err = schema.Validate(input) + c.Assert(err, ErrorMatches, `cannot accept element in "bar.two": -1 is less than the allowed minimum 0`) +} + +func (*schemaSuite) TestValidationError(c *C) { + type testcase struct { + path []interface{} + expected string + } + + cases := []testcase{ + { + path: []interface{}{"foo", "bar"}, + expected: "foo.bar", + }, + { + path: []interface{}{"foo", 1, "bar"}, + expected: "foo[1].bar", + }, + { + path: []interface{}{"foo", 1, 2, "bar"}, + expected: "foo[1][2].bar", + }, + { + path: []interface{}{"foo", 2.9, 1}, + expected: "foo.[1]", + }, + } + + for _, tc := range cases { + err := &aspects.ValidationError{ + Path: tc.path, + Err: fmt.Errorf("base error"), + } + + c.Assert(err.Error(), Equals, fmt.Sprintf(`cannot accept element in %q: base error`, tc.expected)) + } +} + +func (*schemaSuite) TestUnexpectedTypes(c *C) { + type testcase struct { + schemaType string + expectedType string + testValue interface{} + } + + tcs := []testcase{ + { + schemaType: `{"type": "array", "values": "any"}`, + expectedType: "array", + testValue: true, + }, + { + schemaType: `{"type": "map", "values": "any"}`, + expectedType: "map", + testValue: true, + }, + { + schemaType: `"int"`, + expectedType: "int", + testValue: true, + }, + { + schemaType: `"number"`, + expectedType: "number", + testValue: true, + }, + { + schemaType: `"string"`, + expectedType: "string", + testValue: true, + }, + { + schemaType: `"bool"`, + expectedType: "bool", + testValue: `"bar"`, + }, + } + + for _, tc := range tcs { + schemaStr := []byte(fmt.Sprintf(`{ + "schema": { + "foo": %s + } +}`, tc.schemaType)) + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + input := []byte(fmt.Sprintf(`{"foo": %v}`, tc.testValue)) + err = schema.Validate(input) + c.Assert(err, ErrorMatches, fmt.Sprintf(`cannot accept element in "foo": expected %s type but value was %T`, tc.expectedType, tc.testValue)) + } +} + +func (*schemaSuite) TestAlternativeTypesHappy(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": ["string", "int"] + } +}`) + + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + for _, val := range []interface{}{`"one"`, `1`} { + input := []byte(fmt.Sprintf(`{"foo":%s}`, val)) + err = schema.Validate(input) + c.Assert(err, IsNil) + } +} + +func (*schemaSuite) TestAlternativeTypesFail(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": ["string", "int"] + } +}`) + + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + for _, val := range []interface{}{"1.1", "true", `{"bar": 1}`, `[1, 2]`} { + input := []byte(fmt.Sprintf(`{"foo":%s}`, val)) + err = schema.Validate(input) + c.Assert(err, ErrorMatches, `cannot accept element in "foo": no matching schema: + expected string .* + or expected int .*`) + } +} + +func (*schemaSuite) TestAlternativeTypesWithConstraintsHappy(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": [ + { + "type": "int", + "min": 0 + }, + { + "type": "string", + "pattern": "[bB]ar" + } + ] + } +}`) + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + for _, val := range []interface{}{"3", "0", `"Bar"`, `"bar"`} { + input := []byte(fmt.Sprintf(`{"foo":%s}`, val)) + err = schema.Validate(input) + c.Assert(err, IsNil) + } +} + +func (*schemaSuite) TestAlternativeTypesWithConstraintsFail(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": [ + { + "type": "int", + "min": 0 + }, + { + "type": "string", + "pattern": "[bB]ar" + } + ] + } +}`) + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + err = schema.Validate([]byte(`{"foo":-1}`)) + c.Assert(err, ErrorMatches, `cannot accept element in "foo": no matching schema: + -1 is less than the allowed minimum 0 + or expected string type but value was number`) + + err = schema.Validate([]byte(`{"foo":"bAR"}`)) + c.Assert(err, ErrorMatches, `cannot accept element in "foo": no matching schema: + expected int type but value was string + or expected string matching \[bB\]ar but value was "bAR"`) +} + +func (*schemaSuite) TestAlternativeTypesNestedHappy(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": ["int", ["number", ["string"]]] + } +}`) + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + for _, val := range []interface{}{`"one"`, `1`, `1.3`} { + input := []byte(fmt.Sprintf(`{"foo":%s}`, val)) + err = schema.Validate(input) + c.Assert(err, IsNil) + } +} + +func (*schemaSuite) TestAlternativeTypesNestedFail(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": ["int", ["number", ["string"]]] + } +}`) + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + err = schema.Validate([]byte(`{"foo":false}`)) + c.Assert(err, ErrorMatches, `cannot accept element in "foo": no matching schema: + expected int type but value was bool + or expected number type but value was bool + or expected string type but value was bool`) +} + +func (*schemaSuite) TestAlternativeTypesUnknownType(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": ["foo"] + } +}`) + _, err := aspects.ParseSchema(schemaStr) + c.Assert(err, ErrorMatches, `cannot parse alternative types: cannot parse unknown type "foo"`) +} + +func (*schemaSuite) TestAlternativeTypesEmpty(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": [] + } +}`) + _, err := aspects.ParseSchema(schemaStr) + c.Assert(err, ErrorMatches, `cannot parse alternative types: alternative type list cannot be empty`) +} + +func (*schemaSuite) TestAlternativeTypesPathError(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": { + "schema": { + "bar": [{"schema": {"baz": "int"}}, {"schema": {"baz": {"schema": {"zab": {"type": "array", "values": "string"}}}}}] + } + } + } +}`) + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + err = schema.Validate([]byte(`{"foo":{"bar": {"baz": {"zab": [1]}}}}`)) + c.Assert(err, NotNil) + c.Assert(err.Error(), Equals, `cannot accept element in "foo.bar": no matching schema: + ..."baz": expected int type but value was object + or ..."baz.zab[0]": expected string type but value was number`) +} + +func (*schemaSuite) TestInvalidTypeDefinition(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": 1 + } +}`) + _, err := aspects.ParseSchema(schemaStr) + c.Assert(err, ErrorMatches, `cannot parse type definition: type must be expressed as map, string or list: json: cannot unmarshal number.*`) +} + +func schemasToTypes(schemas []aspects.Schema) []aspects.SchemaType { + var types []aspects.SchemaType + for _, s := range schemas { + types = append(types, s.Type()) + } + return types +} + +func (*schemaSuite) TestSchemaAtTopLevel(c *C) { + type testcase struct { + typeStr string + schemaType aspects.SchemaType + } + + tcs := []testcase{ + { + typeStr: `{"type": "array", "values": "any"}`, + schemaType: aspects.Array, + }, + { + typeStr: `{"type": "map", "values": "any"}`, + schemaType: aspects.Map, + }, + { + typeStr: `"int"`, + schemaType: aspects.Int, + }, + { + typeStr: `"number"`, + schemaType: aspects.Number, + }, + { + typeStr: `"string"`, + schemaType: aspects.String, + }, + { + typeStr: `"bool"`, + schemaType: aspects.Bool, + }, + { + typeStr: `"any"`, + schemaType: aspects.Any, + }, + } + + for _, tc := range tcs { + cmt := Commentf("type %q test", tc.typeStr) + schemaStr := []byte(fmt.Sprintf(`{ + "schema": { + "foo": %s + } +}`, tc.typeStr)) + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil, cmt) + + schemas, err := schema.SchemaAt([]string{"foo"}) + c.Assert(err, IsNil, cmt) + types := schemasToTypes(schemas) + c.Assert(types, testutil.DeepUnsortedMatches, []aspects.SchemaType{tc.schemaType}, cmt) + } +} + +func (*schemaSuite) TestSchemaAtNestedMapWithSchema(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": { + "schema": { + "bar": "string" + } + } + } +}`) + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + schemas, err := schema.SchemaAt([]string{"foo", "bar"}) + c.Assert(err, IsNil) + c.Assert(schemasToTypes(schemas), DeepEquals, []aspects.SchemaType{aspects.String}) +} + +func (*schemaSuite) TestSchemaAtNestedInMapWithValues(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": { + "type": "map", + "values": "string" + } + } +}`) + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + schemas, err := schema.SchemaAt([]string{"foo", "bar"}) + c.Assert(err, IsNil) + c.Assert(schemasToTypes(schemas), DeepEquals, []aspects.SchemaType{aspects.String}) +} + +func (*schemaSuite) TestSchemaAtNestedInArray(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": { + "type": "array", + "values": "string" + } + } +}`) + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + schemas, err := schema.SchemaAt([]string{"foo", "0"}) + c.Assert(err, IsNil) + c.Assert(schemasToTypes(schemas), DeepEquals, []aspects.SchemaType{aspects.String}) +} + +func (*schemaSuite) TestSchemaAtExceedingSchemaLeafSchema(c *C) { + for _, typ := range []string{"int", "number", "bool", "string"} { + cmt := Commentf("type %q test", typ) + schemaStr := []byte(fmt.Sprintf(`{ + "schema": { + "foo": %q + } +}`, typ)) + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil, cmt) + + schemas, err := schema.SchemaAt([]string{"foo", "bar"}) + c.Assert(err, ErrorMatches, fmt.Sprintf(`cannot follow path beyond %q type`, typ), cmt) + c.Assert(schemas, IsNil, cmt) + } +} + +func (*schemaSuite) TestSchemaAtExceedingSchemaContainerSchema(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": {"type": "array", "values": "string"} + } +}`) + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + schemas, err := schema.SchemaAt([]string{"foo", "0", "bar"}) + c.Assert(err, ErrorMatches, `cannot follow path beyond "string" type`) + c.Assert(schemas, IsNil) +} + +func (*schemaSuite) TestSchemaAtBadPathArray(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": {"type": "array", "values": "any"} + } +}`) + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + schemas, err := schema.SchemaAt([]string{"foo", "b"}) + c.Assert(err, ErrorMatches, `key "b" cannot be used to index array`) + c.Assert(schemas, IsNil) +} + +func (*schemaSuite) TestSchemaAtBadPathMap(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": { + "schema": { + "bar": "any" + } + } + } +}`) + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + schemas, err := schema.SchemaAt([]string{"foo", "baz"}) + c.Assert(err, ErrorMatches, `cannot use "baz" as key in map`) + c.Assert(schemas, IsNil) +} + +func (*schemaSuite) TestSchemaAtAlternativesDifferentDepthsHappy(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": ["int", {"schema": {"bar": "string"}}] + } +}`) + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + schemas, err := schema.SchemaAt([]string{"foo", "bar"}) + c.Assert(err, IsNil) + c.Assert(schemasToTypes(schemas), DeepEquals, []aspects.SchemaType{aspects.String}) +} + +func (*schemaSuite) TestSchemaAtAlternativesFail(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": ["int", "string"] + } +}`) + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + schemas, err := schema.SchemaAt([]string{"foo", "bar"}) + c.Assert(err, ErrorMatches, `cannot follow path beyond "string" type`) + c.Assert(schemas, IsNil) +} + +func (*schemaSuite) TestSchemaAtAnyAcceptsLongerPath(c *C) { + schemaStr := []byte(`{ + "schema": { + "foo": "any" + } +}`) + schema, err := aspects.ParseSchema(schemaStr) + c.Assert(err, IsNil) + + schemas, err := schema.SchemaAt([]string{"foo", "baz"}) + c.Assert(err, IsNil) + c.Assert(schemas, NotNil) +} diff --git a/aspects/transaction.go b/aspects/transaction.go new file mode 100644 index 00000000..37d33761 --- /dev/null +++ b/aspects/transaction.go @@ -0,0 +1,166 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- +/* + * Copyright (C) 2023 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package aspects + +import ( + "sync" +) + +type DatabagRead func() (JSONDataBag, error) +type DatabagWrite func(JSONDataBag) error + +// Transaction performs read and writes to a databag in an atomic way. +type Transaction struct { + pristine JSONDataBag + schema Schema + + modified JSONDataBag + deltas []map[string]interface{} + appliedDeltas int + + readDatabag DatabagRead + writeDatabag DatabagWrite + mu sync.RWMutex +} + +// NewTransaction takes a getter and setter to read and write the databag. +func NewTransaction(readDatabag DatabagRead, writeDatabag DatabagWrite, schema Schema) (*Transaction, error) { + databag, err := readDatabag() + if err != nil { + return nil, err + } + + return &Transaction{ + pristine: databag.Copy(), + schema: schema, + readDatabag: readDatabag, + writeDatabag: writeDatabag, + }, nil +} + +// Set sets a value in the transaction's databag. The change isn't persisted +// until Commit returns without errors. +func (t *Transaction) Set(path string, value interface{}) error { + t.mu.Lock() + defer t.mu.Unlock() + t.deltas = append(t.deltas, map[string]interface{}{path: value}) + return nil +} + +// Unset unsets a value in the transaction's databag. The change isn't persisted +// until Commit returns without errors. +func (t *Transaction) Unset(path string) error { + t.mu.Lock() + defer t.mu.Unlock() + t.deltas = append(t.deltas, map[string]interface{}{path: nil}) + return nil +} + +// Get reads a value from the transaction's databag including uncommitted changes. +func (t *Transaction) Get(path string) (interface{}, error) { + t.mu.RLock() + defer t.mu.RUnlock() + + // if there aren't any changes, just use the pristine bag + if len(t.deltas) == 0 { + return t.pristine.Get(path) + } + + // if there are changes, use a cached bag with modifications to do the Get + if t.modified == nil { + t.modified = t.pristine.Copy() + t.appliedDeltas = 0 + } + + // apply new changes since the last get + if err := applyDeltas(t.modified, t.deltas[t.appliedDeltas:]); err != nil { + t.modified = nil + t.appliedDeltas = 0 + return nil, err + } + t.appliedDeltas = len(t.deltas) + + return t.modified.Get(path) +} + +// Commit applies the previous writes and validates the final databag. If any +// error occurs, the original databag is kept. +func (t *Transaction) Commit() error { + t.mu.Lock() + defer t.mu.Unlock() + + pristine, err := t.readDatabag() + if err != nil { + return err + } + + // ensure we're using a different databag, so outside changes can't affect + // the transaction + pristine = pristine.Copy() + + if err := applyDeltas(pristine, t.deltas); err != nil { + return err + } + + data, err := pristine.Data() + if err != nil { + return err + } + + if err := t.schema.Validate(data); err != nil { + return err + } + + // copy the databag before writing to make sure the writer can't modify into + // and introduce changes in the transaction + if err := t.writeDatabag(pristine.Copy()); err != nil { + return err + } + + t.pristine = pristine + t.modified = nil + t.deltas = nil + t.appliedDeltas = 0 + return nil +} + +func applyDeltas(bag JSONDataBag, deltas []map[string]interface{}) error { + // changes must be applied in the order they were written + for _, delta := range deltas { + for k, v := range delta { + var err error + if v == nil { + err = bag.Unset(k) + } else { + err = bag.Set(k, v) + } + + if err != nil { + return err + } + } + } + + return nil +} + +// Data returns the transaction's committed data. +func (t *Transaction) Data() ([]byte, error) { + return t.pristine.Data() +} diff --git a/aspects/transaction_test.go b/aspects/transaction_test.go new file mode 100644 index 00000000..814e778e --- /dev/null +++ b/aspects/transaction_test.go @@ -0,0 +1,373 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- +/* + * Copyright (C) 2023 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package aspects_test + +import ( + "errors" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/aspects" +) + +type transactionTestSuite struct{} + +var _ = Suite(&transactionTestSuite{}) + +type witnessReadWriter struct { + readCalled int + writeCalled int + bag aspects.JSONDataBag + writtenDatabag aspects.JSONDataBag +} + +func (w *witnessReadWriter) read() (aspects.JSONDataBag, error) { + w.readCalled++ + return w.bag, nil +} + +func (w *witnessReadWriter) write(bag aspects.JSONDataBag) error { + w.writeCalled++ + w.writtenDatabag = bag + return nil +} + +func (s *transactionTestSuite) TestSet(c *C) { + bag := aspects.NewJSONDataBag() + witness := &witnessReadWriter{bag: bag} + schema := aspects.NewJSONSchema() + tx, err := aspects.NewTransaction(witness.read, witness.write, schema) + c.Assert(err, IsNil) + c.Assert(witness.readCalled, Equals, 1) + + err = tx.Set("foo", "bar") + c.Assert(err, IsNil) + c.Assert(witness.writeCalled, Equals, 0) + + _, err = witness.writtenDatabag.Get("foo") + c.Assert(err, FitsTypeOf, aspects.PathError("")) +} + +func (s *transactionTestSuite) TestCommit(c *C) { + witness := &witnessReadWriter{bag: aspects.NewJSONDataBag()} + schema := aspects.NewJSONSchema() + tx, err := aspects.NewTransaction(witness.read, witness.write, schema) + c.Assert(err, IsNil) + c.Assert(witness.readCalled, Equals, 1) + + err = tx.Set("foo", "bar") + c.Assert(err, IsNil) + c.Assert(witness.readCalled, Equals, 1) + c.Assert(witness.writeCalled, Equals, 0) + c.Assert(witness.writtenDatabag, IsNil) + + err = tx.Commit() + c.Assert(err, IsNil) + + value, err := witness.writtenDatabag.Get("foo") + c.Assert(err, IsNil) + c.Assert(value, Equals, "bar") + c.Assert(witness.writeCalled, Equals, 1) +} + +func (s *transactionTestSuite) TestGetReadsUncommitted(c *C) { + databag := aspects.NewJSONDataBag() + witness := &witnessReadWriter{bag: databag} + schema := aspects.NewJSONSchema() + tx, err := aspects.NewTransaction(witness.read, witness.write, schema) + c.Assert(err, IsNil) + + err = databag.Set("foo", "bar") + c.Assert(err, IsNil) + + err = tx.Set("foo", "baz") + c.Assert(err, IsNil) + // nothing was committed + c.Assert(witness.writeCalled, Equals, 0) + c.Assert(txData(c, tx), Equals, "{}") + + val, err := tx.Get("foo") + c.Assert(err, IsNil) + c.Assert(val, Equals, "baz") +} + +type failingSchema struct { + err error +} + +func (f *failingSchema) Validate([]byte) error { + return f.err +} + +func (f *failingSchema) SchemaAt(path []string) ([]aspects.Schema, error) { + return []aspects.Schema{f}, nil +} + +func (f *failingSchema) Type() aspects.SchemaType { + return aspects.Any +} + +func (s *transactionTestSuite) TestRollBackOnCommitError(c *C) { + databag := aspects.NewJSONDataBag() + witness := &witnessReadWriter{bag: databag} + schema := &failingSchema{err: errors.New("expected error")} + tx, err := aspects.NewTransaction(witness.read, witness.write, schema) + c.Assert(err, IsNil) + + err = tx.Set("foo", "bar") + c.Assert(err, IsNil) + + err = tx.Commit() + c.Assert(err, ErrorMatches, "expected error") + + // nothing was committed + c.Assert(witness.writeCalled, Equals, 0) + c.Assert(txData(c, tx), Equals, "{}") + + // but subsequent Gets still read the uncommitted values + val, err := tx.Get("foo") + c.Assert(err, IsNil) + c.Assert(val, Equals, "bar") +} + +func (s *transactionTestSuite) TestManyWrites(c *C) { + databag := aspects.NewJSONDataBag() + witness := &witnessReadWriter{bag: databag} + schema := aspects.NewJSONSchema() + tx, err := aspects.NewTransaction(witness.read, witness.write, schema) + c.Assert(err, IsNil) + + err = tx.Set("foo", "bar") + c.Assert(err, IsNil) + err = tx.Set("foo", "baz") + c.Assert(err, IsNil) + + err = tx.Commit() + c.Assert(err, IsNil) + c.Assert(witness.writeCalled, Equals, 1) + + // writes are applied in chronological order + c.Assert(txData(c, tx), Equals, `{"foo":"baz"}`) + + value, err := witness.writtenDatabag.Get("foo") + c.Assert(err, IsNil) + c.Assert(value, Equals, "baz") +} + +func (s *transactionTestSuite) TestCommittedIncludesRecentWrites(c *C) { + databag := aspects.NewJSONDataBag() + witness := &witnessReadWriter{bag: databag} + schema := aspects.NewJSONSchema() + tx, err := aspects.NewTransaction(witness.read, witness.write, schema) + c.Assert(err, IsNil) + c.Assert(witness.readCalled, Equals, 1) + + err = tx.Set("foo", "bar") + c.Assert(err, IsNil) + + err = databag.Set("bar", "baz") + c.Assert(err, IsNil) + + err = tx.Commit() + c.Assert(err, IsNil) + // databag was read from state before writing + c.Assert(witness.readCalled, Equals, 2) + c.Assert(witness.writeCalled, Equals, 1) + + // writes are applied in chronological order + value, err := witness.writtenDatabag.Get("foo") + c.Assert(err, IsNil) + c.Assert(value, Equals, "bar") + + // contains recent values not written by the transaction + value, err = witness.writtenDatabag.Get("bar") + c.Assert(err, IsNil) + c.Assert(value, Equals, "baz") +} + +func (s *transactionTestSuite) TestCommittedIncludesPreviousCommit(c *C) { + var databag aspects.JSONDataBag + readBag := func() (aspects.JSONDataBag, error) { + if databag == nil { + return aspects.NewJSONDataBag(), nil + } + return databag, nil + } + + writeBag := func(bag aspects.JSONDataBag) error { + databag = bag + return nil + } + + schema := aspects.NewJSONSchema() + txOne, err := aspects.NewTransaction(readBag, writeBag, schema) + c.Assert(err, IsNil) + + txTwo, err := aspects.NewTransaction(readBag, writeBag, schema) + c.Assert(err, IsNil) + + err = txOne.Set("foo", "bar") + c.Assert(err, IsNil) + + err = txTwo.Set("bar", "baz") + c.Assert(err, IsNil) + + err = txOne.Commit() + c.Assert(err, IsNil) + + value, err := databag.Get("foo") + c.Assert(err, IsNil) + c.Assert(value, Equals, "bar") + + value, err = databag.Get("bar") + c.Assert(err, FitsTypeOf, aspects.PathError("")) + c.Assert(value, IsNil) + + err = txTwo.Commit() + c.Assert(err, IsNil) + + value, err = databag.Get("foo") + c.Assert(err, IsNil) + c.Assert(value, Equals, "bar") + + value, err = databag.Get("bar") + c.Assert(err, IsNil) + c.Assert(value, Equals, "baz") +} + +func (s *transactionTestSuite) TestTransactionBagReadError(c *C) { + var readErr error + readBag := func() (aspects.JSONDataBag, error) { + return nil, readErr + } + writeBag := func(_ aspects.JSONDataBag) error { + return nil + } + + schema := aspects.NewJSONSchema() + txOne, err := aspects.NewTransaction(readBag, writeBag, schema) + c.Assert(err, IsNil) + + readErr = errors.New("expected") + // Commit()'s databag read fails + err = txOne.Commit() + c.Assert(err, ErrorMatches, "expected") + + // NewTransaction()'s databag read fails + txOne, err = aspects.NewTransaction(readBag, writeBag, schema) + c.Assert(err, ErrorMatches, "expected") +} + +func (s *transactionTestSuite) TestTransactionBagWriteError(c *C) { + readBag := func() (aspects.JSONDataBag, error) { + return nil, nil + } + var writeErr error + writeBag := func(_ aspects.JSONDataBag) error { + return writeErr + } + + schema := aspects.NewJSONSchema() + txOne, err := aspects.NewTransaction(readBag, writeBag, schema) + c.Assert(err, IsNil) + + writeErr = errors.New("expected") + // Commit()'s databag write fails + err = txOne.Commit() + c.Assert(err, ErrorMatches, "expected") +} + +func (s *transactionTestSuite) TestTransactionReadsIsolated(c *C) { + databag := aspects.NewJSONDataBag() + readBag := func() (aspects.JSONDataBag, error) { + return databag, nil + } + writeBag := func(aspects.JSONDataBag) error { + return nil + } + + schema := aspects.NewJSONSchema() + tx, err := aspects.NewTransaction(readBag, writeBag, schema) + c.Assert(err, IsNil) + + err = databag.Set("foo", "bar") + c.Assert(err, IsNil) + + _, err = tx.Get("foo") + c.Assert(err, FitsTypeOf, aspects.PathError("")) +} + +func (s *transactionTestSuite) TestReadDatabagsAreCopiedForIsolation(c *C) { + witness := &witnessReadWriter{bag: aspects.NewJSONDataBag()} + schema := &failingSchema{} + tx, err := aspects.NewTransaction(witness.read, witness.write, schema) + c.Assert(err, IsNil) + + err = tx.Set("foo", "bar") + c.Assert(err, IsNil) + + err = tx.Commit() + c.Assert(err, IsNil) + + err = tx.Set("foo", "baz") + c.Assert(err, IsNil) + + value, err := witness.writtenDatabag.Get("foo") + c.Assert(err, IsNil) + c.Assert(value, Equals, "bar") + + schema.err = errors.New("expected error") + err = tx.Commit() + c.Assert(err, ErrorMatches, "expected error") + + value, err = witness.writtenDatabag.Get("foo") + c.Assert(err, IsNil) + c.Assert(value, Equals, "bar") +} + +func (s *transactionTestSuite) TestUnset(c *C) { + witness := &witnessReadWriter{bag: aspects.NewJSONDataBag()} + tx, err := aspects.NewTransaction(witness.read, witness.write, aspects.NewJSONSchema()) + c.Assert(err, IsNil) + + err = tx.Set("foo", "bar") + c.Assert(err, IsNil) + + err = tx.Commit() + c.Assert(err, IsNil) + + val, err := witness.writtenDatabag.Get("foo") + c.Assert(err, IsNil) + c.Assert(val, Equals, "bar") + + err = tx.Unset("foo") + c.Assert(err, IsNil) + + err = tx.Commit() + c.Assert(err, IsNil) + + _, err = witness.writtenDatabag.Get("foo") + c.Assert(err, FitsTypeOf, aspects.PathError("")) +} + +func txData(c *C, tx *aspects.Transaction) string { + data, err := tx.Data() + c.Assert(err, IsNil) + return string(data) +} diff --git a/asserts/account.go b/asserts/account.go new file mode 100644 index 00000000..c659e92a --- /dev/null +++ b/asserts/account.go @@ -0,0 +1,115 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts + +import ( + "fmt" + "regexp" + "time" +) + +var ( + // account ids look like snap-ids or a nice identifier + validAccountID = regexp.MustCompile("^(?:[a-z0-9A-Z]{32}|[-a-z0-9]{2,28})$") +) + +// Account holds an account assertion, which ties a name for an account +// to its identifier and provides the authority's confidence in the name's validity. +type Account struct { + assertionBase + validation string + timestamp time.Time +} + +func IsValidAccountID(accountID string) bool { + return validAccountID.MatchString(accountID) +} + +// AccountID returns the account-id of the account. +func (acc *Account) AccountID() string { + return acc.HeaderString("account-id") +} + +// Username returns the user name for the account. +func (acc *Account) Username() string { + return acc.HeaderString("username") +} + +// DisplayName returns the human-friendly name for the account. +func (acc *Account) DisplayName() string { + return acc.HeaderString("display-name") +} + +// Validation returns the level of confidence of the authority in the +// account's identity, expected to be "unproven", "starred" or "verified". +func (acc *Account) Validation() string { + return acc.validation +} + +// Timestamp returns the time when the account was issued. +func (acc *Account) Timestamp() time.Time { + return acc.timestamp +} + +// Implement further consistency checks. +func (acc *Account) checkConsistency(db RODatabase, acck *AccountKey) error { + if !db.IsTrustedAccount(acc.AuthorityID()) { + return fmt.Errorf("account assertion for %q is not signed by a directly trusted authority: %s", acc.AccountID(), acc.AuthorityID()) + } + return nil +} + +// expected interface is implemented +var _ consistencyChecker = (*Account)(nil) + +func assembleAccount(assert assertionBase) (Assertion, error) { + _, err := checkNotEmptyString(assert.headers, "display-name") + if err != nil { + return nil, err + } + + validation, err := checkNotEmptyString(assert.headers, "validation") + if err != nil { + return nil, err + } + // backward compatibility with the hard-coded trusted account + // assertions + // TODO: generate revision 1 of them with validation + // s/certified/verified/ + if validation == "certified" { + validation = "verified" + } + + timestamp, err := checkRFC3339Date(assert.headers, "timestamp") + if err != nil { + return nil, err + } + + _, err = checkOptionalString(assert.headers, "username") + if err != nil { + return nil, err + } + + return &Account{ + assertionBase: assert, + validation: validation, + timestamp: timestamp, + }, nil +} diff --git a/asserts/account_key.go b/asserts/account_key.go new file mode 100644 index 00000000..7d6d9996 --- /dev/null +++ b/asserts/account_key.go @@ -0,0 +1,415 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2015-2023 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts + +import ( + "errors" + "fmt" + "regexp" + "time" +) + +var validAccountKeyName = regexp.MustCompile(`^(?:[a-z0-9]+-?)*[a-z](?:-?[a-z0-9])*$`) + +// AccountKey holds an account-key assertion, asserting a public key +// belonging to the account. +type AccountKey struct { + assertionBase + sinceUntil + constraintMatchers []attrMatcher + pubKey PublicKey +} + +type sinceUntil struct { + since time.Time + until time.Time +} + +func checkSinceUntilWhat(m map[string]interface{}, what string) (*sinceUntil, error) { + since, err := checkRFC3339DateWhat(m, "since", what) + if err != nil { + return nil, err + } + + until, err := checkRFC3339DateWithDefaultWhat(m, "until", what, time.Time{}) + if err != nil { + return nil, err + } + if !until.IsZero() && until.Before(since) { + return nil, fmt.Errorf("'until' time cannot be before 'since' time") + } + + return &sinceUntil{ + since: since, + until: until, + }, nil +} + +// AccountID returns the account-id of this account-key. +func (ak *AccountKey) AccountID() string { + return ak.HeaderString("account-id") +} + +// Name returns the name of the account key. +func (ak *AccountKey) Name() string { + return ak.HeaderString("name") +} + +func IsValidAccountKeyName(name string) bool { + return validAccountKeyName.MatchString(name) +} + +// Since returns the time when the account key starts being valid. +func (ak *AccountKey) Since() time.Time { + return ak.since +} + +// Until returns the time when the account key stops being valid. A zero time means the key is valid forever. +func (ak *AccountKey) Until() time.Time { + return ak.until +} + +// PublicKeyID returns the key id used for lookup of the account key. +func (ak *AccountKey) PublicKeyID() string { + return ak.pubKey.ID() +} + +// isValidAt returns whether the since-until constraint is valid at 'when' time. +func (su *sinceUntil) isValidAt(when time.Time) bool { + valid := when.After(su.since) || when.Equal(su.since) + if valid && !su.until.IsZero() { + valid = when.Before(su.until) + } + return valid +} + +// isValidAssumingCurTimeWithin returns whether the since-until constraint is +// possibly valid if the current time is known to be within [earliest, +// latest]. That means the intersection of possible current times and +// validity is not empty. +// If latest is zero, then current time is assumed to be >=earliest. +// If earliest == latest this is equivalent to isKeyValidAt(). +func (su *sinceUntil) isValidAssumingCurTimeWithin(earliest, latest time.Time) bool { + if !latest.IsZero() { + // impossible input => false + if latest.Before(earliest) { + return false + } + if latest.Before(su.since) { + return false + } + } + if !su.until.IsZero() { + if earliest.After(su.until) || earliest.Equal(su.until) { + return false + } + } + return true +} + +// publicKey returns the underlying public key of the account key. +func (ak *AccountKey) publicKey() PublicKey { + return ak.pubKey +} + +// ConstraintsPrecheck checks whether the given type and headers match the signing constraints of the account key. +func (ak *AccountKey) ConstraintsPrecheck(assertType *AssertionType, headers map[string]interface{}) error { + headersWithType := copyHeaders(headers) + headersWithType["type"] = assertType.Name + if !ak.matchAgainstConstraints(headersWithType) { + return fmt.Errorf("headers do not match the account-key constraints") + } + return nil +} + +func (ak *AccountKey) matchAgainstConstraints(headers map[string]interface{}) bool { + matchers := ak.constraintMatchers + // no constraints, everything is allowed + if len(matchers) == 0 { + return true + } + for _, m := range matchers { + if m.match("", headers, &attrMatchingContext{ + attrWord: "header", + }) == nil { + return true + } + } + return false +} + +// canSign checks whether the given assertion matches the signing constraints of the account key. +func (ak *AccountKey) canSign(a Assertion) bool { + return ak.matchAgainstConstraints(a.Headers()) +} + +func checkPublicKey(ab *assertionBase, keyIDName string) (PublicKey, error) { + pubKey, err := DecodePublicKey(ab.Body()) + if err != nil { + return nil, err + } + keyID, err := checkNotEmptyString(ab.headers, keyIDName) + if err != nil { + return nil, err + } + if keyID != pubKey.ID() { + return nil, fmt.Errorf("public key does not match provided key id") + } + return pubKey, nil +} + +// Implement further consistency checks. +func (ak *AccountKey) checkConsistency(db RODatabase, acck *AccountKey) error { + if !db.IsTrustedAccount(ak.AuthorityID()) { + return fmt.Errorf("account-key assertion for %q is not signed by a directly trusted authority: %s", ak.AccountID(), ak.AuthorityID()) + } + _, err := db.Find(AccountType, map[string]string{ + "account-id": ak.AccountID(), + }) + if errors.Is(err, &NotFoundError{}) { + return fmt.Errorf("account-key assertion for %q does not have a matching account assertion", ak.AccountID()) + } + if err != nil { + return err + } + // XXX: Make this unconditional once account-key assertions are required to have a name. + if ak.Name() != "" { + // Check that we don't end up with multiple keys with + // different IDs but the same account-id and name. + // Note that this is a non-transactional check-then-add, so + // is not a hard guarantee. Backstores that can implement a + // unique constraint should do so. + assertions, err := db.FindMany(AccountKeyType, map[string]string{ + "account-id": ak.AccountID(), + "name": ak.Name(), + }) + if err != nil && !errors.Is(err, &NotFoundError{}) { + return err + } + for _, assertion := range assertions { + existingAccKey := assertion.(*AccountKey) + if ak.PublicKeyID() != existingAccKey.PublicKeyID() { + return fmt.Errorf("account-key assertion for %q with ID %q has the same name %q as existing ID %q", ak.AccountID(), ak.PublicKeyID(), ak.Name(), existingAccKey.PublicKeyID()) + } + } + } + return nil +} + +// expected interface is implemented +var _ consistencyChecker = (*AccountKey)(nil) + +// Prerequisites returns references to this account-key's prerequisite assertions. +func (ak *AccountKey) Prerequisites() []*Ref { + return []*Ref{ + {Type: AccountType, PrimaryKey: []string{ak.AccountID()}}, + } +} + +func assembleAccountKey(assert assertionBase) (Assertion, error) { + _, err := checkNotEmptyString(assert.headers, "account-id") + if err != nil { + return nil, err + } + + // XXX: We should require name to be present after backfilling existing assertions. + _, ok := assert.headers["name"] + if ok { + _, err = checkStringMatches(assert.headers, "name", validAccountKeyName) + if err != nil { + return nil, err + } + } + + sinceUntil, err := checkSinceUntilWhat(assert.headers, "header") + if err != nil { + return nil, err + } + + pubk, err := checkPublicKey(&assert, "public-key-sha3-384") + if err != nil { + return nil, err + } + + var matchers []attrMatcher + if cs, ok := assert.headers["constraints"]; ok { + matchers, err = checkAKConstraints(cs) + if err != nil { + return nil, err + } + } + + // ignore extra headers for future compatibility + return &AccountKey{ + assertionBase: assert, + sinceUntil: *sinceUntil, + constraintMatchers: matchers, + pubKey: pubk, + }, nil +} + +func checkAKConstraints(cs interface{}) ([]attrMatcher, error) { + csmaps, ok := cs.([]interface{}) + if !ok { + return nil, fmt.Errorf("assertions constraints must be a list of maps") + } + if len(csmaps) == 0 { + // there is no syntax producing this scenario but be robust + return nil, fmt.Errorf("assertions constraints cannot be empty") + } + matchers := make([]attrMatcher, 0, len(csmaps)) + for _, csmap := range csmaps { + m, ok := csmap.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("assertions constraints must be a list of maps") + } + hm, err := checkMapWhat(m, "headers", "constraint") + if err != nil { + return nil, err + } + if hm == nil { + return nil, fmt.Errorf(`"headers" constraint mandatory in asserions constraints`) + } + t, ok := hm["type"] + if !ok { + return nil, fmt.Errorf("type header constraint mandatory in asserions constraints") + } + tstr, ok := t.(string) + if !ok { + return nil, fmt.Errorf("type header constraint must be a string") + } + if tstr != regexp.QuoteMeta(tstr) { + return nil, fmt.Errorf("type header constraint must be a precise string and not a regexp") + } + cc := compileContext{ + opts: &compileAttrMatcherOptions{}, + } + matcher, err := compileAttrMatcher(cc, hm) + if err != nil { + return nil, fmt.Errorf("cannot compile headers constraint: %v", err) + } + matchers = append(matchers, matcher) + } + return matchers, nil +} + +func accountKeyFormatAnalyze(headers map[string]interface{}, body []byte) (formatnum int, err error) { + formatnum = 0 + if _, ok := headers["constraints"]; ok { + formatnum = 1 + } + return formatnum, nil +} + +// AccountKeyRequest holds an account-key-request assertion, which is a self-signed request to prove that the requester holds the private key and wishes to create an account-key assertion for it. +type AccountKeyRequest struct { + assertionBase + sinceUntil + pubKey PublicKey +} + +// AccountID returns the account-id of this account-key-request. +func (akr *AccountKeyRequest) AccountID() string { + return akr.HeaderString("account-id") +} + +// Name returns the name of the account key. +func (akr *AccountKeyRequest) Name() string { + return akr.HeaderString("name") +} + +// Since returns the time when the requested account key starts being valid. +func (akr *AccountKeyRequest) Since() time.Time { + return akr.since +} + +// Until returns the time when the requested account key stops being valid. A zero time means the key is valid forever. +func (akr *AccountKeyRequest) Until() time.Time { + return akr.until +} + +// PublicKeyID returns the underlying public key ID of the requested account key. +func (akr *AccountKeyRequest) PublicKeyID() string { + return akr.pubKey.ID() +} + +// signKey returns the underlying public key of the requested account key. +func (akr *AccountKeyRequest) signKey() PublicKey { + return akr.pubKey +} + +// Implement further consistency checks. +func (akr *AccountKeyRequest) checkConsistency(db RODatabase, acck *AccountKey) error { + _, err := db.Find(AccountType, map[string]string{ + "account-id": akr.AccountID(), + }) + if errors.Is(err, &NotFoundError{}) { + return fmt.Errorf("account-key-request assertion for %q does not have a matching account assertion", akr.AccountID()) + } + if err != nil { + return err + } + return nil +} + +// expected interfaces are implemented +var ( + _ consistencyChecker = (*AccountKeyRequest)(nil) + _ customSigner = (*AccountKeyRequest)(nil) +) + +// Prerequisites returns references to this account-key-request's prerequisite assertions. +func (akr *AccountKeyRequest) Prerequisites() []*Ref { + return []*Ref{ + {Type: AccountType, PrimaryKey: []string{akr.AccountID()}}, + } +} + +func assembleAccountKeyRequest(assert assertionBase) (Assertion, error) { + _, err := checkNotEmptyString(assert.headers, "account-id") + if err != nil { + return nil, err + } + + _, err = checkStringMatches(assert.headers, "name", validAccountKeyName) + if err != nil { + return nil, err + } + + sinceUntil, err := checkSinceUntilWhat(assert.headers, "header") + if err != nil { + return nil, err + } + + pubk, err := checkPublicKey(&assert, "public-key-sha3-384") + if err != nil { + return nil, err + } + + // XXX TODO: support constraints also in account-key-request when + // implementing more fully automated registration flows + + // ignore extra headers for future compatibility + return &AccountKeyRequest{ + assertionBase: assert, + sinceUntil: *sinceUntil, + pubKey: pubk, + }, nil +} diff --git a/asserts/account_key_test.go b/asserts/account_key_test.go new file mode 100644 index 00000000..41f37f34 --- /dev/null +++ b/asserts/account_key_test.go @@ -0,0 +1,1144 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2015-2021 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts_test + +import ( + "encoding/base64" + "fmt" + "path/filepath" + "strings" + "time" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/asserts/assertstest" +) + +type accountKeySuite struct { + privKey asserts.PrivateKey + pubKeyBody string + keyID string + since, until time.Time + sinceLine, untilLine string +} + +var _ = Suite(&accountKeySuite{}) + +func (aks *accountKeySuite) SetUpSuite(c *C) { + cfg1 := &asserts.DatabaseConfig{} + accDb, err := asserts.OpenDatabase(cfg1) + c.Assert(err, IsNil) + aks.privKey = testPrivKey1 + err = accDb.ImportKey(aks.privKey) + c.Assert(err, IsNil) + aks.keyID = aks.privKey.PublicKey().ID() + + pubKey, err := accDb.PublicKey(aks.keyID) + c.Assert(err, IsNil) + pubKeyEncoded, err := asserts.EncodePublicKey(pubKey) + c.Assert(err, IsNil) + aks.pubKeyBody = string(pubKeyEncoded) + + aks.since, err = time.Parse(time.RFC822, "16 Nov 15 15:04 UTC") + c.Assert(err, IsNil) + aks.until = aks.since.AddDate(1, 0, 0) + aks.sinceLine = "since: " + aks.since.Format(time.RFC3339) + "\n" + aks.untilLine = "until: " + aks.until.Format(time.RFC3339) + "\n" +} + +func (aks *accountKeySuite) TestDecodeOK(c *C) { + encoded := "type: account-key\n" + + "authority-id: canonical\n" + + "account-id: acc-id1\n" + + "name: default\n" + + "public-key-sha3-384: " + aks.keyID + "\n" + + aks.sinceLine + + fmt.Sprintf("body-length: %v", len(aks.pubKeyBody)) + "\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + "\n\n" + + aks.pubKeyBody + "\n\n" + + "AXNpZw==" + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + c.Check(a.Type(), Equals, asserts.AccountKeyType) + accKey := a.(*asserts.AccountKey) + c.Check(accKey.AccountID(), Equals, "acc-id1") + c.Check(accKey.Name(), Equals, "default") + c.Check(accKey.PublicKeyID(), Equals, aks.keyID) + c.Check(accKey.Since(), Equals, aks.since) + + // no constraints, anything goes + c.Check(accKey.ConstraintsPrecheck(asserts.AccountKeyType, nil), IsNil) +} + +func (aks *accountKeySuite) TestDecodeNoName(c *C) { + // XXX: remove this test once name is mandatory + encoded := "type: account-key\n" + + "authority-id: canonical\n" + + "account-id: acc-id1\n" + + "public-key-sha3-384: " + aks.keyID + "\n" + + aks.sinceLine + + fmt.Sprintf("body-length: %v", len(aks.pubKeyBody)) + "\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + "\n\n" + + aks.pubKeyBody + "\n\n" + + "AXNpZw==" + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + c.Check(a.Type(), Equals, asserts.AccountKeyType) + accKey := a.(*asserts.AccountKey) + c.Check(accKey.AccountID(), Equals, "acc-id1") + c.Check(accKey.Name(), Equals, "") + c.Check(accKey.PublicKeyID(), Equals, aks.keyID) + c.Check(accKey.Since(), Equals, aks.since) +} + +func (aks *accountKeySuite) TestUntil(c *C) { + + untilSinceLine := "until: " + aks.since.Format(time.RFC3339) + "\n" + + tests := []struct { + untilLine string + until time.Time + }{ + {"", time.Time{}}, // zero time default + {aks.untilLine, aks.until}, // in the future + {untilSinceLine, aks.since}, // same as since + } + + for _, test := range tests { + c.Log(test) + encoded := "type: account-key\n" + + "authority-id: canonical\n" + + "account-id: acc-id1\n" + + "name: default\n" + + "public-key-sha3-384: " + aks.keyID + "\n" + + aks.sinceLine + + test.untilLine + + fmt.Sprintf("body-length: %v", len(aks.pubKeyBody)) + "\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + "\n\n" + + aks.pubKeyBody + "\n\n" + + "openpgp c2ln" + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + accKey := a.(*asserts.AccountKey) + c.Check(accKey.Until(), Equals, test.until) + } +} + +const ( + accKeyErrPrefix = "assertion account-key: " + accKeyReqErrPrefix = "assertion account-key-request: " +) + +func (aks *accountKeySuite) TestDecodeInvalidHeaders(c *C) { + + encoded := "type: account-key\n" + + "authority-id: canonical\n" + + "account-id: acc-id1\n" + + "name: default\n" + + "public-key-sha3-384: " + aks.keyID + "\n" + + aks.sinceLine + + aks.untilLine + + fmt.Sprintf("body-length: %v", len(aks.pubKeyBody)) + "\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + "\n\n" + + aks.pubKeyBody + "\n\n" + + "AXNpZw==" + + untilPast := aks.since.AddDate(-1, 0, 0) + untilPastLine := "until: " + untilPast.Format(time.RFC3339) + "\n" + + invalidHeaderTests := []struct{ original, invalid, expectedErr string }{ + {"account-id: acc-id1\n", "", `"account-id" header is mandatory`}, + {"account-id: acc-id1\n", "account-id: \n", `"account-id" header should not be empty`}, + // XXX: enable this once name is mandatory + // {"name: default\n", "", `"name" header is mandatory`}, + {"name: default\n", "name: \n", `"name" header should not be empty`}, + {"name: default\n", "name: a b\n", `"name" header contains invalid characters: "a b"`}, + {"name: default\n", "name: -default\n", `"name" header contains invalid characters: "-default"`}, + {"name: default\n", "name: foo:bar\n", `"name" header contains invalid characters: "foo:bar"`}, + {"name: default\n", "name: a--b\n", `"name" header contains invalid characters: "a--b"`}, + {"name: default\n", "name: 42\n", `"name" header contains invalid characters: "42"`}, + {"public-key-sha3-384: " + aks.keyID + "\n", "", `"public-key-sha3-384" header is mandatory`}, + {"public-key-sha3-384: " + aks.keyID + "\n", "public-key-sha3-384: \n", `"public-key-sha3-384" header should not be empty`}, + {aks.sinceLine, "", `"since" header is mandatory`}, + {aks.sinceLine, "since: \n", `"since" header should not be empty`}, + {aks.sinceLine, "since: 12:30\n", `"since" header is not a RFC3339 date: .*`}, + {aks.sinceLine, "since: \n", `"since" header should not be empty`}, + {aks.untilLine, "until: \n", `"until" header is not a RFC3339 date: .*`}, + {aks.untilLine, "until: 12:30\n", `"until" header is not a RFC3339 date: .*`}, + {aks.untilLine, untilPastLine, `'until' time cannot be before 'since' time`}, + } + + for _, test := range invalidHeaderTests { + invalid := strings.Replace(encoded, test.original, test.invalid, 1) + _, err := asserts.Decode([]byte(invalid)) + c.Check(err, ErrorMatches, accKeyErrPrefix+test.expectedErr) + } +} + +func (aks *accountKeySuite) TestDecodeInvalidPublicKey(c *C) { + headers := "type: account-key\n" + + "authority-id: canonical\n" + + "account-id: acc-id1\n" + + "name: default\n" + + "public-key-sha3-384: " + aks.keyID + "\n" + + aks.sinceLine + + aks.untilLine + + raw, err := base64.StdEncoding.DecodeString(aks.pubKeyBody) + c.Assert(err, IsNil) + spurious := base64.StdEncoding.EncodeToString(append(raw, "gorp"...)) + + invalidPublicKeyTests := []struct{ body, expectedErr string }{ + {"", "cannot decode public key: no data"}, + {"==", "cannot decode public key: .*"}, + {"stuff", "cannot decode public key: .*"}, + {"AnNpZw==", "unsupported public key format version: 2"}, + {"AUJST0tFTg==", "cannot decode public key: .*"}, + {spurious, "public key has spurious trailing data"}, + } + + for _, test := range invalidPublicKeyTests { + invalid := headers + + fmt.Sprintf("body-length: %v", len(test.body)) + "\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + "\n\n" + + test.body + "\n\n" + + "AXNpZw==" + + _, err := asserts.Decode([]byte(invalid)) + c.Check(err, ErrorMatches, accKeyErrPrefix+test.expectedErr) + } +} + +func (aks *accountKeySuite) TestDecodeKeyIDMismatch(c *C) { + invalid := "type: account-key\n" + + "authority-id: canonical\n" + + "account-id: acc-id1\n" + + "name: default\n" + + "public-key-sha3-384: aa\n" + + aks.sinceLine + + aks.untilLine + + fmt.Sprintf("body-length: %v", len(aks.pubKeyBody)) + "\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + "\n\n" + + aks.pubKeyBody + "\n\n" + + "AXNpZw==" + + _, err := asserts.Decode([]byte(invalid)) + c.Check(err, ErrorMatches, accKeyErrPrefix+"public key does not match provided key id") +} + +func (aks *accountKeySuite) openDB(c *C) *asserts.Database { + trustedKey := testPrivKey0 + + topDir := filepath.Join(c.MkDir(), "asserts-db") + bs, err := asserts.OpenFSBackstore(topDir) + c.Assert(err, IsNil) + cfg := &asserts.DatabaseConfig{ + Backstore: bs, + Trusted: []asserts.Assertion{ + asserts.BootstrapAccountForTest("canonical"), + asserts.BootstrapAccountKeyForTest("canonical", trustedKey.PublicKey()), + }, + } + db, err := asserts.OpenDatabase(cfg) + c.Assert(err, IsNil) + return db +} + +func (aks *accountKeySuite) prereqAccount(c *C, db *asserts.Database) { + trustedKey := testPrivKey0 + + headers := map[string]interface{}{ + "authority-id": "canonical", + "display-name": "Acct1", + "account-id": "acc-id1", + "username": "acc-id1", + "validation": "unproven", + "timestamp": aks.since.Format(time.RFC3339), + } + acct1, err := asserts.AssembleAndSignInTest(asserts.AccountType, headers, nil, trustedKey) + c.Assert(err, IsNil) + + // prereq + db.Add(acct1) +} + +func (aks *accountKeySuite) TestAccountKeyCheck(c *C) { + trustedKey := testPrivKey0 + + headers := map[string]interface{}{ + "authority-id": "canonical", + "account-id": "acc-id1", + "name": "default", + "public-key-sha3-384": aks.keyID, + "since": aks.since.Format(time.RFC3339), + "until": aks.until.Format(time.RFC3339), + } + accKey, err := asserts.AssembleAndSignInTest(asserts.AccountKeyType, headers, []byte(aks.pubKeyBody), trustedKey) + c.Assert(err, IsNil) + + db := aks.openDB(c) + + aks.prereqAccount(c, db) + + err = db.Check(accKey) + c.Assert(err, IsNil) +} + +func (aks *accountKeySuite) TestAccountKeyCheckNoAccount(c *C) { + trustedKey := testPrivKey0 + + headers := map[string]interface{}{ + "authority-id": "canonical", + "account-id": "acc-id1", + "name": "default", + "public-key-sha3-384": aks.keyID, + "since": aks.since.Format(time.RFC3339), + "until": aks.until.Format(time.RFC3339), + } + accKey, err := asserts.AssembleAndSignInTest(asserts.AccountKeyType, headers, []byte(aks.pubKeyBody), trustedKey) + c.Assert(err, IsNil) + + db := aks.openDB(c) + + err = db.Check(accKey) + c.Assert(err, ErrorMatches, `account-key assertion for "acc-id1" does not have a matching account assertion`) +} + +func (aks *accountKeySuite) TestAccountKeyCheckUntrustedAuthority(c *C) { + trustedKey := testPrivKey0 + + db := aks.openDB(c) + storeDB := assertstest.NewSigningDB("canonical", trustedKey) + otherDB := setup3rdPartySigning(c, "other", storeDB, db) + + headers := map[string]interface{}{ + "account-id": "acc-id1", + "name": "default", + "public-key-sha3-384": aks.keyID, + "since": aks.since.Format(time.RFC3339), + "until": aks.until.Format(time.RFC3339), + } + accKey, err := otherDB.Sign(asserts.AccountKeyType, headers, []byte(aks.pubKeyBody), "") + c.Assert(err, IsNil) + + err = db.Check(accKey) + c.Assert(err, ErrorMatches, `account-key assertion for "acc-id1" is not signed by a directly trusted authority:.*`) +} + +func (aks *accountKeySuite) TestAccountKeyCheckSameNameAndNewRevision(c *C) { + trustedKey := testPrivKey0 + + headers := map[string]interface{}{ + "authority-id": "canonical", + "account-id": "acc-id1", + "name": "default", + "public-key-sha3-384": aks.keyID, + "since": aks.since.Format(time.RFC3339), + "until": aks.until.Format(time.RFC3339), + } + accKey, err := asserts.AssembleAndSignInTest(asserts.AccountKeyType, headers, []byte(aks.pubKeyBody), trustedKey) + c.Assert(err, IsNil) + + db := aks.openDB(c) + aks.prereqAccount(c, db) + + err = db.Add(accKey) + c.Assert(err, IsNil) + + headers["revision"] = "1" + newAccKey, err := asserts.AssembleAndSignInTest(asserts.AccountKeyType, headers, []byte(aks.pubKeyBody), trustedKey) + c.Assert(err, IsNil) + + err = db.Check(newAccKey) + c.Assert(err, IsNil) +} + +func (aks *accountKeySuite) TestAccountKeyCheckSameAccountAndDifferentName(c *C) { + trustedKey := testPrivKey0 + + headers := map[string]interface{}{ + "authority-id": "canonical", + "account-id": "acc-id1", + "name": "default", + "public-key-sha3-384": aks.keyID, + "since": aks.since.Format(time.RFC3339), + "until": aks.until.Format(time.RFC3339), + } + accKey, err := asserts.AssembleAndSignInTest(asserts.AccountKeyType, headers, []byte(aks.pubKeyBody), trustedKey) + c.Assert(err, IsNil) + + db := aks.openDB(c) + aks.prereqAccount(c, db) + + err = db.Add(accKey) + c.Assert(err, IsNil) + + newPrivKey, _ := assertstest.GenerateKey(752) + err = db.ImportKey(newPrivKey) + c.Assert(err, IsNil) + newPubKey, err := db.PublicKey(newPrivKey.PublicKey().ID()) + c.Assert(err, IsNil) + newPubKeyEncoded, err := asserts.EncodePublicKey(newPubKey) + c.Assert(err, IsNil) + + headers["name"] = "another" + headers["public-key-sha3-384"] = newPubKey.ID() + newAccKey, err := asserts.AssembleAndSignInTest(asserts.AccountKeyType, headers, newPubKeyEncoded, trustedKey) + c.Assert(err, IsNil) + + err = db.Check(newAccKey) + c.Assert(err, IsNil) +} + +func (aks *accountKeySuite) TestAccountKeyCheckSameNameAndDifferentAccount(c *C) { + trustedKey := testPrivKey0 + + headers := map[string]interface{}{ + "authority-id": "canonical", + "account-id": "acc-id1", + "name": "default", + "public-key-sha3-384": aks.keyID, + "since": aks.since.Format(time.RFC3339), + "until": aks.until.Format(time.RFC3339), + } + accKey, err := asserts.AssembleAndSignInTest(asserts.AccountKeyType, headers, []byte(aks.pubKeyBody), trustedKey) + c.Assert(err, IsNil) + + db := aks.openDB(c) + err = db.ImportKey(trustedKey) + c.Assert(err, IsNil) + aks.prereqAccount(c, db) + + err = db.Add(accKey) + c.Assert(err, IsNil) + + newPrivKey, _ := assertstest.GenerateKey(752) + err = db.ImportKey(newPrivKey) + c.Assert(err, IsNil) + newPubKey, err := db.PublicKey(newPrivKey.PublicKey().ID()) + c.Assert(err, IsNil) + newPubKeyEncoded, err := asserts.EncodePublicKey(newPubKey) + c.Assert(err, IsNil) + + acct2 := assertstest.NewAccount(db, "acc-id2", map[string]interface{}{ + "authority-id": "canonical", + "account-id": "acc-id2", + }, trustedKey.PublicKey().ID()) + db.Add(acct2) + + headers["account-id"] = "acc-id2" + headers["public-key-sha3-384"] = newPubKey.ID() + headers["revision"] = "1" + newAccKey, err := asserts.AssembleAndSignInTest(asserts.AccountKeyType, headers, newPubKeyEncoded, trustedKey) + c.Assert(err, IsNil) + + err = db.Check(newAccKey) + c.Assert(err, IsNil) +} + +func (aks *accountKeySuite) TestAccountKeyCheckNameClash(c *C) { + trustedKey := testPrivKey0 + + headers := map[string]interface{}{ + "authority-id": "canonical", + "account-id": "acc-id1", + "name": "default", + "public-key-sha3-384": aks.keyID, + "since": aks.since.Format(time.RFC3339), + "until": aks.until.Format(time.RFC3339), + } + accKey, err := asserts.AssembleAndSignInTest(asserts.AccountKeyType, headers, []byte(aks.pubKeyBody), trustedKey) + c.Assert(err, IsNil) + + db := aks.openDB(c) + aks.prereqAccount(c, db) + + err = db.Add(accKey) + c.Assert(err, IsNil) + + newPrivKey, _ := assertstest.GenerateKey(752) + err = db.ImportKey(newPrivKey) + c.Assert(err, IsNil) + newPubKey, err := db.PublicKey(newPrivKey.PublicKey().ID()) + c.Assert(err, IsNil) + newPubKeyEncoded, err := asserts.EncodePublicKey(newPubKey) + c.Assert(err, IsNil) + + headers["public-key-sha3-384"] = newPubKey.ID() + headers["revision"] = "1" + newAccKey, err := asserts.AssembleAndSignInTest(asserts.AccountKeyType, headers, newPubKeyEncoded, trustedKey) + c.Assert(err, IsNil) + + err = db.Check(newAccKey) + c.Assert(err, ErrorMatches, fmt.Sprintf(`account-key assertion for "acc-id1" with ID %q has the same name "default" as existing ID %q`, newPubKey.ID(), aks.keyID)) +} + +func (aks *accountKeySuite) TestAccountKeyAddAndFind(c *C) { + trustedKey := testPrivKey0 + + headers := map[string]interface{}{ + "authority-id": "canonical", + "account-id": "acc-id1", + "name": "default", + "public-key-sha3-384": aks.keyID, + "since": aks.since.Format(time.RFC3339), + "until": aks.until.Format(time.RFC3339), + } + accKey, err := asserts.AssembleAndSignInTest(asserts.AccountKeyType, headers, []byte(aks.pubKeyBody), trustedKey) + c.Assert(err, IsNil) + + db := aks.openDB(c) + + aks.prereqAccount(c, db) + + err = db.Add(accKey) + c.Assert(err, IsNil) + + found, err := db.Find(asserts.AccountKeyType, map[string]string{ + "account-id": "acc-id1", + "public-key-sha3-384": aks.keyID, + }) + c.Assert(err, IsNil) + c.Assert(found, NotNil) + c.Check(found.Body(), DeepEquals, []byte(aks.pubKeyBody)) +} + +func (aks *accountKeySuite) TestPublicKeyIsValidAt(c *C) { + // With since and until, i.e. signing account-key expires. + encoded := "type: account-key\n" + + "authority-id: canonical\n" + + "account-id: acc-id1\n" + + "name: default\n" + + "public-key-sha3-384: " + aks.keyID + "\n" + + aks.sinceLine + + aks.untilLine + + fmt.Sprintf("body-length: %v", len(aks.pubKeyBody)) + "\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + "\n\n" + + aks.pubKeyBody + "\n\n" + + "AXNpZw==" + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + + accKey := a.(*asserts.AccountKey) + + c.Check(asserts.AccountKeyIsKeyValidAt(accKey, aks.since), Equals, true) + c.Check(asserts.AccountKeyIsKeyValidAt(accKey, aks.since.AddDate(0, 0, -1)), Equals, false) + c.Check(asserts.AccountKeyIsKeyValidAt(accKey, aks.since.AddDate(0, 0, 1)), Equals, true) + + c.Check(asserts.AccountKeyIsKeyValidAt(accKey, aks.until), Equals, false) + c.Check(asserts.AccountKeyIsKeyValidAt(accKey, aks.until.AddDate(0, -1, 0)), Equals, true) + c.Check(asserts.AccountKeyIsKeyValidAt(accKey, aks.until.AddDate(0, 1, 0)), Equals, false) + + // With no until, i.e. signing account-key never expires. + encoded = "type: account-key\n" + + "authority-id: canonical\n" + + "account-id: acc-id1\n" + + "name: default\n" + + "public-key-sha3-384: " + aks.keyID + "\n" + + aks.sinceLine + + fmt.Sprintf("body-length: %v", len(aks.pubKeyBody)) + "\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + "\n\n" + + aks.pubKeyBody + "\n\n" + + "openpgp c2ln" + a, err = asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + + accKey = a.(*asserts.AccountKey) + + c.Check(asserts.AccountKeyIsKeyValidAt(accKey, aks.since), Equals, true) + c.Check(asserts.AccountKeyIsKeyValidAt(accKey, aks.since.AddDate(0, 0, -1)), Equals, false) + c.Check(asserts.AccountKeyIsKeyValidAt(accKey, aks.since.AddDate(0, 0, 1)), Equals, true) + + // With since == until, i.e. signing account-key has been revoked. + encoded = "type: account-key\n" + + "authority-id: canonical\n" + + "account-id: acc-id1\n" + + "name: default\n" + + "public-key-sha3-384: " + aks.keyID + "\n" + + aks.sinceLine + + "until: " + aks.since.Format(time.RFC3339) + "\n" + + fmt.Sprintf("body-length: %v", len(aks.pubKeyBody)) + "\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + "\n\n" + + aks.pubKeyBody + "\n\n" + + "openpgp c2ln" + a, err = asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + + accKey = a.(*asserts.AccountKey) + + c.Check(asserts.AccountKeyIsKeyValidAt(accKey, aks.since), Equals, false) + c.Check(asserts.AccountKeyIsKeyValidAt(accKey, aks.since.AddDate(0, 0, -1)), Equals, false) + c.Check(asserts.AccountKeyIsKeyValidAt(accKey, aks.since.AddDate(0, 0, 1)), Equals, false) + + c.Check(asserts.AccountKeyIsKeyValidAt(accKey, aks.until), Equals, false) + c.Check(asserts.AccountKeyIsKeyValidAt(accKey, aks.until.AddDate(0, -1, 0)), Equals, false) + c.Check(asserts.AccountKeyIsKeyValidAt(accKey, aks.until.AddDate(0, 1, 0)), Equals, false) +} + +func (aks *accountKeySuite) TestPublicKeyIsValidAssumingCurTimeWithinWithUntilPunctual(c *C) { + // With since and until, i.e. signing account-key expires. + // Key is valid over [since, until) + encoded := "type: account-key\n" + + "authority-id: canonical\n" + + "account-id: acc-id1\n" + + "name: default\n" + + "public-key-sha3-384: " + aks.keyID + "\n" + + aks.sinceLine + + aks.untilLine + + fmt.Sprintf("body-length: %v", len(aks.pubKeyBody)) + "\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + "\n\n" + + aks.pubKeyBody + "\n\n" + + "AXNpZw==" + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + + accKey := a.(*asserts.AccountKey) + + tests := []struct { + timePt time.Time + valid bool + }{ + {aks.since, true}, + {aks.since.AddDate(0, 3, 0), true}, + {aks.since.AddDate(0, -2, 0), false}, + {aks.until, false}, + {aks.until.AddDate(0, 3, 0), false}, + {aks.until.AddDate(0, -2, 0), true}, + } + + for _, t := range tests { + c.Check(asserts.IsValidAssumingCurTimeWithin(accKey, t.timePt, t.timePt), Equals, t.valid) + } +} + +func (aks *accountKeySuite) TestPublicKeyIsValidAssumingCurTimeWithinNoUntilPunctual(c *C) { + // With since but no until, i.e. signing account-key never expires. + // Key is valid for time >= since. + encoded := "type: account-key\n" + + "authority-id: canonical\n" + + "account-id: acc-id1\n" + + "name: default\n" + + "public-key-sha3-384: " + aks.keyID + "\n" + + aks.sinceLine + + fmt.Sprintf("body-length: %v", len(aks.pubKeyBody)) + "\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + "\n\n" + + aks.pubKeyBody + "\n\n" + + "AXNpZw==" + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + + accKey := a.(*asserts.AccountKey) + + later := aks.until + tests := []struct { + timePt time.Time + valid bool + }{ + {aks.since, true}, + {aks.since.AddDate(0, 3, 0), true}, + {aks.since.AddDate(0, -2, 0), false}, + {later, true}, + {later.AddDate(0, 3, 0), true}, + } + + for _, t := range tests { + c.Check(asserts.IsValidAssumingCurTimeWithin(accKey, t.timePt, t.timePt), Equals, t.valid) + } +} + +func (aks *accountKeySuite) TestPublicKeyIsValidAssumingCurTimeWithinWithUntilInterval(c *C) { + // With since and until, i.e. signing account-key expires. + // Key is valid over [since, until) + encoded := "type: account-key\n" + + "authority-id: canonical\n" + + "account-id: acc-id1\n" + + "name: default\n" + + "public-key-sha3-384: " + aks.keyID + "\n" + + aks.sinceLine + + aks.untilLine + + fmt.Sprintf("body-length: %v", len(aks.pubKeyBody)) + "\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + "\n\n" + + aks.pubKeyBody + "\n\n" + + "AXNpZw==" + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + + accKey := a.(*asserts.AccountKey) + + z := time.Time{} + + tests := []struct { + earliest time.Time + latest time.Time + valid bool + }{ + {aks.since, aks.until, true}, + {aks.since, aks.since.AddDate(0, 3, 0), true}, + {aks.since.AddDate(0, 1, 0), aks.since.AddDate(0, 3, 0), true}, + {aks.since.AddDate(0, 1, 0), aks.until, true}, + {aks.until, aks.until.AddDate(0, 3, 0), false}, + {aks.until.AddDate(0, 2, 0), aks.until.AddDate(0, 3, 0), false}, + {aks.since.AddDate(0, -1, 0), aks.since, true}, + {aks.since.AddDate(0, -1, 0), aks.since.AddDate(0, 1, 0), true}, + {aks.since.AddDate(0, -2, 0), aks.since.AddDate(0, -2, 0), false}, + {aks.until.AddDate(0, -1, 0), aks.until.AddDate(0, 1, 0), true}, + {aks.since, z, true}, + {aks.since.AddDate(0, 1, 0), z, true}, + {aks.since.AddDate(0, -3, 0), z, true}, + {aks.until, z, false}, + {aks.until.AddDate(0, 1, 0), z, false}, + // with earliest set to time.Time zero + {z, aks.since, true}, + {z, aks.since.AddDate(0, 1, 0), true}, + {z, aks.since.AddDate(0, -2, 0), false}, + {z, aks.until.AddDate(0, 1, 0), true}, + {z, z, true}, + } + + for _, t := range tests { + c.Check(asserts.IsValidAssumingCurTimeWithin(accKey, t.earliest, t.latest), Equals, t.valid) + } + +} + +func (aks *accountKeySuite) TestPublicKeyIsValidAssumingCurTimeWithinNoUntilInterval(c *C) { + // With since but no until, i.e. signing account-key never expires. + // Key is valid for time >= since. + encoded := "type: account-key\n" + + "authority-id: canonical\n" + + "account-id: acc-id1\n" + + "name: default\n" + + "public-key-sha3-384: " + aks.keyID + "\n" + + aks.sinceLine + + fmt.Sprintf("body-length: %v", len(aks.pubKeyBody)) + "\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + "\n\n" + + aks.pubKeyBody + "\n\n" + + "AXNpZw==" + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + + accKey := a.(*asserts.AccountKey) + + z := time.Time{} + later := aks.until + + tests := []struct { + earliest time.Time + latest time.Time + valid bool + }{ + {aks.since, later, true}, + {aks.since, aks.since.AddDate(0, 3, 0), true}, + {aks.since.AddDate(0, 1, 0), aks.since.AddDate(0, 3, 0), true}, + {aks.since.AddDate(0, 1, 0), later, true}, + {later, later.AddDate(0, 3, 0), true}, + {later.AddDate(0, 2, 0), later.AddDate(0, 3, 0), true}, + {aks.since.AddDate(0, -1, 0), aks.since, true}, + {aks.since.AddDate(0, -1, 0), aks.since.AddDate(0, 1, 0), true}, + {aks.since.AddDate(0, -2, 0), aks.since.AddDate(0, -2, 0), false}, + {later.AddDate(0, -1, 0), later.AddDate(0, 1, 0), true}, + {aks.since, z, true}, + {aks.since.AddDate(0, 1, 0), z, true}, + {aks.since.AddDate(0, -3, 0), z, true}, + {later, z, true}, + {later.AddDate(0, 1, 0), z, true}, + // with earliest set to time.Time zero + {z, aks.since, true}, + {z, aks.since.AddDate(0, 1, 0), true}, + {z, aks.since.AddDate(0, -2, 0), false}, + {z, later.AddDate(0, 1, 0), true}, + {z, z, true}, + } + + for _, t := range tests { + c.Check(asserts.IsValidAssumingCurTimeWithin(accKey, t.earliest, t.latest), Equals, t.valid) + } + +} + +func (aks *accountKeySuite) TestPrerequisites(c *C) { + encoded := "type: account-key\n" + + "authority-id: canonical\n" + + "account-id: acc-id1\n" + + "name: default\n" + + "public-key-sha3-384: " + aks.keyID + "\n" + + aks.sinceLine + + aks.untilLine + + fmt.Sprintf("body-length: %v", len(aks.pubKeyBody)) + "\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + "\n\n" + + aks.pubKeyBody + "\n\n" + + "AXNpZw==" + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + + prereqs := a.Prerequisites() + c.Assert(prereqs, HasLen, 1) + c.Check(prereqs[0], DeepEquals, &asserts.Ref{ + Type: asserts.AccountType, + PrimaryKey: []string{"acc-id1"}, + }) +} + +func (aks *accountKeySuite) TestAccountKeyRequestHappy(c *C) { + akr, err := asserts.SignWithoutAuthority(asserts.AccountKeyRequestType, + map[string]interface{}{ + "account-id": "acc-id1", + "name": "default", + "public-key-sha3-384": aks.keyID, + "since": aks.since.Format(time.RFC3339), + }, []byte(aks.pubKeyBody), aks.privKey) + c.Assert(err, IsNil) + + // roundtrip + a, err := asserts.Decode(asserts.Encode(akr)) + c.Assert(err, IsNil) + + akr2, ok := a.(*asserts.AccountKeyRequest) + c.Assert(ok, Equals, true) + + db := aks.openDB(c) + aks.prereqAccount(c, db) + + err = db.Check(akr2) + c.Check(err, IsNil) + + c.Check(akr2.AccountID(), Equals, "acc-id1") + c.Check(akr2.Name(), Equals, "default") + c.Check(akr2.PublicKeyID(), Equals, aks.keyID) + c.Check(akr2.Since(), Equals, aks.since) +} + +func (aks *accountKeySuite) TestAccountKeyRequestUntil(c *C) { + db := aks.openDB(c) + aks.prereqAccount(c, db) + + tests := []struct { + untilHeader string + until time.Time + }{ + {"", time.Time{}}, // zero time default + {aks.until.Format(time.RFC3339), aks.until}, // in the future + {aks.since.Format(time.RFC3339), aks.since}, // same as since + } + + for _, test := range tests { + c.Log(test) + headers := map[string]interface{}{ + "account-id": "acc-id1", + "name": "default", + "public-key-sha3-384": aks.keyID, + "since": aks.since.Format(time.RFC3339), + } + if test.untilHeader != "" { + headers["until"] = test.untilHeader + } + akr, err := asserts.SignWithoutAuthority(asserts.AccountKeyRequestType, headers, []byte(aks.pubKeyBody), aks.privKey) + c.Assert(err, IsNil) + a, err := asserts.Decode(asserts.Encode(akr)) + c.Assert(err, IsNil) + akr2 := a.(*asserts.AccountKeyRequest) + c.Check(akr2.Until(), Equals, test.until) + err = db.Check(akr2) + c.Check(err, IsNil) + } +} + +func (aks *accountKeySuite) TestAccountKeyRequestAddAndFind(c *C) { + akr, err := asserts.SignWithoutAuthority(asserts.AccountKeyRequestType, + map[string]interface{}{ + "account-id": "acc-id1", + "name": "default", + "public-key-sha3-384": aks.keyID, + "since": aks.since.Format(time.RFC3339), + }, []byte(aks.pubKeyBody), aks.privKey) + c.Assert(err, IsNil) + + db := aks.openDB(c) + aks.prereqAccount(c, db) + + err = db.Add(akr) + c.Assert(err, IsNil) + + found, err := db.Find(asserts.AccountKeyRequestType, map[string]string{ + "account-id": "acc-id1", + "public-key-sha3-384": aks.keyID, + }) + c.Assert(err, IsNil) + c.Assert(found, NotNil) + c.Check(found.Body(), DeepEquals, []byte(aks.pubKeyBody)) +} + +func (aks *accountKeySuite) TestAccountKeyRequestDecodeInvalid(c *C) { + encoded := "type: account-key-request\n" + + "account-id: acc-id1\n" + + "name: default\n" + + "public-key-sha3-384: " + aks.keyID + "\n" + + aks.sinceLine + + aks.untilLine + + fmt.Sprintf("body-length: %v", len(aks.pubKeyBody)) + "\n" + + "sign-key-sha3-384: " + aks.privKey.PublicKey().ID() + "\n\n" + + aks.pubKeyBody + "\n\n" + + "AXNpZw==" + + untilPast := aks.since.AddDate(-1, 0, 0) + untilPastLine := "until: " + untilPast.Format(time.RFC3339) + "\n" + + invalidTests := []struct{ original, invalid, expectedErr string }{ + {"account-id: acc-id1\n", "", `"account-id" header is mandatory`}, + {"account-id: acc-id1\n", "account-id: \n", `"account-id" header should not be empty`}, + {"name: default\n", "", `"name" header is mandatory`}, + {"name: default\n", "name: \n", `"name" header should not be empty`}, + {"name: default\n", "name: a b\n", `"name" header contains invalid characters: "a b"`}, + {"name: default\n", "name: -default\n", `"name" header contains invalid characters: "-default"`}, + {"name: default\n", "name: foo:bar\n", `"name" header contains invalid characters: "foo:bar"`}, + {"public-key-sha3-384: " + aks.keyID + "\n", "", `"public-key-sha3-384" header is mandatory`}, + {"public-key-sha3-384: " + aks.keyID + "\n", "public-key-sha3-384: \n", `"public-key-sha3-384" header should not be empty`}, + {aks.sinceLine, "", `"since" header is mandatory`}, + {aks.sinceLine, "since: \n", `"since" header should not be empty`}, + {aks.sinceLine, "since: 12:30\n", `"since" header is not a RFC3339 date: .*`}, + {aks.sinceLine, "since: \n", `"since" header should not be empty`}, + {aks.untilLine, "until: \n", `"until" header is not a RFC3339 date: .*`}, + {aks.untilLine, "until: 12:30\n", `"until" header is not a RFC3339 date: .*`}, + {aks.untilLine, untilPastLine, `'until' time cannot be before 'since' time`}, + } + + for _, test := range invalidTests { + invalid := strings.Replace(encoded, test.original, test.invalid, 1) + _, err := asserts.Decode([]byte(invalid)) + c.Check(err, ErrorMatches, accKeyReqErrPrefix+test.expectedErr) + } +} + +func (aks *accountKeySuite) TestAccountKeyRequestDecodeInvalidPublicKey(c *C) { + headers := "type: account-key-request\n" + + "account-id: acc-id1\n" + + "name: default\n" + + "public-key-sha3-384: " + aks.keyID + "\n" + + aks.sinceLine + + aks.untilLine + + raw, err := base64.StdEncoding.DecodeString(aks.pubKeyBody) + c.Assert(err, IsNil) + spurious := base64.StdEncoding.EncodeToString(append(raw, "gorp"...)) + + invalidPublicKeyTests := []struct{ body, expectedErr string }{ + {"", "cannot decode public key: no data"}, + {"==", "cannot decode public key: .*"}, + {"stuff", "cannot decode public key: .*"}, + {"AnNpZw==", "unsupported public key format version: 2"}, + {"AUJST0tFTg==", "cannot decode public key: .*"}, + {spurious, "public key has spurious trailing data"}, + } + + for _, test := range invalidPublicKeyTests { + invalid := headers + + fmt.Sprintf("body-length: %v", len(test.body)) + "\n" + + "sign-key-sha3-384: " + aks.privKey.PublicKey().ID() + "\n\n" + + test.body + "\n\n" + + "AXNpZw==" + + _, err := asserts.Decode([]byte(invalid)) + c.Check(err, ErrorMatches, accKeyReqErrPrefix+test.expectedErr) + } +} + +func (aks *accountKeySuite) TestAccountKeyRequestDecodeKeyIDMismatch(c *C) { + invalid := "type: account-key-request\n" + + "account-id: acc-id1\n" + + "name: default\n" + + "public-key-sha3-384: aa\n" + + aks.sinceLine + + aks.untilLine + + fmt.Sprintf("body-length: %v", len(aks.pubKeyBody)) + "\n" + + "sign-key-sha3-384: " + aks.privKey.PublicKey().ID() + "\n\n" + + aks.pubKeyBody + "\n\n" + + "AXNpZw==" + + _, err := asserts.Decode([]byte(invalid)) + c.Check(err, ErrorMatches, "assertion account-key-request: public key does not match provided key id") +} + +func (aks *accountKeySuite) TestAccountKeyRequestNoAccount(c *C) { + headers := map[string]interface{}{ + "account-id": "acc-id1", + "name": "default", + "public-key-sha3-384": aks.keyID, + "since": aks.since.Format(time.RFC3339), + } + akr, err := asserts.SignWithoutAuthority(asserts.AccountKeyRequestType, headers, []byte(aks.pubKeyBody), aks.privKey) + c.Assert(err, IsNil) + + db := aks.openDB(c) + + err = db.Check(akr) + c.Assert(err, ErrorMatches, `account-key-request assertion for "acc-id1" does not have a matching account assertion`) +} + +func (aks *accountKeySuite) TestDecodeConstraints(c *C) { + encoded := "type: account-key\n" + + "format: 1\n" + + "authority-id: canonical\n" + + "account-id: acc-id1\n" + + "name: default\n" + + "constraints:\n" + + " -\n" + + " headers:\n" + + " type: model\n" + + " model: foo-.*\n" + + " -\n" + + " headers:\n" + + " type: preseed\n" + + "public-key-sha3-384: " + aks.keyID + "\n" + + aks.sinceLine + + fmt.Sprintf("body-length: %v", len(aks.pubKeyBody)) + "\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + "\n\n" + + aks.pubKeyBody + "\n\n" + + "AXNpZw==" + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + c.Check(a.Type(), Equals, asserts.AccountKeyType) + accKey := a.(*asserts.AccountKey) + c.Check(accKey.AccountID(), Equals, "acc-id1") + c.Check(accKey.Name(), Equals, "default") + c.Check(accKey.PublicKeyID(), Equals, aks.keyID) + c.Check(accKey.Since(), Equals, aks.since) +} + +func (aks *accountKeySuite) TestDecodeConstraintsInvalid(c *C) { + const constr = "\n" + + " -\n" + + " headers:\n" + + " type: model\n" + + " model: foo-.*\n" + encoded := "type: account-key\n" + + "format: 1\n" + + "authority-id: canonical\n" + + "account-id: acc-id1\n" + + "name: default\n" + + "constraints:" + + constr + + "public-key-sha3-384: " + aks.keyID + "\n" + + aks.sinceLine + + fmt.Sprintf("body-length: %v", len(aks.pubKeyBody)) + "\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + "\n\n" + + aks.pubKeyBody + "\n\n" + + "AXNpZw==" + + invalidHeaderTests := []struct{ original, invalid, expectedErr string }{ + {constr, " x\n", "assertions constraints must be a list of maps"}, {constr, "\n - foo\n", "assertions constraints must be a list of maps"}, + {constr, "\n -\n headers: x\n", `"headers" constraint must be a map`}, + {constr, "\n -\n header:\n t: x\n", `"headers" constraint mandatory in asserions constraints`}, + {constr, "\n -\n headers:\n t: x\n", "type header constraint mandatory in asserions constraints"}, + {constr, "\n -\n headers:\n type:\n - foo\n", "type header constraint must be a string"}, + {constr, "\n -\n headers:\n type: preseed|model\n", "type header constraint must be a precise string and not a regexp"}, + {constr, "\n -\n headers:\n type: foo\n model: $X\n", `cannot compile headers constraint: cannot compile "model" constraint "\$X": no \$OP\(\) constraints supported`}, + } + for _, test := range invalidHeaderTests { + invalid := strings.Replace(encoded, test.original, test.invalid, 1) + _, err := asserts.Decode([]byte(invalid)) + c.Check(err, ErrorMatches, accKeyErrPrefix+test.expectedErr) + } +} + +func (s *accountKeySuite) TestSuggestedFormat(c *C) { + fmtnum, err := asserts.SuggestFormat(asserts.AccountKeyType, nil, nil) + c.Assert(err, IsNil) + c.Check(fmtnum, Equals, 0) + + headers := map[string]interface{}{ + "constraints": []interface{}{map[string]interface{}{"headers": nil}}, + } + fmtnum, err = asserts.SuggestFormat(asserts.AccountKeyType, headers, nil) + c.Assert(err, IsNil) + c.Check(fmtnum, Equals, 1) +} + +func (aks *accountKeySuite) TestCanSignAndConstraintsPrecheck(c *C) { + encoded := "type: account-key\n" + + "format: 1\n" + + "authority-id: canonical\n" + + "account-id: acc-id1\n" + + "name: default\n" + + "constraints:\n" + + " -\n" + + " headers:\n" + + " type: model\n" + + " model: foo-.*\n" + + " -\n" + + " headers:\n" + + " type: preseed\n" + + "public-key-sha3-384: " + aks.keyID + "\n" + + aks.sinceLine + + fmt.Sprintf("body-length: %v", len(aks.pubKeyBody)) + "\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + "\n\n" + + aks.pubKeyBody + "\n\n" + + "AXNpZw==" + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + c.Check(a.Type(), Equals, asserts.AccountKeyType) + accKey := a.(*asserts.AccountKey) + headers := map[string]interface{}{ + "type": "model", + "authority-id": "my-brand", + "brand-id": "my-brand", + "series": "16", + "model": "foo-200", + "classic": "true", + } + c.Check(accKey.ConstraintsPrecheck(asserts.ModelType, headers), IsNil) + mfoo := assertstest.FakeAssertion(headers) + c.Check(accKey.CanSign(mfoo), Equals, true) + headers = map[string]interface{}{ + "type": "model", + "authority-id": "my-brand", + "brand-id": "my-brand", + "series": "16", + "model": "goo-200", + "classic": "true", + } + c.Check(accKey.ConstraintsPrecheck(asserts.ModelType, headers), ErrorMatches, `headers do not match the account-key constraints`) + mnotfoo := assertstest.FakeAssertion(headers) + c.Check(accKey.CanSign(mnotfoo), Equals, false) + headers = map[string]interface{}{ + "type": "preseed", + "authority-id": "my-brand", + "series": "16", + "brand-id": "my-brand", + "model": "goo-200", + "system-label": "2023-07-17", + "snaps": []interface{}{}, + "artifact-sha3-384": "KPIl7M4vQ9d4AUjkoU41TGAwtOMLc_bWUCeW8AvdRWD4_xcP60Oo4ABs1No7BtXj", + } + c.Check(accKey.ConstraintsPrecheck(asserts.PreseedType, headers), IsNil) + pr := assertstest.FakeAssertion(headers) + c.Check(accKey.CanSign(pr), Equals, true) + headers = map[string]interface{}{ + "type": "snap-declaration", + "authority-id": "my-brand", + "series": "16", + "snap-id": "snapid", + "snap-name": "foo", + "publisher-id": "my-brand", + } + c.Check(accKey.ConstraintsPrecheck(asserts.ModelType, headers), ErrorMatches, `headers do not match the account-key constraints`) + snapDecl := assertstest.FakeAssertion(headers) + c.Check(accKey.CanSign(snapDecl), Equals, false) +} diff --git a/asserts/account_test.go b/asserts/account_test.go new file mode 100644 index 00000000..168d636b --- /dev/null +++ b/asserts/account_test.go @@ -0,0 +1,196 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts_test + +import ( + "fmt" + "strings" + "time" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/asserts" +) + +var ( + _ = Suite(&accountSuite{}) +) + +type accountSuite struct { + ts time.Time + tsLine string +} + +func (s *accountSuite) SetUpSuite(c *C) { + s.ts = time.Now().Truncate(time.Second).UTC() + s.tsLine = "timestamp: " + s.ts.Format(time.RFC3339) + "\n" +} + +const accountExample = "type: account\n" + + "authority-id: canonical\n" + + "account-id: abc-123\n" + + "display-name: Nice User\n" + + "username: nice\n" + + "validation: verified\n" + + "TSLINE" + + "body-length: 0\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==" + +func (s *accountSuite) TestDecodeOK(c *C) { + encoded := strings.Replace(accountExample, "TSLINE", s.tsLine, 1) + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + c.Check(a.Type(), Equals, asserts.AccountType) + account := a.(*asserts.Account) + c.Check(account.AuthorityID(), Equals, "canonical") + c.Check(account.Timestamp(), Equals, s.ts) + c.Check(account.AccountID(), Equals, "abc-123") + c.Check(account.DisplayName(), Equals, "Nice User") + c.Check(account.Username(), Equals, "nice") + c.Check(account.Validation(), Equals, "verified") +} + +func (s *accountSuite) TestOptional(c *C) { + encoded := strings.Replace(accountExample, "TSLINE", s.tsLine, 1) + + tests := []struct{ original, replacement string }{ + {"username: nice\n", ""}, + {"username: nice\n", "username: \n"}, + } + + for _, test := range tests { + valid := strings.Replace(encoded, test.original, test.replacement, 1) + _, err := asserts.Decode([]byte(valid)) + c.Check(err, IsNil) + } +} + +func (s *accountSuite) TestValidation(c *C) { + tests := []struct { + value string + isVerified bool + }{ + {"certified", true}, // backward compat for hard-coded trusted assertions + {"verified", true}, + {"unproven", false}, + {"nonsense", false}, + } + + template := strings.Replace(accountExample, "TSLINE", s.tsLine, 1) + for _, test := range tests { + encoded := strings.Replace( + template, + "validation: verified\n", + fmt.Sprintf("validation: %s\n", test.value), + 1, + ) + assert, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + account := assert.(*asserts.Account) + expected := test.value + if test.isVerified { + expected = "verified" + } + c.Check(account.Validation(), Equals, expected) + } +} + +const ( + accountErrPrefix = "assertion account: " +) + +func (s *accountSuite) TestDecodeInvalid(c *C) { + encoded := strings.Replace(accountExample, "TSLINE", s.tsLine, 1) + + invalidTests := []struct{ original, invalid, expectedErr string }{ + {"account-id: abc-123\n", "", `"account-id" header is mandatory`}, + {"account-id: abc-123\n", "account-id: \n", `"account-id" header should not be empty`}, + {"display-name: Nice User\n", "", `"display-name" header is mandatory`}, + {"display-name: Nice User\n", "display-name: \n", `"display-name" header should not be empty`}, + {"username: nice\n", "username:\n - foo\n - bar\n", `"username" header must be a string`}, + {"validation: verified\n", "", `"validation" header is mandatory`}, + {"validation: verified\n", "validation: \n", `"validation" header should not be empty`}, + {s.tsLine, "", `"timestamp" header is mandatory`}, + {s.tsLine, "timestamp: \n", `"timestamp" header should not be empty`}, + {s.tsLine, "timestamp: 12:30\n", `"timestamp" header is not a RFC3339 date: .*`}, + } + + for _, test := range invalidTests { + invalid := strings.Replace(encoded, test.original, test.invalid, 1) + _, err := asserts.Decode([]byte(invalid)) + c.Check(err, ErrorMatches, accountErrPrefix+test.expectedErr) + } +} + +func (s *accountSuite) TestCheckInconsistentTimestamp(c *C) { + ex, err := asserts.Decode([]byte(strings.Replace(accountExample, "TSLINE", s.tsLine, 1))) + c.Assert(err, IsNil) + + storeDB, db := makeStoreAndCheckDB(c) + + headers := ex.Headers() + headers["timestamp"] = "2011-01-01T14:00:00Z" + account, err := storeDB.Sign(asserts.AccountType, headers, nil, "") + c.Assert(err, IsNil) + + err = db.Check(account) + c.Assert(err, ErrorMatches, `account assertion timestamp "2011-01-01 14:00:00 \+0000 UTC" outside of signing key validity \(key valid since.*\)`) +} + +func (s *accountSuite) TestCheckUntrustedAuthority(c *C) { + ex, err := asserts.Decode([]byte(strings.Replace(accountExample, "TSLINE", s.tsLine, 1))) + c.Assert(err, IsNil) + + storeDB, db := makeStoreAndCheckDB(c) + otherDB := setup3rdPartySigning(c, "other", storeDB, db) + + headers := ex.Headers() + // default to signing db's authority + delete(headers, "authority-id") + headers["timestamp"] = time.Now().Format(time.RFC3339) + account, err := otherDB.Sign(asserts.AccountType, headers, nil, "") + c.Assert(err, IsNil) + + err = db.Check(account) + c.Assert(err, ErrorMatches, `account assertion for "abc-123" is not signed by a directly trusted authority:.*`) +} + +func (s *accountSuite) TestIsValidAccountID(c *C) { + ids := []struct { + accountID string + valid bool + }{ + {"f", false}, + {"", false}, + {"foo", true}, + {"foo-baraaaaaaaaaaaaaaaaaaaaa", true}, // 28 characters + {"foo-baraaaaaaaaaaaaaaaaaaaaax", false}, // too long + {"fooBAR9aaaaaaaaaaaaaaaaaaaaaaaaa", true}, // 32 characters + {"fooBAR9aaaaaaaaaaaaaaaaaaaaaaaaax", false}, // too long + {"foo-bar12", true}, + {"-foo-bar", true}, + } + + for i, id := range ids { + c.Assert(asserts.IsValidAccountID(id.accountID), Equals, id.valid, Commentf("%d: %s", i, id.accountID)) + } +} diff --git a/asserts/aspect_bundle.go b/asserts/aspect_bundle.go new file mode 100644 index 00000000..8358a7d5 --- /dev/null +++ b/asserts/aspect_bundle.go @@ -0,0 +1,115 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2024 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts + +import ( + "encoding/json" + "fmt" + "regexp" + "time" + + "github.com/snapcore/snapd/aspects" +) + +// AspectBundle holds an aspect-bundle assertion, which is a definition by an +// account of access aspects ("views") and a storage schema for a set of +// related configuration options under the purview of the account. +type AspectBundle struct { + assertionBase + + bundle *aspects.Bundle + timestamp time.Time +} + +// AccountID returns the identifier of the account that signed this assertion. +func (ab *AspectBundle) AccountID() string { + return ab.HeaderString("account-id") +} + +// Name returns the name for the bundle. +func (ab *AspectBundle) Name() string { + return ab.HeaderString("name") +} + +// Bundle returns a aspects.Bundle implementing the aspect bundle configuration +// handling. +func (ab *AspectBundle) Bundle() *aspects.Bundle { + return ab.bundle +} + +var ( + validAspectBundleName = regexp.MustCompile("^[a-z0-9](?:-?[a-z0-9])*$") +) + +func assembleAspectBundle(assert assertionBase) (Assertion, error) { + authorityID := assert.AuthorityID() + accountID := assert.HeaderString("account-id") + if accountID != authorityID { + return nil, fmt.Errorf("authority-id and account-id must match, aspect-bundle assertions are expected to be signed by the issuer account: %q != %q", authorityID, accountID) + } + + name, err := checkStringMatches(assert.headers, "name", validAspectBundleName) + if err != nil { + return nil, err + } + + aspectsMap, err := checkMap(assert.headers, "aspects") + if err != nil { + return nil, err + } + if aspectsMap == nil { + return nil, fmt.Errorf(`"aspects" stanza is mandatory`) + } + + if _, err := checkOptionalString(assert.headers, "summary"); err != nil { + return nil, err + } + + var bodyMap map[string]json.RawMessage + if err := json.Unmarshal(assert.body, &bodyMap); err != nil { + return nil, err + } + + schemaRaw, ok := bodyMap["storage"] + if !ok { + return nil, fmt.Errorf(`body must contain a "storage" stanza`) + } + + schema, err := aspects.ParseSchema(schemaRaw) + if err != nil { + return nil, fmt.Errorf(`invalid schema: %w`, err) + } + + bundle, err := aspects.NewBundle(accountID, name, aspectsMap, schema) + if err != nil { + return nil, err + } + + timestamp, err := checkRFC3339Date(assert.headers, "timestamp") + if err != nil { + return nil, err + } + + return &AspectBundle{ + assertionBase: assert, + bundle: bundle, + timestamp: timestamp, + }, nil +} diff --git a/asserts/aspect_bundle_test.go b/asserts/aspect_bundle_test.go new file mode 100644 index 00000000..fcf38002 --- /dev/null +++ b/asserts/aspect_bundle_test.go @@ -0,0 +1,201 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2024 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts_test + +import ( + "strings" + "time" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/asserts" +) + +type aspectBundleSuite struct { + ts time.Time + tsLine string +} + +var _ = Suite(&aspectBundleSuite{}) + +func (s *aspectBundleSuite) SetUpSuite(c *C) { + s.ts = time.Now().Truncate(time.Second).UTC() + s.tsLine = "timestamp: " + s.ts.Format(time.RFC3339) + "\n" +} + +const ( + aspectBundleExample = `type: aspect-bundle +authority-id: brand-id1 +account-id: brand-id1 +name: my-network +summary: aspect-bundle description +aspects: + wifi-setup: + rules: + - + request: ssids + storage: wifi.ssids + - + request: ssid + storage: wifi.ssid + access: read-write + - + request: password + storage: wifi.psk + access: write + - + request: status + storage: wifi.status + access: read + - + request: private.{key} + storage: wifi.{key} +` + "TSLINE" + + "sign-key-sha3-384: jv8_jihiizjvco9m55ppdqsdwuvuhfdibjus-3vw7f_idjix7ffn5qmxb21zquij\n" + + "body-length: 115" + + "\n\n" + + schema + + "\n\n" + + "AXNpZw==" +) + +const schema = `{ + "storage": { + "schema": { + "wifi": { + "type": "map", + "values": "any" + } + } + } +}` + +func (s *aspectBundleSuite) TestDecodeOK(c *C) { + encoded := strings.Replace(aspectBundleExample, "TSLINE", s.tsLine, 1) + + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + c.Check(a, NotNil) + c.Check(a.Type(), Equals, asserts.AspectBundleType) + ab := a.(*asserts.AspectBundle) + c.Check(ab.AuthorityID(), Equals, "brand-id1") + c.Check(ab.AccountID(), Equals, "brand-id1") + c.Check(ab.Name(), Equals, "my-network") + bundle := ab.Bundle() + c.Assert(bundle, NotNil) + c.Check(bundle.Aspect("wifi-setup"), NotNil) +} + +func (s *aspectBundleSuite) TestDecodeInvalid(c *C) { + const validationSetErrPrefix = "assertion aspect-bundle: " + + encoded := strings.Replace(aspectBundleExample, "TSLINE", s.tsLine, 1) + + aspectsStanza := encoded[strings.Index(encoded, "aspects:") : strings.Index(encoded, "timestamp:")+1] + body := encoded[strings.Index(encoded, "body-length:"):strings.Index(encoded, "\n\nAXN")] + + invalidTests := []struct{ original, invalid, expectedErr string }{ + {"account-id: brand-id1\n", "", `"account-id" header is mandatory`}, + {"account-id: brand-id1\n", "account-id: \n", `"account-id" header should not be empty`}, + {"account-id: brand-id1\n", "account-id: random\n", `authority-id and account-id must match, aspect-bundle assertions are expected to be signed by the issuer account: "brand-id1" != "random"`}, + {"name: my-network\n", "", `"name" header is mandatory`}, + {"name: my-network\n", "name: \n", `"name" header should not be empty`}, + {"name: my-network\n", "name: my/network\n", `"name" primary key header cannot contain '/'`}, + {"name: my-network\n", "name: my+network\n", `"name" header contains invalid characters: "my\+network"`}, + {s.tsLine, "", `"timestamp" header is mandatory`}, + {aspectsStanza, "aspects: foo\n", `"aspects" header must be a map`}, + {aspectsStanza, "", `"aspects" stanza is mandatory`}, + {"read-write", "update", `cannot define aspect "wifi-setup": cannot create aspect rule:.*`}, + {body, "body-length: 0", `body must contain JSON`}, + {body, "body-length: 8\n\n - foo\n", `invalid JSON in body: invalid character ' ' in numeric literal`}, + {body, "body-length: 2\n\n{}", `body must contain a "storage" stanza`}, + {body, "body-length: 19\n\n{\n \"storage\": {}\n}", `invalid schema: cannot parse top level schema: must have a "schema" constraint`}, + {body, "body-length: 4\n\nnull", `body must contain a "storage" stanza`}, + {body, "body-length: 54\n\n{\n\t\"storage\": {\n\t\t\"schema\": {\n\t\t\t\"foo\": \"any\"\n\t\t}\n\t}\n}", `JSON in body must be indented with 2 spaces and sort object entries by key`}, + {body, `body-length: 79 + +{ + "storage": { + "schema": { + "c": "any", + "a": "any" + } + } +}`, `JSON in body must be indented with 2 spaces and sort object entries by key`}, + } + + for i, test := range invalidTests { + invalid := strings.Replace(encoded, test.original, test.invalid, 1) + _, err := asserts.Decode([]byte(invalid)) + c.Check(err, ErrorMatches, validationSetErrPrefix+test.expectedErr, Commentf("test %d/%d failed", i+1, len(invalidTests))) + } +} + +func (s *aspectBundleSuite) TestAssembleAndSignChecksSchemaFormatOK(c *C) { + headers := map[string]interface{}{ + "authority-id": "brand-id1", + "account-id": "brand-id1", + "name": "my-network", + "aspects": map[string]interface{}{ + "foo": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "wifi", "storage": "wifi"}, + }, + }, + }, + "body-length": "60", + "timestamp": s.ts.Format(time.RFC3339), + } + + schema := `{ + "storage": { + "schema": { + "wifi": { + "type": "map", + "values": "any" + } + } + } +}` + acct1, err := asserts.AssembleAndSignInTest(asserts.AspectBundleType, headers, []byte(schema), testPrivKey0) + c.Assert(err, IsNil) + c.Assert(string(acct1.Body()), Equals, schema) +} + +func (s *aspectBundleSuite) TestAssembleAndSignChecksSchemaFormatFail(c *C) { + headers := map[string]interface{}{ + "authority-id": "brand-id1", + "account-id": "brand-id1", + "name": "my-network", + "aspects": map[string]interface{}{ + "foo": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{"request": "wifi", "storage": "wifi"}, + }, + }, + }, + "body-length": "60", + "timestamp": s.ts.Format(time.RFC3339), + } + + schema := `{ "storage": { "schema": { "foo": "any" } } }` + _, err := asserts.AssembleAndSignInTest(asserts.AspectBundleType, headers, []byte(schema), testPrivKey0) + c.Assert(err, ErrorMatches, `assertion aspect-bundle: JSON in body must be indented with 2 spaces and sort object entries by key`) +} diff --git a/asserts/asserts.go b/asserts/asserts.go new file mode 100644 index 00000000..0bd81682 --- /dev/null +++ b/asserts/asserts.go @@ -0,0 +1,1389 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2015-2024 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts + +import ( + "bufio" + "bytes" + "crypto" + "encoding/json" + "fmt" + "io" + "reflect" + "sort" + "strconv" + "strings" + "unicode/utf8" + + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/snap/naming" +) + +type typeFlags int + +const ( + noAuthority typeFlags = 1 << iota + sequenceForming + jsonBody +) + +// MetaHeaders is a list of headers in assertions which are about the assertion +// itself. +var MetaHeaders = [...]string{ + "type", + "format", + "authority-id", + "revision", + "body-length", + "sign-key-sha3-384", +} + +// AssertionType describes a known assertion type with its name and metadata. +type AssertionType struct { + // Name of the type. + Name string + // PrimaryKey holds the names of the headers that constitute the + // unique primary key for this assertion type. + PrimaryKey []string + // OptionalPrimaryKeyDefaults holds the default values for + // optional primary key headers. + // Optional primary key headers can be added to types defined + // in previous versions of snapd, as long as they are added at + // the end of the old primary key together with a default value set in + // this map. So they must form a contiguous suffix of PrimaryKey with + // each member having a default value set in this map. + // Optional primary key headers are not supported for sequence + // forming types. + OptionalPrimaryKeyDefaults map[string]string + + assembler func(assert assertionBase) (Assertion, error) + flags typeFlags +} + +func (at *AssertionType) validate() { + if len(at.OptionalPrimaryKeyDefaults) != 0 && at.flags&sequenceForming != 0 { + panic(fmt.Sprintf("assertion type %q cannot be both sequence forming and have optional primary keys", at.Name)) + } + noptional := 0 + for _, k := range at.PrimaryKey { + defl := at.OptionalPrimaryKeyDefaults[k] + if noptional > 0 { + if defl == "" { + panic(fmt.Sprintf("assertion type %q primary key header %q has no default, optional primary keys must be a proper suffix of the primary key", at.Name, k)) + } + } + if defl != "" { + noptional++ + } + } + if len(at.OptionalPrimaryKeyDefaults) != noptional { + panic(fmt.Sprintf("assertion type %q has defaults values for unknown primary key headers", at.Name)) + } +} + +// MaxSupportedFormat returns the maximum supported format iteration for the type. +func (at *AssertionType) MaxSupportedFormat() int { + return maxSupportedFormat[at.Name] +} + +// SequenceForming returns true if the assertion type has a positive +// integer >= 1 as the last component (preferably called "sequence") +// of its primary key over which the assertions of the type form +// sequences, usually without gaps, one sequence per sequence key (the +// primary key prefix omitting the sequence number). +// See SequenceMember. +func (at *AssertionType) SequenceForming() bool { + return at.flags&sequenceForming != 0 +} + +// AcceptablePrimaryKey returns whether the given key could be an acceptable primary key for this type, allowing for the omission of optional primary key headers. +func (at *AssertionType) AcceptablePrimaryKey(key []string) bool { + n := len(at.PrimaryKey) + nopt := len(at.OptionalPrimaryKeyDefaults) + ninp := len(key) + if ninp > n || ninp < (n-nopt) { + return false + } + return true +} + +// Understood assertion types. +var ( + AccountType = &AssertionType{"account", []string{"account-id"}, nil, assembleAccount, 0} + AccountKeyType = &AssertionType{"account-key", []string{"public-key-sha3-384"}, nil, assembleAccountKey, 0} + RepairType = &AssertionType{"repair", []string{"brand-id", "repair-id"}, nil, assembleRepair, sequenceForming} + ModelType = &AssertionType{"model", []string{"series", "brand-id", "model"}, nil, assembleModel, 0} + SerialType = &AssertionType{"serial", []string{"brand-id", "model", "serial"}, nil, assembleSerial, 0} + BaseDeclarationType = &AssertionType{"base-declaration", []string{"series"}, nil, assembleBaseDeclaration, 0} + SnapDeclarationType = &AssertionType{"snap-declaration", []string{"series", "snap-id"}, nil, assembleSnapDeclaration, 0} + SnapBuildType = &AssertionType{"snap-build", []string{"snap-sha3-384"}, nil, assembleSnapBuild, 0} + SnapRevisionType = &AssertionType{"snap-revision", []string{"snap-sha3-384", "provenance"}, map[string]string{"provenance": naming.DefaultProvenance}, assembleSnapRevision, 0} + SnapDeveloperType = &AssertionType{"snap-developer", []string{"snap-id", "publisher-id"}, nil, assembleSnapDeveloper, 0} + SystemUserType = &AssertionType{"system-user", []string{"brand-id", "email"}, nil, assembleSystemUser, 0} + ValidationType = &AssertionType{"validation", []string{"series", "snap-id", "approved-snap-id", "approved-snap-revision"}, nil, assembleValidation, 0} + ValidationSetType = &AssertionType{"validation-set", []string{"series", "account-id", "name", "sequence"}, nil, assembleValidationSet, sequenceForming} + StoreType = &AssertionType{"store", []string{"store"}, nil, assembleStore, 0} + PreseedType = &AssertionType{"preseed", []string{"series", "brand-id", "model", "system-label"}, nil, assemblePreseed, 0} + SnapResourceRevisionType = &AssertionType{"snap-resource-revision", []string{"snap-id", "resource-name", "resource-sha3-384", "provenance"}, map[string]string{"provenance": naming.DefaultProvenance}, assembleSnapResourceRevision, 0} + SnapResourcePairType = &AssertionType{"snap-resource-pair", []string{"snap-id", "resource-name", "resource-revision", "snap-revision", "provenance"}, map[string]string{"provenance": naming.DefaultProvenance}, assembleSnapResourcePair, 0} + AspectBundleType = &AssertionType{"aspect-bundle", []string{"account-id", "name"}, nil, assembleAspectBundle, jsonBody} + + // ... +) + +// Assertion types without a definite authority set (on the wire and/or self-signed). +var ( + DeviceSessionRequestType = &AssertionType{"device-session-request", []string{"brand-id", "model", "serial"}, nil, assembleDeviceSessionRequest, noAuthority} + SerialRequestType = &AssertionType{"serial-request", nil, nil, assembleSerialRequest, noAuthority} + AccountKeyRequestType = &AssertionType{"account-key-request", []string{"public-key-sha3-384"}, nil, assembleAccountKeyRequest, noAuthority} +) + +var typeRegistry = map[string]*AssertionType{ + AccountType.Name: AccountType, + AccountKeyType.Name: AccountKeyType, + ModelType.Name: ModelType, + SerialType.Name: SerialType, + BaseDeclarationType.Name: BaseDeclarationType, + SnapDeclarationType.Name: SnapDeclarationType, + SnapBuildType.Name: SnapBuildType, + SnapRevisionType.Name: SnapRevisionType, + SnapDeveloperType.Name: SnapDeveloperType, + SystemUserType.Name: SystemUserType, + ValidationType.Name: ValidationType, + ValidationSetType.Name: ValidationSetType, + RepairType.Name: RepairType, + StoreType.Name: StoreType, + PreseedType.Name: PreseedType, + SnapResourceRevisionType.Name: SnapResourceRevisionType, + SnapResourcePairType.Name: SnapResourcePairType, + AspectBundleType.Name: AspectBundleType, + // no authority + DeviceSessionRequestType.Name: DeviceSessionRequestType, + SerialRequestType.Name: SerialRequestType, + AccountKeyRequestType.Name: AccountKeyRequestType, +} + +// Type returns the AssertionType with name or nil +func Type(name string) *AssertionType { + return typeRegistry[name] +} + +// TypeNames returns a sorted list of known assertion type names. +func TypeNames() []string { + names := make([]string, 0, len(typeRegistry)) + for k := range typeRegistry { + names = append(names, k) + } + + sort.Strings(names) + + return names +} + +var maxSupportedFormat = map[string]int{} + +func init() { + // register maxSupportedFormats while breaking initialisation loop + + // 1: plugs and slots + // 2: support for $SLOT()/$PLUG()/$MISSING + // 3: support for on-store/on-brand/on-model device scope constraints + // 4: support for plug-names/slot-names constraints + // 5: alt attr matcher usage (was unused before, has new behavior now) + maxSupportedFormat[SnapDeclarationType.Name] = 5 + + // 1: support to limit to device serials + // 2: support for user-presence constraint + maxSupportedFormat[SystemUserType.Name] = 2 + + // 1: support for constraints + maxSupportedFormat[AccountKeyType.Name] = 1 + + for _, at := range typeRegistry { + at.validate() + } +} + +func MockMaxSupportedFormat(assertType *AssertionType, maxFormat int) (restore func()) { + prev := maxSupportedFormat[assertType.Name] + maxSupportedFormat[assertType.Name] = maxFormat + return func() { + maxSupportedFormat[assertType.Name] = prev + } +} + +func MockOptionalPrimaryKey(assertType *AssertionType, key, defaultValue string) (restore func()) { + osutil.MustBeTestBinary("mocking new assertion optional primary keys can be done only from tests") + oldPrimaryKey := assertType.PrimaryKey + oldOptionalPrimaryKeyDefaults := assertType.OptionalPrimaryKeyDefaults + newOptionalPrimaryKeyDefaults := make(map[string]string, len(oldOptionalPrimaryKeyDefaults)+1) + for k, defl := range oldOptionalPrimaryKeyDefaults { + newOptionalPrimaryKeyDefaults[k] = defl + } + assertType.PrimaryKey = append(assertType.PrimaryKey, key) + assertType.OptionalPrimaryKeyDefaults = newOptionalPrimaryKeyDefaults + newOptionalPrimaryKeyDefaults[key] = defaultValue + return func() { + assertType.PrimaryKey = oldPrimaryKey + assertType.OptionalPrimaryKeyDefaults = oldOptionalPrimaryKeyDefaults + } +} + +var formatAnalyzer = map[*AssertionType]func(headers map[string]interface{}, body []byte) (formatnum int, err error){ + AccountKeyType: accountKeyFormatAnalyze, + SnapDeclarationType: snapDeclarationFormatAnalyze, + SystemUserType: systemUserFormatAnalyze, +} + +// MaxSupportedFormats returns a mapping between assertion type names +// and corresponding max supported format if it is >= min. Typical +// usage passes 1 or 0 for min. +func MaxSupportedFormats(min int) (maxFormats map[string]int) { + if min == 0 { + maxFormats = make(map[string]int, len(typeRegistry)) + } else { + maxFormats = make(map[string]int) + } + for name := range typeRegistry { + m := maxSupportedFormat[name] + if m >= min { + maxFormats[name] = m + } + } + return maxFormats +} + +// SuggestFormat returns a minimum format that supports the features that would be used by an assertion with the given components. +func SuggestFormat(assertType *AssertionType, headers map[string]interface{}, body []byte) (formatnum int, err error) { + analyzer := formatAnalyzer[assertType] + if analyzer == nil { + // no analyzer, format 0 is all there is + return 0, nil + } + formatnum, err = analyzer(headers, body) + if err != nil { + return 0, fmt.Errorf("assertion %s: %v", assertType.Name, err) + } + return formatnum, nil +} + +// HeadersFromPrimaryKey constructs a headers mapping from the +// primaryKey values and the assertion type, it errors if primaryKey +// does not cover all the non-optional primary key headers or provides +// too many values. +func HeadersFromPrimaryKey(assertType *AssertionType, primaryKey []string) (headers map[string]string, err error) { + if !assertType.AcceptablePrimaryKey(primaryKey) { + return nil, fmt.Errorf("primary key has wrong length for %q assertion", assertType.Name) + } + ninp := len(primaryKey) + headers = make(map[string]string, len(assertType.PrimaryKey)) + for i, name := range assertType.PrimaryKey { + var keyVal string + if i < ninp { + keyVal = primaryKey[i] + if keyVal == "" { + return nil, fmt.Errorf("primary key %q header cannot be empty", name) + } + } else { + keyVal = assertType.OptionalPrimaryKeyDefaults[name] + } + headers[name] = keyVal + } + return headers, nil +} + +// HeadersFromSequenceKey constructs a headers mapping from the +// sequenceKey values and the sequence forming assertion type, +// it errors if sequenceKey has the wrong length; the length must be +// one less than the primary key of the given assertion type. +func HeadersFromSequenceKey(assertType *AssertionType, sequenceKey []string) (headers map[string]string, err error) { + if !assertType.SequenceForming() { + return nil, fmt.Errorf("internal error: HeadersFromSequenceKey should only be used for sequence forming assertion types, got: %s", assertType.Name) + } + if len(sequenceKey) != len(assertType.PrimaryKey)-1 { + return nil, fmt.Errorf("sequence key has wrong length for %q assertion", assertType.Name) + } + headers = make(map[string]string, len(sequenceKey)) + for i, val := range sequenceKey { + key := assertType.PrimaryKey[i] + if val == "" { + return nil, fmt.Errorf("sequence key %q header cannot be empty", key) + } + headers[key] = val + } + return headers, nil +} + +// PrimaryKeyFromHeaders extracts the tuple of values from headers +// corresponding to a primary key under the assertion type, it errors +// if there are missing primary key headers unless they are optional +// in which case it fills in their default values. +func PrimaryKeyFromHeaders(assertType *AssertionType, headers map[string]string) (primaryKey []string, err error) { + return keysFromHeaders(assertType.PrimaryKey, headers, assertType.OptionalPrimaryKeyDefaults) +} + +func keysFromHeaders(keys []string, headers map[string]string, defaults map[string]string) (keyValues []string, err error) { + keyValues = make([]string, len(keys)) + for i, k := range keys { + keyVal := headers[k] + if keyVal == "" { + keyVal = defaults[k] + if keyVal == "" { + return nil, fmt.Errorf("must provide primary key: %v", k) + } + } + keyValues[i] = keyVal + } + return keyValues, nil +} + +// ReducePrimaryKey produces a primary key prefix by omitting any +// suffix of optional primary key headers default values. +// Too short or long primary keys are returned as is. +func ReducePrimaryKey(assertType *AssertionType, primaryKey []string) []string { + n := len(assertType.PrimaryKey) + nopt := len(assertType.OptionalPrimaryKeyDefaults) + ninp := len(primaryKey) + if ninp > n || ninp < (n-nopt) { + return primaryKey + } + reduced := make([]string, n-nopt, n) + copy(reduced, primaryKey[:n-nopt]) + rest := ninp - (n - nopt) + for i := ninp - 1; i >= n-nopt; i-- { + defl := assertType.OptionalPrimaryKeyDefaults[assertType.PrimaryKey[i]] + if primaryKey[i] != defl { + break + } + // it matches the default value, leave it out + rest-- + } + reduced = append(reduced, primaryKey[n-nopt:n-nopt+rest]...) + return reduced +} + +// Ref expresses a reference to an assertion. +type Ref struct { + Type *AssertionType + PrimaryKey []string +} + +func (ref *Ref) String() string { + pkStr := "-" + n := len(ref.Type.PrimaryKey) + nopt := len(ref.Type.OptionalPrimaryKeyDefaults) + ninp := len(ref.PrimaryKey) + if ninp > n || ninp < (n-nopt) { + pkStr = "???" + } else if n > 0 { + pkStr = ref.PrimaryKey[n-nopt-1] + if n > 1 { + sfx := []string{pkStr + ";"} + for i, k := range ref.Type.PrimaryKey[:n-nopt-1] { + sfx = append(sfx, fmt.Sprintf("%s:%s", k, ref.PrimaryKey[i])) + } + // optional primary keys + for i := n - nopt; i < ninp; i++ { + v := ref.PrimaryKey[i] + k := ref.Type.PrimaryKey[i] + defl := ref.Type.OptionalPrimaryKeyDefaults[k] + if v != defl { + sfx = append(sfx, fmt.Sprintf("%s:%s", k, v)) + } + } + pkStr = strings.Join(sfx, " ") + } + } + return fmt.Sprintf("%s (%s)", ref.Type.Name, pkStr) +} + +// Unique returns a unique string representing the reference that can be used as a key in maps. +func (ref *Ref) Unique() string { + return fmt.Sprintf("%s/%s", ref.Type.Name, strings.Join(ReducePrimaryKey(ref.Type, ref.PrimaryKey), "/")) +} + +// Resolve resolves the reference using the given find function. +func (ref *Ref) Resolve(find func(assertType *AssertionType, headers map[string]string) (Assertion, error)) (Assertion, error) { + headers, err := HeadersFromPrimaryKey(ref.Type, ref.PrimaryKey) + if err != nil { + return nil, fmt.Errorf("%q assertion reference primary key has the wrong length (expected %v): %v", ref.Type.Name, ref.Type.PrimaryKey, ref.PrimaryKey) + } + return find(ref.Type, headers) +} + +const RevisionNotKnown = -1 + +// AtRevision represents an assertion at a given revision, possibly +// not known (RevisionNotKnown). +type AtRevision struct { + Ref + Revision int +} + +func (at *AtRevision) String() string { + s := at.Ref.String() + if at.Revision == RevisionNotKnown { + return s + } + return fmt.Sprintf("%s at revision %d", s, at.Revision) +} + +// AtSequence references a sequence forming assertion at a given sequence point, +// possibly <=0 (meaning not specified) and revision, possibly not known +// (RevisionNotKnown). +// Setting Pinned = true means pinning at the given sequence point (which must be +// set, i.e. > 0). Pinned sequence forming assertion will be updated to the +// latest revision at the specified sequence point. +type AtSequence struct { + Type *AssertionType + SequenceKey []string + Sequence int + Pinned bool + Revision int +} + +// Unique returns a unique string representing the sequence by its sequence key +// that can be used as a key in maps. +func (at *AtSequence) Unique() string { + return fmt.Sprintf("%s/%s", at.Type.Name, strings.Join(at.SequenceKey, "/")) +} + +func (at *AtSequence) String() string { + var pkStr string + if len(at.SequenceKey) != len(at.Type.PrimaryKey)-1 { + pkStr = "???" + } else { + n := 0 + // omit series if present in the primary key + if at.Type.PrimaryKey[0] == "series" { + n++ + } + pkStr = strings.Join(at.SequenceKey[n:], "/") + if at.Sequence > 0 { + sep := "/" + if at.Pinned { + sep = "=" + } + pkStr = fmt.Sprintf("%s%s%d", pkStr, sep, at.Sequence) + } + } + sk := fmt.Sprintf("%s %s", at.Type.Name, pkStr) + if at.Revision == RevisionNotKnown { + return sk + } + return fmt.Sprintf("%s at revision %d", sk, at.Revision) +} + +// Resolve resolves the sequence with known sequence number using the given find function. +func (at *AtSequence) Resolve(find func(assertType *AssertionType, headers map[string]string) (Assertion, error)) (Assertion, error) { + if at.Sequence <= 0 { + hdrs, err := HeadersFromSequenceKey(at.Type, at.SequenceKey) + if err != nil { + return nil, fmt.Errorf("%q assertion reference sequence key %v is invalid: %v", at.Type.Name, at.SequenceKey, err) + } + return nil, &NotFoundError{ + Type: at.Type, + Headers: hdrs, + } + } + pkey := append(at.SequenceKey, fmt.Sprintf("%d", at.Sequence)) + headers, err := HeadersFromPrimaryKey(at.Type, pkey) + if err != nil { + return nil, fmt.Errorf("%q assertion reference primary key has the wrong length (expected %v): %v", at.Type.Name, at.Type.PrimaryKey, pkey) + } + return find(at.Type, headers) +} + +// Assertion represents an assertion through its general elements. +type Assertion interface { + // Type returns the type of this assertion + Type() *AssertionType + // Format returns the format iteration of this assertion + Format() int + // SupportedFormat returns whether the assertion uses a supported + // format iteration. If false the assertion might have been only + // partially parsed. + SupportedFormat() bool + // Revision returns the revision of this assertion + Revision() int + // AuthorityID returns the authority responsible for this + // assertion + AuthorityID() string + + // Header retrieves the header with name + Header(name string) interface{} + + // Headers returns the complete headers + Headers() map[string]interface{} + + // HeaderString retrieves the string value of header with name or "" + HeaderString(name string) string + + // Body returns the body of this assertion + Body() []byte + + // Signature returns the signed content and its unprocessed signature + Signature() (content, signature []byte) + + // SignKeyID returns the key id for the key that signed this assertion. + SignKeyID() string + + // Prerequisites returns references to the prerequisite assertions for the validity of this one. + Prerequisites() []*Ref + + // Ref returns a reference representing this assertion. + Ref() *Ref + + // At returns an AtRevision referencing this assertion at its revision. + At() *AtRevision +} + +// SequenceMember is implemented by assertions of sequence forming types. +type SequenceMember interface { + Assertion + + // Sequence returns the sequence number of this assertion. + Sequence() int +} + +// customSigner represents an assertion with special arrangements for its signing key (e.g. self-signed), rather than the usual case where an assertion is signed by its authority. +type customSigner interface { + // signKey returns the public key material for the key that signed this assertion. See also SignKeyID. + signKey() PublicKey +} + +// MediaType is the media type for encoded assertions on the wire. +const MediaType = "application/x.ubuntu.assertion" + +// assertionBase is the concrete base to hold representation data for actual assertions. +type assertionBase struct { + headers map[string]interface{} + body []byte + // parsed format iteration + format int + // parsed revision + revision int + // preserved content + content []byte + // unprocessed signature + signature []byte +} + +// HeaderString retrieves the string value of header with name or "" +func (ab *assertionBase) HeaderString(name string) string { + s, _ := ab.headers[name].(string) + return s +} + +// Type returns the assertion type. +func (ab *assertionBase) Type() *AssertionType { + return Type(ab.HeaderString("type")) +} + +// Format returns the assertion format iteration. +func (ab *assertionBase) Format() int { + return ab.format +} + +// SupportedFormat returns whether the assertion uses a supported +// format iteration. If false the assertion might have been only +// partially parsed. +func (ab *assertionBase) SupportedFormat() bool { + return ab.format <= maxSupportedFormat[ab.HeaderString("type")] +} + +// Revision returns the assertion revision. +func (ab *assertionBase) Revision() int { + return ab.revision +} + +// AuthorityID returns the authority-id a.k.a the authority responsible for the assertion. +func (ab *assertionBase) AuthorityID() string { + return ab.HeaderString("authority-id") +} + +// Header returns the value of an header by name. +func (ab *assertionBase) Header(name string) interface{} { + v := ab.headers[name] + if v == nil { + return nil + } + return copyHeader(v) +} + +// Headers returns the complete headers. +func (ab *assertionBase) Headers() map[string]interface{} { + return copyHeaders(ab.headers) +} + +// Body returns the body of the assertion. +func (ab *assertionBase) Body() []byte { + return ab.body +} + +// Signature returns the signed content and its unprocessed signature. +func (ab *assertionBase) Signature() (content, signature []byte) { + return ab.content, ab.signature +} + +// SignKeyID returns the key id for the key that signed this assertion. +func (ab *assertionBase) SignKeyID() string { + return ab.HeaderString("sign-key-sha3-384") +} + +// Prerequisites returns references to the prerequisite assertions for the validity of this one. +func (ab *assertionBase) Prerequisites() []*Ref { + return nil +} + +// Ref returns a reference representing this assertion. +func (ab *assertionBase) Ref() *Ref { + assertType := ab.Type() + primKey := make([]string, len(assertType.PrimaryKey)) + for i, name := range assertType.PrimaryKey { + primKey[i] = ab.HeaderString(name) + } + return &Ref{ + Type: assertType, + PrimaryKey: primKey, + } +} + +// At returns an AtRevision referencing this assertion at its revision. +func (ab *assertionBase) At() *AtRevision { + return &AtRevision{Ref: *ab.Ref(), Revision: ab.Revision()} +} + +// expected interface is implemented +var _ Assertion = (*assertionBase)(nil) + +// Decode parses a serialized assertion. +// +// The expected serialisation format looks like: +// +// HEADER ("\n\n" BODY?)? "\n\n" SIGNATURE +// +// where: +// +// HEADER is a set of header entries separated by "\n" +// BODY can be arbitrary text, +// SIGNATURE is the signature +// +// Both BODY and HEADER must be UTF8. +// +// A header entry for a single line value (no '\n' in it) looks like: +// +// NAME ": " SIMPLEVALUE +// +// The format supports multiline text values (with '\n's in them) and +// lists or maps, possibly nested, with string scalars in them. +// +// For those a header entry looks like: +// +// NAME ":\n" MULTI(baseindent) +// +// where MULTI can be +// +// * (baseindent + 4)-space indented value (multiline text) +// +// * entries of a list each of the form: +// +// " "*baseindent " -" ( " " SIMPLEVALUE | "\n" MULTI ) +// +// * entries of map each of the form: +// +// " "*baseindent " " NAME ":" ( " " SIMPLEVALUE | "\n" MULTI ) +// +// baseindent starts at 0 and then grows with nesting matching the +// previous level introduction (e.g. the " "*baseindent " -" bit) +// length minus 1. +// +// In general the following headers are mandatory: +// +// type +// authority-id (except for on the wire/self-signed assertions like serial-request) +// +// Further for a given assertion type all the primary key headers +// must be non empty and must not contain '/'. +// +// The following headers expect string representing integer values and +// if omitted otherwise are assumed to be 0: +// +// revision (a positive int) +// body-length (expected to be equal to the length of BODY) +// format (a positive int for the format iteration of the type used) +// +// Times are expected to be in the RFC3339 format: "2006-01-02T15:04:05Z07:00". +func Decode(serializedAssertion []byte) (Assertion, error) { + // copy to get an independent backstorage that can't be mutated later + assertionSnapshot := make([]byte, len(serializedAssertion)) + copy(assertionSnapshot, serializedAssertion) + contentSignatureSplit := bytes.LastIndex(assertionSnapshot, nlnl) + if contentSignatureSplit == -1 { + return nil, fmt.Errorf("assertion content/signature separator not found") + } + content := assertionSnapshot[:contentSignatureSplit] + signature := assertionSnapshot[contentSignatureSplit+2:] + + headersBodySplit := bytes.Index(content, nlnl) + var body, head []byte + if headersBodySplit == -1 { + head = content + } else { + body = content[headersBodySplit+2:] + if len(body) == 0 { + body = nil + } + head = content[:headersBodySplit] + } + + headers, err := parseHeaders(head) + if err != nil { + return nil, fmt.Errorf("parsing assertion headers: %v", err) + } + + return assemble(headers, body, content, signature) +} + +// Maximum assertion component sizes. +const ( + MaxBodySize = 2 * 1024 * 1024 + MaxHeadersSize = 128 * 1024 + MaxSignatureSize = 128 * 1024 +) + +// Decoder parses a stream of assertions bundled by separating them with double newlines. +type Decoder struct { + rd io.Reader + initialBufSize int + b *bufio.Reader + err error + maxHeadersSize int + maxSigSize int + + defaultMaxBodySize int + typeMaxBodySize map[*AssertionType]int +} + +// initBuffer finishes a Decoder initialization by setting up the bufio.Reader, +// it returns the *Decoder for convenience of notation. +func (d *Decoder) initBuffer() *Decoder { + d.b = bufio.NewReaderSize(d.rd, d.initialBufSize) + return d +} + +const defaultDecoderBufSize = 4096 + +// NewDecoder returns a Decoder to parse the stream of assertions from the reader. +func NewDecoder(r io.Reader) *Decoder { + return (&Decoder{ + rd: r, + initialBufSize: defaultDecoderBufSize, + maxHeadersSize: MaxHeadersSize, + maxSigSize: MaxSignatureSize, + defaultMaxBodySize: MaxBodySize, + }).initBuffer() +} + +// NewDecoderWithTypeMaxBodySize returns a Decoder to parse the stream of assertions from the reader enforcing optional per type max body sizes or the default one as fallback. +func NewDecoderWithTypeMaxBodySize(r io.Reader, typeMaxBodySize map[*AssertionType]int) *Decoder { + return (&Decoder{ + rd: r, + initialBufSize: defaultDecoderBufSize, + maxHeadersSize: MaxHeadersSize, + maxSigSize: MaxSignatureSize, + defaultMaxBodySize: MaxBodySize, + typeMaxBodySize: typeMaxBodySize, + }).initBuffer() +} + +func (d *Decoder) peek(size int) ([]byte, error) { + buf, err := d.b.Peek(size) + if err == bufio.ErrBufferFull { + rebuf, reerr := d.b.Peek(d.b.Buffered()) + if reerr != nil { + panic(reerr) + } + mr := io.MultiReader(bytes.NewBuffer(rebuf), d.rd) + d.b = bufio.NewReaderSize(mr, (size/d.initialBufSize+1)*d.initialBufSize) + buf, err = d.b.Peek(size) + } + if err != nil && d.err == nil { + d.err = err + } + return buf, d.err +} + +// NB: readExact and readUntil use peek underneath and their returned +// buffers are valid only until the next reading call + +func (d *Decoder) readExact(size int) ([]byte, error) { + buf, err := d.peek(size) + d.b.Discard(len(buf)) + if len(buf) == size { + return buf, nil + } + if err == io.EOF { + return buf, io.ErrUnexpectedEOF + } + return buf, err +} + +func (d *Decoder) readUntil(delim []byte, maxSize int) ([]byte, error) { + last := 0 + size := d.initialBufSize + for { + buf, err := d.peek(size) + if i := bytes.Index(buf[last:], delim); i >= 0 { + d.b.Discard(last + i + len(delim)) + return buf[:last+i+len(delim)], nil + } + // report errors only once we have consumed what is buffered + if err != nil && len(buf) == d.b.Buffered() { + d.b.Discard(len(buf)) + return buf, err + } + last = size - len(delim) + 1 + size *= 2 + if size > maxSize { + return nil, fmt.Errorf("maximum size exceeded while looking for delimiter %q", delim) + } + } +} + +// Decode parses the next assertion from the stream. +// It returns the error io.EOF at the end of a well-formed stream. +func (d *Decoder) Decode() (Assertion, error) { + // read the headers and the nlnl separator after them + headAndSep, err := d.readUntil(nlnl, d.maxHeadersSize) + if err != nil { + if err == io.EOF { + if len(headAndSep) != 0 { + return nil, io.ErrUnexpectedEOF + } + return nil, io.EOF + } + return nil, fmt.Errorf("error reading assertion headers: %v", err) + } + + headLen := len(headAndSep) - len(nlnl) + headers, err := parseHeaders(headAndSep[:headLen]) + if err != nil { + return nil, fmt.Errorf("parsing assertion headers: %v", err) + } + + typeStr, _ := headers["type"].(string) + typ := Type(typeStr) + + length, err := checkIntWithDefault(headers, "body-length", 0) + if err != nil { + return nil, fmt.Errorf("assertion: %v", err) + } + if typMaxBodySize := d.typeMaxBodySize[typ]; typMaxBodySize != 0 && length > typMaxBodySize { + return nil, fmt.Errorf("assertion body length %d exceeds maximum body size %d for %q assertions", length, typMaxBodySize, typ.Name) + } else if length > d.defaultMaxBodySize { + return nil, fmt.Errorf("assertion body length %d exceeds maximum body size", length) + } + + // save the headers before we try to read more, and setup to capture + // the whole content in a buffer + contentBuf := bytes.NewBuffer(make([]byte, 0, len(headAndSep)+length)) + contentBuf.Write(headAndSep) + + if length > 0 { + // read the body if length != 0 + body, err := d.readExact(length) + if err != nil { + return nil, err + } + contentBuf.Write(body) + } + + // try to read the end of body a.k.a content/signature separator + endOfBody, err := d.readUntil(nlnl, d.maxSigSize) + if err != nil && err != io.EOF { + return nil, fmt.Errorf("error reading assertion trailer: %v", err) + } + + var sig []byte + if bytes.Equal(endOfBody, nlnl) { + // we got the nlnl content/signature separator, read the signature now and the assertion/assertion nlnl separation + sig, err = d.readUntil(nlnl, d.maxSigSize) + if err != nil && err != io.EOF { + return nil, fmt.Errorf("error reading assertion signature: %v", err) + } + } else { + // we got the signature directly which is a ok format only if body length == 0 + if length > 0 { + return nil, fmt.Errorf("missing content/signature separator") + } + sig = endOfBody + contentBuf.Truncate(headLen) + } + + // normalize sig ending newlines + if bytes.HasSuffix(sig, nlnl) { + sig = sig[:len(sig)-1] + } + + finalContent := contentBuf.Bytes() + var finalBody []byte + if length > 0 { + finalBody = finalContent[headLen+len(nlnl):] + } + + finalSig := make([]byte, len(sig)) + copy(finalSig, sig) + + return assemble(headers, finalBody, finalContent, finalSig) +} + +func checkIteration(headers map[string]interface{}, name string) (int, error) { + iternum, err := checkIntWithDefault(headers, name, 0) + if err != nil { + return -1, err + } + if iternum < 0 { + return -1, fmt.Errorf("%s should be positive: %v", name, iternum) + } + return iternum, nil +} + +func checkFormat(headers map[string]interface{}) (int, error) { + return checkIteration(headers, "format") +} + +func checkRevision(headers map[string]interface{}) (int, error) { + return checkIteration(headers, "revision") +} + +// Assemble assembles an assertion from its components. +func Assemble(headers map[string]interface{}, body, content, signature []byte) (Assertion, error) { + err := checkHeaders(headers) + if err != nil { + return nil, err + } + return assemble(headers, body, content, signature) +} + +func checkAuthority(_ *AssertionType, headers map[string]interface{}) error { + if _, err := checkNotEmptyString(headers, "authority-id"); err != nil { + return err + } + return nil +} + +func checkNoAuthority(assertType *AssertionType, headers map[string]interface{}) error { + if _, ok := headers["authority-id"]; ok { + return fmt.Errorf("%q assertion cannot have authority-id set", assertType.Name) + } + return nil +} + +func checkJSON(assertType *AssertionType, body []byte) (err error) { + defer func() { + if err != nil { + err = fmt.Errorf("assertion %s: %v", assertType.Name, err) + } + }() + + if body == nil { + return fmt.Errorf(`body must contain JSON`) + } + + var val interface{} + if err := json.Unmarshal(body, &val); err != nil { + return fmt.Errorf("invalid JSON in body: %v", err) + } + + formatted, err := json.MarshalIndent(val, "", " ") + if err != nil { + return fmt.Errorf("invalid JSON in body: %v", err) + } + + if !reflect.DeepEqual(body, formatted) { + return fmt.Errorf(`JSON in body must be indented with 2 spaces and sort object entries by key`) + } + + return nil +} + +// assemble is the internal variant of Assemble, assumes headers are already checked for supported types +func assemble(headers map[string]interface{}, body, content, signature []byte) (Assertion, error) { + length, err := checkIntWithDefault(headers, "body-length", 0) + if err != nil { + return nil, fmt.Errorf("assertion: %v", err) + } + if length != len(body) { + return nil, fmt.Errorf("assertion body length and declared body-length don't match: %v != %v", len(body), length) + } + + if !utf8.Valid(body) { + return nil, fmt.Errorf("assertion body is not utf8") + } + + if _, err := checkDigest(headers, "sign-key-sha3-384", crypto.SHA3_384); err != nil { + return nil, fmt.Errorf("assertion: %v", err) + } + + typ, err := checkNotEmptyString(headers, "type") + if err != nil { + return nil, fmt.Errorf("assertion: %v", err) + } + assertType := Type(typ) + if assertType == nil { + return nil, fmt.Errorf("unknown assertion type: %q", typ) + } + + if assertType.flags&jsonBody != 0 { + if err := checkJSON(assertType, body); err != nil { + return nil, err + } + } + + if assertType.flags&noAuthority == 0 { + if err := checkAuthority(assertType, headers); err != nil { + return nil, fmt.Errorf("assertion: %v", err) + } + } else { + if err := checkNoAuthority(assertType, headers); err != nil { + return nil, err + } + } + + formatnum, err := checkFormat(headers) + if err != nil { + return nil, fmt.Errorf("assertion: %v", err) + } + + for _, primKey := range assertType.PrimaryKey { + if _, ok := headers[primKey]; !ok { + if defl := assertType.OptionalPrimaryKeyDefaults[primKey]; defl != "" { + headers[primKey] = defl + } + } + if _, err := checkPrimaryKey(headers, primKey); err != nil { + return nil, fmt.Errorf("assertion %s: %v", assertType.Name, err) + } + } + + revision, err := checkRevision(headers) + if err != nil { + return nil, fmt.Errorf("assertion: %v", err) + } + + if len(signature) == 0 { + return nil, fmt.Errorf("empty assertion signature") + } + + assert, err := assertType.assembler(assertionBase{ + headers: headers, + body: body, + format: formatnum, + revision: revision, + content: content, + signature: signature, + }) + if err != nil { + return nil, fmt.Errorf("assertion %s: %v", assertType.Name, err) + } + return assert, nil +} + +func writeHeader(buf *bytes.Buffer, headers map[string]interface{}, name string) { + appendEntry(buf, fmt.Sprintf("%s:", name), headers[name], 0) +} + +func assembleAndSign(assertType *AssertionType, headers map[string]interface{}, body []byte, privKey PrivateKey) (Assertion, error) { + err := checkAssertType(assertType) + if err != nil { + return nil, err + } + + withAuthority := assertType.flags&noAuthority == 0 + withJSONBody := assertType.flags&jsonBody != 0 + + err = checkHeaders(headers) + if err != nil { + return nil, err + } + + // there's no hint at all that we will need non-textual bodies, + // make sure we actually enforce that + if !utf8.Valid(body) { + return nil, fmt.Errorf("assertion body is not utf8") + } + + if withJSONBody { + if err := checkJSON(assertType, body); err != nil { + return nil, err + } + } + + finalHeaders := copyHeaders(headers) + bodyLength := len(body) + finalBody := make([]byte, bodyLength) + copy(finalBody, body) + finalHeaders["type"] = assertType.Name + finalHeaders["body-length"] = strconv.Itoa(bodyLength) + finalHeaders["sign-key-sha3-384"] = privKey.PublicKey().ID() + + if withAuthority { + if err = checkAuthority(assertType, finalHeaders); err != nil { + return nil, err + } + } else { + if err := checkNoAuthority(assertType, finalHeaders); err != nil { + return nil, err + } + } + + formatnum, err := checkFormat(finalHeaders) + if err != nil { + return nil, err + } + + if formatnum > assertType.MaxSupportedFormat() { + return nil, fmt.Errorf("cannot sign %q assertion with format %d higher than max supported format %d", assertType.Name, formatnum, assertType.MaxSupportedFormat()) + } + + suggestedFormat, err := SuggestFormat(assertType, finalHeaders, finalBody) + if err != nil { + return nil, err + } + + if suggestedFormat > formatnum { + return nil, fmt.Errorf("cannot sign %q assertion with format set to %d lower than min format %d covering included features", assertType.Name, formatnum, suggestedFormat) + } + + revision, err := checkRevision(finalHeaders) + if err != nil { + return nil, err + } + + buf := bytes.NewBufferString("type: ") + buf.WriteString(assertType.Name) + + if formatnum > 0 { + writeHeader(buf, finalHeaders, "format") + } else { + delete(finalHeaders, "format") + } + + if withAuthority { + writeHeader(buf, finalHeaders, "authority-id") + } + + if revision > 0 { + writeHeader(buf, finalHeaders, "revision") + } else { + delete(finalHeaders, "revision") + } + written := map[string]bool{ + "type": true, + "format": true, + "authority-id": true, + "revision": true, + "body-length": true, + "sign-key-sha3-384": true, + } + for _, primKey := range assertType.PrimaryKey { + defl := assertType.OptionalPrimaryKeyDefaults[primKey] + _, ok := finalHeaders[primKey] + if !ok && defl != "" { + // optional but expected to be set in headers + // in the result assertion + finalHeaders[primKey] = defl + continue + } + value, err := checkPrimaryKey(finalHeaders, primKey) + if err != nil { + return nil, err + } + if value != defl { + writeHeader(buf, finalHeaders, primKey) + } + written[primKey] = true + } + + // emit other headers in lexicographic order + otherKeys := make([]string, 0, len(finalHeaders)) + for name := range finalHeaders { + if !written[name] { + otherKeys = append(otherKeys, name) + } + } + sort.Strings(otherKeys) + for _, k := range otherKeys { + writeHeader(buf, finalHeaders, k) + } + + // body-length and body + if bodyLength > 0 { + writeHeader(buf, finalHeaders, "body-length") + } else { + delete(finalHeaders, "body-length") + } + + // signing key reference + writeHeader(buf, finalHeaders, "sign-key-sha3-384") + + if bodyLength > 0 { + buf.Grow(bodyLength + 2) + buf.Write(nlnl) + buf.Write(finalBody) + } else { + finalBody = nil + } + content := buf.Bytes() + + signature, err := signContent(content, privKey) + if err != nil { + return nil, fmt.Errorf("cannot sign assertion: %v", err) + } + // be 'cat' friendly, add a ignored newline to the signature which is the last part of the encoded assertion + signature = append(signature, '\n') + + assert, err := assertType.assembler(assertionBase{ + headers: finalHeaders, + body: finalBody, + format: formatnum, + revision: revision, + content: content, + signature: signature, + }) + if err != nil { + return nil, fmt.Errorf("cannot assemble assertion %s: %v", assertType.Name, err) + } + return assert, nil +} + +// SignWithoutAuthority assembles an assertion without a set authority with the provided information and signs it with the given private key. +func SignWithoutAuthority(assertType *AssertionType, headers map[string]interface{}, body []byte, privKey PrivateKey) (Assertion, error) { + if assertType.flags&noAuthority == 0 { + return nil, fmt.Errorf("cannot sign assertions needing a definite authority with SignWithoutAuthority") + } + return assembleAndSign(assertType, headers, body, privKey) +} + +// Encode serializes an assertion. +func Encode(assert Assertion) []byte { + content, signature := assert.Signature() + needed := len(content) + 2 + len(signature) + buf := bytes.NewBuffer(make([]byte, 0, needed)) + buf.Write(content) + buf.Write(nlnl) + buf.Write(signature) + return buf.Bytes() +} + +// Encoder emits a stream of assertions bundled by separating them with double newlines. +type Encoder struct { + wr io.Writer + nextSep []byte +} + +// NewEncoder returns a Encoder to emit a stream of assertions to a writer. +func NewEncoder(w io.Writer) *Encoder { + return &Encoder{wr: w} +} + +func (enc *Encoder) writeSep(last byte) error { + if last != '\n' { + _, err := enc.wr.Write(nl) + if err != nil { + return err + } + } + enc.nextSep = nl + return nil +} + +// WriteEncoded writes the encoded assertion into the stream with the required separator. +func (enc *Encoder) WriteEncoded(encoded []byte) error { + sz := len(encoded) + if sz == 0 { + return fmt.Errorf("internal error: encoded assertion cannot be empty") + } + + _, err := enc.wr.Write(enc.nextSep) + if err != nil { + return err + } + + _, err = enc.wr.Write(encoded) + if err != nil { + return err + } + + return enc.writeSep(encoded[sz-1]) +} + +// WriteContentSignature writes the content and signature of an assertion into the stream with all the required separators. +func (enc *Encoder) WriteContentSignature(content, signature []byte) error { + if len(content) == 0 { + return fmt.Errorf("internal error: content cannot be empty") + } + + sz := len(signature) + if sz == 0 { + return fmt.Errorf("internal error: signature cannot be empty") + } + + _, err := enc.wr.Write(enc.nextSep) + if err != nil { + return err + } + + _, err = enc.wr.Write(content) + if err != nil { + return err + } + _, err = enc.wr.Write(nlnl) + if err != nil { + return err + } + _, err = enc.wr.Write(signature) + if err != nil { + return err + } + + return enc.writeSep(signature[sz-1]) +} + +// Encode emits the assertion into the stream with the required separator. +// Errors here are always about writing given that Encode() itself cannot error. +func (enc *Encoder) Encode(assert Assertion) error { + return enc.WriteContentSignature(assert.Signature()) +} + +// SignatureCheck checks the signature of the assertion against the given public key. Useful for assertions with no authority. +func SignatureCheck(assert Assertion, pubKey PublicKey) error { + content, encodedSig := assert.Signature() + sig, err := decodeSignature(encodedSig) + if err != nil { + return err + } + err = pubKey.verify(content, sig) + if err != nil { + return fmt.Errorf("failed signature verification: %v", err) + } + return nil +} diff --git a/asserts/asserts_test.go b/asserts/asserts_test.go new file mode 100644 index 00000000..0a7fb15e --- /dev/null +++ b/asserts/asserts_test.go @@ -0,0 +1,1348 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2015-2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts_test + +import ( + "bytes" + "io" + "strings" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/asserts" +) + +type assertsSuite struct{} + +var _ = Suite(&assertsSuite{}) + +func (as *assertsSuite) TestType(c *C) { + c.Check(asserts.Type("test-only"), Equals, asserts.TestOnlyType) +} + +func (as *assertsSuite) TestUnknown(c *C) { + c.Check(asserts.Type(""), IsNil) + c.Check(asserts.Type("unknown"), IsNil) +} + +func (as *assertsSuite) TestTypeMaxSupportedFormat(c *C) { + c.Check(asserts.Type("test-only").MaxSupportedFormat(), Equals, 1) +} + +func (as *assertsSuite) TestTypeNames(c *C) { + c.Check(asserts.TypeNames(), DeepEquals, []string{ + "account", + "account-key", + "account-key-request", + "aspect-bundle", + "base-declaration", + "device-session-request", + "model", + "preseed", + "repair", + "serial", + "serial-request", + "snap-build", + "snap-declaration", + "snap-developer", + "snap-resource-pair", + "snap-resource-revision", + "snap-revision", + "store", + "system-user", + "test-only", + "test-only-2", + "test-only-decl", + "test-only-no-authority", + "test-only-no-authority-pk", + "test-only-rev", + "test-only-seq", + "validation", + "validation-set", + }) +} + +func (as *assertsSuite) TestMaxSupportedFormats(c *C) { + accountKeyMaxFormat := asserts.AccountKeyType.MaxSupportedFormat() + snapDeclMaxFormat := asserts.SnapDeclarationType.MaxSupportedFormat() + systemUserMaxFormat := asserts.SystemUserType.MaxSupportedFormat() + // validity + c.Check(accountKeyMaxFormat >= 1, Equals, true) + c.Check(snapDeclMaxFormat >= 4, Equals, true) + c.Check(systemUserMaxFormat >= 2, Equals, true) + c.Check(asserts.MaxSupportedFormats(1), DeepEquals, map[string]int{ + "account-key": accountKeyMaxFormat, + "snap-declaration": snapDeclMaxFormat, + "system-user": systemUserMaxFormat, + "test-only": 1, + "test-only-seq": 2, + }) + + // all + maxFormats := asserts.MaxSupportedFormats(0) + c.Assert(maxFormats, HasLen, len(asserts.TypeNames())) + c.Check(maxFormats["test-only"], Equals, 1) + c.Check(maxFormats["test-only-2"], Equals, 0) + c.Check(maxFormats["snap-declaration"], Equals, snapDeclMaxFormat) +} + +func (as *assertsSuite) TestSuggestFormat(c *C) { + fmtnum, err := asserts.SuggestFormat(asserts.Type("test-only-2"), nil, nil) + c.Assert(err, IsNil) + c.Check(fmtnum, Equals, 0) +} + +func (as *assertsSuite) TestPrimaryKeyHelpers(c *C) { + headers, err := asserts.HeadersFromPrimaryKey(asserts.TestOnlyType, []string{"one"}) + c.Assert(err, IsNil) + c.Check(headers, DeepEquals, map[string]string{ + "primary-key": "one", + }) + + headers, err = asserts.HeadersFromPrimaryKey(asserts.TestOnly2Type, []string{"bar", "baz"}) + c.Assert(err, IsNil) + c.Check(headers, DeepEquals, map[string]string{ + "pk1": "bar", + "pk2": "baz", + }) + + _, err = asserts.HeadersFromPrimaryKey(asserts.TestOnly2Type, []string{"bar"}) + c.Check(err, ErrorMatches, `primary key has wrong length for "test-only-2" assertion`) + + _, err = asserts.HeadersFromPrimaryKey(asserts.TestOnly2Type, []string{"", "baz"}) + c.Check(err, ErrorMatches, `primary key "pk1" header cannot be empty`) + + pk, err := asserts.PrimaryKeyFromHeaders(asserts.TestOnly2Type, headers) + c.Assert(err, IsNil) + c.Check(pk, DeepEquals, []string{"bar", "baz"}) + + headers["other"] = "foo" + pk1, err := asserts.PrimaryKeyFromHeaders(asserts.TestOnly2Type, headers) + c.Assert(err, IsNil) + c.Check(pk1, DeepEquals, pk) + + delete(headers, "pk2") + _, err = asserts.PrimaryKeyFromHeaders(asserts.TestOnly2Type, headers) + c.Check(err, ErrorMatches, `must provide primary key: pk2`) +} + +func (as *assertsSuite) TestPrimaryKeyHelpersOptionalPrimaryKeys(c *C) { + // optional primary key headers + r := asserts.MockOptionalPrimaryKey(asserts.TestOnlyType, "opt1", "o1-defl") + defer r() + + pk, err := asserts.PrimaryKeyFromHeaders(asserts.TestOnlyType, map[string]string{"primary-key": "k1"}) + c.Assert(err, IsNil) + c.Check(pk, DeepEquals, []string{"k1", "o1-defl"}) + + pk, err = asserts.PrimaryKeyFromHeaders(asserts.TestOnlyType, map[string]string{"primary-key": "k1", "opt1": "B"}) + c.Assert(err, IsNil) + c.Check(pk, DeepEquals, []string{"k1", "B"}) + + hdrs, err := asserts.HeadersFromPrimaryKey(asserts.TestOnlyType, []string{"k1", "B"}) + c.Assert(err, IsNil) + c.Check(hdrs, DeepEquals, map[string]string{ + "primary-key": "k1", + "opt1": "B", + }) + + hdrs, err = asserts.HeadersFromPrimaryKey(asserts.TestOnlyType, []string{"k1"}) + c.Assert(err, IsNil) + c.Check(hdrs, DeepEquals, map[string]string{ + "primary-key": "k1", + "opt1": "o1-defl", + }) + + _, err = asserts.HeadersFromPrimaryKey(asserts.TestOnlyType, nil) + c.Check(err, ErrorMatches, `primary key has wrong length for "test-only" assertion`) + + _, err = asserts.HeadersFromPrimaryKey(asserts.TestOnlyType, []string{"pk", "opt1", "what"}) + c.Check(err, ErrorMatches, `primary key has wrong length for "test-only" assertion`) +} + +func (as *assertsSuite) TestRef(c *C) { + ref := &asserts.Ref{ + Type: asserts.TestOnly2Type, + PrimaryKey: []string{"abc", "xyz"}, + } + c.Check(ref.Unique(), Equals, "test-only-2/abc/xyz") +} + +func (as *assertsSuite) TestRefString(c *C) { + ref := &asserts.Ref{ + Type: asserts.AccountType, + PrimaryKey: []string{"canonical"}, + } + + c.Check(ref.String(), Equals, "account (canonical)") + + ref = &asserts.Ref{ + Type: asserts.SnapDeclarationType, + PrimaryKey: []string{"18", "SNAPID"}, + } + + c.Check(ref.String(), Equals, "snap-declaration (SNAPID; series:18)") + + ref = &asserts.Ref{ + Type: asserts.ModelType, + PrimaryKey: []string{"18", "BRAND", "baz-3000"}, + } + + c.Check(ref.String(), Equals, "model (baz-3000; series:18 brand-id:BRAND)") + + // broken primary key + ref = &asserts.Ref{ + Type: asserts.ModelType, + PrimaryKey: []string{"18"}, + } + c.Check(ref.String(), Equals, "model (???)") + + ref = &asserts.Ref{ + Type: asserts.TestOnlyNoAuthorityType, + } + c.Check(ref.String(), Equals, "test-only-no-authority (-)") +} + +func (as *assertsSuite) TestRefResolveError(c *C) { + ref := &asserts.Ref{ + Type: asserts.TestOnly2Type, + PrimaryKey: []string{"abc"}, + } + _, err := ref.Resolve(nil) + c.Check(err, ErrorMatches, `"test-only-2" assertion reference primary key has the wrong length \(expected \[pk1 pk2\]\): \[abc\]`) +} + +func (as *assertsSuite) TestReducePrimaryKey(c *C) { + // optional primary key headers + defer asserts.MockOptionalPrimaryKey(asserts.TestOnly2Type, "opt1", "o1-defl")() + defer asserts.MockOptionalPrimaryKey(asserts.TestOnly2Type, "opt2", "o2-defl")() + + tests := []struct { + pk []string + reduced []string + }{ + {nil, nil}, + {[]string{"k1"}, []string{"k1"}}, + {[]string{"k1", "k2"}, []string{"k1", "k2"}}, + {[]string{"k1", "k2", "A"}, []string{"k1", "k2", "A"}}, + {[]string{"k1", "k2", "o1-defl"}, []string{"k1", "k2"}}, + {[]string{"k1", "k2", "A", "o2-defl"}, []string{"k1", "k2", "A"}}, + {[]string{"k1", "k2", "A", "B"}, []string{"k1", "k2", "A", "B"}}, + {[]string{"k1", "k2", "o1-defl", "B"}, []string{"k1", "k2", "o1-defl", "B"}}, + {[]string{"k1", "k2", "o1-defl", "o2-defl"}, []string{"k1", "k2"}}, + {[]string{"k1", "k2", "o1-defl", "o2-defl", "what"}, []string{"k1", "k2", "o1-defl", "o2-defl", "what"}}, + } + + for _, t := range tests { + c.Check(asserts.ReducePrimaryKey(asserts.TestOnly2Type, t.pk), DeepEquals, t.reduced) + } +} + +func (as *assertsSuite) TestRefOptionalPrimaryKeys(c *C) { + // optional primary key headers + defer asserts.MockOptionalPrimaryKey(asserts.TestOnly2Type, "opt1", "o1-defl")() + defer asserts.MockOptionalPrimaryKey(asserts.TestOnly2Type, "opt2", "o2-defl")() + + ref := &asserts.Ref{ + Type: asserts.TestOnly2Type, + PrimaryKey: []string{"abc", "xyz"}, + } + c.Check(ref.Unique(), Equals, "test-only-2/abc/xyz") + c.Check(ref.String(), Equals, `test-only-2 (xyz; pk1:abc)`) + + ref = &asserts.Ref{ + Type: asserts.TestOnly2Type, + PrimaryKey: []string{"abc", "xyz", "o1-defl"}, + } + c.Check(ref.Unique(), Equals, "test-only-2/abc/xyz") + c.Check(ref.String(), Equals, `test-only-2 (xyz; pk1:abc)`) + + ref = &asserts.Ref{ + Type: asserts.TestOnly2Type, + PrimaryKey: []string{"abc", "xyz", "o1-defl", "o2-defl"}, + } + c.Check(ref.Unique(), Equals, "test-only-2/abc/xyz") + c.Check(ref.String(), Equals, `test-only-2 (xyz; pk1:abc)`) + + ref = &asserts.Ref{ + Type: asserts.TestOnly2Type, + PrimaryKey: []string{"abc", "xyz", "A"}, + } + c.Check(ref.Unique(), Equals, "test-only-2/abc/xyz/A") + c.Check(ref.String(), Equals, `test-only-2 (xyz; pk1:abc opt1:A)`) + + ref = &asserts.Ref{ + Type: asserts.TestOnly2Type, + PrimaryKey: []string{"abc", "xyz", "A", "o2-defl"}, + } + c.Check(ref.Unique(), Equals, "test-only-2/abc/xyz/A") + c.Check(ref.String(), Equals, `test-only-2 (xyz; pk1:abc opt1:A)`) + + ref = &asserts.Ref{ + Type: asserts.TestOnly2Type, + PrimaryKey: []string{"abc", "xyz", "o1-defl", "B"}, + } + c.Check(ref.Unique(), Equals, "test-only-2/abc/xyz/o1-defl/B") + c.Check(ref.String(), Equals, `test-only-2 (xyz; pk1:abc opt2:B)`) + + ref = &asserts.Ref{ + Type: asserts.TestOnly2Type, + PrimaryKey: []string{"abc", "xyz", "A", "B"}, + } + c.Check(ref.Unique(), Equals, "test-only-2/abc/xyz/A/B") + c.Check(ref.String(), Equals, `test-only-2 (xyz; pk1:abc opt1:A opt2:B)`) +} + +func (as *assertsSuite) TestAcceptablePrimaryKey(c *C) { + // optional primary key headers + defer asserts.MockOptionalPrimaryKey(asserts.TestOnly2Type, "opt1", "o1-defl")() + defer asserts.MockOptionalPrimaryKey(asserts.TestOnly2Type, "opt2", "o2-defl")() + + tests := []struct { + pk []string + ok bool + }{ + {nil, false}, + {[]string{"k1"}, false}, + {[]string{"k1", "k2"}, true}, + {[]string{"k1", "k2", "A"}, true}, + {[]string{"k1", "k2", "o1-defl"}, true}, + {[]string{"k1", "k2", "A", "B"}, true}, + {[]string{"k1", "k2", "o1-defl", "o2-defl", "what"}, false}, + } + + for _, t := range tests { + c.Check(asserts.TestOnly2Type.AcceptablePrimaryKey(t.pk), Equals, t.ok) + } +} + +func (as *assertsSuite) TestAtRevisionString(c *C) { + ref := asserts.Ref{ + Type: asserts.AccountType, + PrimaryKey: []string{"canonical"}, + } + + at := &asserts.AtRevision{ + Ref: ref, + } + c.Check(at.String(), Equals, "account (canonical) at revision 0") + + at = &asserts.AtRevision{ + Ref: ref, + Revision: asserts.RevisionNotKnown, + } + c.Check(at.String(), Equals, "account (canonical)") +} + +const exKeyID = "Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + +const exampleEmptyBodyAllDefaults = "type: test-only\n" + + "authority-id: auth-id1\n" + + "primary-key: abc\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==" + +func (as *assertsSuite) TestDecodeEmptyBodyAllDefaults(c *C) { + a, err := asserts.Decode([]byte(exampleEmptyBodyAllDefaults)) + c.Assert(err, IsNil) + c.Check(a.Type(), Equals, asserts.TestOnlyType) + _, ok := a.(*asserts.TestOnly) + c.Check(ok, Equals, true) + c.Check(a.Revision(), Equals, 0) + c.Check(a.Format(), Equals, 0) + c.Check(a.Body(), IsNil) + c.Check(a.Header("header1"), IsNil) + c.Check(a.HeaderString("header1"), Equals, "") + c.Check(a.AuthorityID(), Equals, "auth-id1") + c.Check(a.SignKeyID(), Equals, exKeyID) +} + +const exampleEmptyBodyOptionalPrimaryKeySet = "type: test-only\n" + + "authority-id: auth-id1\n" + + "primary-key: abc\n" + + "opt1: A\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==" + +func (as *assertsSuite) TestDecodeOptionalPrimaryKeys(c *C) { + r := asserts.MockOptionalPrimaryKey(asserts.TestOnlyType, "opt1", "o1-defl") + defer r() + + a, err := asserts.Decode([]byte(exampleEmptyBodyAllDefaults)) + c.Assert(err, IsNil) + c.Check(a.Type(), Equals, asserts.TestOnlyType) + _, ok := a.(*asserts.TestOnly) + c.Check(ok, Equals, true) + c.Check(a.Revision(), Equals, 0) + c.Check(a.Format(), Equals, 0) + c.Check(a.Body(), IsNil) + c.Check(a.HeaderString("opt1"), Equals, "o1-defl") + c.Check(a.Header("header1"), IsNil) + c.Check(a.HeaderString("header1"), Equals, "") + c.Check(a.AuthorityID(), Equals, "auth-id1") + c.Check(a.SignKeyID(), Equals, exKeyID) + + a, err = asserts.Decode([]byte(exampleEmptyBodyOptionalPrimaryKeySet)) + c.Assert(err, IsNil) + c.Check(a.Type(), Equals, asserts.TestOnlyType) + _, ok = a.(*asserts.TestOnly) + c.Check(ok, Equals, true) + c.Check(a.Revision(), Equals, 0) + c.Check(a.Format(), Equals, 0) + c.Check(a.Body(), IsNil) + c.Check(a.HeaderString("opt1"), Equals, "A") + c.Check(a.Header("header1"), IsNil) + c.Check(a.HeaderString("header1"), Equals, "") + c.Check(a.AuthorityID(), Equals, "auth-id1") + c.Check(a.SignKeyID(), Equals, exKeyID) +} + +const exampleEmptyBody2NlNl = "type: test-only\n" + + "authority-id: auth-id1\n" + + "primary-key: xyz\n" + + "revision: 0\n" + + "body-length: 0\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "\n\n" + + "AXNpZw==\n" + +func (as *assertsSuite) TestDecodeEmptyBodyNormalize2NlNl(c *C) { + a, err := asserts.Decode([]byte(exampleEmptyBody2NlNl)) + c.Assert(err, IsNil) + c.Check(a.Type(), Equals, asserts.TestOnlyType) + c.Check(a.Revision(), Equals, 0) + c.Check(a.Format(), Equals, 0) + c.Check(a.Body(), IsNil) +} + +const exampleBodyAndExtraHeaders = "type: test-only\n" + + "format: 1\n" + + "authority-id: auth-id2\n" + + "primary-key: abc\n" + + "revision: 5\n" + + "header1: value1\n" + + "header2: value2\n" + + "body-length: 8\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij\n\n" + + "THE-BODY" + + "\n\n" + + "AXNpZw==\n" + +func (as *assertsSuite) TestDecodeWithABodyAndExtraHeaders(c *C) { + a, err := asserts.Decode([]byte(exampleBodyAndExtraHeaders)) + c.Assert(err, IsNil) + c.Check(a.Type(), Equals, asserts.TestOnlyType) + c.Check(a.AuthorityID(), Equals, "auth-id2") + c.Check(a.SignKeyID(), Equals, exKeyID) + c.Check(a.Header("primary-key"), Equals, "abc") + c.Check(a.Revision(), Equals, 5) + c.Check(a.Format(), Equals, 1) + c.Check(a.SupportedFormat(), Equals, true) + c.Check(a.Header("header1"), Equals, "value1") + c.Check(a.Header("header2"), Equals, "value2") + c.Check(a.Body(), DeepEquals, []byte("THE-BODY")) + +} + +const exampleUnsupportedFormat = "type: test-only\n" + + "format: 77\n" + + "authority-id: auth-id2\n" + + "primary-key: abc\n" + + "revision: 5\n" + + "header1: value1\n" + + "header2: value2\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij\n\n" + + "AXNpZw==\n" + +func (as *assertsSuite) TestDecodeUnsupportedFormat(c *C) { + a, err := asserts.Decode([]byte(exampleUnsupportedFormat)) + c.Assert(err, IsNil) + c.Check(a.Type(), Equals, asserts.TestOnlyType) + c.Check(a.AuthorityID(), Equals, "auth-id2") + c.Check(a.SignKeyID(), Equals, exKeyID) + c.Check(a.Header("primary-key"), Equals, "abc") + c.Check(a.Revision(), Equals, 5) + c.Check(a.Format(), Equals, 77) + c.Check(a.SupportedFormat(), Equals, false) +} + +func (as *assertsSuite) TestDecodeGetSignatureBits(c *C) { + content := "type: test-only\n" + + "authority-id: auth-id1\n" + + "primary-key: xyz\n" + + "revision: 5\n" + + "header1: value1\n" + + "body-length: 8\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij\n\n" + + "THE-BODY" + encoded := content + + "\n\n" + + "AXNpZw==" + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + c.Check(a.Type(), Equals, asserts.TestOnlyType) + c.Check(a.AuthorityID(), Equals, "auth-id1") + c.Check(a.SignKeyID(), Equals, exKeyID) + cont, signature := a.Signature() + c.Check(signature, DeepEquals, []byte("AXNpZw==")) + c.Check(cont, DeepEquals, []byte(content)) +} + +func (as *assertsSuite) TestDecodeNoSignatureSplit(c *C) { + for _, encoded := range []string{"", "foo"} { + _, err := asserts.Decode([]byte(encoded)) + c.Check(err, ErrorMatches, "assertion content/signature separator not found") + } +} + +func (as *assertsSuite) TestDecodeHeaderParsingErrors(c *C) { + headerParsingErrorsTests := []struct{ encoded, expectedErr string }{ + {string([]byte{255, '\n', '\n'}), "header is not utf8"}, + {"foo: a\nbar\n\n", `header entry missing ':' separator: "bar"`}, + {"TYPE: foo\n\n", `invalid header name: "TYPE"`}, + {"foo: a\nbar:>\n\n", `header entry should have a space or newline \(for multiline\) before value: "bar:>"`}, + {"foo: a\nbar:\n\n", `expected 4 chars nesting prefix after multiline introduction "bar:": EOF`}, + {"foo: a\nbar:\nbaz: x\n\n", `expected 4 chars nesting prefix after multiline introduction "bar:": "baz: x"`}, + {"foo: a:\nbar: b\nfoo: x\n\n", `repeated header: "foo"`}, + } + + for _, test := range headerParsingErrorsTests { + _, err := asserts.Decode([]byte(test.encoded)) + c.Check(err, ErrorMatches, "parsing assertion headers: "+test.expectedErr) + } +} + +func (as *assertsSuite) TestDecodeInvalid(c *C) { + keyIDHdr := "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij\n" + encoded := "type: test-only\n" + + "format: 0\n" + + "authority-id: auth-id\n" + + "primary-key: abc\n" + + "revision: 0\n" + + "body-length: 5\n" + + keyIDHdr + + "\n" + + "abcde" + + "\n\n" + + "AXNpZw==" + + invalidAssertTests := []struct{ original, invalid, expectedErr string }{ + {"body-length: 5", "body-length: z", `assertion: "body-length" header is not an integer: z`}, + {"body-length: 5", "body-length: 3", "assertion body length and declared body-length don't match: 5 != 3"}, + {"authority-id: auth-id\n", "", `assertion: "authority-id" header is mandatory`}, + {"authority-id: auth-id\n", "authority-id: \n", `assertion: "authority-id" header should not be empty`}, + {keyIDHdr, "", `assertion: "sign-key-sha3-384" header is mandatory`}, + {keyIDHdr, "sign-key-sha3-384: \n", `assertion: "sign-key-sha3-384" header should not be empty`}, + {keyIDHdr, "sign-key-sha3-384: $\n", `assertion: "sign-key-sha3-384" header cannot be decoded: .*`}, + {keyIDHdr, "sign-key-sha3-384: eHl6\n", `assertion: "sign-key-sha3-384" header does not have the expected bit length: 24`}, + {"AXNpZw==", "", "empty assertion signature"}, + {"type: test-only\n", "", `assertion: "type" header is mandatory`}, + {"type: test-only\n", "type: unknown\n", `unknown assertion type: "unknown"`}, + {"revision: 0\n", "revision: Z\n", `assertion: "revision" header is not an integer: Z`}, + {"revision: 0\n", "revision:\n - 1\n", `assertion: "revision" header is not an integer: \[1\]`}, + {"revision: 0\n", "revision: 00\n", `assertion: "revision" header has invalid prefix zeros: 00`}, + {"revision: 0\n", "revision: -10\n", "assertion: revision should be positive: -10"}, + {"revision: 0\n", "revision: 99999999999999999999\n", `assertion: "revision" header is out of range: 99999999999999999999`}, + {"format: 0\n", "format: Z\n", `assertion: "format" header is not an integer: Z`}, + {"format: 0\n", "format: -10\n", "assertion: format should be positive: -10"}, + {"primary-key: abc\n", "", `assertion test-only: "primary-key" header is mandatory`}, + {"primary-key: abc\n", "primary-key:\n - abc\n", `assertion test-only: "primary-key" header must be a string`}, + {"primary-key: abc\n", "primary-key: a/c\n", `assertion test-only: "primary-key" primary key header cannot contain '/'`}, + {"abcde", "ab\xffde", "assertion body is not utf8"}, + } + + for _, test := range invalidAssertTests { + invalid := strings.Replace(encoded, test.original, test.invalid, 1) + _, err := asserts.Decode([]byte(invalid)) + c.Check(err, ErrorMatches, test.expectedErr) + } +} + +func (as *assertsSuite) TestDecodeNoAuthorityInvalid(c *C) { + invalid := "type: test-only-no-authority\n" + + "authority-id: auth-id1\n" + + "hdr: FOO\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "openpgp c2ln" + + _, err := asserts.Decode([]byte(invalid)) + c.Check(err, ErrorMatches, `"test-only-no-authority" assertion cannot have authority-id set`) +} + +func checkContent(c *C, a asserts.Assertion, encoded string) { + expected, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + expectedCont, _ := expected.Signature() + + cont, _ := a.Signature() + c.Check(cont, DeepEquals, expectedCont) +} + +func (as *assertsSuite) TestEncoderDecoderHappy(c *C) { + stream := new(bytes.Buffer) + enc := asserts.NewEncoder(stream) + enc.WriteEncoded([]byte(exampleEmptyBody2NlNl)) + enc.WriteEncoded([]byte(exampleBodyAndExtraHeaders)) + enc.WriteEncoded([]byte(exampleEmptyBodyAllDefaults)) + + decoder := asserts.NewDecoder(stream) + a, err := decoder.Decode() + c.Assert(err, IsNil) + c.Check(a.Type(), Equals, asserts.TestOnlyType) + _, ok := a.(*asserts.TestOnly) + c.Check(ok, Equals, true) + checkContent(c, a, exampleEmptyBody2NlNl) + + a, err = decoder.Decode() + c.Assert(err, IsNil) + checkContent(c, a, exampleBodyAndExtraHeaders) + + a, err = decoder.Decode() + c.Assert(err, IsNil) + checkContent(c, a, exampleEmptyBodyAllDefaults) + + a, err = decoder.Decode() + c.Assert(err, Equals, io.EOF) + c.Check(a, IsNil) +} + +func (as *assertsSuite) TestDecodeEmptyStream(c *C) { + stream := new(bytes.Buffer) + decoder := asserts.NewDecoder(stream) + _, err := decoder.Decode() + c.Check(err, Equals, io.EOF) +} + +func (as *assertsSuite) TestDecoderHappyWithSeparatorsVariations(c *C) { + streams := []string{ + exampleBodyAndExtraHeaders, + exampleEmptyBody2NlNl, + exampleEmptyBodyAllDefaults, + } + + for _, streamData := range streams { + stream := bytes.NewBufferString(streamData) + decoder := asserts.NewDecoderStressed(stream, 16, 1024, 1024, 1024) + a, err := decoder.Decode() + c.Assert(err, IsNil, Commentf("stream: %q", streamData)) + + checkContent(c, a, streamData) + + a, err = decoder.Decode() + c.Check(a, IsNil) + c.Check(err, Equals, io.EOF, Commentf("stream: %q", streamData)) + } +} + +func (as *assertsSuite) TestDecoderHappyWithTrailerDoubleNewlines(c *C) { + streams := []string{ + exampleBodyAndExtraHeaders, + exampleEmptyBody2NlNl, + exampleEmptyBodyAllDefaults, + } + + for _, streamData := range streams { + stream := bytes.NewBufferString(streamData) + if strings.HasSuffix(streamData, "\n") { + stream.WriteString("\n") + } else { + stream.WriteString("\n\n") + } + + decoder := asserts.NewDecoderStressed(stream, 16, 1024, 1024, 1024) + a, err := decoder.Decode() + c.Assert(err, IsNil, Commentf("stream: %q", streamData)) + + checkContent(c, a, streamData) + + a, err = decoder.Decode() + c.Check(a, IsNil) + c.Check(err, Equals, io.EOF, Commentf("stream: %q", streamData)) + } +} + +func (as *assertsSuite) TestDecoderUnexpectedEOF(c *C) { + streamData := exampleBodyAndExtraHeaders + "\n" + exampleEmptyBodyAllDefaults + fstHeadEnd := strings.Index(exampleBodyAndExtraHeaders, "\n\n") + sndHeadEnd := len(exampleBodyAndExtraHeaders) + 1 + strings.Index(exampleEmptyBodyAllDefaults, "\n\n") + + for _, brk := range []int{1, fstHeadEnd / 2, fstHeadEnd, fstHeadEnd + 1, fstHeadEnd + 2, fstHeadEnd + 6} { + stream := bytes.NewBufferString(streamData[:brk]) + decoder := asserts.NewDecoderStressed(stream, 16, 1024, 1024, 1024) + _, err := decoder.Decode() + c.Check(err, Equals, io.ErrUnexpectedEOF, Commentf("brk: %d", brk)) + } + + for _, brk := range []int{sndHeadEnd, sndHeadEnd + 1} { + stream := bytes.NewBufferString(streamData[:brk]) + decoder := asserts.NewDecoder(stream) + _, err := decoder.Decode() + c.Assert(err, IsNil) + + _, err = decoder.Decode() + c.Check(err, Equals, io.ErrUnexpectedEOF, Commentf("brk: %d", brk)) + } +} + +func (as *assertsSuite) TestDecoderBrokenBodySeparation(c *C) { + streamData := strings.Replace(exampleBodyAndExtraHeaders, "THE-BODY\n\n", "THE-BODY", 1) + decoder := asserts.NewDecoder(bytes.NewBufferString(streamData)) + _, err := decoder.Decode() + c.Assert(err, ErrorMatches, "missing content/signature separator") + + streamData = strings.Replace(exampleBodyAndExtraHeaders, "THE-BODY\n\n", "THE-BODY\n", 1) + decoder = asserts.NewDecoder(bytes.NewBufferString(streamData)) + _, err = decoder.Decode() + c.Assert(err, ErrorMatches, "missing content/signature separator") +} + +func (as *assertsSuite) TestDecoderHeadTooBig(c *C) { + decoder := asserts.NewDecoderStressed(bytes.NewBufferString(exampleBodyAndExtraHeaders), 4, 4, 1024, 1024) + _, err := decoder.Decode() + c.Assert(err, ErrorMatches, `error reading assertion headers: maximum size exceeded while looking for delimiter "\\n\\n"`) +} + +func (as *assertsSuite) TestDecoderBodyTooBig(c *C) { + decoder := asserts.NewDecoderStressed(bytes.NewBufferString(exampleBodyAndExtraHeaders), 1024, 1024, 5, 1024) + _, err := decoder.Decode() + c.Assert(err, ErrorMatches, "assertion body length 8 exceeds maximum body size") +} + +func (as *assertsSuite) TestDecoderSignatureTooBig(c *C) { + decoder := asserts.NewDecoderStressed(bytes.NewBufferString(exampleBodyAndExtraHeaders), 4, 1024, 1024, 7) + _, err := decoder.Decode() + c.Assert(err, ErrorMatches, `error reading assertion signature: maximum size exceeded while looking for delimiter "\\n\\n"`) +} + +func (as *assertsSuite) TestDecoderDefaultMaxBodySize(c *C) { + enc := strings.Replace(exampleBodyAndExtraHeaders, "body-length: 8", "body-length: 2097153", 1) + decoder := asserts.NewDecoder(bytes.NewBufferString(enc)) + _, err := decoder.Decode() + c.Assert(err, ErrorMatches, "assertion body length 2097153 exceeds maximum body size") +} + +func (as *assertsSuite) TestDecoderWithTypeMaxBodySize(c *C) { + ex1 := strings.Replace(exampleBodyAndExtraHeaders, "body-length: 8", "body-length: 2097152", 1) + ex1 = strings.Replace(ex1, "THE-BODY", strings.Repeat("B", 2*1024*1024), 1) + ex1toobig := strings.Replace(exampleBodyAndExtraHeaders, "body-length: 8", "body-length: 2097153", 1) + ex1toobig = strings.Replace(ex1toobig, "THE-BODY", strings.Repeat("B", 2*1024*1024+1), 1) + const ex2 = `type: test-only-2 +authority-id: auth-id1 +pk1: foo +pk2: bar +body-length: 3 +sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij + +XYZ + +AXNpZw==` + + decoder := asserts.NewDecoderWithTypeMaxBodySize(bytes.NewBufferString(ex1+"\n"+ex2), map[*asserts.AssertionType]int{ + asserts.TestOnly2Type: 3, + }) + a1, err := decoder.Decode() + c.Assert(err, IsNil) + c.Check(a1.Body(), HasLen, 2*1024*1024) + a2, err := decoder.Decode() + c.Assert(err, IsNil) + c.Check(a2.Body(), DeepEquals, []byte("XYZ")) + + decoder = asserts.NewDecoderWithTypeMaxBodySize(bytes.NewBufferString(ex1+"\n"+ex2), map[*asserts.AssertionType]int{ + asserts.TestOnly2Type: 2, + }) + a1, err = decoder.Decode() + c.Assert(err, IsNil) + c.Check(a1.Body(), HasLen, 2*1024*1024) + _, err = decoder.Decode() + c.Assert(err, ErrorMatches, `assertion body length 3 exceeds maximum body size 2 for "test-only-2" assertions`) + + decoder = asserts.NewDecoderWithTypeMaxBodySize(bytes.NewBufferString(ex2+"\n\n"+ex1toobig), map[*asserts.AssertionType]int{ + asserts.TestOnly2Type: 3, + }) + a2, err = decoder.Decode() + c.Assert(err, IsNil) + c.Check(a2.Body(), DeepEquals, []byte("XYZ")) + _, err = decoder.Decode() + c.Assert(err, ErrorMatches, "assertion body length 2097153 exceeds maximum body size") +} + +func (as *assertsSuite) TestEncode(c *C) { + encoded := []byte("type: test-only\n" + + "authority-id: auth-id2\n" + + "primary-key: xyz\n" + + "revision: 5\n" + + "header1: value1\n" + + "header2: value2\n" + + "body-length: 8\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij\n\n" + + "THE-BODY" + + "\n\n" + + "AXNpZw==") + a, err := asserts.Decode(encoded) + c.Assert(err, IsNil) + encodeRes := asserts.Encode(a) + c.Check(encodeRes, DeepEquals, encoded) +} + +func (as *assertsSuite) TestEncoderOK(c *C) { + encoded := []byte("type: test-only\n" + + "authority-id: auth-id2\n" + + "primary-key: xyzyz\n" + + "revision: 5\n" + + "header1: value1\n" + + "header2: value2\n" + + "body-length: 8\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij\n\n" + + "THE-BODY" + + "\n\n" + + "AXNpZw==") + a0, err := asserts.Decode(encoded) + c.Assert(err, IsNil) + cont0, _ := a0.Signature() + + stream := new(bytes.Buffer) + enc := asserts.NewEncoder(stream) + enc.Encode(a0) + + c.Check(bytes.HasSuffix(stream.Bytes(), []byte{'\n'}), Equals, true) + + dec := asserts.NewDecoder(stream) + a1, err := dec.Decode() + c.Assert(err, IsNil) + + cont1, _ := a1.Signature() + c.Check(cont1, DeepEquals, cont0) +} + +func (as *assertsSuite) TestEncoderSingleDecodeOK(c *C) { + encoded := []byte("type: test-only\n" + + "authority-id: auth-id2\n" + + "primary-key: abc\n" + + "revision: 5\n" + + "header1: value1\n" + + "header2: value2\n" + + "body-length: 8\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij\n\n" + + "THE-BODY" + + "\n\n" + + "AXNpZw==") + a0, err := asserts.Decode(encoded) + c.Assert(err, IsNil) + cont0, _ := a0.Signature() + + stream := new(bytes.Buffer) + enc := asserts.NewEncoder(stream) + enc.Encode(a0) + + a1, err := asserts.Decode(stream.Bytes()) + c.Assert(err, IsNil) + + cont1, _ := a1.Signature() + c.Check(cont1, DeepEquals, cont0) +} + +func (as *assertsSuite) TestSignFormatValidityEmptyBody(c *C) { + headers := map[string]interface{}{ + "authority-id": "auth-id1", + "primary-key": "0", + } + a, err := asserts.AssembleAndSignInTest(asserts.TestOnlyType, headers, nil, testPrivKey1) + c.Assert(err, IsNil) + + _, err = asserts.Decode(asserts.Encode(a)) + c.Check(err, IsNil) +} + +func (as *assertsSuite) TestSignFormatValidityNonEmptyBody(c *C) { + headers := map[string]interface{}{ + "authority-id": "auth-id1", + "primary-key": "0", + } + body := []byte("THE-BODY") + a, err := asserts.AssembleAndSignInTest(asserts.TestOnlyType, headers, body, testPrivKey1) + c.Assert(err, IsNil) + c.Check(a.Body(), DeepEquals, body) + + decoded, err := asserts.Decode(asserts.Encode(a)) + c.Assert(err, IsNil) + c.Check(decoded.Body(), DeepEquals, body) +} + +func (as *assertsSuite) TestSignFormatValiditySupportMultilineHeaderValues(c *C) { + headers := map[string]interface{}{ + "authority-id": "auth-id1", + "primary-key": "0", + } + + multilineVals := []string{ + "a\n", + "\na", + "a\n\b\nc", + "a\n\b\nc\n", + "\na\n", + "\n\na\n\nb\n\nc", + } + + for _, multilineVal := range multilineVals { + headers["multiline"] = multilineVal + if len(multilineVal)%2 == 1 { + headers["odd"] = "true" + } + + a, err := asserts.AssembleAndSignInTest(asserts.TestOnlyType, headers, nil, testPrivKey1) + c.Assert(err, IsNil) + + decoded, err := asserts.Decode(asserts.Encode(a)) + c.Assert(err, IsNil) + + c.Check(decoded.Header("multiline"), Equals, multilineVal) + } +} + +func (as *assertsSuite) TestSignFormatAndRevision(c *C) { + headers := map[string]interface{}{ + "authority-id": "auth-id1", + "primary-key": "0", + "format": "1", + "revision": "11", + } + + a, err := asserts.AssembleAndSignInTest(asserts.TestOnlyType, headers, nil, testPrivKey1) + c.Assert(err, IsNil) + + c.Check(a.Revision(), Equals, 11) + c.Check(a.Format(), Equals, 1) + c.Check(a.SupportedFormat(), Equals, true) + + a1, err := asserts.Decode(asserts.Encode(a)) + c.Assert(err, IsNil) + + c.Check(a1.Revision(), Equals, 11) + c.Check(a1.Format(), Equals, 1) + c.Check(a1.SupportedFormat(), Equals, true) +} + +func (as *assertsSuite) TestSignFormatOptionalPrimaryKeys(c *C) { + r := asserts.MockOptionalPrimaryKey(asserts.TestOnlyType, "opt1", "o1-defl") + defer r() + + headers := map[string]interface{}{ + "authority-id": "auth-id1", + "primary-key": "k1", + "header1": "a", + } + a, err := asserts.AssembleAndSignInTest(asserts.TestOnlyType, headers, nil, testPrivKey1) + c.Assert(err, IsNil) + + b := asserts.Encode(a) + c.Check(bytes.HasPrefix(b, []byte(`type: test-only +authority-id: auth-id1 +primary-key: k1 +header1:`)), Equals, true) + c.Check(a.HeaderString("opt1"), Equals, "o1-defl") + + _, err = asserts.Decode(b) + c.Check(err, IsNil) + + // defaults are always normalized away + headers = map[string]interface{}{ + "authority-id": "auth-id1", + "primary-key": "k1", + "opt1": "o1-defl", + "header1": "a", + } + a, err = asserts.AssembleAndSignInTest(asserts.TestOnlyType, headers, nil, testPrivKey1) + c.Assert(err, IsNil) + + b = asserts.Encode(a) + c.Check(bytes.HasPrefix(b, []byte(`type: test-only +authority-id: auth-id1 +primary-key: k1 +header1:`)), Equals, true) + c.Check(a.HeaderString("opt1"), Equals, "o1-defl") + + _, err = asserts.Decode(b) + c.Check(err, IsNil) + + headers = map[string]interface{}{ + "authority-id": "auth-id1", + "primary-key": "k1", + "opt1": "A", + "header1": "a", + } + a, err = asserts.AssembleAndSignInTest(asserts.TestOnlyType, headers, nil, testPrivKey1) + c.Assert(err, IsNil) + + b = asserts.Encode(a) + c.Check(bytes.HasPrefix(b, []byte(`type: test-only +authority-id: auth-id1 +primary-key: k1 +opt1: A +header1:`)), Equals, true) + c.Check(a.HeaderString("opt1"), Equals, "A") + + _, err = asserts.Decode(b) + c.Check(err, IsNil) +} + +func (as *assertsSuite) TestSignBodyIsUTF8Text(c *C) { + headers := map[string]interface{}{ + "authority-id": "auth-id1", + "primary-key": "0", + } + _, err := asserts.AssembleAndSignInTest(asserts.TestOnlyType, headers, []byte{'\xff'}, testPrivKey1) + c.Assert(err, ErrorMatches, "assertion body is not utf8") +} + +func (as *assertsSuite) TestHeaders(c *C) { + encoded := []byte("type: test-only\n" + + "authority-id: auth-id2\n" + + "primary-key: abc\n" + + "revision: 5\n" + + "header1: value1\n" + + "header2: value2\n" + + "body-length: 8\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij\n\n" + + "THE-BODY" + + "\n\n" + + "AXNpZw==") + a, err := asserts.Decode(encoded) + c.Assert(err, IsNil) + + hs := a.Headers() + c.Check(hs, DeepEquals, map[string]interface{}{ + "type": "test-only", + "authority-id": "auth-id2", + "primary-key": "abc", + "revision": "5", + "header1": "value1", + "header2": "value2", + "body-length": "8", + "sign-key-sha3-384": exKeyID, + }) +} + +func (as *assertsSuite) TestHeadersReturnsCopy(c *C) { + encoded := []byte("type: test-only\n" + + "authority-id: auth-id2\n" + + "primary-key: xyz\n" + + "revision: 5\n" + + "header1: value1\n" + + "header2: value2\n" + + "body-length: 8\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij\n\n" + + "THE-BODY" + + "\n\n" + + "AXNpZw==") + a, err := asserts.Decode(encoded) + c.Assert(err, IsNil) + + hs := a.Headers() + // casual later result mutation doesn't trip us + delete(hs, "primary-key") + c.Check(a.Header("primary-key"), Equals, "xyz") +} + +func (as *assertsSuite) TestAssembleRoundtrip(c *C) { + encoded := []byte("type: test-only\n" + + "format: 1\n" + + "authority-id: auth-id2\n" + + "primary-key: abc\n" + + "revision: 5\n" + + "header1: value1\n" + + "header2: value2\n" + + "body-length: 8\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij\n\n" + + "THE-BODY" + + "\n\n" + + "AXNpZw==") + a, err := asserts.Decode(encoded) + c.Assert(err, IsNil) + + cont, sig := a.Signature() + reassembled, err := asserts.Assemble(a.Headers(), a.Body(), cont, sig) + c.Assert(err, IsNil) + + c.Check(reassembled.Headers(), DeepEquals, a.Headers()) + c.Check(reassembled.Body(), DeepEquals, a.Body()) + + reassembledEncoded := asserts.Encode(reassembled) + c.Check(reassembledEncoded, DeepEquals, encoded) +} + +func (as *assertsSuite) TestSignKeyID(c *C) { + headers := map[string]interface{}{ + "authority-id": "auth-id1", + "primary-key": "0", + } + a, err := asserts.AssembleAndSignInTest(asserts.TestOnlyType, headers, nil, testPrivKey1) + c.Assert(err, IsNil) + + keyID := a.SignKeyID() + c.Check(keyID, Equals, testPrivKey1.PublicKey().ID()) +} + +func (as *assertsSuite) TestSelfRef(c *C) { + headers := map[string]interface{}{ + "authority-id": "auth-id1", + "primary-key": "0", + } + a1, err := asserts.AssembleAndSignInTest(asserts.TestOnlyType, headers, nil, testPrivKey1) + c.Assert(err, IsNil) + + c.Check(a1.Ref(), DeepEquals, &asserts.Ref{ + Type: asserts.TestOnlyType, + PrimaryKey: []string{"0"}, + }) + + c.Check(a1.At(), DeepEquals, &asserts.AtRevision{ + Ref: asserts.Ref{ + Type: asserts.TestOnlyType, + PrimaryKey: []string{"0"}, + }, + Revision: 0, + }) + + headers = map[string]interface{}{ + "authority-id": "auth-id1", + "pk1": "a", + "pk2": "b", + "revision": "1", + } + a2, err := asserts.AssembleAndSignInTest(asserts.TestOnly2Type, headers, nil, testPrivKey1) + c.Assert(err, IsNil) + + c.Check(a2.Ref(), DeepEquals, &asserts.Ref{ + Type: asserts.TestOnly2Type, + PrimaryKey: []string{"a", "b"}, + }) + + c.Check(a2.At(), DeepEquals, &asserts.AtRevision{ + Ref: asserts.Ref{ + Type: asserts.TestOnly2Type, + PrimaryKey: []string{"a", "b"}, + }, + Revision: 1, + }) +} + +func (as *assertsSuite) TestAssembleHeadersCheck(c *C) { + cont := []byte("type: test-only\n" + + "authority-id: auth-id2\n" + + "primary-key: abc\n" + + "revision: 5") + headers := map[string]interface{}{ + "type": "test-only", + "authority-id": "auth-id2", + "primary-key": "abc", + "revision": 5, // must be a string actually! + } + + _, err := asserts.Assemble(headers, nil, cont, nil) + c.Check(err, ErrorMatches, `header "revision": header values must be strings or nested lists or maps with strings as the only scalars: 5`) +} + +func (as *assertsSuite) TestSignWithoutAuthorityMisuse(c *C) { + _, err := asserts.SignWithoutAuthority(asserts.TestOnlyType, nil, nil, testPrivKey1) + c.Check(err, ErrorMatches, `cannot sign assertions needing a definite authority with SignWithoutAuthority`) + + _, err = asserts.SignWithoutAuthority(asserts.TestOnlyNoAuthorityType, + map[string]interface{}{ + "authority-id": "auth-id1", + "hdr": "FOO", + }, nil, testPrivKey1) + c.Check(err, ErrorMatches, `"test-only-no-authority" assertion cannot have authority-id set`) +} + +func (ss *serialSuite) TestSignatureCheckError(c *C) { + sreq, err := asserts.SignWithoutAuthority(asserts.TestOnlyNoAuthorityType, + map[string]interface{}{ + "hdr": "FOO", + }, nil, testPrivKey1) + c.Assert(err, IsNil) + + err = asserts.SignatureCheck(sreq, testPrivKey2.PublicKey()) + c.Check(err, ErrorMatches, `failed signature verification:.*`) +} + +func (as *assertsSuite) TestWithAuthority(c *C) { + withAuthority := []string{ + "account", + "account-key", + "aspect-bundle", + "base-declaration", + "store", + "snap-declaration", + "snap-build", + "snap-revision", + "snap-resource-pair", + "snap-resource-revision", + "snap-developer", + "model", + "preseed", + "serial", + "system-user", + "validation", + "validation-set", + "repair", + } + c.Check(withAuthority, HasLen, asserts.NumAssertionType-3) // excluding device-session-request, serial-request, account-key-request + for _, name := range withAuthority { + typ := asserts.Type(name) + _, err := asserts.AssembleAndSignInTest(typ, nil, []byte("{}"), testPrivKey1) + c.Check(err, ErrorMatches, `"authority-id" header is mandatory`) + } +} + +func (as *assertsSuite) TestSequenceForming(c *C) { + sequenceForming := []string{ + "repair", + "validation-set", + } + for _, name := range sequenceForming { + typ := asserts.Type(name) + c.Check(typ.SequenceForming(), Equals, true) + } + + c.Check(asserts.SnapDeclarationType.SequenceForming(), Equals, false) +} + +func (as *assertsSuite) TestHeadersFromSequenceKey(c *C) { + headers, err := asserts.HeadersFromSequenceKey(asserts.TestOnlySeqType, []string{"one"}) + c.Assert(err, IsNil) + c.Check(headers, DeepEquals, map[string]string{"n": "one"}) + + _, err = asserts.HeadersFromSequenceKey(asserts.TestOnlySeqType, []string{"one", "two"}) + c.Check(err, ErrorMatches, `sequence key has wrong length for "test-only-seq" assertion`) + + _, err = asserts.HeadersFromSequenceKey(asserts.TestOnlySeqType, []string{}) + c.Check(err, ErrorMatches, `sequence key has wrong length for "test-only-seq" assertion`) + + _, err = asserts.HeadersFromSequenceKey(asserts.TestOnlySeqType, []string{""}) + c.Check(err, ErrorMatches, `sequence key "n" header cannot be empty`) +} + +func (as *assertsSuite) TestAtSequenceString(c *C) { + atSeq := asserts.AtSequence{ + Type: asserts.ValidationSetType, + SequenceKey: []string{"16", "canonical", "foo"}, + Sequence: 8, + Revision: 2, + } + c.Check(atSeq.String(), Equals, "validation-set canonical/foo/8 at revision 2") + + // Sequence number not set + atSeq = asserts.AtSequence{ + Type: asserts.ValidationSetType, + SequenceKey: []string{"16", "canonical", "foo"}, + Revision: asserts.RevisionNotKnown, + } + c.Check(atSeq.String(), Equals, "validation-set canonical/foo") + + atSeq = asserts.AtSequence{ + Type: asserts.ValidationSetType, + SequenceKey: []string{"16", "canonical", "foo"}, + Sequence: 8, + Pinned: true, + Revision: 2, + } + c.Check(atSeq.String(), Equals, "validation-set canonical/foo=8 at revision 2") + + atSeq = asserts.AtSequence{ + Type: asserts.ValidationSetType, + SequenceKey: []string{"16", "canonical"}, + Revision: 2, + } + c.Check(atSeq.String(), Equals, "validation-set ??? at revision 2") +} + +func (as *assertsSuite) TestAtSequenceUnique(c *C) { + atSeq := asserts.AtSequence{ + Type: asserts.ValidationSetType, + SequenceKey: []string{"16", "canonical", "foo"}, + Sequence: 8, + Revision: 2, + } + c.Check(atSeq.Unique(), Equals, "validation-set/16/canonical/foo") + + // not a valid sequence-key (but Unique() doesn't care). + atSeq = asserts.AtSequence{ + Type: asserts.ValidationSetType, + SequenceKey: []string{"16", "canonical"}, + } + c.Check(atSeq.Unique(), Equals, "validation-set/16/canonical") +} + +func (as *assertsSuite) TestAtSequenceResolveError(c *C) { + atSeq := asserts.AtSequence{ + Type: asserts.ValidationSetType, + SequenceKey: []string{"abc"}, + Sequence: 1, + } + _, err := atSeq.Resolve(nil) + c.Check(err, ErrorMatches, `"validation-set" assertion reference primary key has the wrong length \(expected \[series account-id name sequence\]\): \[abc 1\]`) + + atSeq = asserts.AtSequence{ + Type: asserts.ValidationSetType, + SequenceKey: []string{"16", "canonical", "foo"}, + } + _, err = atSeq.Resolve(nil) + c.Assert(err, DeepEquals, &asserts.NotFoundError{ + Type: asserts.ValidationSetType, + Headers: map[string]string{ + "series": "16", + "account-id": "canonical", + "name": "foo", + }, + }) +} + +func (as *assertsSuite) TestAtSequenceResolve(c *C) { + atSeq := asserts.AtSequence{ + Type: asserts.TestOnlySeqType, + SequenceKey: []string{"foo"}, + Sequence: 3, + } + a, err := atSeq.Resolve(func(atype *asserts.AssertionType, hdrs map[string]string) (asserts.Assertion, error) { + c.Assert(atype, Equals, asserts.TestOnlySeqType) + c.Assert(hdrs, DeepEquals, map[string]string{ + "n": "foo", + "sequence": "3", + }) + encoded := []byte("type: test-only-seq\n" + + "format: 1\n" + + "authority-id: auth-id2\n" + + "n: abc\n" + + "revision: 5\n" + + "sequence: 3\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij\n\n" + + "\n\n" + + "AXNpZw==") + a, err := asserts.Decode(encoded) + return a, err + }) + c.Assert(err, IsNil) + c.Assert(a, NotNil) + c.Check(a.Type().Name, Equals, "test-only-seq") +} diff --git a/asserts/assertstest/assertstest.go b/asserts/assertstest/assertstest.go new file mode 100644 index 00000000..ad34d44e --- /dev/null +++ b/asserts/assertstest/assertstest.go @@ -0,0 +1,613 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +// Package assertstest provides helpers for testing code that involves assertions. +package assertstest + +import ( + "bytes" + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "errors" + "fmt" + "io" + "os/exec" + "strings" + "time" + + "golang.org/x/crypto/openpgp/armor" + "golang.org/x/crypto/openpgp/packet" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/randutil" +) + +// GenerateKey generates a private/public key pair of the given bits. It panics on error. +func GenerateKey(bits int) (asserts.PrivateKey, *rsa.PrivateKey) { + priv, err := rsa.GenerateKey(rand.Reader, bits) + if err != nil { + panic(fmt.Errorf("failed to create private key: %v", err)) + } + return asserts.RSAPrivateKey(priv), priv +} + +// ReadPrivKey reads a PGP private key (either armored or simply base64 encoded). It panics on error. +func ReadPrivKey(pk string) (asserts.PrivateKey, *rsa.PrivateKey) { + rd := bytes.NewReader([]byte(pk)) + blk, err := armor.Decode(rd) + var body io.Reader + if err == nil { + body = blk.Body + } else { + rd.Seek(0, 0) + // try unarmored + body = base64.NewDecoder(base64.StdEncoding, rd) + } + pkt, err := packet.Read(body) + if err != nil { + panic(err) + } + + pkPkt := pkt.(*packet.PrivateKey) + rsaPrivKey, ok := pkPkt.PrivateKey.(*rsa.PrivateKey) + if !ok { + panic("not a RSA key") + } + + return asserts.RSAPrivateKey(rsaPrivKey), rsaPrivKey +} + +// A sample developer key. +// See systestkeys for a prebuilt set of trusted keys and assertions. +const ( + DevKey = `-----BEGIN PGP PRIVATE KEY BLOCK----- +Version: GnuPG v1 + +lQcYBFaFwYABEAC0kYiC4rsWFLJHEv/qO93LTMCAYKMLXFU0XN4XvqnkbwFc0QQd +lQcr7PwavYmKdWum+EmGWV/k5vZ0gwfZhBsL2MTWSNvO+5q5AYOqTq01CbSLcoN4 +cJI+BU348Vc/AoiIuuHro+gALs59HWsVSAKq7SNyHQfo257TKe8Q+Jjh095eruYJ +2kOvlAgAzjUv7eGDQ53O87wcwgZlCl0XqM/t+SRUxE5i8dQ4nySSekoTsWJo02kf +uMrWo3E5iEt6KKhfQtit2ZO91NYetIplzzZmaUOOkpziFTFW1NcwDKzDsLMh1EQ+ +ib+8mSWcou9m35aTkAQXlXlgqe5Pelj5+NUxnnoa1MR478Sv+guT+fbFQrl8PkMD +Jb/3PTKDPBNtjki5ZfIN9id4vidfBY4SCDftnj7yZMf5+1PPZ2XXHUoiUhHbGjST +F/23wr6OWvXe/AXX5BF4wJJTJxSxnYR6nleGMj4sbsbVsxIaqh1lMg5cuQjLr7eI +nxn994geUnQQsEPIVuVjLThJ/0sjXjy8kyxh6eieShZ6NZ0yLyIJRN5pnJ0ckRQF +T9Fs0UuMJZro0hR71t9mAuI45mSmznj78AvTvyuL+0aOj/lQa97NKbCsShYnKqsm +3Yzr03ahUMslwd6jZtRg+0ULYp9vaN7nwmsn6WWJ92CsCzFucdeJfJWKZQARAQAB +AA/9GSda3mzKRhms+hSt/MnJLFxtRpTvsZHztp8nOySO0ykZhf4B9kL/5EEXn3v+ +0IBp9jEJQQNrRd5cv79PFSB/igdw6C7vG+bV12bcGhnqrARFl9Vkdh8saCJiCcdI +8ZifP3jVJvfGxlu+3RP/ik/lOz1cnjVoGCqb9euWB4Wx+meCxyrTFdVHb4qOENqo +8xvOufPt5Fn0vwbSUDoA3N5h1NNLmdlc2BC7EQYuWI9biWHBBTxKHSanbv4GtE6F +wScvyVFtEM7J83xWNaHN07/pYpvQUuienSn5nRB6R5HEcWBIm/JPbWzP/mxRHoBe +HDUSa0z5HPXwGiSh84VmJrBgtlQosxk3jOHjynlU194S2cVLcSrFSf4hp6WZVAa1 +Nlkv6v62eU3nDxabkF92Lcv40s1cBqYCvhOtMzgoXL0TuaVJIdUwbJRHoBi8Bh5f +bNYJqyMqJNHcT9ylAWw130ljPTtqzbTMRtitxnJPbf60hpsJ4jcp2bJP9pg9XyuR +ZyIKtLfGQfxvFLsXzNssnVv7ZenK5AgUFTMvmyKCQQeYluheKc0KtRKSYE3iaVAs +Efw5Pd0GD82UGef9WahtnemodTlD3nkzlD50XBsd8xdNBQ7N2TFsP5Ldvfp1Wf2F +qg+rTaS0OID9vDQuekOcDI8lA9E4FYlIkJ6AqIb7hD5hlBMIAMRVXLlPLgzmrY5k +pIPMbgyN0wm3f4qAWIaMtg79x9gTylsGF7lkqNLqFDFYfoUHb+iXINYc51kHV7Ka +JifHhdy8TaBTBrIrsFLJpv06lRex/fdyvswev3W1g3wRJ86eTCqwr1DjB+q2kYX8 +u1qDPFRzK4WF+wOF/FwCBLDpESmHSapXuzL5i6pJfOCFIJqT/Q/yp9tyTcxs82tu +kSlNKoXrZi4xHsDpPBuNjMl3eIz3ogesUG60MMa6xovVGV3ICJcwYwycvvQcjuxS +XtJlHK+/G3kB87BXzNCMyUGfDNy7mcTrXAXoUH8nCu4ipyaT/jEyvi95w/7RJcFU +qs6taH8IAOtxqnBZGDQuYPF0ZmZQ7e1/FXq/LBQryYZgNtyLUdR7ycXGCTXlEIGw +X3r7Qf4+a3MlriP5thKxci+npcIj4e31aS6cpO2IcGJzmNOHzLCl3b4XmO/APBSA +FZpQE3k+lg45tn/vgcPMKKLAAv6TbpVVgLrFXGtX3Gtkd66fPPOcINXi6+MqXfp5 +rl8OJIq5O5ygbbglwcqmeI29RLZ58b0ktCa5ZZNzeSV+T5jHwRNnWm0EJgjx8Lwn +LEWFS/vQjGwaoRJi06jpmM+66sefyTQ3qvyzQLBqlenZf16GGz28cOSjNJ9FDth1 +iKnyk7d8nqhmbSHeW08QUwTF6NGp+xsIAJDa3ouxSjTEB1P7z1VLJp6nSglBQ74n +XAprk2WpggLNrWwyFJsgFh07LxShb/O3t1TanU+Ld/ryyWHztTxag2auAHuVQ4+S +EkjKqkUaSOQML9a9AvZ2rQr84f5ohc/vCOQhpNVLSyw55EW6WhnntNWVwgZxMiAj +oREMJMrBb6LL9b7kHtfYqLNfe3fkUx+tuTsm96Wi1cdkh0qyut0+J+eieZVn7kiM +UP5IZuz9TSjDOrA5qu5NGlbXNaN0cdJ2UUSNekQiysqDpdf00wIwr1XqH+KLUjZv +pO5Mub6NdnVXJRZunpbNXbuxj49NXnZEEi71WQm9KLR8KQ1oQ+RlnHx/XLQHICh0 +ZXN0KYkCOAQTAQIAIgUCVoXBgAIbLwYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AA +CgkQSkI9KKrqS0/YEhAAgJALHrx4kFRcgDJE+khK/CdoaLvi0N40eAE+RzQgcxhh +S4Aeks8n1cL6oAwDfCL+ohyWvPzF2DzsBkEIC3l+JS2tn0JJ+qexY+qhdGkEze/o +SIvH9sfR5LJuKb3OAt2mQlY+sxjlkzU9rTGKsVZxgApNM4665dlagF9tipMQTHnd +eFZRlvNTWKkweW0jbJCpRKlQnjEZ6S/wlPBgH69Ek3bnDcgp6eaAU92Ke9Fa2wMV +LBMaXpUIvddKFjoGtvShDOpcQRE99Z8tK4YSAOg+zbSUeD7HGH00EQERItoJsAv1 +7Du8+jcKSeOhz7PPxOA7mEnYNdoMcrg/2AP+FVI6zGYcKN7Hq3C6Z+bQ4X1VkKmv +NCFomU2AyPVxpJRYw7/EkoRWp/iq6sEb7bsmhmDEiz1MiroAV+efmWyUjxueSzrW +24OxHTWi2GuHBF+FKUD3UxfaWMjH+tuWYPIHzYsT+TfsN0vAEFyhRi8Ncelu1RV4 +x2O3wmjxoaX/2FmyuU5WhcVkcpRFgceyf1/86NP9gT5MKbWtJC85YYpxibnvPdGd ++sqtEEqgX3dSsHT+rkBk7kf3ghDwsLtnliFPOeAaIHGZl754EpK+qPUTnYZK022H +2crhYlApO9+06kBeybSO6joMUR007883I9GELYhzmuEjpVGquJQ3+S5QtW1to0w= +=5Myf +-----END PGP PRIVATE KEY BLOCK----- +` + + DevKeyID = "EAD4DbLxK_kn0gzNCXOs3kd6DeMU3f-L6BEsSEuJGBqCORR0gXkdDxMbOm11mRFu" + + DevKeyPGPFingerprint = "966e70f4b9f257a2772f8f354a423d28aaea4b4f" +) + +// GPGImportKey imports the given PGP armored key into the GnuPG setup at homedir. It panics on error. +func GPGImportKey(homedir, armoredKey string) { + path, err := exec.LookPath("gpg1") + if err != nil { + path, err = exec.LookPath("gpg") + } + if err != nil { + panic(err) + } + gpg := exec.Command(path, "--homedir", homedir, "-q", "--batch", "--import", "--armor") + gpg.Stdin = bytes.NewBufferString(armoredKey) + out, err := gpg.CombinedOutput() + if err != nil { + panic(fmt.Errorf("cannot import test key into GPG setup at %q: %v (%q)", homedir, err, out)) + } +} + +// A SignerDB can sign assertions using its key pairs. +type SignerDB interface { + Sign(assertType *asserts.AssertionType, headers map[string]interface{}, body []byte, keyID string) (asserts.Assertion, error) +} + +// NewAccount creates an account assertion for username, it fills in values for +// other missing headers as needed. It panics on error. +func NewAccount(db SignerDB, username string, otherHeaders map[string]interface{}, keyID string) *asserts.Account { + if otherHeaders == nil { + otherHeaders = make(map[string]interface{}) + } + otherHeaders["username"] = username + if otherHeaders["account-id"] == nil { + otherHeaders["account-id"] = randutil.RandomString(32) + } + if otherHeaders["display-name"] == nil { + otherHeaders["display-name"] = strings.ToTitle(username[:1]) + username[1:] + } + if otherHeaders["validation"] == nil { + otherHeaders["validation"] = "unproven" + } + if otherHeaders["timestamp"] == nil { + otherHeaders["timestamp"] = time.Now().Format(time.RFC3339) + } + a, err := db.Sign(asserts.AccountType, otherHeaders, nil, keyID) + if err != nil { + panic(err) + } + return a.(*asserts.Account) +} + +// NewAccountKey creates an account-key assertion for the account, it fills in +// values for missing headers as needed. In panics on error. +func NewAccountKey(db SignerDB, acct *asserts.Account, otherHeaders map[string]interface{}, pubKey asserts.PublicKey, keyID string) *asserts.AccountKey { + if otherHeaders == nil { + otherHeaders = make(map[string]interface{}) + } + otherHeaders["account-id"] = acct.AccountID() + otherHeaders["public-key-sha3-384"] = pubKey.ID() + if otherHeaders["name"] == nil { + otherHeaders["name"] = "default" + } + if otherHeaders["since"] == nil { + otherHeaders["since"] = time.Now().Format(time.RFC3339) + } + encodedPubKey, err := asserts.EncodePublicKey(pubKey) + if err != nil { + panic(err) + } + a, err := db.Sign(asserts.AccountKeyType, otherHeaders, encodedPubKey, keyID) + if err != nil { + panic(err) + } + return a.(*asserts.AccountKey) +} + +// SigningDB embeds a signing assertion database with a default private key and assigned authority id. +// Sign will use the assigned authority id. +// "" can be passed for keyID to Sign and PublicKey to use the default key. +type SigningDB struct { + AuthorityID string + KeyID string + + *asserts.Database +} + +// NewSigningDB creates a test signing assertion db with the given defaults. It panics on error. +func NewSigningDB(authorityID string, privKey asserts.PrivateKey) *SigningDB { + db, err := asserts.OpenDatabase(&asserts.DatabaseConfig{}) + if err != nil { + panic(err) + } + err = db.ImportKey(privKey) + if err != nil { + panic(err) + } + return &SigningDB{ + AuthorityID: authorityID, + KeyID: privKey.PublicKey().ID(), + Database: db, + } +} + +func (db *SigningDB) Sign(assertType *asserts.AssertionType, headers map[string]interface{}, body []byte, keyID string) (asserts.Assertion, error) { + if _, ok := headers["authority-id"]; !ok { + // copy before modifying + headers2 := make(map[string]interface{}, len(headers)+1) + for h, v := range headers { + headers2[h] = v + } + headers = headers2 + headers["authority-id"] = db.AuthorityID + } + if keyID == "" { + keyID = db.KeyID + } + return db.Database.Sign(assertType, headers, body, keyID) +} + +func (db *SigningDB) PublicKey(keyID string) (asserts.PublicKey, error) { + if keyID == "" { + keyID = db.KeyID + } + return db.Database.PublicKey(keyID) +} + +// StoreStack realises a store-like set of founding trusted assertions and signing setup. +type StoreStack struct { + // Trusted authority assertions. + TrustedAccount *asserts.Account + TrustedKey *asserts.AccountKey + Trusted []asserts.Assertion + + // Generic authority assertions. + GenericAccount *asserts.Account + GenericKey *asserts.AccountKey + GenericModelsKey *asserts.AccountKey + Generic []asserts.Assertion + GenericClassicModel *asserts.Model + + // Signing assertion db that signs with the root private key. + RootSigning *SigningDB + + // The store-like signing functionality that signs with a store key, setup to also store assertions if desired. It stores a default account-key for the store private key, see also the StoreStack.Key method. + *SigningDB +} + +// StoreKeys holds a set of store private keys. +type StoreKeys struct { + Root asserts.PrivateKey + Store asserts.PrivateKey + Generic asserts.PrivateKey + GenericModels asserts.PrivateKey +} + +var ( + rootPrivKey, _ = GenerateKey(1024) + storePrivKey, _ = GenerateKey(752) + genericPrivKey, _ = GenerateKey(752) + genericModelsPrivKey, _ = GenerateKey(752) + + pregenKeys = StoreKeys{ + Root: rootPrivKey, + Store: storePrivKey, + Generic: genericPrivKey, + GenericModels: genericModelsPrivKey, + } +) + +// NewStoreStack creates a new store assertion stack. It panics on error. +// Optional keys specify private keys to use for the various roles. +func NewStoreStack(authorityID string, keys *StoreKeys) *StoreStack { + if keys == nil { + keys = &pregenKeys + } + + rootSigning := NewSigningDB(authorityID, keys.Root) + ts := time.Now().Format(time.RFC3339) + trustedAcct := NewAccount(rootSigning, authorityID, map[string]interface{}{ + "account-id": authorityID, + "validation": "verified", + "timestamp": ts, + }, "") + trustedKey := NewAccountKey(rootSigning, trustedAcct, map[string]interface{}{ + "name": "root", + "since": ts, + }, keys.Root.PublicKey(), "") + trusted := []asserts.Assertion{trustedAcct, trustedKey} + + genericAcct := NewAccount(rootSigning, "generic", map[string]interface{}{ + "account-id": "generic", + "validation": "verified", + "timestamp": ts, + }, "") + + err := rootSigning.ImportKey(keys.GenericModels) + if err != nil { + panic(err) + } + genericModelsKey := NewAccountKey(rootSigning, genericAcct, map[string]interface{}{ + "name": "models", + "since": ts, + }, keys.GenericModels.PublicKey(), "") + generic := []asserts.Assertion{genericAcct, genericModelsKey} + + db, err := asserts.OpenDatabase(&asserts.DatabaseConfig{ + Backstore: asserts.NewMemoryBackstore(), + Trusted: trusted, + OtherPredefined: generic, + }) + if err != nil { + panic(err) + } + err = db.ImportKey(keys.Store) + if err != nil { + panic(err) + } + storeKey := NewAccountKey(rootSigning, trustedAcct, map[string]interface{}{ + "name": "store", + }, keys.Store.PublicKey(), "") + err = db.Add(storeKey) + if err != nil { + panic(err) + } + + err = db.ImportKey(keys.Generic) + if err != nil { + panic(err) + } + genericKey := NewAccountKey(rootSigning, genericAcct, map[string]interface{}{ + "name": "serials", + "since": ts, + }, keys.Generic.PublicKey(), "") + err = db.Add(genericKey) + if err != nil { + panic(err) + } + + a, err := rootSigning.Sign(asserts.ModelType, map[string]interface{}{ + "authority-id": "generic", + "series": "16", + "brand-id": "generic", + "model": "generic-classic", + "classic": "true", + "timestamp": ts, + }, nil, genericModelsKey.PublicKeyID()) + if err != nil { + panic(err) + } + genericClassicMod := a.(*asserts.Model) + + return &StoreStack{ + TrustedAccount: trustedAcct, + TrustedKey: trustedKey, + Trusted: trusted, + + GenericAccount: genericAcct, + GenericKey: genericKey, + GenericModelsKey: genericModelsKey, + Generic: generic, + GenericClassicModel: genericClassicMod, + + RootSigning: rootSigning, + + SigningDB: &SigningDB{ + AuthorityID: authorityID, + KeyID: storeKey.PublicKeyID(), + Database: db, + }, + } +} + +// StoreAccountKey retrieves one of the account-key assertions for the signing keys of the simulated store signing database. +// "" for keyID means the default one. It panics on error. +func (ss *StoreStack) StoreAccountKey(keyID string) *asserts.AccountKey { + if keyID == "" { + keyID = ss.KeyID + } + key, err := ss.Find(asserts.AccountKeyType, map[string]string{ + "account-id": ss.AuthorityID, + "public-key-sha3-384": keyID, + }) + if errors.Is(err, &asserts.NotFoundError{}) { + return nil + } + if err != nil { + panic(err) + } + return key.(*asserts.AccountKey) +} + +// MockBuiltinBaseDeclaration mocks the builtin base-declaration exposed by asserts.BuiltinBaseDeclaration. +func MockBuiltinBaseDeclaration(headers []byte) (restore func()) { + var prevHeaders []byte + decl := asserts.BuiltinBaseDeclaration() + if decl != nil { + prevHeaders, _ = decl.Signature() + } + + err := asserts.InitBuiltinBaseDeclaration(headers) + if err != nil { + panic(err) + } + + return func() { + err := asserts.InitBuiltinBaseDeclaration(prevHeaders) + if err != nil { + panic(err) + } + } +} + +// FakeAssertionWithBody builds a fake assertion with the given body +// and layered headers. A fake assertion cannot be verified or added +// to a database or properly encoded. It can still be useful for unit +// tests but shouldn't be used in integration tests. +func FakeAssertionWithBody(body []byte, headerLayers ...map[string]interface{}) asserts.Assertion { + headers := map[string]interface{}{ + "sign-key-sha3-384": "Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij", + } + for _, h := range headerLayers { + for k, v := range h { + headers[k] = v + } + } + + _, hasTimestamp := headers["timestamp"] + _, hasSince := headers["since"] + if !(hasTimestamp || hasSince) { + headers["timestamp"] = time.Now().Format(time.RFC3339) + } + + a, err := asserts.Assemble(headers, body, nil, []byte("AXNpZw==")) + if err != nil { + panic(fmt.Sprintf("cannot build fake assertion: %v", err)) + } + return a +} + +// FakeAssertion builds a fake assertion with given layered headers +// and an empty body. A fake assertion cannot be verified or added to +// a database or properly encoded. It can still be useful for unit +// tests but shouldn't be used in integration tests. +func FakeAssertion(headerLayers ...map[string]interface{}) asserts.Assertion { + return FakeAssertionWithBody(nil, headerLayers...) +} + +type accuDB interface { + Add(asserts.Assertion) error +} + +// AddMany conveniently adds the given assertions to the db. +// It is idempotent but otherwise panics on error. +func AddMany(db accuDB, assertions ...asserts.Assertion) { + for _, a := range assertions { + err := db.Add(a) + if _, ok := err.(*asserts.RevisionError); !ok { + if err != nil { + panic(fmt.Sprintf("cannot add test assertions: %v", err)) + } + } + } +} + +// SigningAccounts manages a set of brand or user accounts, +// with their keys that can sign models etc. +type SigningAccounts struct { + store *StoreStack + + signing map[string]*SigningDB + + accts map[string]*asserts.Account + acctKeys map[string]*asserts.AccountKey +} + +// NewSigningAccounts creates a new SigningAccounts instance. +func NewSigningAccounts(store *StoreStack) *SigningAccounts { + return &SigningAccounts{ + store: store, + signing: make(map[string]*SigningDB), + accts: make(map[string]*asserts.Account), + acctKeys: make(map[string]*asserts.AccountKey), + } +} + +func (sa *SigningAccounts) Register(accountID string, brandPrivKey asserts.PrivateKey, extra map[string]interface{}) *SigningDB { + brandSigning := NewSigningDB(accountID, brandPrivKey) + sa.signing[accountID] = brandSigning + + acctHeaders := map[string]interface{}{ + "account-id": accountID, + } + for k, v := range extra { + acctHeaders[k] = v + } + + brandAcct := NewAccount(sa.store, accountID, acctHeaders, "") + sa.accts[accountID] = brandAcct + + brandPubKey, err := brandSigning.PublicKey("") + if err != nil { + panic(err) + } + brandAcctKey := NewAccountKey(sa.store, brandAcct, nil, brandPubKey, "") + sa.acctKeys[accountID] = brandAcctKey + + return brandSigning +} + +func (sa *SigningAccounts) Account(accountID string) *asserts.Account { + if acct := sa.accts[accountID]; acct != nil { + return acct + } + panic(fmt.Sprintf("unknown test account-id: %s", accountID)) +} + +func (sa *SigningAccounts) AccountKey(accountID string) *asserts.AccountKey { + if acctKey := sa.acctKeys[accountID]; acctKey != nil { + return acctKey + } + panic(fmt.Sprintf("unknown test account-id: %s", accountID)) +} + +func (sa *SigningAccounts) PublicKey(accountID string) asserts.PublicKey { + pubKey, err := sa.Signing(accountID).PublicKey("") + if err != nil { + panic(err) + } + return pubKey +} + +func (sa *SigningAccounts) Signing(accountID string) *SigningDB { + // convenience + if accountID == sa.store.RootSigning.AuthorityID { + return sa.store.RootSigning + } + if signer := sa.signing[accountID]; signer != nil { + return signer + } + panic(fmt.Sprintf("unknown test account-id: %s", accountID)) +} + +// Model creates a new model for accountID. accountID can also be the account-id of the underlying store stack. +func (sa *SigningAccounts) Model(accountID, model string, extras ...map[string]interface{}) *asserts.Model { + headers := map[string]interface{}{ + "series": "16", + "brand-id": accountID, + "model": model, + "timestamp": time.Now().Format(time.RFC3339), + } + for _, extra := range extras { + for k, v := range extra { + headers[k] = v + } + } + + signer := sa.Signing(accountID) + + modelAs, err := signer.Sign(asserts.ModelType, headers, nil, "") + if err != nil { + panic(err) + } + return modelAs.(*asserts.Model) +} + +// AccountsAndKeys returns the account and account-key for each given +// accountID in that order. +func (sa *SigningAccounts) AccountsAndKeys(accountIDs ...string) []asserts.Assertion { + res := make([]asserts.Assertion, 0, 2*len(accountIDs)) + for _, accountID := range accountIDs { + res = append(res, sa.Account(accountID)) + res = append(res, sa.AccountKey(accountID)) + } + return res +} diff --git a/asserts/assertstest/assertstest_test.go b/asserts/assertstest/assertstest_test.go new file mode 100644 index 00000000..d1f894d2 --- /dev/null +++ b/asserts/assertstest/assertstest_test.go @@ -0,0 +1,225 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package assertstest_test + +import ( + "encoding/hex" + "testing" + "time" + + "golang.org/x/crypto/openpgp/packet" + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/asserts/assertstest" +) + +func TestAssertsTest(t *testing.T) { TestingT(t) } + +type helperSuite struct{} + +var _ = Suite(&helperSuite{}) + +func (s *helperSuite) TestReadPrivKeyArmored(c *C) { + pk, rsaPrivKey := assertstest.ReadPrivKey(assertstest.DevKey) + c.Check(pk, NotNil) + c.Check(rsaPrivKey, NotNil) + c.Check(pk.PublicKey().ID(), Equals, assertstest.DevKeyID) + pkt := packet.NewRSAPrivateKey(time.Date(2016, time.January, 1, 0, 0, 0, 0, time.UTC), rsaPrivKey) + c.Check(hex.EncodeToString(pkt.Fingerprint[:]), Equals, assertstest.DevKeyPGPFingerprint) +} + +const ( + base64PrivKey = ` +xcLYBFaU5cgBCAC/2wUYK7YzvL6f0ZxBfptFVfNmI7G9J9Eszdoq1NZZXaV+aYeC7eNU +1sKdO6wIRcw3lvybtq5W1n4D/jJAb2qXbB6BukuCGVXCLMEUdvheaVVcIZ/LwdbxmgMJsDFoHsDC +RzjkUVTU2b8sK6MwANIsSS5r8Lwm7FazD1qq50UdebsIx8dkjFR5VwrCYgOu1MO2Bqka7UU9as2q +4ZsFzpcS/so41kd4IPFEmNMlejhSjgCaixehpLeXypQVHLluV+oSPMV7GtE7Z6HO4V5cT2c9RdXg +l4jSKY91rHInkmSizF03laL3T/I6oj0FdZG9GB6QzqRCBTzK05cnVP1k7WFJABEBAAEAB/9spiIa +cBa88fSaGWB+Dq7r8yLmAuzTDEt/LgyRGPtSnJ/uGOEvGn0VPJH17ScdgDmIea8Ql8HfV5UBueDH +cNFSc15LZS8BvEs+rY2ig0VgYhJ/HGOcRmftZqS1xdwU9OWAoEjts8lwyOdkoknGE5Dyl3b8ldZX +zJvEx7s28cXITH4UwGEAMHEXrAMCjkcKPVbM7vW81uOWn0U1jMzmfmqrcLkSfvaCnep6+4QphKPy +B4DxJAI34EvJAru4iL5bWWvMeXkBZgmBy4g2SlYbk09cfTmhzw6di5GZtg+77yGACltPBA8MSbzF +v30apQ5iuI/hVin7U2/QtQHP4d0zUDbpBADusynnaFcDnPEUm4RdvNpujaBC/HfIpOstiS36RZy8 +lZeVtffa/+DqzodZD9YF7zEVWeUiC5Os4THirYOZ04dM5yqR/GlKXMHGHaT+mnhD8g1hORx/LrMO +k5wUpD1NmloSjP/0pJRccuXq7O1QQfls1Hq1vOSh3cZ/aIvTONJ/YwQAzcK0/2SrnaUc3oCxMEuI +2FX0LsYDQiXzMK/x/lfZ/ywxt5J/q6CuaG3xXgSHlsk0M8Uo4acZqpCIFA9mwCPxKbrIOGnwJsI/ ++sZBkngtZMSS88Vl32gnzpVWLGpbW2F7hnWrj1YigTcFUdi6TFNa7zHPASzCKxKKiz9YxEWWymME +AIbURnQJJOSfYgFyloQuA2QWyAK5Zu7qPworBoRo+PZPVb5yQmSUQ21VqNfzqIJz1EgiDZ0NyGid +uXAjn58O9tAq7IN5pTeHoTacZ75cI82kQkUxEnfiKjBO/AU30Y3COsIXhtbIXbtcitHSicp4lnpU +NejDkxUnC2wIvJzHWo1FQ18= +` +) + +func (s *helperSuite) TestReadPrivKeyUnarmored(c *C) { + pk, rsaPrivKey := assertstest.ReadPrivKey(base64PrivKey) + c.Check(pk, NotNil) + c.Check(rsaPrivKey, NotNil) +} + +func (s *helperSuite) TestStoreStack(c *C) { + store := assertstest.NewStoreStack("super", nil) + + c.Check(store.TrustedAccount.AccountID(), Equals, "super") + c.Check(store.TrustedAccount.Validation(), Equals, "verified") + + c.Check(store.TrustedKey.AccountID(), Equals, "super") + c.Check(store.TrustedKey.Name(), Equals, "root") + + c.Check(store.GenericAccount.AccountID(), Equals, "generic") + c.Check(store.GenericAccount.Validation(), Equals, "verified") + + db, err := asserts.OpenDatabase(&asserts.DatabaseConfig{ + Backstore: asserts.NewMemoryBackstore(), + Trusted: store.Trusted, + OtherPredefined: store.Generic, + }) + c.Assert(err, IsNil) + + storeAccKey := store.StoreAccountKey("") + c.Assert(storeAccKey, NotNil) + + c.Check(storeAccKey.AccountID(), Equals, "super") + c.Check(storeAccKey.AccountID(), Equals, store.AuthorityID) + c.Check(storeAccKey.PublicKeyID(), Equals, store.KeyID) + c.Check(storeAccKey.Name(), Equals, "store") + + c.Check(store.GenericKey.AccountID(), Equals, "generic") + c.Check(store.GenericKey.Name(), Equals, "serials") + + c.Check(store.GenericModelsKey.AccountID(), Equals, "generic") + c.Check(store.GenericModelsKey.Name(), Equals, "models") + + g, err := store.Find(asserts.AccountType, map[string]string{ + "account-id": "generic", + }) + c.Assert(err, IsNil) + c.Assert(g.Headers(), DeepEquals, store.GenericAccount.Headers()) + + g, err = store.Find(asserts.AccountKeyType, map[string]string{ + "public-key-sha3-384": store.GenericKey.PublicKeyID(), + }) + c.Assert(err, IsNil) + c.Assert(g.Headers(), DeepEquals, store.GenericKey.Headers()) + + g, err = store.Find(asserts.AccountKeyType, map[string]string{ + "public-key-sha3-384": store.GenericModelsKey.PublicKeyID(), + }) + c.Assert(err, IsNil) + c.Assert(g.Headers(), DeepEquals, store.GenericModelsKey.Headers()) + + acct := assertstest.NewAccount(store, "devel1", nil, "") + c.Check(acct.Username(), Equals, "devel1") + c.Check(acct.AccountID(), HasLen, 32) + c.Check(acct.Validation(), Equals, "unproven") + + err = db.Add(storeAccKey) + c.Assert(err, IsNil) + + err = db.Add(acct) + c.Assert(err, IsNil) + + devKey, _ := assertstest.GenerateKey(752) + + acctKey := assertstest.NewAccountKey(store, acct, nil, devKey.PublicKey(), "") + + err = db.Add(acctKey) + c.Assert(err, IsNil) + + c.Check(acctKey.Name(), Equals, "default") + + a, err := db.Find(asserts.AccountType, map[string]string{ + "account-id": "generic", + }) + c.Assert(err, IsNil) + c.Assert(a.Headers(), DeepEquals, store.GenericAccount.Headers()) + + c.Check(store.GenericClassicModel.AuthorityID(), Equals, "generic") + c.Check(store.GenericClassicModel.BrandID(), Equals, "generic") + c.Check(store.GenericClassicModel.Model(), Equals, "generic-classic") + c.Check(store.GenericClassicModel.Classic(), Equals, true) + err = db.Check(store.GenericClassicModel) + c.Assert(err, IsNil) + + err = db.Add(store.GenericKey) + c.Assert(err, IsNil) +} + +func (s *helperSuite) TestSigningAccounts(c *C) { + brandKey, _ := assertstest.GenerateKey(752) + + store := assertstest.NewStoreStack("super", nil) + + sa := assertstest.NewSigningAccounts(store) + sa.Register("my-brand", brandKey, map[string]interface{}{ + "validation": "verified", + }) + + acct := sa.Account("my-brand") + c.Check(acct.Username(), Equals, "my-brand") + c.Check(acct.Validation(), Equals, "verified") + + c.Check(sa.AccountKey("my-brand").PublicKeyID(), Equals, brandKey.PublicKey().ID()) + + c.Check(sa.PublicKey("my-brand").ID(), Equals, brandKey.PublicKey().ID()) + + model := sa.Model("my-brand", "my-model", map[string]interface{}{ + "classic": "true", + }) + c.Check(model.BrandID(), Equals, "my-brand") + c.Check(model.Model(), Equals, "my-model") + c.Check(model.Classic(), Equals, true) + + // can also sign models for store account-id + model = sa.Model("super", "pc", map[string]interface{}{ + "classic": "true", + }) + c.Check(model.BrandID(), Equals, "super") + c.Check(model.Model(), Equals, "pc") +} + +func (s *helperSuite) TestSigningAccountsAccountsAndKeysPlusAddMany(c *C) { + brandKey, _ := assertstest.GenerateKey(752) + + store := assertstest.NewStoreStack("super", nil) + + sa := assertstest.NewSigningAccounts(store) + sa.Register("my-brand", brandKey, map[string]interface{}{ + "validation": "verified", + }) + + db, err := asserts.OpenDatabase(&asserts.DatabaseConfig{ + Backstore: asserts.NewMemoryBackstore(), + Trusted: store.Trusted, + }) + c.Assert(err, IsNil) + err = db.Add(store.StoreAccountKey("")) + c.Assert(err, IsNil) + + assertstest.AddMany(db, sa.AccountsAndKeys("my-brand")...) + as, err := db.FindMany(asserts.AccountKeyType, map[string]string{ + "account-id": "my-brand", + }) + c.Check(err, IsNil) + c.Check(as, HasLen, 1) + + // idempotent + assertstest.AddMany(db, sa.AccountsAndKeys("my-brand")...) +} diff --git a/asserts/batch.go b/asserts/batch.go new file mode 100644 index 00000000..1b1cbacc --- /dev/null +++ b/asserts/batch.go @@ -0,0 +1,251 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016-2021 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts + +import ( + "errors" + "fmt" + "io" + "strings" +) + +// Batch allows to accumulate a set of assertions possibly out of +// prerequisite order and then add them in one go to an assertion +// database. +// Nothing will be committed if there are missing prerequisites, for a full +// consistency check beforehand there is the Precheck option. +type Batch struct { + bs Backstore + added []Assertion + // added is in prereq order + inPrereqOrder bool + + unsupported func(u *Ref, err error) error +} + +// NewBatch creates a new Batch to accumulate assertions to add in one +// go to an assertion database. +// unsupported can be used to ignore/log assertions with unsupported formats, +// default behavior is to error on them. +func NewBatch(unsupported func(u *Ref, err error) error) *Batch { + if unsupported == nil { + unsupported = func(_ *Ref, err error) error { + return err + } + } + + return &Batch{ + bs: NewMemoryBackstore(), + inPrereqOrder: true, // empty list is trivially so + unsupported: unsupported, + } +} + +// Add one assertion to the batch. +func (b *Batch) Add(a Assertion) error { + b.inPrereqOrder = false + + if !a.SupportedFormat() { + err := &UnsupportedFormatError{Ref: a.Ref(), Format: a.Format()} + return b.unsupported(a.Ref(), err) + } + if err := b.bs.Put(a.Type(), a); err != nil { + if revErr, ok := err.(*RevisionError); ok { + if revErr.Current >= a.Revision() { + // we already got something more recent + return nil + } + } + return err + } + b.added = append(b.added, a) + return nil +} + +// AddStream adds a stream of assertions to the batch. +// Returns references to the assertions effectively added. +func (b *Batch) AddStream(r io.Reader) ([]*Ref, error) { + b.inPrereqOrder = false + + start := len(b.added) + dec := NewDecoder(r) + for { + a, err := dec.Decode() + if err == io.EOF { + break + } + if err != nil { + return nil, err + } + if err := b.Add(a); err != nil { + return nil, err + } + } + added := b.added[start:] + if len(added) == 0 { + return nil, nil + } + refs := make([]*Ref, len(added)) + for i, a := range added { + refs[i] = a.Ref() + } + return refs, nil +} + +// Fetch adds to the batch by invoking fetching to drive an internal +// Fetcher that was built with trustedDB and retrieve. +func (b *Batch) Fetch(trustedDB RODatabase, retrieve func(*Ref) (Assertion, error), fetching func(Fetcher) error) error { + f := NewFetcher(trustedDB, retrieve, b.Add) + return fetching(f) +} + +func (b *Batch) precheck(db *Database) error { + db = db.WithStackedBackstore(NewMemoryBackstore()) + return b.commitTo(db, nil) +} + +type CommitOptions struct { + // Precheck indicates whether to do a full consistency check + // before starting adding the batch. + Precheck bool +} + +// CommitTo adds the batch of assertions to the given assertion database. +// Nothing will be committed if there are missing prerequisites, for a full +// consistency check beforehand there is the Precheck option. +func (b *Batch) CommitTo(db *Database, opts *CommitOptions) error { + if opts == nil { + opts = &CommitOptions{} + } + if opts.Precheck { + if err := b.precheck(db); err != nil { + return err + } + } + + return b.commitTo(db, nil) +} + +// CommitToAndObserve adds the batch of assertions to the given +// assertion database while invoking observe for each one after they +// are added. +// Nothing will be committed if there are missing prerequisites, for a +// full consistency check beforehand there is the Precheck option. +// For convenience observe can be nil in which case is ignored. +func (b *Batch) CommitToAndObserve(db *Database, observe func(Assertion), opts *CommitOptions) error { + if opts == nil { + opts = &CommitOptions{} + } + if opts.Precheck { + if err := b.precheck(db); err != nil { + return err + } + } + + return b.commitTo(db, observe) +} + +// commitTo does a best effort of adding all the batch assertions to +// the target database. +func (b *Batch) commitTo(db *Database, observe func(Assertion)) error { + if err := b.prereqSort(db); err != nil { + return err + } + + // TODO: trigger w. caller a global validity check if something is revoked + // (but try to save as much possible still), + // or err is a check error + + var errs []error + for _, a := range b.added { + err := db.Add(a) + if IsUnaccceptedUpdate(err) { + // unsupported format case is handled before + // be idempotent + // system db has already the same or newer + continue + } + if err != nil { + errs = append(errs, err) + } else if observe != nil { + observe(a) + } + } + if len(errs) != 0 { + return &commitError{errs: errs} + } + return nil +} + +func (b *Batch) prereqSort(db *Database) error { + if b.inPrereqOrder { + // nothing to do + return nil + } + + // put in prereq order using a fetcher + ordered := make([]Assertion, 0, len(b.added)) + retrieve := func(ref *Ref) (Assertion, error) { + a, err := b.bs.Get(ref.Type, ref.PrimaryKey, ref.Type.MaxSupportedFormat()) + if errors.Is(err, &NotFoundError{}) { + // fallback to pre-existing assertions + a, err = ref.Resolve(db.Find) + } + if err != nil { + return nil, resolveError("cannot resolve prerequisite assertion: %s", ref, err) + } + return a, nil + } + save := func(a Assertion) error { + ordered = append(ordered, a) + return nil + } + f := NewFetcher(db, retrieve, save) + + for _, a := range b.added { + if err := f.Fetch(a.Ref()); err != nil { + return err + } + } + + b.added = ordered + b.inPrereqOrder = true + return nil +} + +func resolveError(format string, ref *Ref, err error) error { + if errors.Is(err, &NotFoundError{}) { + return fmt.Errorf(format, ref) + } else { + return fmt.Errorf(format+": %v", ref, err) + } +} + +type commitError struct { + errs []error +} + +func (e *commitError) Error() string { + l := []string{""} + for _, e := range e.errs { + l = append(l, e.Error()) + } + return fmt.Sprintf("cannot accept some assertions:%s", strings.Join(l, "\n - ")) +} diff --git a/asserts/batch_test.go b/asserts/batch_test.go new file mode 100644 index 00000000..d43f4659 --- /dev/null +++ b/asserts/batch_test.go @@ -0,0 +1,517 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016-2021 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts_test + +import ( + "bytes" + "errors" + "fmt" + "time" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/asserts/assertstest" +) + +type batchSuite struct { + storeSigning *assertstest.StoreStack + dev1Acct *asserts.Account + + db *asserts.Database +} + +var _ = Suite(&batchSuite{}) + +func (s *batchSuite) SetUpTest(c *C) { + s.storeSigning = assertstest.NewStoreStack("can0nical", nil) + + s.dev1Acct = assertstest.NewAccount(s.storeSigning, "developer1", nil, "") + err := s.storeSigning.Add(s.dev1Acct) + c.Assert(err, IsNil) + + db, err := asserts.OpenDatabase(&asserts.DatabaseConfig{ + Backstore: asserts.NewMemoryBackstore(), + Trusted: s.storeSigning.Trusted, + }) + c.Assert(err, IsNil) + s.db = db +} + +func (s *batchSuite) snapDecl(c *C, name string, extraHeaders map[string]interface{}) *asserts.SnapDeclaration { + headers := map[string]interface{}{ + "series": "16", + "snap-id": name + "-id", + "snap-name": name, + "publisher-id": s.dev1Acct.AccountID(), + "timestamp": time.Now().Format(time.RFC3339), + } + for h, v := range extraHeaders { + headers[h] = v + } + decl, err := s.storeSigning.Sign(asserts.SnapDeclarationType, headers, nil, "") + c.Assert(err, IsNil) + err = s.storeSigning.Add(decl) + c.Assert(err, IsNil) + return decl.(*asserts.SnapDeclaration) +} + +func (s *batchSuite) TestAddStream(c *C) { + b := &bytes.Buffer{} + enc := asserts.NewEncoder(b) + // wrong order is ok + err := enc.Encode(s.dev1Acct) + c.Assert(err, IsNil) + enc.Encode(s.storeSigning.StoreAccountKey("")) + c.Assert(err, IsNil) + + batch := asserts.NewBatch(nil) + refs, err := batch.AddStream(b) + c.Assert(err, IsNil) + c.Check(refs, DeepEquals, []*asserts.Ref{ + {Type: asserts.AccountType, PrimaryKey: []string{s.dev1Acct.AccountID()}}, + {Type: asserts.AccountKeyType, PrimaryKey: []string{s.storeSigning.StoreAccountKey("").PublicKeyID()}}, + }) + + // noop + err = batch.Add(s.storeSigning.StoreAccountKey("")) + c.Assert(err, IsNil) + + err = batch.CommitTo(s.db, nil) + c.Assert(err, IsNil) + + devAcct, err := s.db.Find(asserts.AccountType, map[string]string{ + "account-id": s.dev1Acct.AccountID(), + }) + c.Assert(err, IsNil) + c.Check(devAcct.(*asserts.Account).Username(), Equals, "developer1") +} + +func (s *batchSuite) TestCommitToAndObserve(c *C) { + b := &bytes.Buffer{} + enc := asserts.NewEncoder(b) + // wrong order is ok + err := enc.Encode(s.dev1Acct) + c.Assert(err, IsNil) + enc.Encode(s.storeSigning.StoreAccountKey("")) + c.Assert(err, IsNil) + + batch := asserts.NewBatch(nil) + refs, err := batch.AddStream(b) + c.Assert(err, IsNil) + c.Check(refs, DeepEquals, []*asserts.Ref{ + {Type: asserts.AccountType, PrimaryKey: []string{s.dev1Acct.AccountID()}}, + {Type: asserts.AccountKeyType, PrimaryKey: []string{s.storeSigning.StoreAccountKey("").PublicKeyID()}}, + }) + + // noop + err = batch.Add(s.storeSigning.StoreAccountKey("")) + c.Assert(err, IsNil) + + var seen []*asserts.Ref + obs := func(verified asserts.Assertion) { + seen = append(seen, verified.Ref()) + } + err = batch.CommitToAndObserve(s.db, obs, nil) + c.Assert(err, IsNil) + + devAcct, err := s.db.Find(asserts.AccountType, map[string]string{ + "account-id": s.dev1Acct.AccountID(), + }) + c.Assert(err, IsNil) + c.Check(devAcct.(*asserts.Account).Username(), Equals, "developer1") + + // this is the order they needed to be added + c.Check(seen, DeepEquals, []*asserts.Ref{ + {Type: asserts.AccountKeyType, PrimaryKey: []string{s.storeSigning.StoreAccountKey("").PublicKeyID()}}, + {Type: asserts.AccountType, PrimaryKey: []string{s.dev1Acct.AccountID()}}, + }) +} + +func (s *batchSuite) TestAddEmptyStream(c *C) { + b := &bytes.Buffer{} + + batch := asserts.NewBatch(nil) + refs, err := batch.AddStream(b) + c.Assert(err, IsNil) + c.Check(refs, HasLen, 0) +} + +func (s *batchSuite) TestConsiderPreexisting(c *C) { + // prereq store key + err := s.db.Add(s.storeSigning.StoreAccountKey("")) + c.Assert(err, IsNil) + + batch := asserts.NewBatch(nil) + err = batch.Add(s.dev1Acct) + c.Assert(err, IsNil) + + err = batch.CommitTo(s.db, nil) + c.Assert(err, IsNil) + + devAcct, err := s.db.Find(asserts.AccountType, map[string]string{ + "account-id": s.dev1Acct.AccountID(), + }) + c.Assert(err, IsNil) + c.Check(devAcct.(*asserts.Account).Username(), Equals, "developer1") +} + +func (s *batchSuite) TestAddStreamReturnsEffectivelyAddedRefs(c *C) { + batch := asserts.NewBatch(nil) + + err := batch.Add(s.storeSigning.StoreAccountKey("")) + c.Assert(err, IsNil) + + b := &bytes.Buffer{} + enc := asserts.NewEncoder(b) + // wrong order is ok + err = enc.Encode(s.dev1Acct) + c.Assert(err, IsNil) + // this was already added to the batch + enc.Encode(s.storeSigning.StoreAccountKey("")) + c.Assert(err, IsNil) + + // effectively adds only the developer1 account + refs, err := batch.AddStream(b) + c.Assert(err, IsNil) + c.Check(refs, DeepEquals, []*asserts.Ref{ + {Type: asserts.AccountType, PrimaryKey: []string{s.dev1Acct.AccountID()}}, + }) + + err = batch.CommitTo(s.db, nil) + c.Assert(err, IsNil) + + devAcct, err := s.db.Find(asserts.AccountType, map[string]string{ + "account-id": s.dev1Acct.AccountID(), + }) + c.Assert(err, IsNil) + c.Check(devAcct.(*asserts.Account).Username(), Equals, "developer1") +} + +func (s *batchSuite) TestCommitRefusesSelfSignedKey(c *C) { + aKey, _ := assertstest.GenerateKey(752) + aSignDB := assertstest.NewSigningDB("can0nical", aKey) + + aKeyEncoded, err := asserts.EncodePublicKey(aKey.PublicKey()) + c.Assert(err, IsNil) + + headers := map[string]interface{}{ + "authority-id": "can0nical", + "account-id": "can0nical", + "public-key-sha3-384": aKey.PublicKey().ID(), + "name": "default", + "since": time.Now().UTC().Format(time.RFC3339), + } + acctKey, err := aSignDB.Sign(asserts.AccountKeyType, headers, aKeyEncoded, "") + c.Assert(err, IsNil) + + headers = map[string]interface{}{ + "authority-id": "can0nical", + "brand-id": "can0nical", + "repair-id": "2", + "summary": "repair two", + "timestamp": time.Now().UTC().Format(time.RFC3339), + } + repair, err := aSignDB.Sign(asserts.RepairType, headers, []byte("#script"), "") + c.Assert(err, IsNil) + + batch := asserts.NewBatch(nil) + + err = batch.Add(repair) + c.Assert(err, IsNil) + + err = batch.Add(acctKey) + c.Assert(err, IsNil) + + // this must fail + err = batch.CommitTo(s.db, nil) + c.Assert(err, ErrorMatches, `circular assertions are not expected:.*`) +} + +func (s *batchSuite) TestAddUnsupported(c *C) { + restore := asserts.MockMaxSupportedFormat(asserts.SnapDeclarationType, 111) + defer restore() + + batch := asserts.NewBatch(nil) + + var a asserts.Assertion + (func() { + restore := asserts.MockMaxSupportedFormat(asserts.SnapDeclarationType, 999) + defer restore() + headers := map[string]interface{}{ + "format": "999", + "revision": "1", + "series": "16", + "snap-id": "snap-id-1", + "snap-name": "foo", + "publisher-id": s.dev1Acct.AccountID(), + "timestamp": time.Now().Format(time.RFC3339), + } + var err error + a, err = s.storeSigning.Sign(asserts.SnapDeclarationType, headers, nil, "") + c.Assert(err, IsNil) + })() + + err := batch.Add(a) + c.Check(err, ErrorMatches, `proposed "snap-declaration" assertion has format 999 but 111 is latest supported`) +} + +func (s *batchSuite) TestAddUnsupportedIgnore(c *C) { + restore := asserts.MockMaxSupportedFormat(asserts.SnapDeclarationType, 111) + defer restore() + + var uRef *asserts.Ref + unsupported := func(ref *asserts.Ref, _ error) error { + uRef = ref + return nil + } + + batch := asserts.NewBatch(unsupported) + + var a asserts.Assertion + (func() { + restore := asserts.MockMaxSupportedFormat(asserts.SnapDeclarationType, 999) + defer restore() + headers := map[string]interface{}{ + "format": "999", + "revision": "1", + "series": "16", + "snap-id": "snap-id-1", + "snap-name": "foo", + "publisher-id": s.dev1Acct.AccountID(), + "timestamp": time.Now().Format(time.RFC3339), + } + var err error + a, err = s.storeSigning.Sign(asserts.SnapDeclarationType, headers, nil, "") + c.Assert(err, IsNil) + })() + + err := batch.Add(a) + c.Check(err, IsNil) + c.Check(uRef, DeepEquals, &asserts.Ref{ + Type: asserts.SnapDeclarationType, + PrimaryKey: []string{"16", "snap-id-1"}, + }) +} + +func (s *batchSuite) TestCommitPartial(c *C) { + // Commit does add any successful assertion until the first error + + // store key already present + err := s.db.Add(s.storeSigning.StoreAccountKey("")) + c.Assert(err, IsNil) + + batch := asserts.NewBatch(nil) + + snapDeclFoo := s.snapDecl(c, "foo", nil) + + err = batch.Add(snapDeclFoo) + c.Assert(err, IsNil) + err = batch.Add(s.dev1Acct) + c.Assert(err, IsNil) + + // too old + rev := 1 + headers := map[string]interface{}{ + "snap-id": "foo-id", + "snap-sha3-384": makeDigest(rev), + "snap-size": fmt.Sprintf("%d", len(fakeSnap(rev))), + "snap-revision": fmt.Sprintf("%d", rev), + "developer-id": s.dev1Acct.AccountID(), + "timestamp": time.Time{}.Format(time.RFC3339), + } + snapRev, err := s.storeSigning.Sign(asserts.SnapRevisionType, headers, nil, "") + c.Assert(err, IsNil) + + err = batch.Add(snapRev) + c.Assert(err, IsNil) + + err = batch.CommitTo(s.db, &asserts.CommitOptions{Precheck: false}) + c.Check(err, ErrorMatches, `(?ms).*validity.*`) + + // snap-declaration was added anyway + _, err = s.db.Find(asserts.SnapDeclarationType, map[string]string{ + "series": "16", + "snap-id": "foo-id", + }) + c.Assert(err, IsNil) +} + +func (s *batchSuite) TestCommitMissing(c *C) { + // store key already present + err := s.db.Add(s.storeSigning.StoreAccountKey("")) + c.Assert(err, IsNil) + + batch := asserts.NewBatch(nil) + + snapDeclFoo := s.snapDecl(c, "foo", nil) + + err = batch.Add(snapDeclFoo) + c.Assert(err, IsNil) + + err = batch.CommitTo(s.db, nil) + c.Check(err, ErrorMatches, `cannot resolve prerequisite assertion: account.*`) +} + +func (s *batchSuite) TestPrecheckPartial(c *C) { + // store key already present + err := s.db.Add(s.storeSigning.StoreAccountKey("")) + c.Assert(err, IsNil) + + batch := asserts.NewBatch(nil) + + snapDeclFoo := s.snapDecl(c, "foo", nil) + + err = batch.Add(snapDeclFoo) + c.Assert(err, IsNil) + err = batch.Add(s.dev1Acct) + c.Assert(err, IsNil) + + // too old + rev := 1 + headers := map[string]interface{}{ + "snap-id": "foo-id", + "snap-sha3-384": makeDigest(rev), + "snap-size": fmt.Sprintf("%d", len(fakeSnap(rev))), + "snap-revision": fmt.Sprintf("%d", rev), + "developer-id": s.dev1Acct.AccountID(), + "timestamp": time.Time{}.Format(time.RFC3339), + } + snapRev, err := s.storeSigning.Sign(asserts.SnapRevisionType, headers, nil, "") + c.Assert(err, IsNil) + + err = batch.Add(snapRev) + c.Assert(err, IsNil) + + err = batch.CommitTo(s.db, &asserts.CommitOptions{Precheck: true}) + c.Check(err, ErrorMatches, `(?ms).*validity.*`) + + // nothing was added + _, err = s.db.Find(asserts.SnapDeclarationType, map[string]string{ + "series": "16", + "snap-id": "foo-id", + }) + c.Assert(errors.Is(err, &asserts.NotFoundError{}), Equals, true) +} + +func (s *batchSuite) TestPrecheckHappy(c *C) { + // store key already present + err := s.db.Add(s.storeSigning.StoreAccountKey("")) + c.Assert(err, IsNil) + + batch := asserts.NewBatch(nil) + + snapDeclFoo := s.snapDecl(c, "foo", nil) + + err = batch.Add(snapDeclFoo) + c.Assert(err, IsNil) + err = batch.Add(s.dev1Acct) + c.Assert(err, IsNil) + + rev := 1 + revDigest := makeDigest(rev) + headers := map[string]interface{}{ + "snap-id": "foo-id", + "snap-sha3-384": revDigest, + "snap-size": fmt.Sprintf("%d", len(fakeSnap(rev))), + "snap-revision": fmt.Sprintf("%d", rev), + "developer-id": s.dev1Acct.AccountID(), + "timestamp": time.Now().Format(time.RFC3339), + } + snapRev, err := s.storeSigning.Sign(asserts.SnapRevisionType, headers, nil, "") + c.Assert(err, IsNil) + + err = batch.Add(snapRev) + c.Assert(err, IsNil) + + // test precheck on its own + err = batch.DoPrecheck(s.db) + c.Assert(err, IsNil) + + // nothing was added yet + _, err = s.db.Find(asserts.SnapDeclarationType, map[string]string{ + "series": "16", + "snap-id": "foo-id", + }) + c.Assert(errors.Is(err, &asserts.NotFoundError{}), Equals, true) + + // commit (with precheck) + err = batch.CommitTo(s.db, &asserts.CommitOptions{Precheck: true}) + c.Assert(err, IsNil) + + _, err = s.db.Find(asserts.SnapRevisionType, map[string]string{ + "snap-sha3-384": revDigest, + }) + c.Check(err, IsNil) +} + +func (s *batchSuite) TestFetch(c *C) { + err := s.db.Add(s.storeSigning.StoreAccountKey("")) + c.Assert(err, IsNil) + + s.snapDecl(c, "foo", nil) + + rev := 10 + revDigest := makeDigest(rev) + headers := map[string]interface{}{ + "snap-id": "foo-id", + "snap-sha3-384": revDigest, + "snap-size": fmt.Sprintf("%d", len(fakeSnap(rev))), + "snap-revision": fmt.Sprintf("%d", rev), + "developer-id": s.dev1Acct.AccountID(), + "timestamp": time.Now().Format(time.RFC3339), + } + snapRev, err := s.storeSigning.Sign(asserts.SnapRevisionType, headers, nil, "") + c.Assert(err, IsNil) + + err = s.storeSigning.Add(snapRev) + c.Assert(err, IsNil) + ref := snapRev.Ref() + + batch := asserts.NewBatch(nil) + + // retrieve from storeSigning + retrieve := func(ref *asserts.Ref) (asserts.Assertion, error) { + return ref.Resolve(s.storeSigning.Find) + } + // fetching the snap-revision + fetching := func(f asserts.Fetcher) error { + return f.Fetch(ref) + } + + err = batch.Fetch(s.db, retrieve, fetching) + c.Assert(err, IsNil) + + // nothing was added yet + _, err = s.db.Find(asserts.SnapDeclarationType, map[string]string{ + "series": "16", + "snap-id": "foo-id", + }) + c.Assert(errors.Is(err, &asserts.NotFoundError{}), Equals, true) + + // commit + err = batch.CommitTo(s.db, nil) + c.Assert(err, IsNil) + + _, err = s.db.Find(asserts.SnapRevisionType, map[string]string{ + "snap-sha3-384": revDigest, + }) + c.Check(err, IsNil) +} diff --git a/asserts/constraint.go b/asserts/constraint.go new file mode 100644 index 00000000..89c2487a --- /dev/null +++ b/asserts/constraint.go @@ -0,0 +1,464 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2015-2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts + +import ( + "fmt" + "reflect" + "regexp" + "strconv" + "strings" + + "github.com/snapcore/snapd/strutil" +) + +const ( + // feature label for $SLOT()/$PLUG()/$MISSING + dollarAttrConstraintsFeature = "dollar-attr-constraints" + // feature label for alt attribute matcher usage + altAttrMatcherFeature = "alt-attr-matcher" +) + +type attrMatchingContext struct { + // attrWord is the usage context word for "attribute", mainly + // useful in errors + attrWord string + helper AttrMatchContext +} + +type attrMatcher interface { + match(apath string, v interface{}, ctx *attrMatchingContext) error + + feature(flabel string) bool +} + +func chain(path, k string) string { + if path == "" { + return k + } + return fmt.Sprintf("%s.%s", path, k) +} + +type compileAttrMatcherOptions struct { + allowedOperations []string +} + +type compileContext struct { + dotted string + hadMap bool + wasAlt bool + + opts *compileAttrMatcherOptions +} + +func (cc compileContext) String() string { + return cc.dotted +} + +func (cc compileContext) keyEntry(k string) compileContext { + return compileContext{ + dotted: chain(cc.dotted, k), + hadMap: true, + wasAlt: false, + opts: cc.opts, + } +} + +func (cc compileContext) alt(alt int) compileContext { + return compileContext{ + dotted: fmt.Sprintf("%s/alt#%d/", cc.dotted, alt+1), + hadMap: cc.hadMap, + wasAlt: true, + opts: cc.opts, + } +} + +// compileAttrMatcher compiles an attrMatcher derived from constraints, +func compileAttrMatcher(cc compileContext, constraints interface{}) (attrMatcher, error) { + switch x := constraints.(type) { + case map[string]interface{}: + return compileMapAttrMatcher(cc, x) + case []interface{}: + if cc.wasAlt { + return nil, fmt.Errorf("cannot nest alternative constraints directly at %q", cc) + } + return compileAltAttrMatcher(cc, x) + case string: + if !cc.hadMap { + return nil, fmt.Errorf("first level of non alternative constraints must be a set of key-value contraints") + } + if strings.HasPrefix(x, "$") { + if x == "$MISSING" { + return missingAttrMatcher{}, nil + } + return compileEvalAttrMatcher(cc, x) + } + return compileRegexpAttrMatcher(cc, x) + default: + c := cc.String() + if c == "" { + c = "top constraint" + } else { + c = fmt.Sprintf("constraint %q", c) + } + return nil, fmt.Errorf("%s must be a key-value map, regexp or a list of alternative constraints: %v", c, x) + } +} + +type mapAttrMatcher map[string]attrMatcher + +func compileMapAttrMatcher(cc compileContext, m map[string]interface{}) (attrMatcher, error) { + matcher := make(mapAttrMatcher) + for k, constraint := range m { + matcher1, err := compileAttrMatcher(cc.keyEntry(k), constraint) + if err != nil { + return nil, err + } + matcher[k] = matcher1 + } + return matcher, nil +} + +func matchEntry(apath, k string, matcher1 attrMatcher, v interface{}, ctx *attrMatchingContext) error { + apath = chain(apath, k) + // every entry matcher expects the attribute to be set except for $MISSING + if _, ok := matcher1.(missingAttrMatcher); !ok && v == nil { + return fmt.Errorf("%s %q has constraints but is unset", ctx.attrWord, apath) + } + if err := matcher1.match(apath, v, ctx); err != nil { + return err + } + return nil +} + +func matchList(apath string, matcher attrMatcher, l []interface{}, ctx *attrMatchingContext) error { + for i, elem := range l { + if err := matcher.match(chain(apath, strconv.Itoa(i)), elem, ctx); err != nil { + return err + } + } + return nil +} + +func (matcher mapAttrMatcher) feature(flabel string) bool { + for _, matcher1 := range matcher { + if matcher1.feature(flabel) { + return true + } + } + return false +} + +func (matcher mapAttrMatcher) match(apath string, v interface{}, ctx *attrMatchingContext) error { + switch x := v.(type) { + case Attrer: + // we get Atter from root-level Check (apath is "") + for k, matcher1 := range matcher { + v, _ := x.Lookup(k) + if err := matchEntry("", k, matcher1, v, ctx); err != nil { + return err + } + } + case map[string]interface{}: // maps in attributes look like this + for k, matcher1 := range matcher { + if err := matchEntry(apath, k, matcher1, x[k], ctx); err != nil { + return err + } + } + case []interface{}: + return matchList(apath, matcher, x, ctx) + default: + return fmt.Errorf("%s %q must be a map", ctx.attrWord, apath) + } + return nil +} + +type missingAttrMatcher struct{} + +func (matcher missingAttrMatcher) feature(flabel string) bool { + return flabel == dollarAttrConstraintsFeature +} + +func (matcher missingAttrMatcher) match(apath string, v interface{}, ctx *attrMatchingContext) error { + if v != nil { + return fmt.Errorf("%s %q is constrained to be missing but is set", ctx.attrWord, apath) + } + return nil +} + +type evalAttrMatcher struct { + // first iteration supports just $(SLOT|PLUG)(arg) + op string + arg string +} + +var ( + validEvalAttrMatcher = regexp.MustCompile(`^\$([A-Z]+)\(([^,]+)(?:,([^,]+))?\)$`) + validEvalAttrMatcherOps = map[string]bool{ + "PLUG": true, + "SLOT": true, + } +) + +func compileEvalAttrMatcher(cc compileContext, s string) (attrMatcher, error) { + if len(cc.opts.allowedOperations) == 0 { + return nil, fmt.Errorf("cannot compile %q constraint %q: no $OP() constraints supported", cc, s) + } + ops := validEvalAttrMatcher.FindStringSubmatch(s) + if len(ops) == 0 || !validEvalAttrMatcherOps[ops[1]] || !strutil.ListContains(cc.opts.allowedOperations, ops[1]) { + oplst := make([]string, 0, len(cc.opts.allowedOperations)) + for _, op := range cc.opts.allowedOperations { + oplst = append(oplst, fmt.Sprintf("$%s()", op)) + } + return nil, fmt.Errorf("cannot compile %q constraint %q: not a valid %s constraint", cc, s, strings.Join(oplst, "/")) + } + if ops[3] != "" { + return nil, fmt.Errorf("cannot compile %q constraint %q: $%s() constraint expects 1 argument", cc, s, ops[1]) + } + return evalAttrMatcher{ + op: ops[1], + arg: ops[2], + }, nil +} + +func (matcher evalAttrMatcher) feature(flabel string) bool { + return flabel == dollarAttrConstraintsFeature +} + +func (matcher evalAttrMatcher) match(apath string, v interface{}, ctx *attrMatchingContext) error { + if ctx.helper == nil { + return fmt.Errorf("%s %q cannot be matched without context", ctx.attrWord, apath) + } + var comp func(string) (interface{}, error) + switch matcher.op { + case "SLOT": + comp = ctx.helper.SlotAttr + case "PLUG": + comp = ctx.helper.PlugAttr + } + v1, err := comp(matcher.arg) + if err != nil { + return fmt.Errorf("%s %q constraint $%s(%s) cannot be evaluated: %v", ctx.attrWord, apath, matcher.op, matcher.arg, err) + } + if !reflect.DeepEqual(v, v1) { + return fmt.Errorf("%s %q does not match $%s(%s): %v != %v", ctx.attrWord, apath, matcher.op, matcher.arg, v, v1) + } + return nil +} + +type regexpAttrMatcher struct { + *regexp.Regexp +} + +func compileRegexpAttrMatcher(cc compileContext, s string) (attrMatcher, error) { + rx, err := regexp.Compile("^(" + s + ")$") + if err != nil { + return nil, fmt.Errorf("cannot compile %q constraint %q: %v", cc, s, err) + } + return regexpAttrMatcher{rx}, nil +} + +func (matcher regexpAttrMatcher) feature(flabel string) bool { + return false +} + +func (matcher regexpAttrMatcher) match(apath string, v interface{}, ctx *attrMatchingContext) error { + var s string + switch x := v.(type) { + case string: + s = x + case bool: + s = strconv.FormatBool(x) + case int64: + s = strconv.FormatInt(x, 10) + case []interface{}: + return matchList(apath, matcher, x, ctx) + default: + return fmt.Errorf("%s %q must be a scalar or list", ctx.attrWord, apath) + } + if !matcher.Regexp.MatchString(s) { + return fmt.Errorf("%s %q value %q does not match %v", ctx.attrWord, apath, s, matcher.Regexp) + } + return nil + +} + +type altAttrMatcher struct { + alts []attrMatcher +} + +func compileAltAttrMatcher(cc compileContext, l []interface{}) (attrMatcher, error) { + alts := make([]attrMatcher, len(l)) + for i, constraint := range l { + matcher1, err := compileAttrMatcher(cc.alt(i), constraint) + if err != nil { + return nil, err + } + alts[i] = matcher1 + } + return altAttrMatcher{alts}, nil + +} + +func (matcher altAttrMatcher) feature(flabel string) bool { + if flabel == altAttrMatcherFeature { + return true + } + for _, alt := range matcher.alts { + if alt.feature(flabel) { + return true + } + } + return false +} + +func (matcher altAttrMatcher) match(apath string, v interface{}, ctx *attrMatchingContext) error { + // if the value is a list apply the alternative matcher to each element + // like we do for other matchers + switch x := v.(type) { + case []interface{}: + return matchList(apath, matcher, x, ctx) + default: + } + + var firstErr error + for _, alt := range matcher.alts { + err := alt.match(apath, v, ctx) + if err == nil { + return nil + } + if firstErr == nil { + firstErr = err + } + } + apathDescr := "" + if apath != "" { + apathDescr = fmt.Sprintf(" for %s %q", ctx.attrWord, apath) + } + return fmt.Errorf("no alternative%s matches: %v", apathDescr, firstErr) +} + +// DeviceScopeConstraint specifies a constraint based on which brand +// store, brand or model the device belongs to. +type DeviceScopeConstraint struct { + Store []string + Brand []string + // Model is a list of precise "/" constraints + Model []string +} + +var ( + validStoreID = regexp.MustCompile("^[-A-Z0-9a-z_]+$") + validBrandSlashModel = regexp.MustCompile("^(" + + strings.Trim(validAccountID.String(), "^$") + + ")/(" + + strings.Trim(validModel.String(), "^$") + + ")$") + deviceScopeConstraints = map[string]*regexp.Regexp{ + "on-store": validStoreID, + "on-brand": validAccountID, + // on-model constraints are of the form list of + // / strings where are account + // IDs as they appear in the respective model assertion + "on-model": validBrandSlashModel, + } +) + +func detectDeviceScopeConstraint(cMap map[string]interface{}) bool { + // for consistency and simplicity we support all of on-store, + // on-brand, and on-model to appear together. The interpretation + // layer will AND them as usual + for field := range deviceScopeConstraints { + if cMap[field] != nil { + return true + } + } + return false +} + +// compileDeviceScopeConstraint compiles a DeviceScopeConstraint out of cMap, +// it returns nil and no error if there are no on-store/on-brand/on-model +// constraints in cMap +func compileDeviceScopeConstraint(cMap map[string]interface{}, context string) (constr *DeviceScopeConstraint, err error) { + if !detectDeviceScopeConstraint(cMap) { + return nil, nil + } + // initial map size of 2: we expect usual cases to have just one of the + // constraints or rarely 2 + deviceConstr := make(map[string][]string, 2) + for field, validRegexp := range deviceScopeConstraints { + vals, err := checkStringListInMap(cMap, field, fmt.Sprintf("%s in %s", field, context), validRegexp) + if err != nil { + return nil, err + } + deviceConstr[field] = vals + } + + return &DeviceScopeConstraint{ + Store: deviceConstr["on-store"], + Brand: deviceConstr["on-brand"], + Model: deviceConstr["on-model"], + }, nil +} + +type DeviceScopeConstraintCheckOptions struct { + UseFriendlyStores bool +} + +// Check tests whether the model and the optional store match the constraints. +func (c *DeviceScopeConstraint) Check(model *Model, store *Store, opts *DeviceScopeConstraintCheckOptions) error { + if model == nil { + return fmt.Errorf("cannot match on-store/on-brand/on-model without model") + } + if store != nil && store.Store() != model.Store() { + return fmt.Errorf("store assertion and model store must match") + } + if opts == nil { + opts = &DeviceScopeConstraintCheckOptions{} + } + if len(c.Store) != 0 { + if !strutil.ListContains(c.Store, model.Store()) { + mismatch := true + if store != nil && opts.UseFriendlyStores { + for _, sto := range c.Store { + if strutil.ListContains(store.FriendlyStores(), sto) { + mismatch = false + break + } + } + } + if mismatch { + return fmt.Errorf("on-store mismatch") + } + } + } + if len(c.Brand) != 0 { + if !strutil.ListContains(c.Brand, model.BrandID()) { + return fmt.Errorf("on-brand mismatch") + } + } + if len(c.Model) != 0 { + brandModel := fmt.Sprintf("%s/%s", model.BrandID(), model.Model()) + if !strutil.ListContains(c.Model, brandModel) { + return fmt.Errorf("on-model mismatch") + } + } + return nil +} diff --git a/asserts/constraint_test.go b/asserts/constraint_test.go new file mode 100644 index 00000000..c3415ac0 --- /dev/null +++ b/asserts/constraint_test.go @@ -0,0 +1,822 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2015-2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts_test + +import ( + "fmt" + "regexp" + + . "gopkg.in/check.v1" + "gopkg.in/yaml.v2" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/metautil" + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/testutil" +) + +type attrMatcherSuite struct { + testutil.BaseTest +} + +var _ = Suite(&attrMatcherSuite{}) + +func vals(yml string) map[string]interface{} { + var vs map[string]interface{} + err := yaml.Unmarshal([]byte(yml), &vs) + if err != nil { + panic(err) + } + v, err := metautil.NormalizeValue(vs) + if err != nil { + panic(err) + } + return v.(map[string]interface{}) +} + +func (s *attrMatcherSuite) SetUpTest(c *C) { + s.BaseTest.SetUpTest(c) + s.BaseTest.AddCleanup(snap.MockSanitizePlugsSlots(func(snapInfo *snap.Info) {})) +} + +func (s *attrMatcherSuite) TestSimple(c *C) { + m, err := asserts.ParseHeaders([]byte(`attrs: + foo: FOO + bar: BAR`)) + c.Assert(err, IsNil) + + domatch, err := asserts.CompileAttrMatcher(m["attrs"].(map[string]interface{}), nil) + c.Assert(err, IsNil) + + values := map[string]interface{}{ + "foo": "FOO", + "bar": "BAR", + "baz": "BAZ", + } + err = domatch(values, nil) + c.Check(err, IsNil) + + values = map[string]interface{}{ + "foo": "FOO", + "bar": "BAZ", + "baz": "BAZ", + } + err = domatch(values, nil) + c.Check(err, ErrorMatches, `field "bar" value "BAZ" does not match \^\(BAR\)\$`) + + values = map[string]interface{}{ + "foo": "FOO", + "baz": "BAZ", + } + err = domatch(values, nil) + c.Check(err, ErrorMatches, `field "bar" has constraints but is unset`) +} + +func (s *attrMatcherSuite) TestSimpleAnchorsVsRegexpAlt(c *C) { + m, err := asserts.ParseHeaders([]byte(`attrs: + bar: BAR|BAZ`)) + c.Assert(err, IsNil) + + domatch, err := asserts.CompileAttrMatcher(m["attrs"].(map[string]interface{}), nil) + c.Assert(err, IsNil) + + values := map[string]interface{}{ + "bar": "BAR", + } + err = domatch(values, nil) + c.Check(err, IsNil) + + values = map[string]interface{}{ + "bar": "BARR", + } + err = domatch(values, nil) + c.Check(err, ErrorMatches, `field "bar" value "BARR" does not match \^\(BAR|BAZ\)\$`) + + values = map[string]interface{}{ + "bar": "BBAZ", + } + err = domatch(values, nil) + c.Check(err, ErrorMatches, `field "bar" value "BAZZ" does not match \^\(BAR|BAZ\)\$`) + + values = map[string]interface{}{ + "bar": "BABAZ", + } + err = domatch(values, nil) + c.Check(err, ErrorMatches, `field "bar" value "BABAZ" does not match \^\(BAR|BAZ\)\$`) + + values = map[string]interface{}{ + "bar": "BARAZ", + } + err = domatch(values, nil) + c.Check(err, ErrorMatches, `field "bar" value "BARAZ" does not match \^\(BAR|BAZ\)\$`) +} + +func (s *attrMatcherSuite) TestNested(c *C) { + m, err := asserts.ParseHeaders([]byte(`attrs: + foo: FOO + bar: + bar1: BAR1 + bar2: BAR2`)) + c.Assert(err, IsNil) + + domatch, err := asserts.CompileAttrMatcher(m["attrs"].(map[string]interface{}), nil) + c.Assert(err, IsNil) + + err = domatch(vals(` +foo: FOO +bar: + bar1: BAR1 + bar2: BAR2 + bar3: BAR3 +baz: BAZ +`), nil) + c.Check(err, IsNil) + + err = domatch(vals(` +foo: FOO +bar: BAZ +baz: BAZ +`), nil) + c.Check(err, ErrorMatches, `field "bar" must be a map`) + + err = domatch(vals(` +foo: FOO +bar: + bar1: BAR1 + bar2: BAR22 + bar3: BAR3 +baz: BAZ +`), nil) + c.Check(err, ErrorMatches, `field "bar\.bar2" value "BAR22" does not match \^\(BAR2\)\$`) + + err = domatch(vals(` +foo: FOO +bar: + bar1: BAR1 + bar2: + bar22: true + bar3: BAR3 +baz: BAZ +`), nil) + c.Check(err, ErrorMatches, `field "bar\.bar2" must be a scalar or list`) +} + +func (s *attrMatcherSuite) TestAlternative(c *C) { + m, err := asserts.ParseHeaders([]byte(`attrs: + - + foo: FOO + bar: BAR + - + foo: FOO + bar: BAZ`)) + c.Assert(err, IsNil) + + domatch, err := asserts.CompileAttrMatcher(m["attrs"], nil) + c.Assert(err, IsNil) + + values := map[string]interface{}{ + "foo": "FOO", + "bar": "BAR", + "baz": "BAZ", + } + err = domatch(values, nil) + c.Check(err, IsNil) + + values = map[string]interface{}{ + "foo": "FOO", + "bar": "BAZ", + "baz": "BAZ", + } + err = domatch(values, nil) + c.Check(err, IsNil) + + values = map[string]interface{}{ + "foo": "FOO", + "bar": "BARR", + "baz": "BAR", + } + err = domatch(values, nil) + c.Check(err, ErrorMatches, `no alternative matches: field "bar" value "BARR" does not match \^\(BAR\)\$`) +} + +func (s *attrMatcherSuite) TestNestedAlternative(c *C) { + m, err := asserts.ParseHeaders([]byte(`attrs: + foo: FOO + bar: + bar1: BAR1 + bar2: + - BAR2 + - BAR22`)) + c.Assert(err, IsNil) + + domatch, err := asserts.CompileAttrMatcher(m["attrs"].(map[string]interface{}), nil) + c.Assert(err, IsNil) + + err = domatch(vals(` +foo: FOO +bar: + bar1: BAR1 + bar2: BAR2 +`), nil) + c.Check(err, IsNil) + + err = domatch(vals(` +foo: FOO +bar: + bar1: BAR1 + bar2: BAR22 +`), nil) + c.Check(err, IsNil) + + err = domatch(vals(` +foo: FOO +bar: + bar1: BAR1 + bar2: BAR3 +`), nil) + c.Check(err, ErrorMatches, `no alternative for field "bar\.bar2" matches: field "bar\.bar2" value "BAR3" does not match \^\(BAR2\)\$`) +} + +func (s *attrMatcherSuite) TestAlternativeMatchingStringList(c *C) { + toMatch := vals(` +write: + - /var/tmp + - /var/lib/snapd/snapshots +`) + m, err := asserts.ParseHeaders([]byte(`attrs: + write: /var/(tmp|lib/snapd/snapshots)`)) + c.Assert(err, IsNil) + + domatch, err := asserts.CompileAttrMatcher(m["attrs"].(map[string]interface{}), nil) + c.Assert(err, IsNil) + + err = domatch(toMatch, nil) + c.Check(err, IsNil) + + m, err = asserts.ParseHeaders([]byte(`attrs: + write: + - /var/tmp + - /var/lib/snapd/snapshots`)) + c.Assert(err, IsNil) + + domatchLst, err := asserts.CompileAttrMatcher(m["attrs"].(map[string]interface{}), nil) + c.Assert(err, IsNil) + + err = domatchLst(toMatch, nil) + c.Check(err, IsNil) +} + +func (s *attrMatcherSuite) TestAlternativeMatchingComplex(c *C) { + toMatch := vals(` +mnt: [{what: "/dev/x*", where: "/foo/*", options: ["rw", "nodev"]}, {what: "/bar/*", where: "/baz/*", options: ["rw", "bind"]}] +`) + + m, err := asserts.ParseHeaders([]byte(`attrs: + mnt: + - + what: /(bar/|dev/x)\* + where: /(foo|baz)/\* + options: rw|bind|nodev`)) + c.Assert(err, IsNil) + + domatch, err := asserts.CompileAttrMatcher(m["attrs"].(map[string]interface{}), nil) + c.Assert(err, IsNil) + + err = domatch(toMatch, nil) + c.Check(err, IsNil) + + m, err = asserts.ParseHeaders([]byte(`attrs: + mnt: + - + what: /dev/x\* + where: /foo/\* + options: + - nodev + - rw + - + what: /bar/\* + where: /baz/\* + options: + - rw + - bind`)) + c.Assert(err, IsNil) + + domatchExtensive, err := asserts.CompileAttrMatcher(m["attrs"].(map[string]interface{}), nil) + c.Assert(err, IsNil) + + err = domatchExtensive(toMatch, nil) + c.Check(err, IsNil) + + // not matching case + m, err = asserts.ParseHeaders([]byte(`attrs: + mnt: + - + what: /dev/x\* + where: /foo/\* + options: + - rw + - + what: /bar/\* + where: /baz/\* + options: + - rw + - bind`)) + c.Assert(err, IsNil) + + domatchExtensiveNoMatch, err := asserts.CompileAttrMatcher(m["attrs"].(map[string]interface{}), nil) + c.Assert(err, IsNil) + + err = domatchExtensiveNoMatch(toMatch, nil) + c.Check(err, ErrorMatches, `no alternative for field "mnt\.0" matches: no alternative for field "mnt\.0.options\.1" matches:.*`) +} + +func (s *attrMatcherSuite) TestOtherScalars(c *C) { + m, err := asserts.ParseHeaders([]byte(`attrs: + foo: 1 + bar: true`)) + c.Assert(err, IsNil) + + domatch, err := asserts.CompileAttrMatcher(m["attrs"].(map[string]interface{}), nil) + c.Assert(err, IsNil) + + err = domatch(vals(` +foo: 1 +bar: true +`), nil) + c.Check(err, IsNil) + + values := map[string]interface{}{ + "foo": int64(1), + "bar": true, + } + err = domatch(values, nil) + c.Check(err, IsNil) +} + +func (s *attrMatcherSuite) TestCompileErrors(c *C) { + _, err := asserts.CompileAttrMatcher(1, nil) + c.Check(err, ErrorMatches, `top constraint must be a key-value map, regexp or a list of alternative constraints: 1`) + + _, err = asserts.CompileAttrMatcher(map[string]interface{}{ + "foo": 1, + }, nil) + c.Check(err, ErrorMatches, `constraint "foo" must be a key-value map, regexp or a list of alternative constraints: 1`) + + _, err = asserts.CompileAttrMatcher(map[string]interface{}{ + "foo": "[", + }, nil) + c.Check(err, ErrorMatches, `cannot compile "foo" constraint "\[": error parsing regexp:.*`) + + _, err = asserts.CompileAttrMatcher(map[string]interface{}{ + "foo": []interface{}{"foo", "["}, + }, nil) + c.Check(err, ErrorMatches, `cannot compile "foo/alt#2/" constraint "\[": error parsing regexp:.*`) + + _, err = asserts.CompileAttrMatcher(map[string]interface{}{ + "foo": []interface{}{"foo", []interface{}{"bar", "baz"}}, + }, nil) + c.Check(err, ErrorMatches, `cannot nest alternative constraints directly at "foo/alt#2/"`) + + _, err = asserts.CompileAttrMatcher("FOO", nil) + c.Check(err, ErrorMatches, `first level of non alternative constraints must be a set of key-value contraints`) + + _, err = asserts.CompileAttrMatcher([]interface{}{"FOO"}, nil) + c.Check(err, ErrorMatches, `first level of non alternative constraints must be a set of key-value contraints`) + + _, err = asserts.CompileAttrMatcher(map[string]interface{}{ + "foo": "$FOO()", + }, nil) + c.Check(err, ErrorMatches, `cannot compile "foo" constraint "\$FOO\(\)": no \$OP\(\) constraints supported`) + + wrongDollarConstraints := []string{ + "$", + "$FOO(a)", + "$SLOT", + "$SLOT()", + "$SLOT(x,y)", + "$SLOT(x,y,z)", + } + + for _, wrong := range wrongDollarConstraints { + _, err := asserts.CompileAttrMatcher(map[string]interface{}{ + "foo": wrong, + }, []string{"SLOT", "OP"}) + if wrong != "$SLOT(x,y)" { + c.Check(err, ErrorMatches, fmt.Sprintf(`cannot compile "foo" constraint "%s": not a valid \$SLOT\(\)/\$OP\(\) constraint`, regexp.QuoteMeta(wrong))) + } else { + c.Check(err, ErrorMatches, fmt.Sprintf(`cannot compile "foo" constraint "%s": \$SLOT\(\) constraint expects 1 argument`, regexp.QuoteMeta(wrong))) + } + + } +} + +func (s *attrMatcherSuite) TestMatchingListsSimple(c *C) { + m, err := asserts.ParseHeaders([]byte(`attrs: + foo: /foo/.*`)) + c.Assert(err, IsNil) + + domatch, err := asserts.CompileAttrMatcher(m["attrs"].(map[string]interface{}), nil) + c.Assert(err, IsNil) + + err = domatch(vals(` +foo: ["/foo/x", "/foo/y"] +`), nil) + c.Check(err, IsNil) + + err = domatch(vals(` +foo: ["/foo/x", "/foo"] +`), nil) + c.Check(err, ErrorMatches, `field "foo\.1" value "/foo" does not match \^\(/foo/\.\*\)\$`) +} + +func (s *attrMatcherSuite) TestMissingCheck(c *C) { + m, err := asserts.ParseHeaders([]byte(`attrs: + foo: $MISSING`)) + c.Assert(err, IsNil) + + domatch, err := asserts.CompileAttrMatcher(m["attrs"].(map[string]interface{}), nil) + c.Assert(err, IsNil) + + err = domatch(vals(` +bar: baz +`), nil) + c.Check(err, IsNil) + + err = domatch(vals(` +foo: ["x"] +`), nil) + c.Check(err, ErrorMatches, `field "foo" is constrained to be missing but is set`) +} + +func (s *attrMatcherSuite) TestEvalCheck(c *C) { + // TODO: consider rewriting once we have $WITHIN + m, err := asserts.ParseHeaders([]byte(`attrs: + foo: $SLOT(foo) + bar: $PLUG(bar.baz)`)) + c.Assert(err, IsNil) + + domatch, err := asserts.CompileAttrMatcher(m["attrs"].(map[string]interface{}), []string{"SLOT", "PLUG"}) + c.Assert(err, IsNil) + + err = domatch(vals(` +foo: foo +bar: bar +`), nil) + c.Check(err, ErrorMatches, `field "(foo|bar)" cannot be matched without context`) + + calls := make(map[[2]string]bool) + comp1 := func(op string, arg string) (interface{}, error) { + calls[[2]string{op, arg}] = true + return arg, nil + } + + err = domatch(vals(` +foo: foo +bar: bar.baz +`), testEvalAttr{comp1}) + c.Check(err, IsNil) + + c.Check(calls, DeepEquals, map[[2]string]bool{ + {"slot", "foo"}: true, + {"plug", "bar.baz"}: true, + }) + + comp2 := func(op string, arg string) (interface{}, error) { + if op == "plug" { + return nil, fmt.Errorf("boom") + } + return arg, nil + } + + err = domatch(vals(` +foo: foo +bar: bar.baz +`), testEvalAttr{comp2}) + c.Check(err, ErrorMatches, `field "bar" constraint \$PLUG\(bar\.baz\) cannot be evaluated: boom`) + + comp3 := func(op string, arg string) (interface{}, error) { + if op == "slot" { + return "other-value", nil + } + return arg, nil + } + + err = domatch(vals(` +foo: foo +bar: bar.baz +`), testEvalAttr{comp3}) + c.Check(err, ErrorMatches, `field "foo" does not match \$SLOT\(foo\): foo != other-value`) +} + +func (s *attrMatcherSuite) TestMatchingListsMap(c *C) { + m, err := asserts.ParseHeaders([]byte(`attrs: + foo: + p: /foo/.*`)) + c.Assert(err, IsNil) + + domatch, err := asserts.CompileAttrMatcher(m["attrs"].(map[string]interface{}), nil) + c.Assert(err, IsNil) + + err = domatch(vals(` +foo: [{p: "/foo/x"}, {p: "/foo/y"}] +`), nil) + c.Check(err, IsNil) + + err = domatch(vals(` +foo: [{p: "zzz"}, {p: "/foo/y"}] +`), nil) + c.Check(err, ErrorMatches, `field "foo\.0\.p" value "zzz" does not match \^\(/foo/\.\*\)\$`) +} + +type deviceScopeConstraintSuite struct { + testutil.BaseTest +} + +var _ = Suite(&deviceScopeConstraintSuite{}) + +func (s *deviceScopeConstraintSuite) TestCompile(c *C) { + tests := []struct { + m map[string]interface{} + exp *asserts.DeviceScopeConstraint + err string + }{ + {m: nil, exp: nil}, + {m: map[string]interface{}{"on-store": []interface{}{"foo", "bar"}}, exp: &asserts.DeviceScopeConstraint{Store: []string{"foo", "bar"}}}, + {m: map[string]interface{}{"on-brand": []interface{}{"foo", "bar"}}, exp: &asserts.DeviceScopeConstraint{Brand: []string{"foo", "bar"}}}, + {m: map[string]interface{}{"on-model": []interface{}{"foo/model1", "bar/model-2"}}, exp: &asserts.DeviceScopeConstraint{Model: []string{"foo/model1", "bar/model-2"}}}, + { + m: map[string]interface{}{ + "on-brand": []interface{}{"foo", "bar"}, + "on-model": []interface{}{"foo/model1", "bar/model-2"}, + "on-store": []interface{}{"foo", "bar"}, + }, + exp: &asserts.DeviceScopeConstraint{ + Store: []string{"foo", "bar"}, + Brand: []string{"foo", "bar"}, + Model: []string{"foo/model1", "bar/model-2"}, + }, + }, + {m: map[string]interface{}{"on-store": ""}, err: `on-store in constraint must be a list of strings`}, + {m: map[string]interface{}{"on-brand": "foo"}, err: `on-brand in constraint must be a list of strings`}, + {m: map[string]interface{}{"on-model": map[string]interface{}{"brand": "x"}}, err: `on-model in constraint must be a list of strings`}, + } + + for _, t := range tests { + dsc, err := asserts.CompileDeviceScopeConstraint(t.m, "constraint") + if t.err == "" { + c.Check(err, IsNil) + c.Check(dsc, DeepEquals, t.exp) + } else { + c.Check(err, ErrorMatches, t.err) + c.Check(dsc, IsNil) + } + } +} + +func (s *deviceScopeConstraintSuite) TestValidOnStoreBrandModel(c *C) { + tests := []struct { + constr string + value string + valid bool + }{ + {"on-store", "", false}, + {"on-store", "foo", true}, + {"on-store", "F_o-O88", true}, + {"on-store", "foo!", false}, + {"on-store", "foo.", false}, + {"on-store", "foo/", false}, + {"on-brand", "", false}, + // custom set brands (length 2-28) + {"on-brand", "dwell", true}, + {"on-brand", "Dwell", false}, + {"on-brand", "dwell-88", true}, + {"on-brand", "dwell_88", false}, + {"on-brand", "dwell.88", false}, + {"on-brand", "dwell:88", false}, + {"on-brand", "dwell!88", false}, + {"on-brand", "a", false}, + {"on-brand", "ab", true}, + {"on-brand", "0123456789012345678901234567", true}, + // snappy id brands (fixed length 32) + {"on-brand", "01234567890123456789012345678", false}, + {"on-brand", "012345678901234567890123456789", false}, + {"on-brand", "0123456789012345678901234567890", false}, + {"on-brand", "01234567890123456789012345678901", true}, + {"on-brand", "abcdefghijklmnopqrstuvwxyz678901", true}, + {"on-brand", "ABCDEFGHIJKLMNOPQRSTUVWCYZ678901", true}, + {"on-brand", "ABCDEFGHIJKLMNOPQRSTUVWCYZ678901X", false}, + {"on-brand", "ABCDEFGHIJKLMNOPQ!STUVWCYZ678901", false}, + {"on-brand", "ABCDEFGHIJKLMNOPQ_STUVWCYZ678901", false}, + {"on-brand", "ABCDEFGHIJKLMNOPQ-STUVWCYZ678901", false}, + {"on-model", "", false}, + {"on-model", "/", false}, + {"on-model", "dwell/dwell1", true}, + {"on-model", "dwell", false}, + {"on-model", "dwell/", false}, + {"on-model", "dwell//dwell1", false}, + {"on-model", "dwell/-dwell1", false}, + {"on-model", "dwell/dwell1-", false}, + {"on-model", "dwell/dwell1-23", true}, + {"on-model", "dwell/dwell1!", false}, + {"on-model", "dwell/dwe_ll1", false}, + {"on-model", "dwell/dwe.ll1", false}, + } + + check := func(constr, value string, valid bool) { + cMap := map[string]interface{}{ + constr: []interface{}{value}, + } + + _, err := asserts.CompileDeviceScopeConstraint(cMap, "constraint") + if valid { + c.Check(err, IsNil, Commentf("%v", cMap)) + } else { + c.Check(err, ErrorMatches, fmt.Sprintf(`%s in constraint contains an invalid element: %q`, constr, value), Commentf("%v", cMap)) + } + } + + for _, t := range tests { + check(t.constr, t.value, t.valid) + + if t.constr == "on-brand" { + // reuse and double check all brands also in the context of on-model! + + check("on-model", t.value+"/foo", t.valid) + } + } +} + +func (s *deviceScopeConstraintSuite) TestCheck(c *C) { + a, err := asserts.Decode([]byte(`type: model +authority-id: my-brand +series: 16 +brand-id: my-brand +model: my-model1 +store: store1 +architecture: armhf +kernel: krnl +gadget: gadget +timestamp: 2018-09-12T12:00:00Z +sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij + +AXNpZw==`)) + c.Assert(err, IsNil) + myModel1 := a.(*asserts.Model) + + a, err = asserts.Decode([]byte(`type: model +authority-id: my-brand-subbrand +series: 16 +brand-id: my-brand-subbrand +model: my-model2 +store: store2 +architecture: armhf +kernel: krnl +gadget: gadget +timestamp: 2018-09-12T12:00:00Z +sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij + +AXNpZw==`)) + c.Assert(err, IsNil) + myModel2 := a.(*asserts.Model) + + a, err = asserts.Decode([]byte(`type: model +authority-id: my-brand +series: 16 +brand-id: my-brand +model: my-model3 +store: substore1 +architecture: armhf +kernel: krnl +gadget: gadget +timestamp: 2018-09-12T12:00:00Z +sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij + +AXNpZw==`)) + c.Assert(err, IsNil) + myModel3 := a.(*asserts.Model) + + a, err = asserts.Decode([]byte(`type: store +store: substore1 +authority-id: canonical +operator-id: canonical +friendly-stores: + - a-store + - store1 + - store2 +timestamp: 2018-09-12T12:00:00Z +sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij + +AXNpZw==`)) + c.Assert(err, IsNil) + substore1 := a.(*asserts.Store) + + tests := []struct { + m map[string]interface{} + model *asserts.Model + store *asserts.Store + useFriendlyStores bool + err string + }{ + {m: map[string]interface{}{"on-store": []interface{}{"store1"}}, model: myModel1}, + {m: map[string]interface{}{"on-store": []interface{}{"a-store", "store1"}}, model: myModel1}, + {m: map[string]interface{}{"on-store": []interface{}{"store1"}}, model: myModel2, err: "on-store mismatch"}, + {m: map[string]interface{}{"on-store": []interface{}{"store1"}}, model: myModel3, store: substore1, useFriendlyStores: true}, + {m: map[string]interface{}{"on-store": []interface{}{"store1"}}, model: myModel3, store: substore1, useFriendlyStores: false, err: "on-store mismatch"}, + {m: map[string]interface{}{"on-store": []interface{}{"store1"}}, model: nil, err: `cannot match on-store/on-brand/on-model without model`}, + {m: map[string]interface{}{"on-store": []interface{}{"store1"}}, model: myModel2, store: substore1, err: `store assertion and model store must match`, useFriendlyStores: true}, + {m: map[string]interface{}{"on-store": []interface{}{"other-store"}}, model: myModel3, store: substore1, err: "on-store mismatch"}, + {m: map[string]interface{}{"on-brand": []interface{}{"my-brand"}}, model: myModel1}, + {m: map[string]interface{}{"on-brand": []interface{}{"my-brand", "my-brand-subbrand"}}, model: myModel2}, + {m: map[string]interface{}{"on-brand": []interface{}{"other-brand"}}, model: myModel2, err: "on-brand mismatch"}, + {m: map[string]interface{}{"on-model": []interface{}{"my-brand/my-model1"}}, model: myModel1}, + {m: map[string]interface{}{"on-model": []interface{}{"my-brand/other-model"}}, model: myModel1, err: "on-model mismatch"}, + {m: map[string]interface{}{"on-model": []interface{}{"my-brand/my-model", "my-brand-subbrand/my-model2", "other-brand/other-model"}}, model: myModel2}, + { + m: map[string]interface{}{ + "on-store": []interface{}{"store2"}, + "on-brand": []interface{}{"my-brand", "my-brand-subbrand"}, + "on-model": []interface{}{"my-brand/my-model3", "my-brand-subbrand/my-model2"}, + }, + model: myModel2, + }, { + m: map[string]interface{}{ + "on-store": []interface{}{"store2"}, + "on-brand": []interface{}{"my-brand", "my-brand-subbrand"}, + "on-model": []interface{}{"my-brand/my-model3", "my-brand-subbrand/my-model2"}, + }, + model: myModel3, store: substore1, + useFriendlyStores: true, + }, { + m: map[string]interface{}{ + "on-store": []interface{}{"other-store"}, + "on-brand": []interface{}{"my-brand", "my-brand-subbrand"}, + "on-model": []interface{}{"my-brand/my-model3", "my-brand-subbrand/my-model2"}, + }, + model: myModel3, store: substore1, + useFriendlyStores: true, + err: "on-store mismatch", + }, { + m: map[string]interface{}{ + "on-store": []interface{}{"store2"}, + "on-brand": []interface{}{"other-brand", "my-brand-subbrand"}, + "on-model": []interface{}{"my-brand/my-model3", "my-brand-subbrand/my-model2"}, + }, + model: myModel3, store: substore1, + useFriendlyStores: true, + err: "on-brand mismatch", + }, { + m: map[string]interface{}{ + "on-store": []interface{}{"store2"}, + "on-brand": []interface{}{"my-brand", "my-brand-subbrand"}, + "on-model": []interface{}{"my-brand/my-model1", "my-brand-subbrand/my-model2"}, + }, + model: myModel3, store: substore1, + useFriendlyStores: true, + err: "on-model mismatch", + }, { + m: map[string]interface{}{ + "on-store": []interface{}{"store2"}, + "on-brand": []interface{}{"my-brand", "my-brand-subbrand"}, + "on-model": []interface{}{"my-brand/my-model1", "my-brand-subbrand/my-model2"}, + }, + model: myModel3, store: substore1, + useFriendlyStores: false, + err: "on-store mismatch", + }, + } + + for _, t := range tests { + constr, err := asserts.CompileDeviceScopeConstraint(t.m, "constraint") + c.Assert(err, IsNil) + + var opts *asserts.DeviceScopeConstraintCheckOptions + if t.useFriendlyStores { + opts = &asserts.DeviceScopeConstraintCheckOptions{ + UseFriendlyStores: true, + } + } + err = constr.Check(t.model, t.store, opts) + if t.err == "" { + c.Check(err, IsNil, Commentf("%v", t.m)) + } else { + c.Check(err, ErrorMatches, t.err) + } + } +} diff --git a/asserts/crypto.go b/asserts/crypto.go new file mode 100644 index 00000000..be81d9c1 --- /dev/null +++ b/asserts/crypto.go @@ -0,0 +1,389 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2015-2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts + +import ( + "bytes" + "crypto" + "crypto/rand" + "crypto/rsa" + + // be explicit about supporting SHA256 + _ "crypto/sha256" + + // be explicit about needing SHA512 + _ "crypto/sha512" + "encoding/base64" + "errors" + "fmt" + "io" + "time" + + "golang.org/x/crypto/openpgp/packet" + "golang.org/x/crypto/sha3" +) + +const ( + maxEncodeLineLength = 76 + v1 = 0x1 +) + +var ( + v1Header = []byte{v1} + v1FixedTimestamp = time.Date(2016, time.January, 1, 0, 0, 0, 0, time.UTC) +) + +func encodeV1(data []byte) []byte { + buf := new(bytes.Buffer) + buf.Grow(base64.StdEncoding.EncodedLen(len(data) + 1)) + enc := base64.NewEncoder(base64.StdEncoding, buf) + enc.Write(v1Header) + enc.Write(data) + enc.Close() + flat := buf.Bytes() + flatSize := len(flat) + + buf = new(bytes.Buffer) + buf.Grow(flatSize + flatSize/maxEncodeLineLength + 1) + off := 0 + for { + endOff := off + maxEncodeLineLength + if endOff > flatSize { + endOff = flatSize + } + buf.Write(flat[off:endOff]) + off = endOff + if off >= flatSize { + break + } + buf.WriteByte('\n') + } + + return buf.Bytes() +} + +type keyEncoder interface { + keyEncode(w io.Writer) error +} + +func encodeKey(key keyEncoder, kind string) ([]byte, error) { + buf := new(bytes.Buffer) + err := key.keyEncode(buf) + if err != nil { + return nil, fmt.Errorf("cannot encode %s: %v", kind, err) + } + return encodeV1(buf.Bytes()), nil +} + +type openpgpSigner interface { + sign(content []byte) (*packet.Signature, error) +} + +func signContent(content []byte, privateKey PrivateKey) ([]byte, error) { + signer, ok := privateKey.(openpgpSigner) + if !ok { + panic(fmt.Errorf("not an internally supported PrivateKey: %T", privateKey)) + } + + sig, err := signer.sign(content) + if err != nil { + return nil, err + } + + buf := new(bytes.Buffer) + err = sig.Serialize(buf) + if err != nil { + return nil, err + } + + return encodeV1(buf.Bytes()), nil +} + +func decodeV1(b []byte, kind string) (packet.Packet, error) { + if len(b) == 0 { + return nil, fmt.Errorf("cannot decode %s: no data", kind) + } + buf := make([]byte, base64.StdEncoding.DecodedLen(len(b))) + n, err := base64.StdEncoding.Decode(buf, b) + if err != nil { + return nil, fmt.Errorf("cannot decode %s: %v", kind, err) + } + if n == 0 { + return nil, fmt.Errorf("cannot decode %s: base64 without data", kind) + } + buf = buf[:n] + if buf[0] != v1 { + return nil, fmt.Errorf("unsupported %s format version: %d", kind, buf[0]) + } + rd := bytes.NewReader(buf[1:]) + pkt, err := packet.Read(rd) + if err != nil { + return nil, fmt.Errorf("cannot decode %s: %v", kind, err) + } + if rd.Len() != 0 { + return nil, fmt.Errorf("%s has spurious trailing data", kind) + } + return pkt, nil +} + +func decodeSignature(signature []byte) (*packet.Signature, error) { + pkt, err := decodeV1(signature, "signature") + if err != nil { + return nil, err + } + sig, ok := pkt.(*packet.Signature) + if !ok { + return nil, fmt.Errorf("expected signature, got instead: %T", pkt) + } + return sig, nil +} + +// PublicKey is the public part of a cryptographic private/public key pair. +type PublicKey interface { + // ID returns the id of the key used for lookup. + ID() string + + // verify verifies signature is valid for content using the key. + verify(content []byte, sig *packet.Signature) error + + keyEncoder +} + +type openpgpPubKey struct { + pubKey *packet.PublicKey + sha3_384 string +} + +func (opgPubKey *openpgpPubKey) ID() string { + return opgPubKey.sha3_384 +} + +func (opgPubKey *openpgpPubKey) verify(content []byte, sig *packet.Signature) error { + h := sig.Hash.New() + h.Write(content) + return opgPubKey.pubKey.VerifySignature(h, sig) +} + +func (opgPubKey openpgpPubKey) keyEncode(w io.Writer) error { + return opgPubKey.pubKey.Serialize(w) +} + +func newOpenPGPPubKey(intPubKey *packet.PublicKey) *openpgpPubKey { + h := sha3.New384() + h.Write(v1Header) + err := intPubKey.Serialize(h) + if err != nil { + panic("internal error: cannot compute public key sha3-384") + } + sha3_384, err := EncodeDigest(crypto.SHA3_384, h.Sum(nil)) + if err != nil { + panic("internal error: cannot compute public key sha3-384") + } + return &openpgpPubKey{pubKey: intPubKey, sha3_384: sha3_384} +} + +// RSAPublicKey returns a database useable public key out of rsa.PublicKey. +func RSAPublicKey(pubKey *rsa.PublicKey) PublicKey { + intPubKey := packet.NewRSAPublicKey(v1FixedTimestamp, pubKey) + return newOpenPGPPubKey(intPubKey) +} + +// DecodePublicKey deserializes a public key. +func DecodePublicKey(pubKey []byte) (PublicKey, error) { + pkt, err := decodeV1(pubKey, "public key") + if err != nil { + return nil, err + } + pubk, ok := pkt.(*packet.PublicKey) + if !ok { + return nil, fmt.Errorf("expected public key, got instead: %T", pkt) + } + rsaPubKey, ok := pubk.PublicKey.(*rsa.PublicKey) + if !ok { + return nil, fmt.Errorf("expected RSA public key, got instead: %T", pubk.PublicKey) + } + return RSAPublicKey(rsaPubKey), nil +} + +// EncodePublicKey serializes a public key, typically for embedding in an assertion. +func EncodePublicKey(pubKey PublicKey) ([]byte, error) { + return encodeKey(pubKey, "public key") +} + +// PrivateKey is a cryptographic private/public key pair. +type PrivateKey interface { + // PublicKey returns the public part of the pair. + PublicKey() PublicKey + + keyEncoder +} + +type openpgpPrivateKey struct { + privk *packet.PrivateKey +} + +func (opgPrivK openpgpPrivateKey) PublicKey() PublicKey { + return newOpenPGPPubKey(&opgPrivK.privk.PublicKey) +} + +func (opgPrivK openpgpPrivateKey) keyEncode(w io.Writer) error { + return opgPrivK.privk.Serialize(w) +} + +var openpgpConfig = &packet.Config{ + DefaultHash: crypto.SHA512, +} + +func (opgPrivK openpgpPrivateKey) sign(content []byte) (*packet.Signature, error) { + privk := opgPrivK.privk + sig := new(packet.Signature) + sig.PubKeyAlgo = privk.PubKeyAlgo + sig.Hash = openpgpConfig.Hash() + sig.CreationTime = time.Now() + + h := openpgpConfig.Hash().New() + h.Write(content) + + err := sig.Sign(h, privk, openpgpConfig) + if err != nil { + return nil, err + } + + return sig, nil +} + +func decodePrivateKey(privKey []byte) (PrivateKey, error) { + pkt, err := decodeV1(privKey, "private key") + if err != nil { + return nil, err + } + privk, ok := pkt.(*packet.PrivateKey) + if !ok { + return nil, fmt.Errorf("expected private key, got instead: %T", pkt) + } + if _, ok := privk.PrivateKey.(*rsa.PrivateKey); !ok { + return nil, fmt.Errorf("expected RSA private key, got instead: %T", privk.PrivateKey) + } + return openpgpPrivateKey{privk}, nil +} + +// RSAPrivateKey returns a PrivateKey for database use out of a rsa.PrivateKey. +func RSAPrivateKey(privk *rsa.PrivateKey) PrivateKey { + intPrivk := packet.NewRSAPrivateKey(v1FixedTimestamp, privk) + return openpgpPrivateKey{intPrivk} +} + +// GenerateKey generates a private/public key pair. +func GenerateKey() (PrivateKey, error) { + priv, err := rsa.GenerateKey(rand.Reader, 4096) + if err != nil { + return nil, err + } + return RSAPrivateKey(priv), nil +} + +func encodePrivateKey(privKey PrivateKey) ([]byte, error) { + return encodeKey(privKey, "private key") +} + +// externally held key pairs + +type extPGPPrivateKey struct { + pubKey PublicKey + from string + externalID string + bitLen int + doSign func(content []byte) (*packet.Signature, error) +} + +func newExtPGPPrivateKey(exportedPubKeyStream io.Reader, from string, sign func(content []byte) (*packet.Signature, error)) (*extPGPPrivateKey, error) { + var pubKey *packet.PublicKey + + rd := packet.NewReader(exportedPubKeyStream) + for { + pkt, err := rd.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("cannot read exported public key: %v", err) + } + cand, ok := pkt.(*packet.PublicKey) + if ok { + if cand.IsSubkey { + continue + } + if pubKey != nil { + return nil, fmt.Errorf("cannot select exported public key, found many") + } + pubKey = cand + } + } + + if pubKey == nil { + return nil, fmt.Errorf("cannot read exported public key, found none (broken export)") + + } + + rsaPubKey, ok := pubKey.PublicKey.(*rsa.PublicKey) + if !ok { + return nil, fmt.Errorf("not a RSA key") + } + + return &extPGPPrivateKey{ + pubKey: RSAPublicKey(rsaPubKey), + from: from, + externalID: fmt.Sprintf("%X", pubKey.Fingerprint), + bitLen: rsaPubKey.N.BitLen(), + doSign: sign, + }, nil +} + +func (expk *extPGPPrivateKey) PublicKey() PublicKey { + return expk.pubKey +} + +func (expk *extPGPPrivateKey) keyEncode(w io.Writer) error { + return fmt.Errorf("cannot access external private key to encode it") +} + +func (expk *extPGPPrivateKey) sign(content []byte) (*packet.Signature, error) { + if expk.bitLen < 4096 { + return nil, fmt.Errorf("signing needs at least a 4096 bits key, got %d", expk.bitLen) + } + + sig, err := expk.doSign(content) + if err != nil { + return nil, err + } + + badSig := fmt.Sprintf("bad %s produced signature: ", expk.from) + + if sig.Hash != crypto.SHA512 { + return nil, errors.New(badSig + "expected SHA512 digest") + } + + err = expk.pubKey.verify(content, sig) + if err != nil { + return nil, fmt.Errorf("%sit does not verify: %v", badSig, err) + } + + return sig, nil +} diff --git a/asserts/database.go b/asserts/database.go new file mode 100644 index 00000000..d22e23d0 --- /dev/null +++ b/asserts/database.go @@ -0,0 +1,851 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2015-2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +// Package asserts implements snappy assertions and a database +// abstraction for managing and holding them. +package asserts + +import ( + "errors" + "fmt" + "regexp" + "time" +) + +// NotFoundError is returned when an assertion can not be found. +type NotFoundError struct { + Type *AssertionType + Headers map[string]string +} + +func (e *NotFoundError) Error() string { + pk, err := PrimaryKeyFromHeaders(e.Type, e.Headers) + if err != nil || len(e.Headers) != len(pk) { + // TODO: worth conveying more information? + return fmt.Sprintf("%s assertion not found", e.Type.Name) + } + + return fmt.Sprintf("%v not found", &Ref{Type: e.Type, PrimaryKey: pk}) +} + +func (e *NotFoundError) Is(err error) bool { + _, ok := err.(*NotFoundError) + return ok +} + +// A Backstore stores assertions. It can store and retrieve assertions +// by type under unique primary key headers (whose names are available +// from assertType.PrimaryKey). Plus it supports searching by headers. +// Lookups can be limited to a maximum allowed format. +type Backstore interface { + // Put stores an assertion. + // It is responsible for checking that assert is newer than a + // previously stored revision with the same primary key headers. + Put(assertType *AssertionType, assert Assertion) error + // Get returns the assertion with the given unique key for its + // primary key headers. + // A suffix of optional primary keys can be left out from key + // in which case their default values are implied. + // If the assertion is not present it returns a + // NotFoundError, usually with omitted Headers. + Get(assertType *AssertionType, key []string, maxFormat int) (Assertion, error) + // Search returns assertions matching the given headers. + // It invokes foundCb for each found assertion. + Search(assertType *AssertionType, headers map[string]string, foundCb func(Assertion), maxFormat int) error + // SequenceMemberAfter returns for a sequence-forming assertType the + // first assertion in the sequence under the given sequenceKey + // with sequence number larger than after. If after==-1 it + // returns the assertion with largest sequence number. If none + // exists it returns a NotFoundError, usually with omitted + // Headers. If assertType is not sequence-forming it can + // panic. + SequenceMemberAfter(assertType *AssertionType, sequenceKey []string, after, maxFormat int) (SequenceMember, error) +} + +type nullBackstore struct{} + +func (nbs nullBackstore) Put(t *AssertionType, a Assertion) error { + return fmt.Errorf("cannot store assertions without setting a proper assertion backstore implementation") +} + +func (nbs nullBackstore) Get(t *AssertionType, k []string, maxFormat int) (Assertion, error) { + return nil, &NotFoundError{Type: t} +} + +func (nbs nullBackstore) Search(t *AssertionType, h map[string]string, f func(Assertion), maxFormat int) error { + return nil +} + +func (nbs nullBackstore) SequenceMemberAfter(t *AssertionType, kp []string, after, maxFormat int) (SequenceMember, error) { + return nil, &NotFoundError{Type: t} +} + +// keyNotFoundError is returned when the key with a given ID cannot be found. +type keyNotFoundError struct { + msg string +} + +func (e *keyNotFoundError) Error() string { return e.msg } + +func (e *keyNotFoundError) Is(target error) bool { + _, ok := target.(*keyNotFoundError) + return ok +} + +// IsKeyNotFound returns true when the error indicates that a given key was not +// found. +func IsKeyNotFound(err error) bool { + return errors.Is(err, &keyNotFoundError{}) +} + +// A KeypairManager is a manager and backstore for private/public key pairs. +type KeypairManager interface { + // Put stores the given private/public key pair, + // making sure it can be later retrieved by its unique key id with Get. + // Trying to store a key with an already present key id should + // result in an error. + Put(privKey PrivateKey) error + // Get returns the private/public key pair with the given key id. The + // error can be tested with IsKeyNotFound to check whether the given key + // was not found, or other error occurred. + Get(keyID string) (PrivateKey, error) + // Delete deletes the private/public key pair with the given key id. + Delete(keyID string) error +} + +// DatabaseConfig for an assertion database. +type DatabaseConfig struct { + // trusted set of assertions (account and account-key supported), + // used to establish root keys and trusted authorities + Trusted []Assertion + // predefined assertions but that do not establish foundational trust + OtherPredefined []Assertion + // backstore for assertions, left unset storing assertions will error + Backstore Backstore + // manager/backstore for keypairs, defaults to in-memory implementation + KeypairManager KeypairManager + // assertion checkers used by Database.Check, left unset DefaultCheckers will be used which is recommended + Checkers []Checker +} + +// RevisionError indicates a revision improperly used for an operation. +type RevisionError struct { + Used, Current int +} + +func (e *RevisionError) Error() string { + if e.Used < 0 || e.Current < 0 { + // TODO: message may need tweaking once there's a use. + return "assertion revision is unknown" + } + if e.Used == e.Current { + return fmt.Sprintf("revision %d is already the current revision", e.Used) + } + if e.Used < e.Current { + return fmt.Sprintf("revision %d is older than current revision %d", e.Used, e.Current) + } + return fmt.Sprintf("revision %d is more recent than current revision %d", e.Used, e.Current) +} + +// UnsupportedFormatError indicates an assertion with a format iteration not yet supported by the present version of asserts. +type UnsupportedFormatError struct { + Ref *Ref + Format int + // Update marks there was already a current revision of the assertion and it has been kept. + Update bool +} + +func (e *UnsupportedFormatError) Error() string { + postfx := "" + if e.Update { + postfx = " (current not updated)" + } + return fmt.Sprintf("proposed %q assertion has format %d but %d is latest supported%s", e.Ref.Type.Name, e.Format, e.Ref.Type.MaxSupportedFormat(), postfx) +} + +// IsUnaccceptedUpdate returns whether the error indicates that an +// assertion revision was already present and has been kept because +// the update was not accepted. +func IsUnaccceptedUpdate(err error) bool { + switch x := err.(type) { + case *UnsupportedFormatError: + return x.Update + case *RevisionError: + return x.Used <= x.Current + } + return false +} + +// A RODatabase exposes read-only access to an assertion database. +type RODatabase interface { + // IsTrustedAccount returns whether the account is part of the trusted set. + IsTrustedAccount(accountID string) bool + // Find an assertion based on arbitrary headers. + // Provided headers must contain the primary key for the assertion type. + // Optional primary key headers can be omitted in which case + // their default values will be used. + // It returns a NotFoundError if the assertion cannot be found. + Find(assertionType *AssertionType, headers map[string]string) (Assertion, error) + // FindPredefined finds an assertion in the predefined sets + // (trusted or not) based on arbitrary headers. Provided + // headers must contain the primary key for the assertion + // type. + // Optional primary key headers can be omitted in which case + // their default values will be used. + // It returns a NotFoundError if the assertion cannot + // be found. + FindPredefined(assertionType *AssertionType, headers map[string]string) (Assertion, error) + // FindTrusted finds an assertion in the trusted set based on + // arbitrary headers. Provided headers must contain the + // primary key for the assertion type. + // Optional primary key headers can be omitted in which case + // their default values will be used. + // It returns a NotFoundError if the assertion cannot be + // found. + FindTrusted(assertionType *AssertionType, headers map[string]string) (Assertion, error) + // FindMany finds assertions based on arbitrary headers. + // It returns a NotFoundError if no assertion can be found. + FindMany(assertionType *AssertionType, headers map[string]string) ([]Assertion, error) + // FindManyPredefined finds assertions in the predefined sets + // (trusted or not) based on arbitrary headers. It returns a + // NotFoundError if no assertion can be found. + FindManyPredefined(assertionType *AssertionType, headers map[string]string) ([]Assertion, error) + // FindSequence finds an assertion for the given headers and after for + // a sequence-forming type. + // The provided headers must contain a sequence key, i.e. a prefix of + // the primary key for the assertion type except for the sequence + // number header. + // The assertion is the first in the sequence under the sequence key + // with sequence number > after. + // If after is -1 it returns instead the assertion with the largest + // sequence number. + // It will constraint itself to assertions with format <= maxFormat + // unless maxFormat is -1. + // It returns a NotFoundError if the assertion cannot be found. + FindSequence(assertType *AssertionType, sequenceHeaders map[string]string, after, maxFormat int) (SequenceMember, error) + // Check tests whether the assertion is properly signed and consistent with all the stored knowledge. + Check(assert Assertion) error +} + +// A Checker defines a check on an assertion considering aspects such as +// the signing key, and consistency with other +// assertions in the database. +type Checker func(assert Assertion, signingKey *AccountKey, roDB RODatabase, checkTimeEarliest, checkTimeLatest time.Time) error + +// Database holds assertions and can be used to sign or check +// further assertions. +type Database struct { + bs Backstore + keypairMgr KeypairManager + + trusted Backstore + predefined Backstore + // all backstores to consider for find + backstores []Backstore + // backstores of dbs this was built on by stacking + stackedOn []Backstore + + checkers []Checker + earliestTime time.Time +} + +// OpenDatabase opens the assertion database based on the configuration. +func OpenDatabase(cfg *DatabaseConfig) (*Database, error) { + bs := cfg.Backstore + keypairMgr := cfg.KeypairManager + + if bs == nil { + bs = nullBackstore{} + } + if keypairMgr == nil { + keypairMgr = NewMemoryKeypairManager() + } + + trustedBackstore := NewMemoryBackstore() + + for _, a := range cfg.Trusted { + switch accepted := a.(type) { + case *AccountKey: + accKey := accepted + err := trustedBackstore.Put(AccountKeyType, accKey) + if err != nil { + return nil, fmt.Errorf("cannot predefine trusted account key %q for %q: %v", accKey.PublicKeyID(), accKey.AccountID(), err) + } + + case *Account: + acct := accepted + err := trustedBackstore.Put(AccountType, acct) + if err != nil { + return nil, fmt.Errorf("cannot predefine trusted account %q: %v", acct.DisplayName(), err) + } + default: + return nil, fmt.Errorf("cannot predefine trusted assertions that are not account-key or account: %s", a.Type().Name) + } + } + + otherPredefinedBackstore := NewMemoryBackstore() + + for _, a := range cfg.OtherPredefined { + err := otherPredefinedBackstore.Put(a.Type(), a) + if err != nil { + return nil, fmt.Errorf("cannot predefine assertion %v: %v", a.Ref(), err) + } + } + + checkers := cfg.Checkers + if len(checkers) == 0 { + checkers = DefaultCheckers + } + dbCheckers := make([]Checker, len(checkers)) + copy(dbCheckers, checkers) + + return &Database{ + bs: bs, + keypairMgr: keypairMgr, + trusted: trustedBackstore, + predefined: otherPredefinedBackstore, + // order here is relevant, Find* precedence and + // findAccountKey depend on it, trusted should win over the + // general backstore! + backstores: []Backstore{trustedBackstore, otherPredefinedBackstore, bs}, + checkers: dbCheckers, + }, nil +} + +// WithStackedBackstore returns a new database that adds to the given backstore +// only but finds in backstore and the base database backstores and +// cross-checks against all of them. +// This is useful to cross-check a set of assertions without adding +// them to the database. +func (db *Database) WithStackedBackstore(backstore Backstore) *Database { + // original bs goes in front of stacked-on ones + stackedOn := []Backstore{db.bs} + stackedOn = append(stackedOn, db.stackedOn...) + // find order: trusted, predefined, new backstore, stacked-on ones + backstores := []Backstore{db.trusted, db.predefined} + backstores = append(backstores, backstore) + backstores = append(backstores, stackedOn...) + return &Database{ + bs: backstore, + keypairMgr: db.keypairMgr, + trusted: db.trusted, + predefined: db.predefined, + backstores: backstores, + stackedOn: stackedOn, + checkers: db.checkers, + } +} + +// ImportKey stores the given private/public key pair. +func (db *Database) ImportKey(privKey PrivateKey) error { + return db.keypairMgr.Put(privKey) +} + +var ( + // for validity checking of base64 hash strings + base64HashLike = regexp.MustCompile("^[[:alnum:]_-]*$") +) + +func (db *Database) safeGetPrivateKey(keyID string) (PrivateKey, error) { + if keyID == "" { + return nil, fmt.Errorf("key id is empty") + } + if !base64HashLike.MatchString(keyID) { + return nil, fmt.Errorf("key id contains unexpected chars: %q", keyID) + } + return db.keypairMgr.Get(keyID) +} + +// PublicKey returns the public key part of the key pair that has the given key id. +func (db *Database) PublicKey(keyID string) (PublicKey, error) { + privKey, err := db.safeGetPrivateKey(keyID) + if err != nil { + return nil, err + } + return privKey.PublicKey(), nil +} + +// Sign assembles an assertion with the provided information and signs it +// with the private key from `headers["authority-id"]` that has the provided key id. +func (db *Database) Sign(assertType *AssertionType, headers map[string]interface{}, body []byte, keyID string) (Assertion, error) { + privKey, err := db.safeGetPrivateKey(keyID) + if err != nil { + return nil, err + } + return assembleAndSign(assertType, headers, body, privKey) +} + +// findAccountKey finds an AccountKey exactly with account id and key id. +func (db *Database) findAccountKey(authorityID, keyID string) (*AccountKey, error) { + key := []string{keyID} + // consider trusted account keys then disk stored account keys + for _, bs := range db.backstores { + a, err := bs.Get(AccountKeyType, key, AccountKeyType.MaxSupportedFormat()) + if err == nil { + hit := a.(*AccountKey) + if hit.AccountID() != authorityID { + return nil, fmt.Errorf("found public key %q from %q but expected it from: %s", keyID, hit.AccountID(), authorityID) + } + return hit, nil + } + if !errors.Is(err, &NotFoundError{}) { + return nil, err + } + } + return nil, &NotFoundError{Type: AccountKeyType} +} + +// IsTrustedAccount returns whether the account is part of the trusted set. +func (db *Database) IsTrustedAccount(accountID string) bool { + if accountID == "" { + return false + } + _, err := db.trusted.Get(AccountType, []string{accountID}, AccountType.MaxSupportedFormat()) + return err == nil +} + +var timeNow = time.Now + +// SetEarliestTime affects how key expiration is checked. +// Instead of considering current system time, only assume that current time +// is >= earliest. If earliest is zero reset to considering current system time. +func (db *Database) SetEarliestTime(earliest time.Time) { + db.earliestTime = earliest +} + +// Check tests whether the assertion is properly signed and consistent with all the stored knowledge. +func (db *Database) Check(assert Assertion) error { + if !assert.SupportedFormat() { + return &UnsupportedFormatError{Ref: assert.Ref(), Format: assert.Format()} + } + + typ := assert.Type() + // assume current time is >= earliestTime and <= latestTime + earliestTime := db.earliestTime + var latestTime time.Time + if earliestTime.IsZero() { + // use the current system time by setting both to it + earliestTime = timeNow() + latestTime = earliestTime + } + + var accKey *AccountKey + var err error + if typ.flags&noAuthority == 0 { + // TODO: later may need to consider type of assert to find candidate keys + accKey, err = db.findAccountKey(assert.AuthorityID(), assert.SignKeyID()) + if errors.Is(err, &NotFoundError{}) { + return fmt.Errorf("no matching public key %q for signature by %q", assert.SignKeyID(), assert.AuthorityID()) + } + if err != nil { + return fmt.Errorf("error finding matching public key for signature: %v", err) + } + } else { + if assert.AuthorityID() != "" { + return fmt.Errorf("internal error: %q assertion cannot have authority-id set", typ.Name) + } + } + + for _, checker := range db.checkers { + err := checker(assert, accKey, db, earliestTime, latestTime) + if err != nil { + return err + } + } + + return nil +} + +// Add persists the assertion after ensuring it is properly signed and consistent with all the stored knowledge. +// It will return an error when trying to add an older revision of the assertion than the one currently stored. +func (db *Database) Add(assert Assertion) error { + ref := assert.Ref() + + if len(ref.PrimaryKey) == 0 { + return fmt.Errorf("internal error: assertion type %q has no primary key", ref.Type.Name) + } + + err := db.Check(assert) + if err != nil { + if ufe, ok := err.(*UnsupportedFormatError); ok { + _, err := ref.Resolve(db.Find) + if err != nil && !errors.Is(err, &NotFoundError{}) { + return err + } + return &UnsupportedFormatError{Ref: ufe.Ref, Format: ufe.Format, Update: err == nil} + } + return err + } + + for i, keyVal := range ref.PrimaryKey { + if keyVal == "" { + return fmt.Errorf("missing or non-string primary key header: %v", ref.Type.PrimaryKey[i]) + } + } + + // assuming trusted account keys/assertions will be managed + // through the os snap this seems the safest policy until we + // know more/better + _, err = db.trusted.Get(ref.Type, ref.PrimaryKey, ref.Type.MaxSupportedFormat()) + if !errors.Is(err, &NotFoundError{}) { + return fmt.Errorf("cannot add %q assertion with primary key clashing with a trusted assertion: %v", ref.Type.Name, ref.PrimaryKey) + } + + _, err = db.predefined.Get(ref.Type, ref.PrimaryKey, ref.Type.MaxSupportedFormat()) + if !errors.Is(err, &NotFoundError{}) { + return fmt.Errorf("cannot add %q assertion with primary key clashing with a predefined assertion: %v", ref.Type.Name, ref.PrimaryKey) + } + + // this is non empty only in the stacked case + if len(db.stackedOn) != 0 { + headers, err := HeadersFromPrimaryKey(ref.Type, ref.PrimaryKey) + if err != nil { + return fmt.Errorf("internal error: HeadersFromPrimaryKey for %q failed on prechecked data: %s", ref.Type.Name, ref.PrimaryKey) + } + cur, err := find(db.stackedOn, ref.Type, headers, -1) + if err == nil { + curRev := cur.Revision() + rev := assert.Revision() + if curRev >= rev { + return &RevisionError{Current: curRev, Used: rev} + } + } else if !errors.Is(err, &NotFoundError{}) { + return err + } + } + + return db.bs.Put(ref.Type, assert) +} + +func searchMatch(assert Assertion, expectedHeaders map[string]string) bool { + // check non-primary-key headers as well + for expectedKey, expectedValue := range expectedHeaders { + if assert.Header(expectedKey) != expectedValue { + return false + } + } + return true +} + +func find(backstores []Backstore, assertionType *AssertionType, headers map[string]string, maxFormat int) (Assertion, error) { + err := checkAssertType(assertionType) + if err != nil { + return nil, err + } + maxSupp := assertionType.MaxSupportedFormat() + if maxFormat == -1 { + maxFormat = maxSupp + } else { + if maxFormat > maxSupp { + return nil, fmt.Errorf("cannot find %q assertions for format %d higher than supported format %d", assertionType.Name, maxFormat, maxSupp) + } + } + + keyValues, err := PrimaryKeyFromHeaders(assertionType, headers) + if err != nil { + return nil, err + } + + var assert Assertion + for _, bs := range backstores { + a, err := bs.Get(assertionType, keyValues, maxFormat) + if err == nil { + assert = a + break + } + if !errors.Is(err, &NotFoundError{}) { + return nil, err + } + } + + if assert == nil || !searchMatch(assert, headers) { + return nil, &NotFoundError{Type: assertionType, Headers: headers} + } + + return assert, nil +} + +// Find an assertion based on arbitrary headers. +// Provided headers must contain the primary key for the assertion type. +// Optional primary key headers can be omitted in which case +// their default values will be used. +// It returns a NotFoundError if the assertion cannot be found. +func (db *Database) Find(assertionType *AssertionType, headers map[string]string) (Assertion, error) { + return find(db.backstores, assertionType, headers, -1) +} + +// FindMaxFormat finds an assertion like Find but such that its +// format is <= maxFormat by passing maxFormat along to the backend. +// It returns a NotFoundError if such an assertion cannot be found. +func (db *Database) FindMaxFormat(assertionType *AssertionType, headers map[string]string, maxFormat int) (Assertion, error) { + return find(db.backstores, assertionType, headers, maxFormat) +} + +// FindPredefined finds an assertion in the predefined sets (trusted +// or not) based on arbitrary headers. Provided headers must contain +// the primary key for the assertion type. +// Optional primary key headers can be omitted in which case +// their default values will be used. +// It returns a NotFoundError if the assertion cannot be found. +func (db *Database) FindPredefined(assertionType *AssertionType, headers map[string]string) (Assertion, error) { + return find([]Backstore{db.trusted, db.predefined}, assertionType, headers, -1) +} + +// FindTrusted finds an assertion in the trusted set based on arbitrary headers. +// Provided headers must contain the primary key for the assertion type. +// Optional primary key headers can be omitted in which case +// their default values will be used. +// It returns a NotFoundError if the assertion cannot be found. +func (db *Database) FindTrusted(assertionType *AssertionType, headers map[string]string) (Assertion, error) { + return find([]Backstore{db.trusted}, assertionType, headers, -1) +} + +func (db *Database) findMany(backstores []Backstore, assertionType *AssertionType, headers map[string]string) ([]Assertion, error) { + err := checkAssertType(assertionType) + if err != nil { + return nil, err + } + res := []Assertion{} + + foundCb := func(assert Assertion) { + res = append(res, assert) + } + + // TODO: Find variant taking this + maxFormat := assertionType.MaxSupportedFormat() + for _, bs := range backstores { + err = bs.Search(assertionType, headers, foundCb, maxFormat) + if err != nil { + return nil, err + } + } + + if len(res) == 0 { + return nil, &NotFoundError{Type: assertionType, Headers: headers} + } + return res, nil +} + +// FindMany finds assertions based on arbitrary headers. +// It returns a NotFoundError if no assertion can be found. +func (db *Database) FindMany(assertionType *AssertionType, headers map[string]string) ([]Assertion, error) { + return db.findMany(db.backstores, assertionType, headers) +} + +// FindManyPrefined finds assertions in the predefined sets (trusted +// or not) based on arbitrary headers. It returns a NotFoundError if +// no assertion can be found. +func (db *Database) FindManyPredefined(assertionType *AssertionType, headers map[string]string) ([]Assertion, error) { + return db.findMany([]Backstore{db.trusted, db.predefined}, assertionType, headers) +} + +// FindSequence finds an assertion for the given headers and after for +// a sequence-forming type. +// The provided headers must contain a sequence key, i.e. a prefix of +// the primary key for the assertion type except for the sequence +// number header. +// The assertion is the first in the sequence under the sequence key +// with sequence number > after. +// If after is -1 it returns instead the assertion with the largest +// sequence number. +// It will constraint itself to assertions with format <= maxFormat +// unless maxFormat is -1. +// It returns a NotFoundError if the assertion cannot be found. +func (db *Database) FindSequence(assertType *AssertionType, sequenceHeaders map[string]string, after, maxFormat int) (SequenceMember, error) { + err := checkAssertType(assertType) + if err != nil { + return nil, err + } + if !assertType.SequenceForming() { + return nil, fmt.Errorf("cannot use FindSequence with non sequence-forming assertion type %q", assertType.Name) + } + maxSupp := assertType.MaxSupportedFormat() + if maxFormat == -1 { + maxFormat = maxSupp + } else { + if maxFormat > maxSupp { + return nil, fmt.Errorf("cannot find %q assertions for format %d higher than supported format %d", assertType.Name, maxFormat, maxSupp) + } + } + + // form the sequence key using all keys but the last one which + // is the sequence number + seqKey, err := keysFromHeaders(assertType.PrimaryKey[:len(assertType.PrimaryKey)-1], sequenceHeaders, nil) + if err != nil { + return nil, err + } + + // find the better result across backstores' results + better := func(cur, a SequenceMember) SequenceMember { + if cur == nil { + return a + } + curSeq := cur.Sequence() + aSeq := a.Sequence() + if after == -1 { + if aSeq > curSeq { + return a + } + } else { + if aSeq < curSeq { + return a + } + } + return cur + } + + var assert SequenceMember + for _, bs := range db.backstores { + a, err := bs.SequenceMemberAfter(assertType, seqKey, after, maxFormat) + if err == nil { + assert = better(assert, a) + continue + } + if !errors.Is(err, &NotFoundError{}) { + return nil, err + } + } + + if assert != nil { + return assert, nil + } + + return nil, &NotFoundError{Type: assertType, Headers: sequenceHeaders} +} + +// assertion checkers + +// CheckSigningKeyIsNotExpired checks that the signing key is not expired. +func CheckSigningKeyIsNotExpired(assert Assertion, signingKey *AccountKey, roDB RODatabase, checkTimeEarliest, checkTimeLatest time.Time) error { + if signingKey == nil { + // assert isn't signed with an account-key key, CheckSignature + // will fail anyway unless we teach it more stuff, + // Also this check isn't so relevant for self-signed asserts + // (e.g. account-key-request) + return nil + } + if !signingKey.isValidAssumingCurTimeWithin(checkTimeEarliest, checkTimeLatest) { + mismatchReason := timeMismatchMsg(checkTimeEarliest, checkTimeLatest, signingKey.since, signingKey.until) + return fmt.Errorf("assertion is signed with expired public key %q from %q: %s", assert.SignKeyID(), assert.AuthorityID(), mismatchReason) + } + return nil +} + +func timeMismatchMsg(earliest, latest, keySince, keyUntil time.Time) string { + var msg string + + validFrom := earliest.Format(time.RFC3339) + if !latest.IsZero() && !latest.Equal(earliest) { + validTo := latest.Format(time.RFC3339) + msg = fmt.Sprintf("current time range is [%s, %s]", validFrom, validTo) + } else { + msg = fmt.Sprintf("current time is %s", validFrom) + } + + keyFrom := keySince.Format(time.RFC3339) + if !keyUntil.IsZero() { + keyTo := keyUntil.Format(time.RFC3339) + return msg + fmt.Sprintf(" but key is valid during [%s, %s)", keyFrom, keyTo) + } + + return msg + fmt.Sprintf(" but key is valid from %s", keyFrom) +} + +// CheckSignature checks that the signature is valid. +func CheckSignature(assert Assertion, signingKey *AccountKey, roDB RODatabase, checkTimeEarliest, checkTimeLatest time.Time) (err error) { + var pubKey PublicKey + if signingKey != nil { + pubKey = signingKey.publicKey() + if assert.AuthorityID() != signingKey.AccountID() { + return fmt.Errorf("assertion authority %q does not match public key from %q", assert.AuthorityID(), signingKey.AccountID()) + } + if !signingKey.canSign(assert) { + return fmt.Errorf("assertion does not match signing constraints for public key %q from %q", assert.SignKeyID(), assert.AuthorityID()) + } + } else { + custom, ok := assert.(customSigner) + if !ok { + return fmt.Errorf("cannot check no-authority assertion type %q", assert.Type().Name) + } + pubKey = custom.signKey() + } + content, encSig := assert.Signature() + signature, err := decodeSignature(encSig) + if err != nil { + return err + } + err = pubKey.verify(content, signature) + if err != nil { + return fmt.Errorf("failed signature verification: %v", err) + } + return nil +} + +type timestamped interface { + Timestamp() time.Time +} + +// CheckTimestampVsSigningKeyValidity verifies that the timestamp of +// the assertion is within the signing key validity. +func CheckTimestampVsSigningKeyValidity(assert Assertion, signingKey *AccountKey, roDB RODatabase, checkTimeEarliest, checkTimeLatest time.Time) error { + if signingKey == nil { + // assert isn't signed with an account-key key, CheckSignature + // will fail anyway unless we teach it more stuff. + // Also this check isn't so relevant for self-signed asserts + // (e.g. account-key-request) + return nil + } + if tstamped, ok := assert.(timestamped); ok { + checkTime := tstamped.Timestamp() + if !signingKey.isValidAt(checkTime) { + until := "" + if !signingKey.Until().IsZero() { + until = fmt.Sprintf(" until %q", signingKey.Until()) + } + return fmt.Errorf("%s assertion timestamp %q outside of signing key validity (key valid since %q%s)", + assert.Type().Name, checkTime, signingKey.Since(), until) + } + } + return nil +} + +// A consistencyChecker performs further checks based on the full +// assertion database knowledge and its own signing key. +type consistencyChecker interface { + checkConsistency(roDB RODatabase, signingKey *AccountKey) error +} + +// CheckCrossConsistency verifies that the assertion is consistent with the other statements in the database. +func CheckCrossConsistency(assert Assertion, signingKey *AccountKey, roDB RODatabase, checkTimeEarliest, checkTimeLatest time.Time) error { + // see if the assertion requires further checks + if checker, ok := assert.(consistencyChecker); ok { + return checker.checkConsistency(roDB, signingKey) + } + return nil +} + +// DefaultCheckers lists the default and recommended assertion +// checkers used by Database if none are specified in the +// DatabaseConfig.Checkers. +var DefaultCheckers = []Checker{ + CheckSigningKeyIsNotExpired, + CheckSignature, + CheckTimestampVsSigningKeyValidity, + CheckCrossConsistency, +} diff --git a/asserts/database_test.go b/asserts/database_test.go new file mode 100644 index 00000000..87227915 --- /dev/null +++ b/asserts/database_test.go @@ -0,0 +1,1700 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2015-2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts_test + +import ( + "bytes" + "crypto" + "encoding/base64" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "testing" + "time" + + "golang.org/x/crypto/openpgp/packet" + "golang.org/x/crypto/sha3" + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/asserts/assertstest" + "github.com/snapcore/snapd/testutil" +) + +func Test(t *testing.T) { TestingT(t) } + +var _ = Suite(&openSuite{}) +var _ = Suite(&revisionErrorSuite{}) +var _ = Suite(&isUnacceptedUpdateSuite{}) + +type openSuite struct{} + +func (opens *openSuite) TestOpenDatabaseOK(c *C) { + cfg := &asserts.DatabaseConfig{ + Backstore: asserts.NewMemoryBackstore(), + } + db, err := asserts.OpenDatabase(cfg) + c.Assert(err, IsNil) + c.Assert(db, NotNil) +} + +func (opens *openSuite) TestOpenDatabaseTrustedAccount(c *C) { + headers := map[string]interface{}{ + "authority-id": "canonical", + "account-id": "trusted", + "display-name": "Trusted", + "validation": "verified", + "timestamp": "2015-01-01T14:00:00Z", + } + acct, err := asserts.AssembleAndSignInTest(asserts.AccountType, headers, nil, testPrivKey0) + c.Assert(err, IsNil) + + cfg := &asserts.DatabaseConfig{ + Backstore: asserts.NewMemoryBackstore(), + Trusted: []asserts.Assertion{acct}, + } + + db, err := asserts.OpenDatabase(cfg) + c.Assert(err, IsNil) + + a, err := db.Find(asserts.AccountType, map[string]string{ + "account-id": "trusted", + }) + c.Assert(err, IsNil) + acct1 := a.(*asserts.Account) + c.Check(acct1.AccountID(), Equals, "trusted") + c.Check(acct1.DisplayName(), Equals, "Trusted") + + c.Check(db.IsTrustedAccount("trusted"), Equals, true) + + // empty account id (invalid) is not trusted + c.Check(db.IsTrustedAccount(""), Equals, false) +} + +func (opens *openSuite) TestOpenDatabaseTrustedWrongType(c *C) { + headers := map[string]interface{}{ + "authority-id": "canonical", + "primary-key": "0", + } + a, err := asserts.AssembleAndSignInTest(asserts.TestOnlyType, headers, nil, testPrivKey0) + c.Assert(err, IsNil) + + cfg := &asserts.DatabaseConfig{ + Trusted: []asserts.Assertion{a}, + } + + _, err = asserts.OpenDatabase(cfg) + c.Assert(err, ErrorMatches, "cannot predefine trusted assertions that are not account-key or account: test-only") +} + +type databaseSuite struct { + topDir string + db *asserts.Database +} + +var _ = Suite(&databaseSuite{}) + +func (dbs *databaseSuite) SetUpTest(c *C) { + dbs.topDir = filepath.Join(c.MkDir(), "asserts-db") + fsKeypairMgr, err := asserts.OpenFSKeypairManager(dbs.topDir) + c.Assert(err, IsNil) + cfg := &asserts.DatabaseConfig{ + KeypairManager: fsKeypairMgr, + } + db, err := asserts.OpenDatabase(cfg) + c.Assert(err, IsNil) + dbs.db = db +} + +func (dbs *databaseSuite) TestImportKey(c *C) { + err := dbs.db.ImportKey(testPrivKey1) + c.Assert(err, IsNil) + + keyPath := filepath.Join(dbs.topDir, "private-keys-v1", testPrivKey1SHA3_384) + info, err := os.Stat(keyPath) + c.Assert(err, IsNil) + c.Check(info.Mode().Perm(), Equals, os.FileMode(0600)) // secret + // too much "clear box" testing? ok at least until we have + // more functionality + privKey, err := os.ReadFile(keyPath) + c.Assert(err, IsNil) + + privKeyFromDisk, err := asserts.DecodePrivateKeyInTest(privKey) + c.Assert(err, IsNil) + + c.Check(privKeyFromDisk.PublicKey().ID(), Equals, testPrivKey1SHA3_384) +} + +func (dbs *databaseSuite) TestImportKeyAlreadyExists(c *C) { + err := dbs.db.ImportKey(testPrivKey1) + c.Assert(err, IsNil) + + err = dbs.db.ImportKey(testPrivKey1) + c.Check(err, ErrorMatches, "key pair with given key id already exists") +} + +func (dbs *databaseSuite) TestPublicKey(c *C) { + pk := testPrivKey1 + keyID := pk.PublicKey().ID() + err := dbs.db.ImportKey(pk) + c.Assert(err, IsNil) + + pubk, err := dbs.db.PublicKey(keyID) + c.Assert(err, IsNil) + c.Check(pubk.ID(), Equals, keyID) + + // usual pattern is to then encode it + encoded, err := asserts.EncodePublicKey(pubk) + c.Assert(err, IsNil) + data, err := base64.StdEncoding.DecodeString(string(encoded)) + c.Assert(err, IsNil) + c.Check(data[0], Equals, uint8(1)) // v1 + + // check details of packet + const newHeaderBits = 0x80 | 0x40 + c.Check(data[1]&newHeaderBits, Equals, uint8(newHeaderBits)) + c.Check(data[2] < 192, Equals, true) // small packet, 1 byte length + c.Check(data[3], Equals, uint8(4)) // openpgp v4 + pkt, err := packet.Read(bytes.NewBuffer(data[1:])) + c.Assert(err, IsNil) + pubKey, ok := pkt.(*packet.PublicKey) + c.Assert(ok, Equals, true) + c.Check(pubKey.PubKeyAlgo, Equals, packet.PubKeyAlgoRSA) + c.Check(pubKey.IsSubkey, Equals, false) + fixedTimestamp := time.Date(2016, time.January, 1, 0, 0, 0, 0, time.UTC) + c.Check(pubKey.CreationTime.Equal(fixedTimestamp), Equals, true) + // hash of blob content == hash of key + h384 := sha3.Sum384(data) + encHash := base64.RawURLEncoding.EncodeToString(h384[:]) + c.Check(encHash, DeepEquals, testPrivKey1SHA3_384) +} + +func (dbs *databaseSuite) TestPublicKeyNotFound(c *C) { + pk := testPrivKey1 + keyID := pk.PublicKey().ID() + + _, err := dbs.db.PublicKey(keyID) + c.Check(err, ErrorMatches, "cannot find key pair") + + err = dbs.db.ImportKey(pk) + c.Assert(err, IsNil) + + _, err = dbs.db.PublicKey("ff" + keyID) + c.Check(err, ErrorMatches, "cannot find key pair") +} + +func (dbs *databaseSuite) TestNotFoundErrorIs(c *C) { + this := &asserts.NotFoundError{ + Headers: map[string]string{"a": "a"}, + Type: asserts.ValidationSetType, + } + that := &asserts.NotFoundError{ + Headers: map[string]string{"b": "b"}, + Type: asserts.RepairType, + } + c.Check(this, testutil.ErrorIs, that) +} + +type checkSuite struct { + bs asserts.Backstore + a asserts.Assertion +} + +var _ = Suite(&checkSuite{}) + +func (chks *checkSuite) SetUpTest(c *C) { + var err error + + topDir := filepath.Join(c.MkDir(), "asserts-db") + chks.bs, err = asserts.OpenFSBackstore(topDir) + c.Assert(err, IsNil) + + headers := map[string]interface{}{ + "authority-id": "canonical", + "primary-key": "0", + } + chks.a, err = asserts.AssembleAndSignInTest(asserts.TestOnlyType, headers, nil, testPrivKey0) + c.Assert(err, IsNil) +} + +func (chks *checkSuite) TestCheckNoPubKey(c *C) { + cfg := &asserts.DatabaseConfig{ + Backstore: chks.bs, + } + db, err := asserts.OpenDatabase(cfg) + c.Assert(err, IsNil) + + err = db.Check(chks.a) + c.Assert(err, ErrorMatches, `no matching public key "[[:alnum:]_-]+" for signature by "canonical"`) +} + +func (chks *checkSuite) TestCheckExpiredPubKey(c *C) { + fixedTimeStr := "0003-01-01T00:00:00Z" + fixedTime, err := time.Parse(time.RFC3339, fixedTimeStr) + c.Assert(err, IsNil) + + restore := asserts.MockTimeNow(fixedTime) + defer restore() + + trustedKey := testPrivKey0 + + expiredAccKey := asserts.ExpiredAccountKeyForTest("canonical", trustedKey.PublicKey()) + cfg := &asserts.DatabaseConfig{ + Backstore: chks.bs, + Trusted: []asserts.Assertion{expiredAccKey}, + } + db, err := asserts.OpenDatabase(cfg) + c.Assert(err, IsNil) + + expSince := regexp.QuoteMeta(expiredAccKey.Since().Format(time.RFC3339)) + expUntil := regexp.QuoteMeta(expiredAccKey.Until().Format(time.RFC3339)) + curTime := regexp.QuoteMeta(fixedTimeStr) + err = db.Check(chks.a) + c.Assert(err, ErrorMatches, fmt.Sprintf(`assertion is signed with expired public key "[[:alnum:]_-]+" from "canonical": current time is %s but key is valid during \[%s, %s\)`, curTime, expSince, expUntil)) +} + +func (chks *checkSuite) TestCheckExpiredPubKeyNoUntil(c *C) { + curTimeStr := "0002-01-01T00:00:00Z" + curTime, err := time.Parse(time.RFC3339, curTimeStr) + c.Assert(err, IsNil) + + restore := asserts.MockTimeNow(curTime) + defer restore() + + trustedKey := testPrivKey0 + + keyTimeStr := "0003-01-01T00:00:00Z" + keyTime, err := time.Parse(time.RFC3339, keyTimeStr) + c.Assert(err, IsNil) + expiredAccKey := asserts.MakeAccountKeyForTestWithUntil("canonical", trustedKey.PublicKey(), keyTime, time.Time{}, 1) + cfg := &asserts.DatabaseConfig{ + Backstore: chks.bs, + Trusted: []asserts.Assertion{expiredAccKey}, + } + + db, err := asserts.OpenDatabase(cfg) + c.Assert(err, IsNil) + + err = db.Check(chks.a) + c.Assert(err, ErrorMatches, fmt.Sprintf(`assertion is signed with expired public key "[[:alnum:]_-]+" from "canonical": current time is %s but key is valid from %s`, regexp.QuoteMeta(curTimeStr), regexp.QuoteMeta(keyTimeStr))) +} + +func (chks *checkSuite) TestCheckForgery(c *C) { + trustedKey := testPrivKey0 + + cfg := &asserts.DatabaseConfig{ + Backstore: chks.bs, + Trusted: []asserts.Assertion{asserts.BootstrapAccountKeyForTest("canonical", trustedKey.PublicKey())}, + } + db, err := asserts.OpenDatabase(cfg) + c.Assert(err, IsNil) + + encoded := asserts.Encode(chks.a) + content, encodedSig := chks.a.Signature() + // forgery + forgedSig := new(packet.Signature) + forgedSig.PubKeyAlgo = packet.PubKeyAlgoRSA + forgedSig.Hash = crypto.SHA512 + forgedSig.CreationTime = time.Now() + h := crypto.SHA512.New() + h.Write(content) + pk1 := packet.NewRSAPrivateKey(time.Unix(1, 0), testPrivKey1RSA) + err = forgedSig.Sign(h, pk1, &packet.Config{DefaultHash: crypto.SHA512}) + c.Assert(err, IsNil) + buf := new(bytes.Buffer) + forgedSig.Serialize(buf) + b := append([]byte{0x1}, buf.Bytes()...) + forgedSigEncoded := base64.StdEncoding.EncodeToString(b) + forgedEncoded := bytes.Replace(encoded, encodedSig, []byte(forgedSigEncoded), 1) + c.Assert(forgedEncoded, Not(DeepEquals), encoded) + + forgedAssert, err := asserts.Decode(forgedEncoded) + c.Assert(err, IsNil) + + err = db.Check(forgedAssert) + c.Assert(err, ErrorMatches, "failed signature verification: .*") +} + +func (chks *checkSuite) TestCheckUnsupportedFormat(c *C) { + trustedKey := testPrivKey0 + + cfg := &asserts.DatabaseConfig{ + Backstore: chks.bs, + Trusted: []asserts.Assertion{asserts.BootstrapAccountKeyForTest("canonical", trustedKey.PublicKey())}, + } + db, err := asserts.OpenDatabase(cfg) + c.Assert(err, IsNil) + + var a asserts.Assertion + (func() { + restore := asserts.MockMaxSupportedFormat(asserts.TestOnlyType, 77) + defer restore() + var err error + + headers := map[string]interface{}{ + "authority-id": "canonical", + "primary-key": "0", + "format": "77", + } + a, err = asserts.AssembleAndSignInTest(asserts.TestOnlyType, headers, nil, trustedKey) + c.Assert(err, IsNil) + })() + + err = db.Check(a) + c.Assert(err, FitsTypeOf, &asserts.UnsupportedFormatError{}) + c.Check(err, ErrorMatches, `proposed "test-only" assertion has format 77 but 1 is latest supported`) +} + +func (chks *checkSuite) TestCheckMismatchedAccountIDandKey(c *C) { + trustedKey := testPrivKey0 + + cfg := &asserts.DatabaseConfig{ + Backstore: chks.bs, + Trusted: []asserts.Assertion{asserts.BootstrapAccountKeyForTest("canonical", trustedKey.PublicKey())}, + } + db, err := asserts.OpenDatabase(cfg) + c.Assert(err, IsNil) + + headers := map[string]interface{}{ + "authority-id": "random", + "primary-key": "0", + } + a, err := asserts.AssembleAndSignInTest(asserts.TestOnlyType, headers, nil, trustedKey) + c.Assert(err, IsNil) + + err = db.Check(a) + c.Check(err, ErrorMatches, `error finding matching public key for signature: found public key ".*" from "canonical" but expected it from: random`) + + err = asserts.CheckSignature(a, cfg.Trusted[0].(*asserts.AccountKey), db, time.Time{}, time.Time{}) + c.Check(err, ErrorMatches, `assertion authority "random" does not match public key from "canonical"`) +} + +func (chks *checkSuite) TestCheckAndSetEarliestTime(c *C) { + trustedKey := testPrivKey0 + + ak := asserts.MakeAccountKeyForTest("canonical", trustedKey.PublicKey(), time.Date(2016, 1, 1, 0, 0, 0, 0, time.UTC), 2) + + cfg := &asserts.DatabaseConfig{ + Backstore: chks.bs, + Trusted: []asserts.Assertion{ak}, + } + db, err := asserts.OpenDatabase(cfg) + c.Assert(err, IsNil) + + headers := map[string]interface{}{ + "authority-id": "canonical", + "primary-key": "0", + } + a, err := asserts.AssembleAndSignInTest(asserts.TestOnlyType, headers, nil, trustedKey) + c.Assert(err, IsNil) + + // now is since + 1 year, key is valid + r := asserts.MockTimeNow(ak.Since().AddDate(1, 0, 0)) + defer r() + + err = db.Check(a) + c.Check(err, IsNil) + + // now is since - 1 year, key is invalid + pastTime := ak.Since().AddDate(-1, 0, 0) + asserts.MockTimeNow(pastTime) + + err = db.Check(a) + c.Check(err, ErrorMatches, `assertion is signed with expired public key .*`) + + // now is ignored but known to be at least >= pastTime + // key is considered valid + db.SetEarliestTime(pastTime) + err = db.Check(a) + c.Check(err, IsNil) + + // move earliest after until + db.SetEarliestTime(ak.Until().AddDate(0, 0, 1)) + err = db.Check(a) + c.Check(err, ErrorMatches, `assertion is signed with expired public key .*`) + + // check using now = since - 1 year again + db.SetEarliestTime(time.Time{}) + err = db.Check(a) + c.Check(err, ErrorMatches, `assertion is signed with expired public key .*`) + + // now is since + 1 month, key is valid + asserts.MockTimeNow(ak.Since().AddDate(0, 1, 0)) + err = db.Check(a) + c.Check(err, IsNil) +} + +type signAddFindSuite struct { + signingDB *asserts.Database + signingKeyID string + db *asserts.Database +} + +var _ = Suite(&signAddFindSuite{}) + +func (safs *signAddFindSuite) SetUpTest(c *C) { + cfg0 := &asserts.DatabaseConfig{} + db0, err := asserts.OpenDatabase(cfg0) + c.Assert(err, IsNil) + safs.signingDB = db0 + + pk := testPrivKey0 + err = db0.ImportKey(pk) + c.Assert(err, IsNil) + safs.signingKeyID = pk.PublicKey().ID() + + topDir := filepath.Join(c.MkDir(), "asserts-db") + bs, err := asserts.OpenFSBackstore(topDir) + c.Assert(err, IsNil) + + headers := map[string]interface{}{ + "type": "account", + "authority-id": "canonical", + "account-id": "predefined", + "validation": "verified", + "display-name": "Predef", + "timestamp": time.Now().Format(time.RFC3339), + } + predefAcct, err := safs.signingDB.Sign(asserts.AccountType, headers, nil, safs.signingKeyID) + c.Assert(err, IsNil) + + trustedKey := testPrivKey0 + cfg := &asserts.DatabaseConfig{ + Backstore: bs, + Trusted: []asserts.Assertion{ + asserts.BootstrapAccountForTest("canonical"), + asserts.BootstrapAccountKeyForTest("canonical", trustedKey.PublicKey()), + }, + OtherPredefined: []asserts.Assertion{ + predefAcct, + }, + } + db, err := asserts.OpenDatabase(cfg) + c.Assert(err, IsNil) + safs.db = db +} + +func (safs *signAddFindSuite) TestSign(c *C) { + headers := map[string]interface{}{ + "authority-id": "canonical", + "primary-key": "a", + } + a1, err := safs.signingDB.Sign(asserts.TestOnlyType, headers, nil, safs.signingKeyID) + c.Assert(err, IsNil) + + err = safs.db.Check(a1) + c.Check(err, IsNil) +} + +func (safs *signAddFindSuite) TestSignEmptyKeyID(c *C) { + headers := map[string]interface{}{ + "authority-id": "canonical", + "primary-key": "a", + } + a1, err := safs.signingDB.Sign(asserts.TestOnlyType, headers, nil, "") + c.Assert(err, ErrorMatches, "key id is empty") + c.Check(a1, IsNil) +} + +func (safs *signAddFindSuite) TestSignMissingAuthorityId(c *C) { + headers := map[string]interface{}{ + "primary-key": "a", + } + a1, err := safs.signingDB.Sign(asserts.TestOnlyType, headers, nil, safs.signingKeyID) + c.Assert(err, ErrorMatches, `"authority-id" header is mandatory`) + c.Check(a1, IsNil) +} + +func (safs *signAddFindSuite) TestSignMissingPrimaryKey(c *C) { + headers := map[string]interface{}{ + "authority-id": "canonical", + } + a1, err := safs.signingDB.Sign(asserts.TestOnlyType, headers, nil, safs.signingKeyID) + c.Assert(err, ErrorMatches, `"primary-key" header is mandatory`) + c.Check(a1, IsNil) +} + +func (safs *signAddFindSuite) TestSignPrimaryKeyWithSlash(c *C) { + headers := map[string]interface{}{ + "authority-id": "canonical", + "primary-key": "baz/9000", + } + a1, err := safs.signingDB.Sign(asserts.TestOnlyType, headers, nil, safs.signingKeyID) + c.Assert(err, ErrorMatches, `"primary-key" primary key header cannot contain '/'`) + c.Check(a1, IsNil) +} + +func (safs *signAddFindSuite) TestSignNoPrivateKey(c *C) { + headers := map[string]interface{}{ + "authority-id": "canonical", + "primary-key": "a", + } + a1, err := safs.signingDB.Sign(asserts.TestOnlyType, headers, nil, "abcd") + c.Assert(err, ErrorMatches, "cannot find key pair") + c.Check(a1, IsNil) +} + +func (safs *signAddFindSuite) TestSignUnknownType(c *C) { + headers := map[string]interface{}{ + "authority-id": "canonical", + } + a1, err := safs.signingDB.Sign(&asserts.AssertionType{Name: "xyz", PrimaryKey: nil}, headers, nil, safs.signingKeyID) + c.Assert(err, ErrorMatches, `internal error: unknown assertion type: "xyz"`) + c.Check(a1, IsNil) +} + +func (safs *signAddFindSuite) TestSignNonPredefinedType(c *C) { + headers := map[string]interface{}{ + "authority-id": "canonical", + } + a1, err := safs.signingDB.Sign(&asserts.AssertionType{Name: "test-only", PrimaryKey: nil}, headers, nil, safs.signingKeyID) + c.Assert(err, ErrorMatches, `internal error: unpredefined assertion type for name "test-only" used.*`) + c.Check(a1, IsNil) +} + +func (safs *signAddFindSuite) TestSignBadRevision(c *C) { + headers := map[string]interface{}{ + "authority-id": "canonical", + "primary-key": "a", + "revision": "zzz", + } + a1, err := safs.signingDB.Sign(asserts.TestOnlyType, headers, nil, safs.signingKeyID) + c.Assert(err, ErrorMatches, `"revision" header is not an integer: zzz`) + c.Check(a1, IsNil) +} + +func (safs *signAddFindSuite) TestSignBadFormat(c *C) { + headers := map[string]interface{}{ + "authority-id": "canonical", + "primary-key": "a", + "format": "zzz", + } + a1, err := safs.signingDB.Sign(asserts.TestOnlyType, headers, nil, safs.signingKeyID) + c.Assert(err, ErrorMatches, `"format" header is not an integer: zzz`) + c.Check(a1, IsNil) +} + +func (safs *signAddFindSuite) TestSignHeadersCheck(c *C) { + headers := map[string]interface{}{ + "authority-id": "canonical", + "primary-key": "a", + "extra": []interface{}{1, 2}, + } + a1, err := safs.signingDB.Sign(asserts.TestOnlyType, headers, nil, safs.signingKeyID) + c.Check(err, ErrorMatches, `header "extra": header values must be strings or nested lists or maps with strings as the only scalars: 1`) + c.Check(a1, IsNil) +} + +func (safs *signAddFindSuite) TestSignHeadersCheckMap(c *C) { + headers := map[string]interface{}{ + "authority-id": "canonical", + "primary-key": "a", + "extra": map[string]interface{}{"a": "a", "b": 1}, + } + a1, err := safs.signingDB.Sign(asserts.TestOnlyType, headers, nil, safs.signingKeyID) + c.Check(err, ErrorMatches, `header "extra": header values must be strings or nested lists or maps with strings as the only scalars: 1`) + c.Check(a1, IsNil) +} + +func (safs *signAddFindSuite) TestSignAssemblerError(c *C) { + headers := map[string]interface{}{ + "authority-id": "canonical", + "primary-key": "a", + "count": "zzz", + } + a1, err := safs.signingDB.Sign(asserts.TestOnlyType, headers, nil, safs.signingKeyID) + c.Assert(err, ErrorMatches, `cannot assemble assertion test-only: "count" header is not an integer: zzz`) + c.Check(a1, IsNil) +} + +func (safs *signAddFindSuite) TestSignUnsupportedFormat(c *C) { + headers := map[string]interface{}{ + "authority-id": "canonical", + "primary-key": "a", + "format": "77", + } + a1, err := safs.signingDB.Sign(asserts.TestOnlyType, headers, nil, safs.signingKeyID) + c.Assert(err, ErrorMatches, `cannot sign "test-only" assertion with format 77 higher than max supported format 1`) + c.Check(a1, IsNil) +} + +func (safs *signAddFindSuite) TestSignInadequateFormat(c *C) { + headers := map[string]interface{}{ + "authority-id": "canonical", + "primary-key": "a", + "format-1-feature": "true", + } + a1, err := safs.signingDB.Sign(asserts.TestOnlyType, headers, nil, safs.signingKeyID) + c.Assert(err, ErrorMatches, `cannot sign "test-only" assertion with format set to 0 lower than min format 1 covering included features`) + c.Check(a1, IsNil) +} + +func (safs *signAddFindSuite) TestAddRefusesSelfSignedKey(c *C) { + aKey := testPrivKey2 + + aKeyEncoded, err := asserts.EncodePublicKey(aKey.PublicKey()) + c.Assert(err, IsNil) + + now := time.Now().UTC() + headers := map[string]interface{}{ + "authority-id": "canonical", + "account-id": "canonical", + "public-key-sha3-384": aKey.PublicKey().ID(), + "name": "default", + "since": now.Format(time.RFC3339), + } + acctKey, err := asserts.AssembleAndSignInTest(asserts.AccountKeyType, headers, aKeyEncoded, aKey) + c.Assert(err, IsNil) + + // this must fail + err = safs.db.Add(acctKey) + c.Check(err, ErrorMatches, `no matching public key.*`) +} + +func (safs *signAddFindSuite) TestAddSuperseding(c *C) { + headers := map[string]interface{}{ + "authority-id": "canonical", + "primary-key": "a", + } + a1, err := safs.signingDB.Sign(asserts.TestOnlyType, headers, nil, safs.signingKeyID) + c.Assert(err, IsNil) + + err = safs.db.Add(a1) + c.Assert(err, IsNil) + + retrieved1, err := safs.db.Find(asserts.TestOnlyType, map[string]string{ + "primary-key": "a", + }) + c.Assert(err, IsNil) + c.Check(retrieved1, NotNil) + c.Check(retrieved1.Revision(), Equals, 0) + + headers["revision"] = "1" + a2, err := safs.signingDB.Sign(asserts.TestOnlyType, headers, nil, safs.signingKeyID) + c.Assert(err, IsNil) + + err = safs.db.Add(a2) + c.Assert(err, IsNil) + + retrieved2, err := safs.db.Find(asserts.TestOnlyType, map[string]string{ + "primary-key": "a", + }) + c.Assert(err, IsNil) + c.Check(retrieved2, NotNil) + c.Check(retrieved2.Revision(), Equals, 1) + + err = safs.db.Add(a1) + c.Check(err, ErrorMatches, "revision 0 is older than current revision 1") + c.Check(asserts.IsUnaccceptedUpdate(err), Equals, true) +} + +func (safs *signAddFindSuite) TestAddNoAuthorityNoPrimaryKey(c *C) { + headers := map[string]interface{}{ + "hdr": "FOO", + } + a, err := asserts.SignWithoutAuthority(asserts.TestOnlyNoAuthorityType, headers, nil, testPrivKey0) + c.Assert(err, IsNil) + + err = safs.db.Add(a) + c.Assert(err, ErrorMatches, `internal error: assertion type "test-only-no-authority" has no primary key`) +} + +func (safs *signAddFindSuite) TestAddNoAuthorityButPrimaryKey(c *C) { + headers := map[string]interface{}{ + "pk": "primary", + } + a, err := asserts.SignWithoutAuthority(asserts.TestOnlyNoAuthorityPKType, headers, nil, testPrivKey0) + c.Assert(err, IsNil) + + err = safs.db.Add(a) + c.Assert(err, ErrorMatches, `cannot check no-authority assertion type "test-only-no-authority-pk"`) +} + +func (safs *signAddFindSuite) TestAddUnsupportedFormat(c *C) { + const unsupported = "type: test-only\n" + + "format: 77\n" + + "authority-id: canonical\n" + + "primary-key: a\n" + + "payload: unsupported\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==" + aUnsupp, err := asserts.Decode([]byte(unsupported)) + c.Assert(err, IsNil) + c.Assert(aUnsupp.SupportedFormat(), Equals, false) + + err = safs.db.Add(aUnsupp) + c.Assert(err, FitsTypeOf, &asserts.UnsupportedFormatError{}) + c.Check(err.(*asserts.UnsupportedFormatError).Update, Equals, false) + c.Check(err, ErrorMatches, `proposed "test-only" assertion has format 77 but 1 is latest supported`) + c.Check(asserts.IsUnaccceptedUpdate(err), Equals, false) + + headers := map[string]interface{}{ + "authority-id": "canonical", + "primary-key": "a", + "format": "1", + "payload": "supported", + } + aSupp, err := asserts.AssembleAndSignInTest(asserts.TestOnlyType, headers, nil, testPrivKey0) + c.Assert(err, IsNil) + + err = safs.db.Add(aSupp) + c.Assert(err, IsNil) + + err = safs.db.Add(aUnsupp) + c.Assert(err, FitsTypeOf, &asserts.UnsupportedFormatError{}) + c.Check(err.(*asserts.UnsupportedFormatError).Update, Equals, true) + c.Check(err, ErrorMatches, `proposed "test-only" assertion has format 77 but 1 is latest supported \(current not updated\)`) + c.Check(asserts.IsUnaccceptedUpdate(err), Equals, true) +} + +func (safs *signAddFindSuite) TestNotFoundError(c *C) { + err1 := &asserts.NotFoundError{ + Type: asserts.SnapDeclarationType, + Headers: map[string]string{ + "series": "16", + "snap-id": "snap-id", + }, + } + c.Check(errors.Is(err1, &asserts.NotFoundError{}), Equals, true) + c.Check(err1.Error(), Equals, "snap-declaration (snap-id; series:16) not found") + + err2 := &asserts.NotFoundError{ + Type: asserts.SnapRevisionType, + } + c.Check(errors.Is(err2, &asserts.NotFoundError{}), Equals, true) + c.Check(err2.Error(), Equals, "snap-revision assertion not found") +} + +func (safs *signAddFindSuite) TestFindNotFound(c *C) { + headers := map[string]interface{}{ + "authority-id": "canonical", + "primary-key": "a", + } + a1, err := safs.signingDB.Sign(asserts.TestOnlyType, headers, nil, safs.signingKeyID) + c.Assert(err, IsNil) + + err = safs.db.Add(a1) + c.Assert(err, IsNil) + + hdrs := map[string]string{ + "primary-key": "b", + } + retrieved1, err := safs.db.Find(asserts.TestOnlyType, hdrs) + c.Assert(err, DeepEquals, &asserts.NotFoundError{ + Type: asserts.TestOnlyType, + Headers: hdrs, + }) + c.Check(retrieved1, IsNil) + + // checking also extra headers + hdrs = map[string]string{ + "primary-key": "a", + "authority-id": "other-auth-id", + } + retrieved1, err = safs.db.Find(asserts.TestOnlyType, hdrs) + c.Assert(err, DeepEquals, &asserts.NotFoundError{ + Type: asserts.TestOnlyType, + Headers: hdrs, + }) + c.Check(retrieved1, IsNil) +} + +func (safs *signAddFindSuite) TestFindPrimaryLeftOut(c *C) { + retrieved1, err := safs.db.Find(asserts.TestOnlyType, map[string]string{}) + c.Assert(err, ErrorMatches, "must provide primary key: primary-key") + c.Check(retrieved1, IsNil) +} + +func (safs *signAddFindSuite) TestFindMany(c *C) { + headers := map[string]interface{}{ + "authority-id": "canonical", + "primary-key": "a", + "other": "other-x", + } + aa, err := safs.signingDB.Sign(asserts.TestOnlyType, headers, nil, safs.signingKeyID) + c.Assert(err, IsNil) + err = safs.db.Add(aa) + c.Assert(err, IsNil) + + headers = map[string]interface{}{ + "authority-id": "canonical", + "primary-key": "b", + "other": "other-y", + } + ab, err := safs.signingDB.Sign(asserts.TestOnlyType, headers, nil, safs.signingKeyID) + c.Assert(err, IsNil) + err = safs.db.Add(ab) + c.Assert(err, IsNil) + + headers = map[string]interface{}{ + "authority-id": "canonical", + "primary-key": "c", + "other": "other-x", + } + ac, err := safs.signingDB.Sign(asserts.TestOnlyType, headers, nil, safs.signingKeyID) + c.Assert(err, IsNil) + err = safs.db.Add(ac) + c.Assert(err, IsNil) + + res, err := safs.db.FindMany(asserts.TestOnlyType, map[string]string{ + "other": "other-x", + }) + c.Assert(err, IsNil) + c.Assert(res, HasLen, 2) + primKeys := []string{res[0].HeaderString("primary-key"), res[1].HeaderString("primary-key")} + sort.Strings(primKeys) + c.Check(primKeys, DeepEquals, []string{"a", "c"}) + + res, err = safs.db.FindMany(asserts.TestOnlyType, map[string]string{ + "other": "other-y", + }) + c.Assert(err, IsNil) + c.Assert(res, HasLen, 1) + c.Check(res[0].Header("primary-key"), Equals, "b") + + res, err = safs.db.FindMany(asserts.TestOnlyType, map[string]string{}) + c.Assert(err, IsNil) + c.Assert(res, HasLen, 3) + + res, err = safs.db.FindMany(asserts.TestOnlyType, map[string]string{ + "primary-key": "b", + "other": "other-y", + }) + c.Assert(err, IsNil) + c.Assert(res, HasLen, 1) + + hdrs := map[string]string{ + "primary-key": "b", + "other": "other-x", + } + res, err = safs.db.FindMany(asserts.TestOnlyType, hdrs) + c.Assert(res, HasLen, 0) + c.Check(err, DeepEquals, &asserts.NotFoundError{ + Type: asserts.TestOnlyType, + Headers: hdrs, + }) +} + +func (safs *signAddFindSuite) TestFindFindsPredefined(c *C) { + pk1 := testPrivKey1 + + acct1 := assertstest.NewAccount(safs.signingDB, "acc-id1", map[string]interface{}{ + "authority-id": "canonical", + }, safs.signingKeyID) + + acct1Key := assertstest.NewAccountKey(safs.signingDB, acct1, map[string]interface{}{ + "authority-id": "canonical", + }, pk1.PublicKey(), safs.signingKeyID) + + err := safs.db.Add(acct1) + c.Assert(err, IsNil) + err = safs.db.Add(acct1Key) + c.Assert(err, IsNil) + + // find the trusted key as well + tKey, err := safs.db.Find(asserts.AccountKeyType, map[string]string{ + "account-id": "canonical", + "public-key-sha3-384": safs.signingKeyID, + }) + c.Assert(err, IsNil) + c.Assert(tKey.(*asserts.AccountKey).AccountID(), Equals, "canonical") + c.Assert(tKey.(*asserts.AccountKey).PublicKeyID(), Equals, safs.signingKeyID) + + // find predefined account as well + predefAcct, err := safs.db.Find(asserts.AccountType, map[string]string{ + "account-id": "predefined", + }) + c.Assert(err, IsNil) + c.Assert(predefAcct.(*asserts.Account).AccountID(), Equals, "predefined") + c.Assert(predefAcct.(*asserts.Account).DisplayName(), Equals, "Predef") + + // find trusted and indirectly trusted + accKeys, err := safs.db.FindMany(asserts.AccountKeyType, nil) + c.Assert(err, IsNil) + c.Check(accKeys, HasLen, 2) + + accts, err := safs.db.FindMany(asserts.AccountType, nil) + c.Assert(err, IsNil) + c.Check(accts, HasLen, 3) +} + +func (safs *signAddFindSuite) TestFindTrusted(c *C) { + pk1 := testPrivKey1 + + acct1 := assertstest.NewAccount(safs.signingDB, "acc-id1", map[string]interface{}{ + "authority-id": "canonical", + }, safs.signingKeyID) + + acct1Key := assertstest.NewAccountKey(safs.signingDB, acct1, map[string]interface{}{ + "authority-id": "canonical", + }, pk1.PublicKey(), safs.signingKeyID) + + err := safs.db.Add(acct1) + c.Assert(err, IsNil) + err = safs.db.Add(acct1Key) + c.Assert(err, IsNil) + + // find the trusted account + tAcct, err := safs.db.FindTrusted(asserts.AccountType, map[string]string{ + "account-id": "canonical", + }) + c.Assert(err, IsNil) + c.Assert(tAcct.(*asserts.Account).AccountID(), Equals, "canonical") + + // find the trusted key + tKey, err := safs.db.FindTrusted(asserts.AccountKeyType, map[string]string{ + "account-id": "canonical", + "public-key-sha3-384": safs.signingKeyID, + }) + c.Assert(err, IsNil) + c.Assert(tKey.(*asserts.AccountKey).AccountID(), Equals, "canonical") + c.Assert(tKey.(*asserts.AccountKey).PublicKeyID(), Equals, safs.signingKeyID) + + // doesn't find not trusted assertions + hdrs := map[string]string{ + "account-id": acct1.AccountID(), + } + _, err = safs.db.FindTrusted(asserts.AccountType, hdrs) + c.Check(err, DeepEquals, &asserts.NotFoundError{ + Type: asserts.AccountType, + Headers: hdrs, + }) + + hdrs = map[string]string{ + "account-id": acct1.AccountID(), + "public-key-sha3-384": acct1Key.PublicKeyID(), + } + _, err = safs.db.FindTrusted(asserts.AccountKeyType, hdrs) + c.Check(err, DeepEquals, &asserts.NotFoundError{ + Type: asserts.AccountKeyType, + Headers: hdrs, + }) + + _, err = safs.db.FindTrusted(asserts.AccountType, map[string]string{ + "account-id": "predefined", + }) + c.Check(errors.Is(err, &asserts.NotFoundError{}), Equals, true) +} + +func (safs *signAddFindSuite) TestFindPredefined(c *C) { + pk1 := testPrivKey1 + + acct1 := assertstest.NewAccount(safs.signingDB, "acc-id1", map[string]interface{}{ + "authority-id": "canonical", + }, safs.signingKeyID) + + acct1Key := assertstest.NewAccountKey(safs.signingDB, acct1, map[string]interface{}{ + "authority-id": "canonical", + }, pk1.PublicKey(), safs.signingKeyID) + + err := safs.db.Add(acct1) + c.Assert(err, IsNil) + err = safs.db.Add(acct1Key) + c.Assert(err, IsNil) + + // find the trusted account + tAcct, err := safs.db.FindPredefined(asserts.AccountType, map[string]string{ + "account-id": "canonical", + }) + c.Assert(err, IsNil) + c.Assert(tAcct.(*asserts.Account).AccountID(), Equals, "canonical") + + // find the trusted key + tKey, err := safs.db.FindPredefined(asserts.AccountKeyType, map[string]string{ + "account-id": "canonical", + "public-key-sha3-384": safs.signingKeyID, + }) + c.Assert(err, IsNil) + c.Assert(tKey.(*asserts.AccountKey).AccountID(), Equals, "canonical") + c.Assert(tKey.(*asserts.AccountKey).PublicKeyID(), Equals, safs.signingKeyID) + + // find predefined account as well + predefAcct, err := safs.db.FindPredefined(asserts.AccountType, map[string]string{ + "account-id": "predefined", + }) + c.Assert(err, IsNil) + c.Assert(predefAcct.(*asserts.Account).AccountID(), Equals, "predefined") + c.Assert(predefAcct.(*asserts.Account).DisplayName(), Equals, "Predef") + + // doesn't find not trusted or predefined assertions + hdrs := map[string]string{ + "account-id": acct1.AccountID(), + } + _, err = safs.db.FindPredefined(asserts.AccountType, hdrs) + c.Check(err, DeepEquals, &asserts.NotFoundError{ + Type: asserts.AccountType, + Headers: hdrs, + }) + + hdrs = map[string]string{ + "account-id": acct1.AccountID(), + "public-key-sha3-384": acct1Key.PublicKeyID(), + } + _, err = safs.db.FindPredefined(asserts.AccountKeyType, hdrs) + c.Check(err, DeepEquals, &asserts.NotFoundError{ + Type: asserts.AccountKeyType, + Headers: hdrs, + }) +} + +func (safs *signAddFindSuite) TestFindManyPredefined(c *C) { + headers := map[string]interface{}{ + "type": "account", + "authority-id": "canonical", + "account-id": "predefined", + "validation": "verified", + "display-name": "Predef", + "timestamp": time.Now().Format(time.RFC3339), + } + predefAcct, err := safs.signingDB.Sign(asserts.AccountType, headers, nil, safs.signingKeyID) + c.Assert(err, IsNil) + + trustedKey0 := testPrivKey0 + trustedKey1 := testPrivKey1 + cfg := &asserts.DatabaseConfig{ + Backstore: asserts.NewMemoryBackstore(), + Trusted: []asserts.Assertion{ + asserts.BootstrapAccountForTest("canonical"), + asserts.BootstrapAccountKeyForTest("canonical", trustedKey0.PublicKey()), + asserts.BootstrapAccountKeyForTest("canonical", trustedKey1.PublicKey()), + }, + OtherPredefined: []asserts.Assertion{ + predefAcct, + }, + } + db, err := asserts.OpenDatabase(cfg) + c.Assert(err, IsNil) + + pk1 := testPrivKey2 + + acct1 := assertstest.NewAccount(safs.signingDB, "acc-id1", map[string]interface{}{ + "authority-id": "canonical", + }, safs.signingKeyID) + + acct1Key := assertstest.NewAccountKey(safs.signingDB, acct1, map[string]interface{}{ + "authority-id": "canonical", + }, pk1.PublicKey(), safs.signingKeyID) + + err = db.Add(acct1) + c.Assert(err, IsNil) + err = db.Add(acct1Key) + c.Assert(err, IsNil) + + // find the trusted account + tAccts, err := db.FindManyPredefined(asserts.AccountType, map[string]string{ + "account-id": "canonical", + }) + c.Assert(err, IsNil) + c.Assert(tAccts, HasLen, 1) + c.Assert(tAccts[0].(*asserts.Account).AccountID(), Equals, "canonical") + + // find the predefined account + pAccts, err := db.FindManyPredefined(asserts.AccountType, map[string]string{ + "account-id": "predefined", + }) + c.Assert(err, IsNil) + c.Assert(pAccts, HasLen, 1) + c.Assert(pAccts[0].(*asserts.Account).AccountID(), Equals, "predefined") + + // find the multiple trusted keys + tKeys, err := db.FindManyPredefined(asserts.AccountKeyType, map[string]string{ + "account-id": "canonical", + }) + c.Assert(err, IsNil) + c.Assert(tKeys, HasLen, 2) + got := make(map[string]string) + for _, a := range tKeys { + acctKey := a.(*asserts.AccountKey) + got[acctKey.PublicKeyID()] = acctKey.AccountID() + } + c.Check(got, DeepEquals, map[string]string{ + trustedKey0.PublicKey().ID(): "canonical", + trustedKey1.PublicKey().ID(): "canonical", + }) + + // doesn't find not predefined assertions + hdrs := map[string]string{ + "account-id": acct1.AccountID(), + } + _, err = db.FindManyPredefined(asserts.AccountType, hdrs) + c.Check(err, DeepEquals, &asserts.NotFoundError{ + Type: asserts.AccountType, + Headers: hdrs, + }) + + _, err = db.FindManyPredefined(asserts.AccountKeyType, map[string]string{ + "account-id": acct1.AccountID(), + "public-key-sha3-384": acct1Key.PublicKeyID(), + }) + c.Check(errors.Is(err, &asserts.NotFoundError{}), Equals, true) +} + +func (safs *signAddFindSuite) TestDontLetAddConfusinglyAssertionClashingWithTrustedOnes(c *C) { + // trusted + pubKey0, err := safs.signingDB.PublicKey(safs.signingKeyID) + c.Assert(err, IsNil) + pubKey0Encoded, err := asserts.EncodePublicKey(pubKey0) + c.Assert(err, IsNil) + + now := time.Now().UTC() + headers := map[string]interface{}{ + "authority-id": "canonical", + "account-id": "canonical", + "public-key-sha3-384": safs.signingKeyID, + "name": "default", + "since": now.Format(time.RFC3339), + "until": now.AddDate(1, 0, 0).Format(time.RFC3339), + } + tKey, err := safs.signingDB.Sign(asserts.AccountKeyType, headers, []byte(pubKey0Encoded), safs.signingKeyID) + c.Assert(err, IsNil) + + err = safs.db.Add(tKey) + c.Check(err, ErrorMatches, `cannot add "account-key" assertion with primary key clashing with a trusted assertion: .*`) +} + +func (safs *signAddFindSuite) TestDontLetAddConfusinglyAssertionClashingWithPredefinedOnes(c *C) { + headers := map[string]interface{}{ + "type": "account", + "authority-id": "canonical", + "account-id": "predefined", + "validation": "verified", + "display-name": "Predef", + "timestamp": time.Now().Format(time.RFC3339), + } + predefAcct, err := safs.signingDB.Sign(asserts.AccountType, headers, nil, safs.signingKeyID) + c.Assert(err, IsNil) + + err = safs.db.Add(predefAcct) + c.Check(err, ErrorMatches, `cannot add "account" assertion with primary key clashing with a predefined assertion: .*`) +} + +func (safs *signAddFindSuite) TestFindAndRefResolve(c *C) { + headers := map[string]interface{}{ + "authority-id": "canonical", + "pk1": "ka", + "pk2": "kb", + } + a1, err := safs.signingDB.Sign(asserts.TestOnly2Type, headers, nil, safs.signingKeyID) + c.Assert(err, IsNil) + + err = safs.db.Add(a1) + c.Assert(err, IsNil) + + ref := &asserts.Ref{ + Type: asserts.TestOnly2Type, + PrimaryKey: []string{"ka", "kb"}, + } + + resolved, err := ref.Resolve(safs.db.Find) + c.Assert(err, IsNil) + c.Check(resolved.Headers(), DeepEquals, map[string]interface{}{ + "type": "test-only-2", + "authority-id": "canonical", + "pk1": "ka", + "pk2": "kb", + "sign-key-sha3-384": resolved.SignKeyID(), + }) + + ref = &asserts.Ref{ + Type: asserts.TestOnly2Type, + PrimaryKey: []string{"kb", "ka"}, + } + _, err = ref.Resolve(safs.db.Find) + c.Assert(err, DeepEquals, &asserts.NotFoundError{ + Type: ref.Type, + Headers: map[string]string{ + "pk1": "kb", + "pk2": "ka", + }, + }) +} + +func (safs *signAddFindSuite) TestFindMaxFormat(c *C) { + headers := map[string]interface{}{ + "authority-id": "canonical", + "primary-key": "foo", + } + af0, err := safs.signingDB.Sign(asserts.TestOnlyType, headers, nil, safs.signingKeyID) + c.Assert(err, IsNil) + + err = safs.db.Add(af0) + c.Assert(err, IsNil) + + headers = map[string]interface{}{ + "authority-id": "canonical", + "primary-key": "foo", + "format": "1", + "revision": "1", + } + af1, err := safs.signingDB.Sign(asserts.TestOnlyType, headers, nil, safs.signingKeyID) + c.Assert(err, IsNil) + + err = safs.db.Add(af1) + c.Assert(err, IsNil) + + a, err := safs.db.FindMaxFormat(asserts.TestOnlyType, map[string]string{ + "primary-key": "foo", + }, 1) + c.Assert(err, IsNil) + c.Check(a.Revision(), Equals, 1) + + a, err = safs.db.FindMaxFormat(asserts.TestOnlyType, map[string]string{ + "primary-key": "foo", + }, 0) + c.Assert(err, IsNil) + c.Check(a.Revision(), Equals, 0) + + a, err = safs.db.FindMaxFormat(asserts.TestOnlyType, map[string]string{ + "primary-key": "foo", + }, 3) + c.Check(err, ErrorMatches, `cannot find "test-only" assertions for format 3 higher than supported format 1`) + c.Check(a, IsNil) +} + +func (safs *signAddFindSuite) TestFindOptionalPrimaryKeys(c *C) { + r := asserts.MockOptionalPrimaryKey(asserts.TestOnlyType, "opt1", "o1-defl") + defer r() + + headers := map[string]interface{}{ + "authority-id": "canonical", + "primary-key": "k1", + } + a1, err := safs.signingDB.Sign(asserts.TestOnlyType, headers, nil, safs.signingKeyID) + c.Assert(err, IsNil) + + err = safs.db.Add(a1) + c.Assert(err, IsNil) + + headers = map[string]interface{}{ + "authority-id": "canonical", + "primary-key": "k2", + "opt1": "A", + } + a2, err := safs.signingDB.Sign(asserts.TestOnlyType, headers, nil, safs.signingKeyID) + c.Assert(err, IsNil) + + err = safs.db.Add(a2) + c.Assert(err, IsNil) + + a, err := safs.db.Find(asserts.TestOnlyType, map[string]string{ + "primary-key": "k1", + }) + c.Assert(err, IsNil) + c.Check(a.HeaderString("primary-key"), Equals, "k1") + c.Check(a.HeaderString("opt1"), Equals, "o1-defl") + + a, err = safs.db.Find(asserts.TestOnlyType, map[string]string{ + "primary-key": "k1", + "opt1": "o1-defl", + }) + c.Assert(err, IsNil) + c.Check(a.HeaderString("primary-key"), Equals, "k1") + c.Check(a.HeaderString("opt1"), Equals, "o1-defl") + + a, err = safs.db.Find(asserts.TestOnlyType, map[string]string{ + "primary-key": "k2", + "opt1": "A", + }) + c.Assert(err, IsNil) + c.Check(a.HeaderString("primary-key"), Equals, "k2") + c.Check(a.HeaderString("opt1"), Equals, "A") + + _, err = safs.db.Find(asserts.TestOnlyType, map[string]string{ + "primary-key": "k3", + }) + c.Check(err, DeepEquals, &asserts.NotFoundError{ + Type: asserts.TestOnlyType, + Headers: map[string]string{ + "primary-key": "k3", + }, + }) + + _, err = safs.db.Find(asserts.TestOnlyType, map[string]string{ + "primary-key": "k2", + }) + c.Check(err, DeepEquals, &asserts.NotFoundError{ + Type: asserts.TestOnlyType, + Headers: map[string]string{ + "primary-key": "k2", + }, + }) + + _, err = safs.db.Find(asserts.TestOnlyType, map[string]string{ + "primary-key": "k2", + "opt1": "B", + }) + c.Check(err, DeepEquals, &asserts.NotFoundError{ + Type: asserts.TestOnlyType, + Headers: map[string]string{ + "primary-key": "k2", + "opt1": "B", + }, + }) + + _, err = safs.db.Find(asserts.TestOnlyType, map[string]string{ + "primary-key": "k1", + "opt1": "B", + }) + c.Check(err, DeepEquals, &asserts.NotFoundError{ + Type: asserts.TestOnlyType, + Headers: map[string]string{ + "primary-key": "k1", + "opt1": "B", + }, + }) +} + +func (safs *signAddFindSuite) TestWithStackedBackstore(c *C) { + headers := map[string]interface{}{ + "authority-id": "canonical", + "primary-key": "one", + } + a1, err := safs.signingDB.Sign(asserts.TestOnlyType, headers, nil, safs.signingKeyID) + c.Assert(err, IsNil) + + err = safs.db.Add(a1) + c.Assert(err, IsNil) + + headers = map[string]interface{}{ + "authority-id": "canonical", + "primary-key": "two", + } + a2, err := safs.signingDB.Sign(asserts.TestOnlyType, headers, nil, safs.signingKeyID) + c.Assert(err, IsNil) + + bs := asserts.NewMemoryBackstore() + stacked := safs.db.WithStackedBackstore(bs) + + err = stacked.Add(a2) + c.Assert(err, IsNil) + + _, err = stacked.Find(asserts.TestOnlyType, map[string]string{ + "primary-key": "one", + }) + c.Check(err, IsNil) + + _, err = stacked.Find(asserts.TestOnlyType, map[string]string{ + "primary-key": "two", + }) + c.Check(err, IsNil) + + _, err = safs.db.Find(asserts.TestOnlyType, map[string]string{ + "primary-key": "two", + }) + c.Check(errors.Is(err, &asserts.NotFoundError{}), Equals, true) + + _, err = stacked.Find(asserts.AccountKeyType, map[string]string{ + "public-key-sha3-384": safs.signingKeyID, + }) + c.Check(err, IsNil) + + // stored in backstore + _, err = bs.Get(asserts.TestOnlyType, []string{"two"}, 0) + c.Check(err, IsNil) +} + +func (safs *signAddFindSuite) TestWithStackedBackstoreSafety(c *C) { + stacked := safs.db.WithStackedBackstore(asserts.NewMemoryBackstore()) + + // usual add safety + pubKey0, err := safs.signingDB.PublicKey(safs.signingKeyID) + c.Assert(err, IsNil) + pubKey0Encoded, err := asserts.EncodePublicKey(pubKey0) + c.Assert(err, IsNil) + + now := time.Now().UTC() + headers := map[string]interface{}{ + "authority-id": "canonical", + "account-id": "canonical", + "public-key-sha3-384": safs.signingKeyID, + "name": "default", + "since": now.Format(time.RFC3339), + "until": now.AddDate(1, 0, 0).Format(time.RFC3339), + } + tKey, err := safs.signingDB.Sign(asserts.AccountKeyType, headers, []byte(pubKey0Encoded), safs.signingKeyID) + c.Assert(err, IsNil) + + err = stacked.Add(tKey) + c.Check(err, ErrorMatches, `cannot add "account-key" assertion with primary key clashing with a trusted assertion: .*`) + + // cannot go back to old revisions + headers = map[string]interface{}{ + "authority-id": "canonical", + "primary-key": "one", + } + a0, err := safs.signingDB.Sign(asserts.TestOnlyType, headers, nil, safs.signingKeyID) + c.Assert(err, IsNil) + + headers = map[string]interface{}{ + "authority-id": "canonical", + "primary-key": "one", + "revision": "1", + } + a1, err := safs.signingDB.Sign(asserts.TestOnlyType, headers, nil, safs.signingKeyID) + c.Assert(err, IsNil) + + err = safs.db.Add(a1) + c.Assert(err, IsNil) + + err = stacked.Add(a0) + c.Assert(err, DeepEquals, &asserts.RevisionError{ + Used: 0, + Current: 1, + }) +} + +func (safs *signAddFindSuite) TestFindSequence(c *C) { + headers := map[string]interface{}{ + "authority-id": "canonical", + "n": "s1", + "sequence": "1", + } + sq1f0, err := safs.signingDB.Sign(asserts.TestOnlySeqType, headers, nil, safs.signingKeyID) + c.Assert(err, IsNil) + + headers = map[string]interface{}{ + "authority-id": "canonical", + "n": "s1", + "sequence": "2", + } + sq2f0, err := safs.signingDB.Sign(asserts.TestOnlySeqType, headers, nil, safs.signingKeyID) + c.Assert(err, IsNil) + + headers = map[string]interface{}{ + "authority-id": "canonical", + "format": "1", + "n": "s1", + "sequence": "2", + "revision": "1", + } + sq2f1, err := safs.signingDB.Sign(asserts.TestOnlySeqType, headers, nil, safs.signingKeyID) + c.Assert(err, IsNil) + + headers = map[string]interface{}{ + "authority-id": "canonical", + "format": "1", + "n": "s1", + "sequence": "3", + } + sq3f1, err := safs.signingDB.Sign(asserts.TestOnlySeqType, headers, nil, safs.signingKeyID) + c.Assert(err, IsNil) + + headers = map[string]interface{}{ + "authority-id": "canonical", + "format": "2", + "n": "s1", + "sequence": "3", + "revision": "1", + } + sq3f2, err := safs.signingDB.Sign(asserts.TestOnlySeqType, headers, nil, safs.signingKeyID) + c.Assert(err, IsNil) + + for _, a := range []asserts.Assertion{sq1f0, sq2f0, sq2f1, sq3f1} { + + err = safs.db.Add(a) + c.Assert(err, IsNil) + } + + // stack a backstore, for test completeness, this is an unlikely + // scenario atm + bs := asserts.NewMemoryBackstore() + db := safs.db.WithStackedBackstore(bs) + err = db.Add(sq3f2) + c.Assert(err, IsNil) + + seqHeaders := map[string]string{ + "n": "s1", + } + tests := []struct { + after int + maxFormat int + sequence int + format int + revision int + }{ + {after: 0, maxFormat: 0, sequence: 1, format: 0, revision: 0}, + {after: 0, maxFormat: 2, sequence: 1, format: 0, revision: 0}, + {after: 1, maxFormat: 0, sequence: 2, format: 0, revision: 0}, + {after: 1, maxFormat: 1, sequence: 2, format: 1, revision: 1}, + {after: 1, maxFormat: 2, sequence: 2, format: 1, revision: 1}, + {after: 2, maxFormat: 0, sequence: -1}, + {after: 2, maxFormat: 1, sequence: 3, format: 1, revision: 0}, + {after: 2, maxFormat: 2, sequence: 3, format: 2, revision: 1}, + {after: 3, maxFormat: 0, sequence: -1}, + {after: 3, maxFormat: 2, sequence: -1}, + {after: 4, maxFormat: 2, sequence: -1}, + {after: -1, maxFormat: 0, sequence: 2, format: 0, revision: 0}, + {after: -1, maxFormat: 1, sequence: 3, format: 1, revision: 0}, + {after: -1, maxFormat: 2, sequence: 3, format: 2, revision: 1}, + } + + for _, t := range tests { + a, err := db.FindSequence(asserts.TestOnlySeqType, seqHeaders, t.after, t.maxFormat) + if t.sequence == -1 { + c.Check(err, DeepEquals, &asserts.NotFoundError{ + Type: asserts.TestOnlySeqType, + Headers: seqHeaders, + }) + } else { + c.Assert(err, IsNil) + c.Assert(a.HeaderString("n"), Equals, "s1") + c.Check(a.Sequence(), Equals, t.sequence) + c.Check(a.Format(), Equals, t.format) + c.Check(a.Revision(), Equals, t.revision) + } + } + + seqHeaders = map[string]string{ + "n": "s2", + } + _, err = db.FindSequence(asserts.TestOnlySeqType, seqHeaders, -1, 2) + c.Check(err, DeepEquals, &asserts.NotFoundError{ + Type: asserts.TestOnlySeqType, Headers: seqHeaders, + }) + +} + +func (safs *signAddFindSuite) TestCheckConstraints(c *C) { + headers := map[string]interface{}{ + "type": "account", + "authority-id": "canonical", + "account-id": "my-brand", + "display-name": "My Brand", + "validation": "verified", + "timestamp": time.Now().Format(time.RFC3339), + } + acct, err := safs.signingDB.Sign(asserts.AccountType, headers, nil, safs.signingKeyID) + c.Assert(err, IsNil) + + err = safs.db.Add(acct) + c.Check(err, IsNil) + + pubKey1 := testPrivKey1.PublicKey() + pubKey1Encoded, err := asserts.EncodePublicKey(pubKey1) + c.Assert(err, IsNil) + + now := time.Now().UTC() + headers = map[string]interface{}{ + "authority-id": "canonical", + "format": "1", + "account-id": "my-brand", + "public-key-sha3-384": pubKey1.ID(), + "name": "default", + "since": now.Format(time.RFC3339), + "until": now.AddDate(1, 0, 0).Format(time.RFC3339), + "constraints": []interface{}{ + map[string]interface{}{ + "headers": map[string]interface{}{ + "type": "model", + "model": "foo-.*", + }, + }, + }, + } + accKey, err := safs.signingDB.Sign(asserts.AccountKeyType, headers, []byte(pubKey1Encoded), safs.signingKeyID) + c.Assert(err, IsNil) + + err = safs.db.Add(accKey) + c.Check(err, IsNil) + + headers = map[string]interface{}{ + "type": "model", + "authority-id": "my-brand", + "brand-id": "my-brand", + "series": "16", + "model": "foo-200", + "classic": "true", + "timestamp": now.Format(time.RFC3339), + } + mfoo, err := asserts.AssembleAndSignInTest(asserts.ModelType, headers, nil, testPrivKey1) + c.Assert(err, IsNil) + + err = safs.db.Add(mfoo) + c.Check(err, IsNil) + + headers = map[string]interface{}{ + "type": "model", + "authority-id": "my-brand", + "brand-id": "my-brand", + "series": "16", + "model": "goo-200", + "classic": "true", + "timestamp": now.Format(time.RFC3339), + } + mnotfoo, err := asserts.AssembleAndSignInTest(asserts.ModelType, headers, nil, testPrivKey1) + c.Assert(err, IsNil) + + err = safs.db.Add(mnotfoo) + c.Check(err, ErrorMatches, `assertion does not match signing constraints for public key ".*" from "my-brand"`) +} + +type revisionErrorSuite struct{} + +func (res *revisionErrorSuite) TestErrorText(c *C) { + tests := []struct { + err error + expected string + }{ + // Invalid revisions. + {&asserts.RevisionError{Used: -1}, "assertion revision is unknown"}, + {&asserts.RevisionError{Used: -100}, "assertion revision is unknown"}, + {&asserts.RevisionError{Current: -1}, "assertion revision is unknown"}, + {&asserts.RevisionError{Current: -100}, "assertion revision is unknown"}, + {&asserts.RevisionError{Used: -1, Current: -1}, "assertion revision is unknown"}, + // Used == Current. + {&asserts.RevisionError{}, "revision 0 is already the current revision"}, + {&asserts.RevisionError{Used: 100, Current: 100}, "revision 100 is already the current revision"}, + // Used < Current. + {&asserts.RevisionError{Used: 1, Current: 2}, "revision 1 is older than current revision 2"}, + {&asserts.RevisionError{Used: 2, Current: 100}, "revision 2 is older than current revision 100"}, + // Used > Current. + {&asserts.RevisionError{Current: 1, Used: 2}, "revision 2 is more recent than current revision 1"}, + {&asserts.RevisionError{Current: 2, Used: 100}, "revision 100 is more recent than current revision 2"}, + } + + for _, test := range tests { + c.Check(test.err, ErrorMatches, test.expected) + } +} + +type isUnacceptedUpdateSuite struct{} + +func (s *isUnacceptedUpdateSuite) TestIsUnacceptedUpdate(c *C) { + tests := []struct { + err error + keptCurrent bool + }{ + {&asserts.UnsupportedFormatError{}, false}, + {&asserts.UnsupportedFormatError{Update: true}, true}, + {&asserts.RevisionError{Used: 1, Current: 1}, true}, + {&asserts.RevisionError{Used: 1, Current: 5}, true}, + {&asserts.RevisionError{Used: 3, Current: 1}, false}, + {errors.New("other error"), false}, + {&asserts.NotFoundError{Type: asserts.TestOnlyType}, false}, + } + + for _, t := range tests { + c.Check(asserts.IsUnaccceptedUpdate(t.err), Equals, t.keptCurrent, Commentf("%v", t.err)) + } +} diff --git a/asserts/digest.go b/asserts/digest.go new file mode 100644 index 00000000..6578772e --- /dev/null +++ b/asserts/digest.go @@ -0,0 +1,43 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2015 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts + +import ( + "crypto" + "encoding/base64" + "fmt" +) + +// EncodeDigest encodes the digest from hash algorithm to be put in an assertion header. +func EncodeDigest(hash crypto.Hash, hashDigest []byte) (string, error) { + algo := "" + switch hash { + case crypto.SHA512: + algo = "sha512" + case crypto.SHA3_384: + algo = "sha3-384" + default: + return "", fmt.Errorf("unsupported hash") + } + if len(hashDigest) != hash.Size() { + return "", fmt.Errorf("hash digest by %s should be %d bytes", algo, hash.Size()) + } + return base64.RawURLEncoding.EncodeToString(hashDigest), nil +} diff --git a/asserts/digest_test.go b/asserts/digest_test.go new file mode 100644 index 00000000..a9406925 --- /dev/null +++ b/asserts/digest_test.go @@ -0,0 +1,65 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2015 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts_test + +import ( + "crypto" + _ "crypto/sha256" + "encoding/base64" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/asserts" +) + +type encodeDigestSuite struct{} + +var _ = Suite(&encodeDigestSuite{}) + +func (eds *encodeDigestSuite) TestEncodeDigestOK(c *C) { + h := crypto.SHA512.New() + h.Write([]byte("some stuff to hash")) + digest := h.Sum(nil) + encoded, err := asserts.EncodeDigest(crypto.SHA512, digest) + c.Assert(err, IsNil) + + decoded, err := base64.RawURLEncoding.DecodeString(encoded) + c.Assert(err, IsNil) + c.Check(decoded, DeepEquals, digest) + + // sha3-384 + b, err := base64.RawURLEncoding.DecodeString(blobSHA3_384) + c.Assert(err, IsNil) + encoded, err = asserts.EncodeDigest(crypto.SHA3_384, b) + c.Assert(err, IsNil) + c.Check(encoded, Equals, blobSHA3_384) + +} + +func (eds *encodeDigestSuite) TestEncodeDigestErrors(c *C) { + _, err := asserts.EncodeDigest(crypto.SHA1, nil) + c.Check(err, ErrorMatches, "unsupported hash") + + _, err = asserts.EncodeDigest(crypto.SHA512, []byte{1, 2}) + c.Check(err, ErrorMatches, "hash digest by sha512 should be 64 bytes") + + _, err = asserts.EncodeDigest(crypto.SHA3_384, []byte{1, 2}) + c.Check(err, ErrorMatches, "hash digest by sha3-384 should be 48 bytes") +} diff --git a/asserts/export_test.go b/asserts/export_test.go new file mode 100644 index 00000000..662f4698 --- /dev/null +++ b/asserts/export_test.go @@ -0,0 +1,369 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2015-2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts + +import ( + "io" + "time" + + "github.com/snapcore/snapd/asserts/internal" + "github.com/snapcore/snapd/testutil" +) + +// expose test-only things here + +var NumAssertionType int + +func init() { + NumAssertionType = len(typeRegistry) +} + +// v1FixedTimestamp exposed for tests +var V1FixedTimestamp = v1FixedTimestamp + +// assembleAndSign exposed for tests +var AssembleAndSignInTest = assembleAndSign + +// decodePrivateKey exposed for tests +var DecodePrivateKeyInTest = decodePrivateKey + +// NewDecoderStressed makes a Decoder with a stressed setup with the given buffer and maximum sizes. +func NewDecoderStressed(r io.Reader, bufSize, maxHeadersSize, maxBodySize, maxSigSize int) *Decoder { + return (&Decoder{ + rd: r, + initialBufSize: bufSize, + maxHeadersSize: maxHeadersSize, + maxSigSize: maxSigSize, + defaultMaxBodySize: maxBodySize, + }).initBuffer() +} + +func BootstrapAccountForTest(authorityID string) *Account { + return &Account{ + assertionBase: assertionBase{ + headers: map[string]interface{}{ + "type": "account", + "authority-id": authorityID, + "account-id": authorityID, + "validation": "verified", + }, + }, + timestamp: time.Now().UTC(), + } +} + +func MakeAccountKeyForTest(authorityID string, openPGPPubKey PublicKey, since time.Time, validYears int) *AccountKey { + return MakeAccountKeyForTestWithUntil(authorityID, openPGPPubKey, since, since.AddDate(validYears, 0, 0), validYears) +} + +func MakeAccountKeyForTestWithUntil(authorityID string, openPGPPubKey PublicKey, since, until time.Time, validYears int) *AccountKey { + return &AccountKey{ + assertionBase: assertionBase{ + headers: map[string]interface{}{ + "type": "account-key", + "authority-id": authorityID, + "account-id": authorityID, + "public-key-sha3-384": openPGPPubKey.ID(), + }, + }, + sinceUntil: sinceUntil{ + since: since.UTC(), + until: until.UTC(), + }, + pubKey: openPGPPubKey, + } +} + +func BootstrapAccountKeyForTest(authorityID string, pubKey PublicKey) *AccountKey { + return MakeAccountKeyForTest(authorityID, pubKey, time.Time{}, 9999) +} + +func ExpiredAccountKeyForTest(authorityID string, pubKey PublicKey) *AccountKey { + return MakeAccountKeyForTest(authorityID, pubKey, time.Time{}, 1) +} + +func MockTimeNow(t time.Time) (restore func()) { + oldTimeNow := timeNow + timeNow = func() time.Time { + return t + } + return func() { + timeNow = oldTimeNow + } +} + +// define test assertion types to use in the tests + +type TestOnly struct { + assertionBase +} + +func assembleTestOnly(assert assertionBase) (Assertion, error) { + // for testing error cases + if _, err := checkIntWithDefault(assert.headers, "count", 0); err != nil { + return nil, err + } + return &TestOnly{assert}, nil +} + +var TestOnlyType = &AssertionType{"test-only", []string{"primary-key"}, nil, assembleTestOnly, 0} + +type TestOnly2 struct { + assertionBase +} + +func assembleTestOnly2(assert assertionBase) (Assertion, error) { + return &TestOnly2{assert}, nil +} + +var TestOnly2Type = &AssertionType{"test-only-2", []string{"pk1", "pk2"}, nil, assembleTestOnly2, 0} + +// TestOnlyDecl is a test-only assertion that mimics snap-declaration +// relations with other assertions. +type TestOnlyDecl struct { + assertionBase +} + +func (dcl *TestOnlyDecl) ID() string { + return dcl.HeaderString("id") +} + +func (dcl *TestOnlyDecl) DevID() string { + return dcl.HeaderString("dev-id") +} + +func (dcl *TestOnlyDecl) Prerequisites() []*Ref { + return []*Ref{ + {Type: AccountType, PrimaryKey: []string{dcl.DevID()}}, + } +} + +func assembleTestOnlyDecl(assert assertionBase) (Assertion, error) { + return &TestOnlyDecl{assert}, nil +} + +var TestOnlyDeclType = &AssertionType{"test-only-decl", []string{"id"}, nil, assembleTestOnlyDecl, 0} + +// TestOnlyRev is a test-only assertion that mimics snap-revision +// relations with other assertions. +type TestOnlyRev struct { + assertionBase +} + +func (rev *TestOnlyRev) H() string { + return rev.HeaderString("h") +} + +func (rev *TestOnlyRev) ID() string { + return rev.HeaderString("id") +} + +func (rev *TestOnlyRev) DevID() string { + return rev.HeaderString("dev-id") +} + +func (rev *TestOnlyRev) Prerequisites() []*Ref { + return []*Ref{ + {Type: TestOnlyDeclType, PrimaryKey: []string{rev.ID()}}, + {Type: AccountType, PrimaryKey: []string{rev.DevID()}}, + } +} + +func assembleTestOnlyRev(assert assertionBase) (Assertion, error) { + return &TestOnlyRev{assert}, nil +} + +var TestOnlyRevType = &AssertionType{"test-only-rev", []string{"h"}, nil, assembleTestOnlyRev, 0} + +// TestOnlySeq is a test-only assertion that is sequence-forming. +type TestOnlySeq struct { + assertionBase + seq int +} + +func (seq *TestOnlySeq) N() string { + return seq.HeaderString("n") +} + +func (seq *TestOnlySeq) Sequence() int { + return seq.seq +} + +func assembleTestOnlySeq(assert assertionBase) (Assertion, error) { + seq, err := checkSequence(assert.headers, "sequence") + if err != nil { + return nil, err + } + return &TestOnlySeq{ + assertionBase: assert, + seq: seq, + }, nil +} + +var TestOnlySeqType = &AssertionType{"test-only-seq", []string{"n", "sequence"}, nil, assembleTestOnlySeq, sequenceForming} + +type TestOnlyNoAuthority struct { + assertionBase +} + +func assembleTestOnlyNoAuthority(assert assertionBase) (Assertion, error) { + if _, err := checkNotEmptyString(assert.headers, "hdr"); err != nil { + return nil, err + } + return &TestOnlyNoAuthority{assert}, nil +} + +var TestOnlyNoAuthorityType = &AssertionType{"test-only-no-authority", nil, nil, assembleTestOnlyNoAuthority, noAuthority} + +type TestOnlyNoAuthorityPK struct { + assertionBase +} + +func assembleTestOnlyNoAuthorityPK(assert assertionBase) (Assertion, error) { + return &TestOnlyNoAuthorityPK{assert}, nil +} + +var TestOnlyNoAuthorityPKType = &AssertionType{"test-only-no-authority-pk", []string{"pk"}, nil, assembleTestOnlyNoAuthorityPK, noAuthority} + +func init() { + typeRegistry[TestOnlyType.Name] = TestOnlyType + maxSupportedFormat[TestOnlyType.Name] = 1 + typeRegistry[TestOnly2Type.Name] = TestOnly2Type + typeRegistry[TestOnlyNoAuthorityType.Name] = TestOnlyNoAuthorityType + typeRegistry[TestOnlyNoAuthorityPKType.Name] = TestOnlyNoAuthorityPKType + formatAnalyzer[TestOnlyType] = func(headers map[string]interface{}, _ []byte) (int, error) { + if _, ok := headers["format-1-feature"]; ok { + return 1, nil + } + return 0, nil + } + typeRegistry[TestOnlyDeclType.Name] = TestOnlyDeclType + typeRegistry[TestOnlyRevType.Name] = TestOnlyRevType + typeRegistry[TestOnlySeqType.Name] = TestOnlySeqType + maxSupportedFormat[TestOnlySeqType.Name] = 2 +} + +func (ak *AccountKey) CanSign(a Assertion) bool { + return ak.canSign(a) +} + +// AccountKeyIsKeyValidAt exposes isKeyValidAt on AccountKey for tests +func AccountKeyIsKeyValidAt(ak *AccountKey, when time.Time) bool { + return ak.isValidAt(when) +} + +type sinceUntilLike interface { + isValidAssumingCurTimeWithin(earliest, latest time.Time) bool +} + +// IsValidAssumingCurTimeWithin exposes sinceUntil.isValidAssumingCurTimeWithin +func IsValidAssumingCurTimeWithin(su sinceUntilLike, earliest, latest time.Time) bool { + return su.isValidAssumingCurTimeWithin(earliest, latest) +} + +type GPGRunner func(input []byte, args ...string) ([]byte, error) + +func MockRunGPG(mock func(prev GPGRunner, input []byte, args ...string) ([]byte, error)) (restore func()) { + prevRunGPG := runGPG + runGPG = func(input []byte, args ...string) ([]byte, error) { + return mock(prevRunGPG, input, args...) + } + return func() { + runGPG = prevRunGPG + } +} + +func GPGBatchYes() (restore func()) { + gpgBatchYes = true + return func() { + gpgBatchYes = false + } +} + +// Headers helpers to test +var ( + ParseHeaders = parseHeaders + AppendEntry = appendEntry +) + +// ParametersForGenerate exposes parametersForGenerate for tests. +func (gkm *GPGKeypairManager) ParametersForGenerate(passphrase string, name string) string { + return gkm.parametersForGenerate(passphrase, name) +} + +// constraint tests + +func CompileAttrMatcher(constraints interface{}, allowedOperations []string) (func(attrs map[string]interface{}, helper AttrMatchContext) error, error) { + // XXX adjust + cc := compileContext{ + opts: &compileAttrMatcherOptions{ + allowedOperations: allowedOperations, + }, + } + matcher, err := compileAttrMatcher(cc, constraints) + if err != nil { + return nil, err + } + domatch := func(attrs map[string]interface{}, helper AttrMatchContext) error { + return matcher.match("", attrs, &attrMatchingContext{ + attrWord: "field", + helper: helper, + }) + } + return domatch, nil +} + +var ( + CompileDeviceScopeConstraint = compileDeviceScopeConstraint +) + +// ifacedecls tests +var ( + CompileAttributeConstraints = compileAttributeConstraints + CompileNameConstraints = compileNameConstraints + CompilePlugRule = compilePlugRule + CompileSlotRule = compileSlotRule +) + +type featureExposer interface { + feature(flabel string) bool +} + +func RuleFeature(rule featureExposer, flabel string) bool { + return rule.feature(flabel) +} + +func (b *Batch) DoPrecheck(db *Database) error { + return b.precheck(db) +} + +// pool tests + +func MakePoolGrouping(elems ...uint16) Grouping { + return Grouping(internal.Serialize(elems)) +} + +// fetcher tests + +func MockAssertionPrereqs(f func(a Assertion) []*Ref) func() { + r := testutil.Backup(&assertionPrereqs) + assertionPrereqs = f + return r +} diff --git a/asserts/extkeypairmgr.go b/asserts/extkeypairmgr.go new file mode 100644 index 00000000..b6620a0c --- /dev/null +++ b/asserts/extkeypairmgr.go @@ -0,0 +1,302 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2021 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts + +import ( + "bytes" + "crypto" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "fmt" + "io" + "os/exec" + + "golang.org/x/crypto/openpgp/packet" + + "github.com/snapcore/snapd/strutil" +) + +type ExternalKeyInfo struct { + Name string + ID string +} + +// ExternalKeypairManager is key pair manager implemented via an external program interface. +// TODO: points to interface docs +type ExternalKeypairManager struct { + keyMgrPath string + nameToID map[string]string + cache map[string]*cachedExtKey +} + +// NewExternalKeypairManager creates a new ExternalKeypairManager using the program at keyMgrPath. +func NewExternalKeypairManager(keyMgrPath string) (*ExternalKeypairManager, error) { + em := &ExternalKeypairManager{ + keyMgrPath: keyMgrPath, + nameToID: make(map[string]string), + cache: make(map[string]*cachedExtKey), + } + if err := em.checkFeatures(); err != nil { + return nil, err + } + return em, nil +} + +func (em *ExternalKeypairManager) keyMgr(op string, args []string, in []byte, out interface{}) error { + args = append([]string{op}, args...) + cmd := exec.Command(em.keyMgrPath, args...) + var outBuf bytes.Buffer + var errBuf bytes.Buffer + + if len(in) != 0 { + cmd.Stdin = bytes.NewBuffer(in) + } + cmd.Stdout = &outBuf + cmd.Stderr = &errBuf + + if err := cmd.Run(); err != nil { + return fmt.Errorf("external keypair manager %q %v failed: %v (%q)", em.keyMgrPath, args, err, errBuf.Bytes()) + + } + switch o := out.(type) { + case *[]byte: + *o = outBuf.Bytes() + default: + if err := json.Unmarshal(outBuf.Bytes(), out); err != nil { + return fmt.Errorf("cannot decode external keypair manager %q %v output: %v", em.keyMgrPath, args, err) + } + } + return nil +} + +func (em *ExternalKeypairManager) checkFeatures() error { + var feats struct { + Signing []string `json:"signing"` + PublicKeys []string `json:"public-keys"` + } + if err := em.keyMgr("features", nil, nil, &feats); err != nil { + return err + } + if !strutil.ListContains(feats.Signing, "RSA-PKCS") { + return fmt.Errorf("external keypair manager %q missing support for RSA-PKCS signing", em.keyMgrPath) + } + if !strutil.ListContains(feats.PublicKeys, "DER") { + return fmt.Errorf("external keypair manager %q missing support for public key DER output format", em.keyMgrPath) + } + return nil +} + +func (em *ExternalKeypairManager) keyNames() ([]string, error) { + var knames struct { + Names []string `json:"key-names"` + } + if err := em.keyMgr("key-names", nil, nil, &knames); err != nil { + return nil, fmt.Errorf("cannot get all external keypair manager key names: %v", err) + } + return knames.Names, nil +} + +func (em *ExternalKeypairManager) findByName(name string) (PublicKey, *rsa.PublicKey, error) { + var k []byte + err := em.keyMgr("get-public-key", []string{"-f", "DER", "-k", name}, nil, &k) + if err != nil { + return nil, nil, &keyNotFoundError{msg: fmt.Sprintf("cannot find external key pair: %v", err)} + } + pubk, err := x509.ParsePKIXPublicKey(k) + if err != nil { + return nil, nil, fmt.Errorf("cannot decode external key %q: %v", name, err) + } + rsaPub, ok := pubk.(*rsa.PublicKey) + if !ok { + return nil, nil, fmt.Errorf("expected RSA public key, got instead: %T", pubk) + } + pubKey := RSAPublicKey(rsaPub) + return pubKey, rsaPub, nil +} + +func (em *ExternalKeypairManager) Export(keyName string) ([]byte, error) { + pubKey, _, err := em.findByName(keyName) + if err != nil { + return nil, err + } + return EncodePublicKey(pubKey) +} + +func (em *ExternalKeypairManager) loadKey(name string) (*cachedExtKey, error) { + id, ok := em.nameToID[name] + if ok { + return em.cache[id], nil + } + pubKey, rsaPub, err := em.findByName(name) + if err != nil { + return nil, err + } + id = pubKey.ID() + em.nameToID[name] = id + cachedKey := &cachedExtKey{ + pubKey: pubKey, + signer: &extSigner{ + keyName: name, + rsaPub: rsaPub, + // signWith is filled later + }, + } + em.cache[id] = cachedKey + return cachedKey, nil +} + +func (em *ExternalKeypairManager) privateKey(cachedKey *cachedExtKey) PrivateKey { + if cachedKey.privKey == nil { + extSigner := cachedKey.signer + // fill signWith + extSigner.signWith = em.signWith + signer := packet.NewSignerPrivateKey(v1FixedTimestamp, extSigner) + signk := openpgpPrivateKey{privk: signer} + extKey := &extPGPPrivateKey{ + pubKey: cachedKey.pubKey, + from: fmt.Sprintf("external keypair manager %q", em.keyMgrPath), + externalID: extSigner.keyName, + bitLen: extSigner.rsaPub.N.BitLen(), + doSign: signk.sign, + } + cachedKey.privKey = extKey + } + return cachedKey.privKey +} + +func (em *ExternalKeypairManager) GetByName(keyName string) (PrivateKey, error) { + cachedKey, err := em.loadKey(keyName) + if err != nil { + return nil, err + } + return em.privateKey(cachedKey), nil +} + +// ExternalUnsupportedOpError represents the error situation of operations +// that are not supported/mediated via ExternalKeypairManager. +type ExternalUnsupportedOpError struct { + msg string +} + +func (euoe *ExternalUnsupportedOpError) Error() string { + return euoe.msg +} + +func (em *ExternalKeypairManager) Put(privKey PrivateKey) error { + return &ExternalUnsupportedOpError{"cannot import private key into external keypair manager"} +} + +func (em *ExternalKeypairManager) Delete(keyID string) error { + return &ExternalUnsupportedOpError{"no support to delete external keypair manager keys"} +} + +func (em *ExternalKeypairManager) DeleteByName(keyName string) error { + return &ExternalUnsupportedOpError{"no support to delete external keypair manager keys"} +} + +func (em *ExternalKeypairManager) Generate(keyName string) error { + return &ExternalUnsupportedOpError{"no support to mediate generating an external keypair manager key"} +} + +func (em *ExternalKeypairManager) loadAllKeys() ([]string, error) { + names, err := em.keyNames() + if err != nil { + return nil, err + } + for _, name := range names { + if _, err := em.loadKey(name); err != nil { + return nil, err + } + } + return names, nil +} + +func (em *ExternalKeypairManager) Get(keyID string) (PrivateKey, error) { + cachedKey, ok := em.cache[keyID] + if !ok { + // try to load all keys + if _, err := em.loadAllKeys(); err != nil { + return nil, err + } + cachedKey, ok = em.cache[keyID] + if !ok { + return nil, &keyNotFoundError{msg: "cannot find external key pair"} + } + } + return em.privateKey(cachedKey), nil +} + +func (em *ExternalKeypairManager) List() ([]ExternalKeyInfo, error) { + names, err := em.loadAllKeys() + if err != nil { + return nil, err + } + res := make([]ExternalKeyInfo, len(names)) + for i, name := range names { + res[i].Name = name + res[i].ID = em.cache[em.nameToID[name]].pubKey.ID() + } + return res, nil +} + +// see https://datatracker.ietf.org/doc/html/rfc2313 and more recently +// and more precisely about SHA-512: +// https://datatracker.ietf.org/doc/html/rfc3447#section-9.2 Notes 1. +var digestInfoSHA512Prefix = []byte{0x30, 0x51, 0x30, 0x0d, 0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x03, 0x05, 0x00, 0x04, 0x40} + +func (em *ExternalKeypairManager) signWith(keyName string, digest []byte) (signature []byte, err error) { + // wrap the digest into the needed DigestInfo, the RSA-PKCS + // mechanism or equivalent is expected not to do this on its + // own + toSign := &bytes.Buffer{} + toSign.Write(digestInfoSHA512Prefix) + toSign.Write(digest) + + err = em.keyMgr("sign", []string{"-m", "RSA-PKCS", "-k", keyName}, toSign.Bytes(), &signature) + if err != nil { + return nil, err + } + return signature, nil +} + +type cachedExtKey struct { + pubKey PublicKey + signer *extSigner + privKey PrivateKey +} + +type extSigner struct { + keyName string + rsaPub *rsa.PublicKey + signWith func(keyName string, digest []byte) (signature []byte, err error) +} + +func (es *extSigner) Public() crypto.PublicKey { + return es.rsaPub +} + +func (es *extSigner) Sign(rand io.Reader, digest []byte, opts crypto.SignerOpts) (signature []byte, err error) { + if opts.HashFunc() != crypto.SHA512 { + return nil, fmt.Errorf("unexpected pgp signature digest") + } + + return es.signWith(es.keyName, digest) +} diff --git a/asserts/extkeypairmgr_test.go b/asserts/extkeypairmgr_test.go new file mode 100644 index 00000000..e93d444e --- /dev/null +++ b/asserts/extkeypairmgr_test.go @@ -0,0 +1,331 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2021 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts_test + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "fmt" + "os" + "os/exec" + "path/filepath" + "time" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/asserts/assertstest" + "github.com/snapcore/snapd/testutil" +) + +type extKeypairMgrSuite struct { + pgm *testutil.MockCmd + + defaultPub *rsa.PublicKey + modelsPub *rsa.PublicKey +} + +var _ = Suite(&extKeypairMgrSuite{}) + +func (s *extKeypairMgrSuite) SetUpSuite(c *C) { + tmpdir := c.MkDir() + k1, err := rsa.GenerateKey(rand.Reader, 4096) + c.Assert(err, IsNil) + k2, err := rsa.GenerateKey(rand.Reader, 4096) + c.Assert(err, IsNil) + + derPub1, err := x509.MarshalPKIXPublicKey(&k1.PublicKey) + c.Assert(err, IsNil) + err = os.WriteFile(filepath.Join(tmpdir, "default.pub"), derPub1, 0644) + c.Assert(err, IsNil) + derPub2, err := x509.MarshalPKIXPublicKey(&k2.PublicKey) + c.Assert(err, IsNil) + err = os.WriteFile(filepath.Join(tmpdir, "models.pub"), derPub2, 0644) + c.Assert(err, IsNil) + + err = os.WriteFile(filepath.Join(tmpdir, "default.key"), x509.MarshalPKCS1PrivateKey(k1), 0600) + c.Assert(err, IsNil) + err = os.WriteFile(filepath.Join(tmpdir, "models.key"), x509.MarshalPKCS1PrivateKey(k2), 0600) + c.Assert(err, IsNil) + + s.defaultPub = &k1.PublicKey + s.modelsPub = &k2.PublicKey + + s.pgm = testutil.MockCommand(c, "keymgr", fmt.Sprintf(` +keydir=%q +case $1 in + features) + echo '{"signing":["RSA-PKCS"] , "public-keys":["DER"]}' + ;; + key-names) + echo '{"key-names": ["default", "models"]}' + ;; + get-public-key) + if [ "$5" = missing ]; then + echo not found + exit 1 + fi + cat ${keydir}/"$5".pub + ;; + sign) + openssl rsautl -sign -pkcs -keyform DER -inkey ${keydir}/"$5".key + ;; + *) + exit 1 + ;; +esac +`, tmpdir)) +} + +func (s *extKeypairMgrSuite) TearDownSuite(c *C) { + s.pgm.Restore() +} + +func (s *extKeypairMgrSuite) TestFeaturesErrors(c *C) { + pgm := testutil.MockCommand(c, "keymgr", ` +if [ "$1" != "features" ]; then + exit 2 +fi +if [ "${EXT_KEYMGR_FAIL}" = "exit-1" ]; then + exit 1 +fi +echo "${EXT_KEYMGR_FAIL}" +`) + defer pgm.Restore() + defer os.Unsetenv("EXT_KEYMGR_FAIL") + + tests := []struct { + outcome string + err string + }{ + {"exit-1", `.*exit status 1.*`}, + {`{"signing":["RSA-PKCS"]}`, `external keypair manager "keymgr" missing support for public key DER output format`}, + {"{}", `external keypair manager \"keymgr\" missing support for RSA-PKCS signing`}, + {"{", `cannot decode external keypair manager "keymgr" \[features\] output.*`}, + {"", `cannot decode external keypair manager "keymgr" \[features\] output.*`}, + } + + defer os.Unsetenv("EXT_KEYMGR_FAIL") + for _, t := range tests { + os.Setenv("EXT_KEYMGR_FAIL", t.outcome) + + _, err := asserts.NewExternalKeypairManager("keymgr") + c.Check(err, ErrorMatches, t.err) + c.Check(pgm.Calls(), DeepEquals, [][]string{ + {"keymgr", "features"}, + }) + pgm.ForgetCalls() + } +} + +func (s *extKeypairMgrSuite) TestGetByName(c *C) { + kmgr, err := asserts.NewExternalKeypairManager("keymgr") + c.Assert(err, IsNil) + s.pgm.ForgetCalls() + + pk, err := kmgr.GetByName("default") + c.Assert(err, IsNil) + + expPK := asserts.RSAPublicKey(s.defaultPub) + + c.Check(pk.PublicKey().ID(), DeepEquals, expPK.ID()) + + c.Check(s.pgm.Calls(), DeepEquals, [][]string{ + {"keymgr", "get-public-key", "-f", "DER", "-k", "default"}, + }) +} + +func (s *extKeypairMgrSuite) TestGetByNameNotFound(c *C) { + kmgr, err := asserts.NewExternalKeypairManager("keymgr") + c.Assert(err, IsNil) + + _, err = kmgr.GetByName("missing") + c.Check(err, ErrorMatches, `cannot find external key pair: external keypair manager "keymgr" .* failed: .*`) + c.Check(asserts.IsKeyNotFound(err), Equals, true) +} + +func (s *extKeypairMgrSuite) TestGet(c *C) { + kmgr, err := asserts.NewExternalKeypairManager("keymgr") + c.Assert(err, IsNil) + s.pgm.ForgetCalls() + + defaultID := asserts.RSAPublicKey(s.defaultPub).ID() + modelsID := asserts.RSAPublicKey(s.modelsPub).ID() + + pk1, err := kmgr.Get(defaultID) + c.Assert(err, IsNil) + c.Check(pk1.PublicKey().ID(), Equals, defaultID) + + pk2, err := kmgr.Get(modelsID) + c.Assert(err, IsNil) + c.Check(pk2.PublicKey().ID(), Equals, modelsID) + + c.Check(s.pgm.Calls(), DeepEquals, [][]string{ + {"keymgr", "key-names"}, + {"keymgr", "get-public-key", "-f", "DER", "-k", "default"}, + {"keymgr", "get-public-key", "-f", "DER", "-k", "models"}, + }) + + _, err = kmgr.Get("unknown-id") + c.Check(err, ErrorMatches, `cannot find external key pair`) + c.Check(asserts.IsKeyNotFound(err), Equals, true) +} + +func (s *extKeypairMgrSuite) TestSignFlow(c *C) { + // the signing uses openssl + _, err := exec.LookPath("openssl") + if err != nil { + c.Skip("cannot locate openssl on this system to test signing") + } + kmgr, err := asserts.NewExternalKeypairManager("keymgr") + c.Assert(err, IsNil) + s.pgm.ForgetCalls() + + pk, err := kmgr.GetByName("default") + c.Assert(err, IsNil) + + store := assertstest.NewStoreStack("trusted", nil) + + brandAcct := assertstest.NewAccount(store, "brand", map[string]interface{}{ + "account-id": "brand-id", + }, "") + brandAccKey := assertstest.NewAccountKey(store, brandAcct, nil, pk.PublicKey(), "") + + signDB, err := asserts.OpenDatabase(&asserts.DatabaseConfig{ + KeypairManager: kmgr, + }) + c.Assert(err, IsNil) + + checkDB, err := asserts.OpenDatabase(&asserts.DatabaseConfig{ + Backstore: asserts.NewMemoryBackstore(), + Trusted: store.Trusted, + }) + c.Assert(err, IsNil) + // add store key + err = checkDB.Add(store.StoreAccountKey("")) + c.Assert(err, IsNil) + // enable brand key + err = checkDB.Add(brandAcct) + c.Assert(err, IsNil) + err = checkDB.Add(brandAccKey) + c.Assert(err, IsNil) + + modelHdsrs := map[string]interface{}{ + "authority-id": "brand-id", + "brand-id": "brand-id", + "model": "model", + "series": "16", + "architecture": "amd64", + "base": "core18", + "gadget": "gadget", + "kernel": "pc-kernel", + "timestamp": time.Now().Format(time.RFC3339), + } + a, err := signDB.Sign(asserts.ModelType, modelHdsrs, nil, pk.PublicKey().ID()) + c.Assert(err, IsNil) + + // valid + err = checkDB.Check(a) + c.Assert(err, IsNil) + + c.Check(s.pgm.Calls(), DeepEquals, [][]string{ + {"keymgr", "get-public-key", "-f", "DER", "-k", "default"}, + {"keymgr", "sign", "-m", "RSA-PKCS", "-k", "default"}, + }) +} + +func (s *extKeypairMgrSuite) TestExport(c *C) { + kmgr, err := asserts.NewExternalKeypairManager("keymgr") + c.Assert(err, IsNil) + + keys := []struct { + name string + pk *rsa.PublicKey + }{ + {name: "default", pk: s.defaultPub}, + {name: "models", pk: s.modelsPub}, + } + + for _, tk := range keys { + exported, err := kmgr.Export(tk.name) + c.Assert(err, IsNil) + + expected, err := asserts.EncodePublicKey(asserts.RSAPublicKey(tk.pk)) + c.Assert(err, IsNil) + c.Check(exported, DeepEquals, expected) + } +} + +func (s *extKeypairMgrSuite) TestList(c *C) { + kmgr, err := asserts.NewExternalKeypairManager("keymgr") + c.Assert(err, IsNil) + + keys, err := kmgr.List() + c.Assert(err, IsNil) + + defaultID := asserts.RSAPublicKey(s.defaultPub).ID() + modelsID := asserts.RSAPublicKey(s.modelsPub).ID() + + c.Check(keys, DeepEquals, []asserts.ExternalKeyInfo{ + {Name: "default", ID: defaultID}, + {Name: "models", ID: modelsID}, + }) +} + +func (s *extKeypairMgrSuite) TestListError(c *C) { + kmgr, err := asserts.NewExternalKeypairManager("keymgr") + c.Assert(err, IsNil) + + pgm := testutil.MockCommand(c, "keymgr", `exit 1`) + defer pgm.Restore() + + _, err = kmgr.List() + c.Check(err, ErrorMatches, `cannot get all external keypair manager key names:.*exit status 1.*`) +} + +func (s *extKeypairMgrSuite) TestDeleteByNameUnsupported(c *C) { + kmgr, err := asserts.NewExternalKeypairManager("keymgr") + c.Assert(err, IsNil) + + err = kmgr.DeleteByName("key") + c.Check(err, ErrorMatches, `no support to delete external keypair manager keys`) + c.Check(err, FitsTypeOf, &asserts.ExternalUnsupportedOpError{}) + +} + +func (s *extKeypairMgrSuite) TestDelete(c *C) { + kmgr, err := asserts.NewExternalKeypairManager("keymgr") + c.Assert(err, IsNil) + + err = kmgr.Delete("key-id") + c.Check(err, ErrorMatches, `no support to delete external keypair manager keys`) + c.Check(err, FitsTypeOf, &asserts.ExternalUnsupportedOpError{}) + +} + +func (s *extKeypairMgrSuite) TestGenerateUnsupported(c *C) { + kmgr, err := asserts.NewExternalKeypairManager("keymgr") + c.Assert(err, IsNil) + + err = kmgr.Generate("key") + c.Check(err, ErrorMatches, `no support to mediate generating an external keypair manager key`) + c.Check(err, FitsTypeOf, &asserts.ExternalUnsupportedOpError{}) +} diff --git a/asserts/fetcher.go b/asserts/fetcher.go new file mode 100644 index 00000000..3bbe9bc7 --- /dev/null +++ b/asserts/fetcher.go @@ -0,0 +1,197 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016-2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts + +import ( + "errors" + "fmt" +) + +type fetchProgress int + +const ( + fetchNotSeen fetchProgress = iota + fetchRetrieved + fetchSaved +) + +// To allow us to mock prerequisites of an assertion for testing. +var assertionPrereqs = func(a Assertion) []*Ref { + return a.Prerequisites() +} + +// A Fetcher helps fetching assertions and their prerequisites. +type Fetcher interface { + // Fetch retrieves the assertion indicated by ref then its prerequisites + // recursively, along the way saving prerequisites before dependent assertions. + Fetch(*Ref) error + // Save retrieves the prerequisites of the assertion recursively, + // along the way saving them, and finally saves the assertion. + Save(Assertion) error +} + +type fetcher struct { + db RODatabase + retrieve func(*Ref) (Assertion, error) + retrieveSeq func(*AtSequence) (Assertion, error) + save func(Assertion) error + + fetched map[string]fetchProgress +} + +// NewFetcher creates a Fetcher which will use trustedDB to determine trusted assertions, +// will fetch assertions following prerequisites using retrieve, and then will pass +// them to save, saving prerequisites before dependent assertions. +func NewFetcher(trustedDB RODatabase, retrieve func(*Ref) (Assertion, error), save func(Assertion) error) Fetcher { + return &fetcher{ + db: trustedDB, + retrieve: retrieve, + save: save, + fetched: make(map[string]fetchProgress), + } +} + +// SequenceFormingFetcher is a Fetcher with special support for fetching sequence-forming assertions through FetchSequence. +type SequenceFormingFetcher interface { + // SequenceFormingFetcher must also implement the interface of the Fetcher. + Fetcher + + // FetchSequence retrieves the assertion as indicated the given sequence reference. Retrieving multiple + // sequence points of the same assertion is currently unsupported. The first sequence fetched through this + // will be the one passed to the save callback. Any subsequent sequences fetched will not have any + // effect and will be treated as if they've already been fetched. + FetchSequence(*AtSequence) error +} + +// NewSequenceFormingFetcher creates a SequenceFormingFetcher which will use trustedDB to determine trusted assertions, +// will fetch assertions following prerequisites using retrieve and sequence-forming assertions using retrieveSeq, and then will pass +// them to save, saving prerequisites before dependent assertions. +func NewSequenceFormingFetcher(trustedDB RODatabase, retrieve func(*Ref) (Assertion, error), retrieveSeq func(*AtSequence) (Assertion, error), save func(Assertion) error) SequenceFormingFetcher { + return &fetcher{ + db: trustedDB, + retrieve: retrieve, + retrieveSeq: retrieveSeq, + save: save, + fetched: make(map[string]fetchProgress), + } +} + +func (f *fetcher) wasFetched(ref *Ref) (bool, error) { + switch f.fetched[ref.Unique()] { + case fetchSaved: + return true, nil // nothing to do + case fetchRetrieved: + return false, fmt.Errorf("circular assertions are not expected: %s", ref) + } + return false, nil +} + +func (f *fetcher) fetchPrerequisitesAndSave(key string, a Assertion) error { + f.fetched[key] = fetchRetrieved + for _, preref := range assertionPrereqs(a) { + if err := f.Fetch(preref); err != nil { + return err + } + } + if err := f.fetchAccountKey(a.SignKeyID()); err != nil { + return err + } + if err := f.save(a); err != nil { + return err + } + f.fetched[key] = fetchSaved + return nil +} + +func (f *fetcher) chase(ref *Ref, a Assertion) error { + // check if ref points to predefined assertion, in which case + // there is nothing to do + _, err := ref.Resolve(f.db.FindPredefined) + if err == nil { + return nil + } + if !errors.Is(err, &NotFoundError{}) { + return err + } + if ok, err := f.wasFetched(ref); err != nil || ok { + // if ok is true, then the assertion was fetched and err is nil + return err + } + if a == nil { + retrieved, err := f.retrieve(ref) + if err != nil { + return err + } + a = retrieved + } + return f.fetchPrerequisitesAndSave(ref.Unique(), a) +} + +// Fetch retrieves the assertion indicated by ref then its prerequisites +// recursively, along the way saving prerequisites before dependent assertions. +func (f *fetcher) Fetch(ref *Ref) error { + return f.chase(ref, nil) +} + +func (f *fetcher) wasSeqFetched(seq *AtSequence) (bool, error) { + switch f.fetched[seq.Unique()] { + case fetchSaved: + return true, nil // nothing to do + case fetchRetrieved: + return false, fmt.Errorf("circular assertions are not expected: %s", seq) + } + return false, nil +} + +func (f *fetcher) fetchSequence(seq *AtSequence) error { + // sequence forming assertions are never predefined, so we don't check for it. + if ok, err := f.wasSeqFetched(seq); err != nil || ok { + // if ok is true, then the assertion was fetched and err is nil + return err + } + a, err := f.retrieveSeq(seq) + if err != nil { + return err + } + return f.fetchPrerequisitesAndSave(seq.Unique(), a) +} + +// FetchSequence retrieves the assertion as indicated by its sequence reference. +func (f *fetcher) FetchSequence(seq *AtSequence) error { + if f.retrieveSeq == nil { + return fmt.Errorf("cannot fetch assertion sequence point, fetcher must be created using NewSequenceFormingFetcher") + } + return f.fetchSequence(seq) +} + +// fetchAccountKey behaves like Fetch for the account-key with the given key id. +func (f *fetcher) fetchAccountKey(keyID string) error { + keyRef := &Ref{ + Type: AccountKeyType, + PrimaryKey: []string{keyID}, + } + return f.Fetch(keyRef) +} + +// Save retrieves the prerequisites of the assertion recursively, +// along the way saving them, and finally saves the assertion. +func (f *fetcher) Save(a Assertion) error { + return f.chase(a.Ref(), a) +} diff --git a/asserts/fetcher_test.go b/asserts/fetcher_test.go new file mode 100644 index 00000000..28d2ddec --- /dev/null +++ b/asserts/fetcher_test.go @@ -0,0 +1,375 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016-2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts_test + +import ( + "crypto" + "fmt" + "time" + + "golang.org/x/crypto/sha3" + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/asserts/assertstest" + "github.com/snapcore/snapd/release" +) + +type fetcherSuite struct { + storeSigning *assertstest.StoreStack +} + +var _ = Suite(&fetcherSuite{}) + +func (s *fetcherSuite) SetUpTest(c *C) { + s.storeSigning = assertstest.NewStoreStack("can0nical", nil) +} + +func fakeSnap(rev int) []byte { + fake := fmt.Sprintf("hsqs________________%d", rev) + return []byte(fake) +} + +func fakeHash(rev int) []byte { + h := sha3.Sum384(fakeSnap(rev)) + return h[:] +} + +func makeDigest(rev int) string { + d, err := asserts.EncodeDigest(crypto.SHA3_384, fakeHash(rev)) + if err != nil { + panic(err) + } + return string(d) +} + +func (s *fetcherSuite) prereqSnapAssertions(c *C, revisions ...int) { + dev1Acct := assertstest.NewAccount(s.storeSigning, "developer1", nil, "") + err := s.storeSigning.Add(dev1Acct) + c.Assert(err, IsNil) + + headers := map[string]interface{}{ + "series": "16", + "snap-id": "snap-id-1", + "snap-name": "foo", + "publisher-id": dev1Acct.AccountID(), + "timestamp": time.Now().Format(time.RFC3339), + } + snapDecl, err := s.storeSigning.Sign(asserts.SnapDeclarationType, headers, nil, "") + c.Assert(err, IsNil) + err = s.storeSigning.Add(snapDecl) + c.Assert(err, IsNil) + + for _, rev := range revisions { + headers = map[string]interface{}{ + "series": "16", + "snap-id": "snap-id-1", + "snap-sha3-384": makeDigest(rev), + "snap-size": "1000", + "snap-revision": fmt.Sprintf("%d", rev), + "developer-id": dev1Acct.AccountID(), + "timestamp": time.Now().Format(time.RFC3339), + } + snapRev, err := s.storeSigning.Sign(asserts.SnapRevisionType, headers, nil, "") + c.Assert(err, IsNil) + err = s.storeSigning.Add(snapRev) + c.Assert(err, IsNil) + } +} + +func (s *fetcherSuite) TestFetch(c *C) { + s.prereqSnapAssertions(c, 10) + + db, err := asserts.OpenDatabase(&asserts.DatabaseConfig{ + Backstore: asserts.NewMemoryBackstore(), + Trusted: s.storeSigning.Trusted, + }) + c.Assert(err, IsNil) + + ref := &asserts.Ref{ + Type: asserts.SnapRevisionType, + PrimaryKey: []string{makeDigest(10)}, + } + + retrieve := func(ref *asserts.Ref) (asserts.Assertion, error) { + return ref.Resolve(s.storeSigning.Find) + } + + f := asserts.NewFetcher(db, retrieve, db.Add) + + err = f.Fetch(ref) + c.Assert(err, IsNil) + + snapRev, err := ref.Resolve(db.Find) + c.Assert(err, IsNil) + c.Check(snapRev.(*asserts.SnapRevision).SnapRevision(), Equals, 10) + + snapDecl, err := db.Find(asserts.SnapDeclarationType, map[string]string{ + "series": "16", + "snap-id": "snap-id-1", + }) + c.Assert(err, IsNil) + c.Check(snapDecl.(*asserts.SnapDeclaration).SnapName(), Equals, "foo") +} + +func (s *fetcherSuite) TestFetchCircularReference(c *C) { + s.prereqSnapAssertions(c, 10) + + db, err := asserts.OpenDatabase(&asserts.DatabaseConfig{ + Backstore: asserts.NewMemoryBackstore(), + Trusted: s.storeSigning.Trusted, + }) + c.Assert(err, IsNil) + + ref := &asserts.Ref{ + Type: asserts.SnapRevisionType, + PrimaryKey: []string{makeDigest(10)}, + } + + retrieve := func(ref *asserts.Ref) (asserts.Assertion, error) { + return ref.Resolve(s.storeSigning.Find) + } + + f := asserts.NewFetcher(db, retrieve, db.Add) + + // Mock that we refer to ourself + r := asserts.MockAssertionPrereqs(func(a asserts.Assertion) []*asserts.Ref { + return []*asserts.Ref{ref} + }) + defer r() + + err = f.Fetch(ref) + c.Assert(err, ErrorMatches, `circular assertions are not expected: snap-revision \(tzGsQxT_xJGzbnJ_-25Bbj_8lBHY39c5uUuQWgDTGxAEd0NALdxVaSAD59Pou_Ko;\)`) +} + +func (s *fetcherSuite) TestSave(c *C) { + s.prereqSnapAssertions(c, 10) + + db, err := asserts.OpenDatabase(&asserts.DatabaseConfig{ + Backstore: asserts.NewMemoryBackstore(), + Trusted: s.storeSigning.Trusted, + }) + c.Assert(err, IsNil) + + retrieve := func(ref *asserts.Ref) (asserts.Assertion, error) { + return ref.Resolve(s.storeSigning.Find) + } + + f := asserts.NewFetcher(db, retrieve, db.Add) + + ref := &asserts.Ref{ + Type: asserts.SnapRevisionType, + PrimaryKey: []string{makeDigest(10)}, + } + rev, err := ref.Resolve(s.storeSigning.Find) + c.Assert(err, IsNil) + + err = f.Save(rev) + c.Assert(err, IsNil) + + snapRev, err := ref.Resolve(db.Find) + c.Assert(err, IsNil) + c.Check(snapRev.(*asserts.SnapRevision).SnapRevision(), Equals, 10) + + snapDecl, err := db.Find(asserts.SnapDeclarationType, map[string]string{ + "series": "16", + "snap-id": "snap-id-1", + }) + c.Assert(err, IsNil) + c.Check(snapDecl.(*asserts.SnapDeclaration).SnapName(), Equals, "foo") +} + +func (s *fetcherSuite) prereqValidationSetAssertion(c *C) { + vs, err := s.storeSigning.Sign(asserts.ValidationSetType, map[string]interface{}{ + "type": "validation-set", + "authority-id": "can0nical", + "series": "16", + "account-id": "can0nical", + "name": "base-set", + "sequence": "2", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "pc-kernel", + "id": "123456ididididididididididididid", + "presence": "required", + "revision": "7", + }, + }, + "timestamp": time.Now().UTC().Format(time.RFC3339), + }, nil, "") + c.Assert(err, IsNil) + err = s.storeSigning.Add(vs) + c.Check(err, IsNil) +} + +func (s *fetcherSuite) TestFetchSequence(c *C) { + s.prereqValidationSetAssertion(c) + + db, err := asserts.OpenDatabase(&asserts.DatabaseConfig{ + Backstore: asserts.NewMemoryBackstore(), + Trusted: s.storeSigning.Trusted, + }) + c.Assert(err, IsNil) + + seq := &asserts.AtSequence{ + Type: asserts.ValidationSetType, + SequenceKey: []string{release.Series, "can0nical", "base-set"}, + Sequence: 2, + Revision: asserts.RevisionNotKnown, + } + retrieve := func(ref *asserts.Ref) (asserts.Assertion, error) { + return ref.Resolve(s.storeSigning.Find) + } + retrieveSeq := func(seq *asserts.AtSequence) (asserts.Assertion, error) { + return seq.Resolve(s.storeSigning.Find) + } + + f := asserts.NewSequenceFormingFetcher(db, retrieve, retrieveSeq, db.Add) + + // Fetch the sequence, this will fetch the validation-set with sequence + // 2. After that we should be able to find the validation-set (sequence 2) + // in the DB. + err = f.FetchSequence(seq) + c.Assert(err, IsNil) + + // Calling resolve works when we provide the correct sequence number. This + // will then find the assertion we just fetched + vsa, err := seq.Resolve(db.Find) + c.Assert(err, IsNil) + c.Check(vsa.(*asserts.ValidationSet).Name(), Equals, "base-set") + c.Check(vsa.(*asserts.ValidationSet).Sequence(), Equals, 2) + + // Calling resolve doesn't find the assertion when another sequence number + // is provided. + seq.Sequence = 4 + _, err = seq.Resolve(db.Find) + c.Assert(err, ErrorMatches, `validation-set \(4; series:16 account-id:can0nical name:base-set\) not found`) +} + +func (s *fetcherSuite) TestFetchSequenceCircularReference(c *C) { + s.prereqValidationSetAssertion(c) + + db, err := asserts.OpenDatabase(&asserts.DatabaseConfig{ + Backstore: asserts.NewMemoryBackstore(), + Trusted: s.storeSigning.Trusted, + }) + c.Assert(err, IsNil) + + seq := &asserts.AtSequence{ + Type: asserts.ValidationSetType, + SequenceKey: []string{release.Series, "can0nical", "base-set"}, + Sequence: 2, + Revision: asserts.RevisionNotKnown, + } + retrieve := func(ref *asserts.Ref) (asserts.Assertion, error) { + return ref.Resolve(s.storeSigning.Find) + } + retrieveSeq := func(seq *asserts.AtSequence) (asserts.Assertion, error) { + return seq.Resolve(s.storeSigning.Find) + } + + f := asserts.NewSequenceFormingFetcher(db, retrieve, retrieveSeq, db.Add) + + // Mock that we refer to ourself + r := asserts.MockAssertionPrereqs(func(a asserts.Assertion) []*asserts.Ref { + return []*asserts.Ref{ + { + Type: asserts.ValidationSetType, + PrimaryKey: []string{release.Series, "can0nical", "base-set", "2"}, + }, + } + }) + defer r() + + err = f.FetchSequence(seq) + c.Assert(err, ErrorMatches, `circular assertions are not expected: validation-set \(2; series:16 account-id:can0nical name:base-set\)`) +} + +func (s *fetcherSuite) TestFetchSequenceMultipleSequencesNotSupported(c *C) { + s.prereqValidationSetAssertion(c) + + db, err := asserts.OpenDatabase(&asserts.DatabaseConfig{ + Backstore: asserts.NewMemoryBackstore(), + Trusted: s.storeSigning.Trusted, + }) + c.Assert(err, IsNil) + + seq := &asserts.AtSequence{ + Type: asserts.ValidationSetType, + SequenceKey: []string{release.Series, "can0nical", "base-set"}, + Sequence: 2, + Revision: asserts.RevisionNotKnown, + } + retrieve := func(ref *asserts.Ref) (asserts.Assertion, error) { + return ref.Resolve(s.storeSigning.Find) + } + retrieveSeq := func(seq *asserts.AtSequence) (asserts.Assertion, error) { + return seq.Resolve(s.storeSigning.Find) + } + + f := asserts.NewSequenceFormingFetcher(db, retrieve, retrieveSeq, db.Add) + err = f.FetchSequence(seq) + c.Assert(err, IsNil) + + // Fetch same validation-set, but with a different sequence. Currently the + // AtSequence.Unique() does not include the sequence number or revision, meaning + // that the first sequence we fetch is the one that will be put into the DB. + // XXX: This test is here to document the behavior. If we want it to spit an error + // or support multiple sequences of an assertion, then changes are required. + seq.Sequence = 4 + err = f.FetchSequence(seq) + c.Assert(err, IsNil) + + // We fetch 2 first, it should exist. + seq.Sequence = 2 + vsa, err := seq.Resolve(db.Find) + c.Assert(err, IsNil) + c.Check(vsa.(*asserts.ValidationSet).Name(), Equals, "base-set") + c.Check(vsa.(*asserts.ValidationSet).Sequence(), Equals, 2) + + // 4 will not exist, as 2 already was present. + seq.Sequence = 4 + _, err = seq.Resolve(db.Find) + c.Assert(err, ErrorMatches, `validation-set \(4; series:16 account-id:can0nical name:base-set\) not found`) +} + +func (s *fetcherSuite) TestFetcherNotCreatedUsingNewSequenceFormingFetcher(c *C) { + db, err := asserts.OpenDatabase(&asserts.DatabaseConfig{ + Backstore: asserts.NewMemoryBackstore(), + Trusted: s.storeSigning.Trusted, + }) + c.Assert(err, IsNil) + + retrieve := func(ref *asserts.Ref) (asserts.Assertion, error) { + return ref.Resolve(s.storeSigning.Find) + } + + f := asserts.NewFetcher(db, retrieve, db.Add) + c.Assert(f, NotNil) + + // Cast the fetcher to a SequenceFormingFetcher, which should succeed + // since the fetcher actually implements FetchSequence. + ff := f.(asserts.SequenceFormingFetcher) + c.Assert(ff, NotNil) + + // Make sure this produces an error and not a crash + err = f.(asserts.SequenceFormingFetcher).FetchSequence(nil) + c.Check(err, ErrorMatches, `cannot fetch assertion sequence point, fetcher must be created using NewSequenceFormingFetcher`) +} diff --git a/asserts/findwildcard.go b/asserts/findwildcard.go new file mode 100644 index 00000000..621652d1 --- /dev/null +++ b/asserts/findwildcard.go @@ -0,0 +1,187 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2015 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts + +import ( + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strconv" + "strings" +) + +/* +findWildcard invokes foundCb once for each parent directory of regular files matching: + +//... + +where each descendantWithWildcard component can contain the * wildcard. + +One of the descendantWithWildcard components except the last +can be "#>" or "#<", in which case that level is assumed to have names +that can be parsed as positive integers, which will be enumerated in +ascending (#>) or descending order respectively (#<); if seqnum != -1 +then only the values >seqnum or respectively " || k == "#<" { + if len(descendantWithWildcard) == 1 { + return fmt.Errorf("findWildcard: sequence wildcard (#>|<#) cannot be the last component") + } + return findWildcardSequence(top, current, k, descendantWithWildcard[1:], seqnum, foundCb) + } + if len(descendantWithWildcard) > 1 && strings.IndexByte(k, '*') == -1 { + return findWildcardDescend(top, filepath.Join(current, k), descendantWithWildcard[1:], seqnum, foundCb) + } + + d, err := os.Open(current) + // ignore missing directory, higher level will produce + // NotFoundError as needed + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + defer d.Close() + names, err := d.Readdirnames(-1) + if err != nil { + return err + } + if len(descendantWithWildcard) == 1 { + return findWildcardBottom(top, current, k, names, foundCb) + } + for _, name := range names { + ok, err := filepath.Match(k, name) + if err != nil { + return fmt.Errorf("findWildcard: invoked with malformed wildcard: %v", err) + } + if ok { + err = findWildcardDescend(top, filepath.Join(current, name), descendantWithWildcard[1:], seqnum, foundCb) + if err != nil { + return err + } + } + } + return nil +} + +func findWildcardSequence(top, current, seqWildcard string, descendantWithWildcard []string, seqnum int, foundCb func(relpath []string) error) error { + filter := func(i int) bool { return true } + if seqnum != -1 { + if seqWildcard == "#>" { + filter = func(i int) bool { return i > seqnum } + } else { // "#<", guaranteed by the caller + filter = func(i int) bool { return i < seqnum } + } + } + + d, err := os.Open(current) + // ignore missing directory, higher level will produce + // NotFoundError as needed + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + defer d.Close() + var seq []int + for { + names, err := d.Readdirnames(100) + if err == io.EOF { + break + } + if err != nil { + return err + } + for _, n := range names { + sqn, err := strconv.Atoi(n) + if err != nil || sqn < 0 || prefixZeros(n) { + return fmt.Errorf("cannot parse %q name as a valid sequence number", filepath.Join(current, n)) + } + if filter(sqn) { + seq = append(seq, sqn) + } + } + } + sort.Ints(seq) + + var start, direction int + if seqWildcard == "#>" { + start = 0 + direction = 1 + } else { + start = len(seq) - 1 + direction = -1 + } + for i := start; i >= 0 && i < len(seq); i += direction { + err = findWildcardDescend(top, filepath.Join(current, strconv.Itoa(seq[i])), descendantWithWildcard, -1, foundCb) + if err != nil { + return err + } + } + return nil +} diff --git a/asserts/findwildcard_test.go b/asserts/findwildcard_test.go new file mode 100644 index 00000000..66130f2a --- /dev/null +++ b/asserts/findwildcard_test.go @@ -0,0 +1,281 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2015-2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts + +import ( + "errors" + "os" + "path/filepath" + "sort" + + "gopkg.in/check.v1" +) + +type findWildcardSuite struct{} + +var _ = check.Suite(&findWildcardSuite{}) + +func (fs *findWildcardSuite) TestFindWildcard(c *check.C) { + top := filepath.Join(c.MkDir(), "top") + + err := os.MkdirAll(top, os.ModePerm) + c.Assert(err, check.IsNil) + err = os.MkdirAll(filepath.Join(top, "acc-id1"), os.ModePerm) + c.Assert(err, check.IsNil) + err = os.MkdirAll(filepath.Join(top, "acc-id1", "abcd"), os.ModePerm) + c.Assert(err, check.IsNil) + err = os.MkdirAll(filepath.Join(top, "acc-id1", "e5cd"), os.ModePerm) + c.Assert(err, check.IsNil) + err = os.MkdirAll(filepath.Join(top, "acc-id2"), os.ModePerm) + c.Assert(err, check.IsNil) + err = os.MkdirAll(filepath.Join(top, "acc-id2", "f444"), os.ModePerm) + c.Assert(err, check.IsNil) + + err = os.WriteFile(filepath.Join(top, "acc-id1", "abcd", "active"), nil, os.ModePerm) + c.Assert(err, check.IsNil) + err = os.WriteFile(filepath.Join(top, "acc-id1", "abcd", "active.1"), nil, os.ModePerm) + c.Assert(err, check.IsNil) + err = os.WriteFile(filepath.Join(top, "acc-id1", "e5cd", "active"), nil, os.ModePerm) + c.Assert(err, check.IsNil) + err = os.WriteFile(filepath.Join(top, "acc-id2", "f444", "active"), nil, os.ModePerm) + c.Assert(err, check.IsNil) + + var res []string + foundCb := func(relpath []string) error { + res = append(res, relpath...) + return nil + } + + err = findWildcard(top, []string{"*", "*", "active"}, 0, foundCb) + c.Assert(err, check.IsNil) + sort.Strings(res) + c.Check(res, check.DeepEquals, []string{"acc-id1/abcd/active", "acc-id1/e5cd/active", "acc-id2/f444/active"}) + + res = nil + err = findWildcard(top, []string{"*", "*", "active*"}, 0, foundCb) + c.Assert(err, check.IsNil) + sort.Strings(res) + c.Check(res, check.DeepEquals, []string{"acc-id1/abcd/active", "acc-id1/abcd/active.1", "acc-id1/e5cd/active", "acc-id2/f444/active"}) + + res = nil + err = findWildcard(top, []string{"zoo", "*", "active"}, 0, foundCb) + c.Assert(err, check.IsNil) + c.Check(res, check.HasLen, 0) + + res = nil + err = findWildcard(top, []string{"zoo", "*", "active*"}, 0, foundCb) + c.Assert(err, check.IsNil) + c.Check(res, check.HasLen, 0) + + res = nil + err = findWildcard(top, []string{"a*", "zoo", "active"}, 0, foundCb) + c.Assert(err, check.IsNil) + c.Check(res, check.HasLen, 0) + + res = nil + err = findWildcard(top, []string{"acc-id1", "*cd", "active"}, 0, foundCb) + c.Assert(err, check.IsNil) + sort.Strings(res) + c.Check(res, check.DeepEquals, []string{"acc-id1/abcd/active", "acc-id1/e5cd/active"}) + + res = nil + err = findWildcard(top, []string{"acc-id1", "*cd", "active*"}, 0, foundCb) + c.Assert(err, check.IsNil) + sort.Strings(res) + c.Check(res, check.DeepEquals, []string{"acc-id1/abcd/active", "acc-id1/abcd/active.1", "acc-id1/e5cd/active"}) + +} + +func (fs *findWildcardSuite) TestFindWildcardSomeErrors(c *check.C) { + top := filepath.Join(c.MkDir(), "top-errors") + + err := os.MkdirAll(top, os.ModePerm) + c.Assert(err, check.IsNil) + err = os.MkdirAll(filepath.Join(top, "acc-id1"), os.ModePerm) + c.Assert(err, check.IsNil) + err = os.MkdirAll(filepath.Join(top, "acc-id2"), os.ModePerm) + c.Assert(err, check.IsNil) + + err = os.WriteFile(filepath.Join(top, "acc-id1", "abcd"), nil, os.ModePerm) + c.Assert(err, check.IsNil) + + err = os.MkdirAll(filepath.Join(top, "acc-id2", "dddd"), os.ModePerm) + c.Assert(err, check.IsNil) + + var res []string + var retErr error + foundCb := func(relpath []string) error { + res = append(res, relpath...) + return retErr + } + + myErr := errors.New("boom") + retErr = myErr + err = findWildcard(top, []string{"acc-id1", "*"}, 0, foundCb) + c.Check(err, check.Equals, myErr) + + retErr = nil + res = nil + err = findWildcard(top, []string{"acc-id2", "*"}, 0, foundCb) + c.Check(err, check.ErrorMatches, "expected a regular file: .*") +} + +func (fs *findWildcardSuite) TestFindWildcardSequence(c *check.C) { + top := filepath.Join(c.MkDir(), "top") + + err := os.MkdirAll(top, os.ModePerm) + c.Assert(err, check.IsNil) + + files := []string{ + "s1/3/active.1", + "s1/3/active.2", + "s1/2/active", + "s1/2/active.1", + "s1/1/active", + } + for _, fn := range files { + err := os.MkdirAll(filepath.Dir(filepath.Join(top, fn)), os.ModePerm) + c.Assert(err, check.IsNil) + err = os.WriteFile(filepath.Join(top, fn), nil, os.ModePerm) + c.Assert(err, check.IsNil) + } + + var res [][]string + foundCb := func(relpath []string) error { + res = append(res, relpath) + return nil + } + + sort := func() { + for _, r := range res { + sort.Strings(r) + } + } + + // ascending + + err = findWildcard(top, []string{"s1", "#>", "active*"}, 1, foundCb) + c.Assert(err, check.IsNil) + sort() + c.Check(res, check.DeepEquals, [][]string{ + {"s1/2/active", "s1/2/active.1"}, + {"s1/3/active.1", "s1/3/active.2"}, + }) + + res = nil + err = findWildcard(top, []string{"s1", "#>", "active*"}, 2, foundCb) + c.Assert(err, check.IsNil) + sort() + c.Check(res, check.DeepEquals, [][]string{ + {"s1/3/active.1", "s1/3/active.2"}, + }) + + res = nil + err = findWildcard(top, []string{"s1", "#>", "active*"}, 3, foundCb) + c.Assert(err, check.IsNil) + c.Check(res, check.HasLen, 0) + + res = nil + err = findWildcard(top, []string{"s1", "#>", "active*"}, -1, foundCb) + c.Assert(err, check.IsNil) + sort() + c.Check(res, check.DeepEquals, [][]string{ + {"s1/1/active"}, + {"s1/2/active", "s1/2/active.1"}, + {"s1/3/active.1", "s1/3/active.2"}, + }) + + // descending + + res = nil + err = findWildcard(top, []string{"s1", "#<", "active*"}, -1, foundCb) + c.Assert(err, check.IsNil) + sort() + c.Check(res, check.DeepEquals, [][]string{ + {"s1/3/active.1", "s1/3/active.2"}, + {"s1/2/active", "s1/2/active.1"}, + {"s1/1/active"}, + }) + + res = nil + err = findWildcard(top, []string{"s1", "#<", "active*"}, 3, foundCb) + c.Assert(err, check.IsNil) + sort() + c.Check(res, check.DeepEquals, [][]string{ + {"s1/2/active", "s1/2/active.1"}, + {"s1/1/active"}, + }) + + res = nil + err = findWildcard(top, []string{"s1", "#<", "active*"}, 2, foundCb) + c.Assert(err, check.IsNil) + sort() + c.Check(res, check.DeepEquals, [][]string{ + {"s1/1/active"}, + }) + + res = nil + err = findWildcard(top, []string{"s1", "#<", "active*"}, 1, foundCb) + c.Assert(err, check.IsNil) + c.Check(res, check.HasLen, 0) + + // missing dir + res = nil + err = findWildcard(top, []string{"s2", "#<", "active*"}, 1, foundCb) + c.Assert(err, check.IsNil) + c.Check(res, check.HasLen, 0) +} + +func (fs *findWildcardSuite) TestFindWildcardSequenceSomeErrors(c *check.C) { + top := filepath.Join(c.MkDir(), "top-errors") + + err := os.MkdirAll(top, os.ModePerm) + c.Assert(err, check.IsNil) + + files := []string{ + "s1/1/active", + "s2/a/active.1", + "s3/-9/active.1", + "s4/01/active", + } + for _, fn := range files { + err := os.MkdirAll(filepath.Dir(filepath.Join(top, fn)), os.ModePerm) + c.Assert(err, check.IsNil) + err = os.WriteFile(filepath.Join(top, fn), nil, os.ModePerm) + c.Assert(err, check.IsNil) + } + + myErr := errors.New("boom") + foundCb := func(relpath []string) error { + return myErr + } + + err = findWildcard(top, []string{"s1", "#>", "active*"}, -1, foundCb) + c.Assert(err, check.Equals, myErr) + + err = findWildcard(top, []string{"s2", "#>", "active*"}, -1, foundCb) + c.Assert(err, check.ErrorMatches, `cannot parse ".*/top-errors/s2/a" name as a valid sequence number`) + + err = findWildcard(top, []string{"s3", "#>", "active*"}, -1, foundCb) + c.Assert(err, check.ErrorMatches, `cannot parse ".*/top-errors/s3/-9" name as a valid sequence number`) + + err = findWildcard(top, []string{"s4", "#>", "active*"}, -1, foundCb) + c.Assert(err, check.ErrorMatches, `cannot parse ".*/top-errors/s4/01" name as a valid sequence number`) +} diff --git a/asserts/fsbackstore.go b/asserts/fsbackstore.go new file mode 100644 index 00000000..5886e063 --- /dev/null +++ b/asserts/fsbackstore.go @@ -0,0 +1,321 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2015-2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts + +import ( + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "sync" +) + +// the default filesystem based backstore for assertions + +const ( + assertionsLayoutVersion = "v0" + assertionsRoot = "asserts-" + assertionsLayoutVersion +) + +type filesystemBackstore struct { + top string + mu sync.RWMutex +} + +// OpenFSBackstore opens a filesystem backed assertions backstore under path. +func OpenFSBackstore(path string) (Backstore, error) { + top := filepath.Join(path, assertionsRoot) + err := ensureTop(top) + if err != nil { + return nil, err + } + return &filesystemBackstore{top: top}, nil +} + +// guarantees that result assertion is of the expected type (both in the AssertionType and go type sense) +func (fsbs *filesystemBackstore) readAssertion(assertType *AssertionType, diskPrimaryPath string) (Assertion, error) { + encoded, err := readEntry(fsbs.top, assertType.Name, diskPrimaryPath) + if os.IsNotExist(err) { + return nil, errNotFound + } + if err != nil { + return nil, fmt.Errorf("broken assertion storage, cannot read assertion: %v", err) + } + assert, err := Decode(encoded) + if err != nil { + return nil, fmt.Errorf("broken assertion storage, cannot decode assertion: %v", err) + } + if assert.Type() != assertType { + return nil, fmt.Errorf("assertion that is not of type %q under their storage tree", assertType.Name) + } + // because of Decode() construction assert has also the expected go type + return assert, nil +} + +func (fsbs *filesystemBackstore) pickLatestAssertion(assertType *AssertionType, diskPrimaryPaths []string, maxFormat int) (a Assertion, er error) { + for _, diskPrimaryPath := range diskPrimaryPaths { + fn := filepath.Base(diskPrimaryPath) + parts := strings.SplitN(fn, ".", 2) + formatnum := 0 + if len(parts) == 2 { + var err error + formatnum, err = strconv.Atoi(parts[1]) + if err != nil { + return nil, fmt.Errorf("invalid active assertion filename: %q", fn) + } + } + if formatnum <= maxFormat { + a1, err := fsbs.readAssertion(assertType, diskPrimaryPath) + if err != nil { + return nil, err + } + if a == nil || a1.Revision() > a.Revision() { + a = a1 + } + } + } + if a == nil { + return nil, errNotFound + } + return a, nil +} + +// diskPrimaryPathComps computes the components of the path for an assertion. +// The path will look like this: (all are query escaped) +// /...[/0:[/1:]...]/ +// The components #: for the optional primary path values +// appear only if their value is not the default. +// This makes it so that assertions with default values have the same +// paths as for snapd versions without those optional primary keys +// yet. +func diskPrimaryPathComps(assertType *AssertionType, primaryPath []string, active string) []string { + n := len(primaryPath) + comps := make([]string, 0, n+1) + // safety against '/' etc + noptional := -1 + for i, comp := range primaryPath { + defl := assertType.OptionalPrimaryKeyDefaults[assertType.PrimaryKey[i]] + qvalue := url.QueryEscape(comp) + if defl != "" { + noptional++ + if comp == defl { + continue + } + qvalue = fmt.Sprintf("%d:%s", noptional, qvalue) + } + comps = append(comps, qvalue) + } + comps = append(comps, active) + return comps +} + +func (fsbs *filesystemBackstore) currentAssertion(assertType *AssertionType, primaryPath []string, maxFormat int) (Assertion, error) { + var a Assertion + namesCb := func(relpaths []string) error { + var err error + a, err = fsbs.pickLatestAssertion(assertType, relpaths, maxFormat) + if err == errNotFound { + return nil + } + return err + } + + comps := diskPrimaryPathComps(assertType, primaryPath, "active*") + assertTypeTop := filepath.Join(fsbs.top, assertType.Name) + err := findWildcard(assertTypeTop, comps, 0, namesCb) + if err != nil { + return nil, fmt.Errorf("broken assertion storage, looking for %s: %v", assertType.Name, err) + } + + if a == nil { + return nil, errNotFound + } + + return a, nil +} + +func (fsbs *filesystemBackstore) Put(assertType *AssertionType, assert Assertion) error { + fsbs.mu.Lock() + defer fsbs.mu.Unlock() + + primaryPath := assert.Ref().PrimaryKey + + curAssert, err := fsbs.currentAssertion(assertType, primaryPath, assertType.MaxSupportedFormat()) + if err == nil { + curRev := curAssert.Revision() + rev := assert.Revision() + if curRev >= rev { + return &RevisionError{Current: curRev, Used: rev} + } + } else if err != errNotFound { + return err + } + + formatnum := assert.Format() + activeFn := "active" + if formatnum > 0 { + activeFn = fmt.Sprintf("active.%d", formatnum) + } + diskPrimaryPath := filepath.Join(diskPrimaryPathComps(assertType, primaryPath, activeFn)...) + err = atomicWriteEntry(Encode(assert), false, fsbs.top, assertType.Name, diskPrimaryPath) + if err != nil { + return fmt.Errorf("broken assertion storage, cannot write assertion: %v", err) + } + return nil +} + +func (fsbs *filesystemBackstore) Get(assertType *AssertionType, key []string, maxFormat int) (Assertion, error) { + fsbs.mu.RLock() + defer fsbs.mu.RUnlock() + + if len(key) > len(assertType.PrimaryKey) { + return nil, fmt.Errorf("internal error: Backstore.Get given a key longer than expected for %q: %v", assertType.Name, key) + } + + a, err := fsbs.currentAssertion(assertType, key, maxFormat) + if err == errNotFound { + return nil, &NotFoundError{Type: assertType} + } + return a, err +} + +func (fsbs *filesystemBackstore) search(assertType *AssertionType, diskPattern []string, foundCb func(Assertion), maxFormat int) error { + assertTypeTop := filepath.Join(fsbs.top, assertType.Name) + candCb := func(diskPrimaryPaths []string) error { + a, err := fsbs.pickLatestAssertion(assertType, diskPrimaryPaths, maxFormat) + if err == errNotFound { + return nil + } + if err != nil { + return err + } + foundCb(a) + return nil + } + err := findWildcard(assertTypeTop, diskPattern, 0, candCb) + if err != nil { + return fmt.Errorf("broken assertion storage, searching for %s: %v", assertType.Name, err) + } + return nil +} + +func (fsbs *filesystemBackstore) searchOptional(assertType *AssertionType, kopt, pattPos, firstOpt int, diskPattern []string, headers map[string]string, foundCb func(Assertion), maxFormat int) error { + if kopt == len(assertType.PrimaryKey) { + candCb := func(a Assertion) { + if searchMatch(a, headers) { + foundCb(a) + } + } + + diskPattern[pattPos] = "active*" + return fsbs.search(assertType, diskPattern[:pattPos+1], candCb, maxFormat) + } + k := assertType.PrimaryKey[kopt] + keyVal := headers[k] + switch keyVal { + case "": + diskPattern[pattPos] = fmt.Sprintf("%d:*", kopt-firstOpt) + if err := fsbs.searchOptional(assertType, kopt+1, pattPos+1, firstOpt, diskPattern, headers, foundCb, maxFormat); err != nil { + return err + } + fallthrough + case assertType.OptionalPrimaryKeyDefaults[k]: + return fsbs.searchOptional(assertType, kopt+1, pattPos, firstOpt, diskPattern, headers, foundCb, maxFormat) + default: + diskPattern[pattPos] = fmt.Sprintf("%d:%s", kopt-firstOpt, url.QueryEscape(keyVal)) + return fsbs.searchOptional(assertType, kopt+1, pattPos+1, firstOpt, diskPattern, headers, foundCb, maxFormat) + } +} + +func (fsbs *filesystemBackstore) Search(assertType *AssertionType, headers map[string]string, foundCb func(Assertion), maxFormat int) error { + fsbs.mu.RLock() + defer fsbs.mu.RUnlock() + + n := len(assertType.PrimaryKey) + nopt := len(assertType.OptionalPrimaryKeyDefaults) + diskPattern := make([]string, n+1) + for i, k := range assertType.PrimaryKey[:n-nopt] { + keyVal := headers[k] + if keyVal == "" { + diskPattern[i] = "*" + } else { + diskPattern[i] = url.QueryEscape(keyVal) + } + } + pattPos := n - nopt + + return fsbs.searchOptional(assertType, pattPos, pattPos, pattPos, diskPattern, headers, foundCb, maxFormat) +} + +// errFound marks the case an assertion was found +var errFound = errors.New("found") + +func (fsbs *filesystemBackstore) SequenceMemberAfter(assertType *AssertionType, sequenceKey []string, after, maxFormat int) (SequenceMember, error) { + if !assertType.SequenceForming() { + panic(fmt.Sprintf("internal error: SequenceMemberAfter on non sequence-forming assertion type %s", assertType.Name)) + } + if len(sequenceKey) != len(assertType.PrimaryKey)-1 { + return nil, fmt.Errorf("internal error: SequenceMemberAfter's sequence key argument length must be exactly 1 less than the assertion type primary key") + } + + fsbs.mu.RLock() + defer fsbs.mu.RUnlock() + + n := len(assertType.PrimaryKey) + diskPattern := make([]string, n+1) + for i, k := range sequenceKey { + diskPattern[i] = url.QueryEscape(k) + } + seqWildcard := "#>" // ascending sequence wildcard + if after == -1 { + // find the latest in sequence + // use descending sequence wildcard + seqWildcard = "#<" + } + diskPattern[n-1] = seqWildcard + diskPattern[n] = "active*" + + var a Assertion + candCb := func(diskPrimaryPaths []string) error { + var err error + a, err = fsbs.pickLatestAssertion(assertType, diskPrimaryPaths, maxFormat) + if err == errNotFound { + return nil + } + if err != nil { + return err + } + return errFound + } + + assertTypeTop := filepath.Join(fsbs.top, assertType.Name) + err := findWildcard(assertTypeTop, diskPattern, after, candCb) + if err == errFound { + return a.(SequenceMember), nil + } + if err != nil { + return nil, fmt.Errorf("broken assertion storage, searching for %s: %v", assertType.Name, err) + } + + return nil, &NotFoundError{Type: assertType} +} diff --git a/asserts/fsbackstore_test.go b/asserts/fsbackstore_test.go new file mode 100644 index 00000000..72330271 --- /dev/null +++ b/asserts/fsbackstore_test.go @@ -0,0 +1,921 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016-2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts_test + +import ( + "os" + "path/filepath" + "strings" + "syscall" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/asserts" +) + +type fsBackstoreSuite struct{} + +var _ = Suite(&fsBackstoreSuite{}) + +func (fsbss *fsBackstoreSuite) TestOpenOK(c *C) { + // ensure umask is clean when creating the DB dir + oldUmask := syscall.Umask(0) + defer syscall.Umask(oldUmask) + + topDir := filepath.Join(c.MkDir(), "asserts-db") + + bs, err := asserts.OpenFSBackstore(topDir) + c.Check(err, IsNil) + c.Check(bs, NotNil) + + info, err := os.Stat(filepath.Join(topDir, "asserts-v0")) + c.Assert(err, IsNil) + c.Assert(info.IsDir(), Equals, true) + c.Check(info.Mode().Perm(), Equals, os.FileMode(0775)) +} + +func (fsbss *fsBackstoreSuite) TestOpenCreateFail(c *C) { + parent := filepath.Join(c.MkDir(), "var") + topDir := filepath.Join(parent, "asserts-db") + // make it not writable + err := os.Mkdir(parent, 0555) + c.Assert(err, IsNil) + + bs, err := asserts.OpenFSBackstore(topDir) + c.Assert(err, ErrorMatches, "cannot create assert storage root: .*") + c.Check(bs, IsNil) +} + +func (fsbss *fsBackstoreSuite) TestOpenWorldWritableFail(c *C) { + topDir := filepath.Join(c.MkDir(), "asserts-db") + // make it world-writable + oldUmask := syscall.Umask(0) + os.MkdirAll(filepath.Join(topDir, "asserts-v0"), 0777) + syscall.Umask(oldUmask) + + bs, err := asserts.OpenFSBackstore(topDir) + c.Assert(err, ErrorMatches, "assert storage root unexpectedly world-writable: .*") + c.Check(bs, IsNil) +} + +func (fsbss *fsBackstoreSuite) TestPutOldRevision(c *C) { + topDir := filepath.Join(c.MkDir(), "asserts-db") + bs, err := asserts.OpenFSBackstore(topDir) + c.Assert(err, IsNil) + + // Create two revisions of assertion. + a0, err := asserts.Decode([]byte("type: test-only\n" + + "authority-id: auth-id1\n" + + "primary-key: foo\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==")) + c.Assert(err, IsNil) + a1, err := asserts.Decode([]byte("type: test-only\n" + + "authority-id: auth-id1\n" + + "primary-key: foo\n" + + "revision: 1\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==")) + c.Assert(err, IsNil) + + // Put newer revision, follwed by old revision. + err = bs.Put(asserts.TestOnlyType, a1) + c.Assert(err, IsNil) + err = bs.Put(asserts.TestOnlyType, a0) + + c.Check(err, ErrorMatches, `revision 0 is older than current revision 1`) + c.Check(err, DeepEquals, &asserts.RevisionError{Current: 1, Used: 0}) +} + +func (fsbss *fsBackstoreSuite) TestGetFormat(c *C) { + topDir := filepath.Join(c.MkDir(), "asserts-db") + bs, err := asserts.OpenFSBackstore(topDir) + c.Assert(err, IsNil) + + af0, err := asserts.Decode([]byte("type: test-only\n" + + "authority-id: auth-id1\n" + + "primary-key: foo\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==")) + c.Assert(err, IsNil) + af1, err := asserts.Decode([]byte("type: test-only\n" + + "authority-id: auth-id1\n" + + "primary-key: foo\n" + + "format: 1\n" + + "revision: 1\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==")) + c.Assert(err, IsNil) + af2, err := asserts.Decode([]byte("type: test-only\n" + + "authority-id: auth-id1\n" + + "primary-key: zoo\n" + + "format: 2\n" + + "revision: 22\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==")) + c.Assert(err, IsNil) + + err = bs.Put(asserts.TestOnlyType, af0) + c.Assert(err, IsNil) + err = bs.Put(asserts.TestOnlyType, af1) + c.Assert(err, IsNil) + + a, err := bs.Get(asserts.TestOnlyType, []string{"foo"}, 1) + c.Assert(err, IsNil) + c.Check(a.Revision(), Equals, 1) + + a, err = bs.Get(asserts.TestOnlyType, []string{"foo"}, 0) + c.Assert(err, IsNil) + c.Check(a.Revision(), Equals, 0) + + a, err = bs.Get(asserts.TestOnlyType, []string{"zoo"}, 0) + c.Assert(err, DeepEquals, &asserts.NotFoundError{ + Type: asserts.TestOnlyType, + // Headers can be omitted by Backstores + }) + c.Check(a, IsNil) + + err = bs.Put(asserts.TestOnlyType, af2) + c.Assert(err, IsNil) + + a, err = bs.Get(asserts.TestOnlyType, []string{"zoo"}, 1) + c.Assert(err, DeepEquals, &asserts.NotFoundError{ + Type: asserts.TestOnlyType, + }) + c.Check(a, IsNil) + + a, err = bs.Get(asserts.TestOnlyType, []string{"zoo"}, 2) + c.Assert(err, IsNil) + c.Check(a.Revision(), Equals, 22) +} + +func (fsbss *fsBackstoreSuite) TestSearchFormat(c *C) { + topDir := filepath.Join(c.MkDir(), "asserts-db") + bs, err := asserts.OpenFSBackstore(topDir) + c.Assert(err, IsNil) + + af0, err := asserts.Decode([]byte("type: test-only-2\n" + + "authority-id: auth-id1\n" + + "pk1: foo\n" + + "pk2: bar\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==")) + c.Assert(err, IsNil) + af1, err := asserts.Decode([]byte("type: test-only-2\n" + + "authority-id: auth-id1\n" + + "pk1: foo\n" + + "pk2: bar\n" + + "format: 1\n" + + "revision: 1\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==")) + c.Assert(err, IsNil) + + af2, err := asserts.Decode([]byte("type: test-only-2\n" + + "authority-id: auth-id1\n" + + "pk1: foo\n" + + "pk2: baz\n" + + "format: 2\n" + + "revision: 1\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==")) + c.Assert(err, IsNil) + + err = bs.Put(asserts.TestOnly2Type, af0) + c.Assert(err, IsNil) + + queries := []map[string]string{ + {"pk1": "foo", "pk2": "bar"}, + {"pk1": "foo"}, + {"pk2": "bar"}, + } + + for _, q := range queries { + var a asserts.Assertion + foundCb := func(a1 asserts.Assertion) { + a = a1 + } + err := bs.Search(asserts.TestOnly2Type, q, foundCb, 1) + c.Assert(err, IsNil) + c.Check(a.Revision(), Equals, 0) + } + + err = bs.Put(asserts.TestOnly2Type, af1) + c.Assert(err, IsNil) + + for _, q := range queries { + var a asserts.Assertion + foundCb := func(a1 asserts.Assertion) { + a = a1 + } + err := bs.Search(asserts.TestOnly2Type, q, foundCb, 1) + c.Assert(err, IsNil) + c.Check(a.Revision(), Equals, 1) + + err = bs.Search(asserts.TestOnly2Type, q, foundCb, 0) + c.Assert(err, IsNil) + c.Check(a.Revision(), Equals, 0) + } + + err = bs.Put(asserts.TestOnly2Type, af2) + c.Assert(err, IsNil) + + var as []asserts.Assertion + foundCb := func(a1 asserts.Assertion) { + as = append(as, a1) + } + err = bs.Search(asserts.TestOnly2Type, map[string]string{ + "pk1": "foo", + }, foundCb, 1) // will not find af2 + c.Assert(err, IsNil) + c.Check(as, HasLen, 1) + c.Check(as[0].Revision(), Equals, 1) + +} + +func (fsbss *fsBackstoreSuite) TestSequenceMemberAfter(c *C) { + topDir := filepath.Join(c.MkDir(), "asserts-db") + bs, err := asserts.OpenFSBackstore(topDir) + c.Assert(err, IsNil) + + other1, err := asserts.Decode([]byte("type: test-only-seq\n" + + "authority-id: auth-id1\n" + + "n: other\n" + + "sequence: 1\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==")) + c.Assert(err, IsNil) + + sq1f0, err := asserts.Decode([]byte("type: test-only-seq\n" + + "authority-id: auth-id1\n" + + "n: s1\n" + + "sequence: 1\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==")) + c.Assert(err, IsNil) + + sq2f0, err := asserts.Decode([]byte("type: test-only-seq\n" + + "authority-id: auth-id1\n" + + "n: s1\n" + + "sequence: 2\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==")) + c.Assert(err, IsNil) + + sq2f1, err := asserts.Decode([]byte("type: test-only-seq\n" + + "authority-id: auth-id1\n" + + "format: 1\n" + + "n: s1\n" + + "sequence: 2\n" + + "revision: 1\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==")) + c.Assert(err, IsNil) + + sq3f1, err := asserts.Decode([]byte("type: test-only-seq\n" + + "authority-id: auth-id1\n" + + "format: 1\n" + + "n: s1\n" + + "sequence: 3\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==")) + c.Assert(err, IsNil) + + sq3f2, err := asserts.Decode([]byte("type: test-only-seq\n" + + "authority-id: auth-id1\n" + + "format: 2\n" + + "n: s1\n" + + "sequence: 3\n" + + "revision: 1\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==")) + c.Assert(err, IsNil) + + for _, a := range []asserts.Assertion{other1, sq1f0, sq2f0, sq2f1, sq3f1, sq3f2} { + err = bs.Put(asserts.TestOnlySeqType, a) + c.Assert(err, IsNil) + } + + seqKey := []string{"s1"} + tests := []struct { + after int + maxFormat int + sequence int + format int + revision int + }{ + {after: 0, maxFormat: 0, sequence: 1, format: 0, revision: 0}, + {after: 0, maxFormat: 2, sequence: 1, format: 0, revision: 0}, + {after: 1, maxFormat: 0, sequence: 2, format: 0, revision: 0}, + {after: 1, maxFormat: 1, sequence: 2, format: 1, revision: 1}, + {after: 1, maxFormat: 2, sequence: 2, format: 1, revision: 1}, + {after: 2, maxFormat: 0, sequence: -1}, + {after: 2, maxFormat: 1, sequence: 3, format: 1, revision: 0}, + {after: 2, maxFormat: 2, sequence: 3, format: 2, revision: 1}, + {after: 3, maxFormat: 0, sequence: -1}, + {after: 3, maxFormat: 2, sequence: -1}, + {after: 4, maxFormat: 2, sequence: -1}, + {after: -1, maxFormat: 0, sequence: 2, format: 0, revision: 0}, + {after: -1, maxFormat: 1, sequence: 3, format: 1, revision: 0}, + {after: -1, maxFormat: 2, sequence: 3, format: 2, revision: 1}, + } + + for _, t := range tests { + a, err := bs.SequenceMemberAfter(asserts.TestOnlySeqType, seqKey, t.after, t.maxFormat) + if t.sequence == -1 { + c.Check(err, DeepEquals, &asserts.NotFoundError{ + Type: asserts.TestOnlySeqType, + }) + } else { + c.Assert(err, IsNil) + c.Assert(a.HeaderString("n"), Equals, "s1") + c.Check(a.Sequence(), Equals, t.sequence) + c.Check(a.Format(), Equals, t.format) + c.Check(a.Revision(), Equals, t.revision) + } + } + + _, err = bs.SequenceMemberAfter(asserts.TestOnlySeqType, []string{"s2"}, -1, 2) + c.Check(err, DeepEquals, &asserts.NotFoundError{ + Type: asserts.TestOnlySeqType, + }) +} + +func (fsbss *fsBackstoreSuite) TestOptionalPrimaryKeys(c *C) { + topDir := filepath.Join(c.MkDir(), "asserts-db") + bs, err := asserts.OpenFSBackstore(topDir) + c.Assert(err, IsNil) + + a1, err := asserts.Decode([]byte("type: test-only\n" + + "authority-id: auth-id1\n" + + "primary-key: k1\n" + + "marker: a1\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==")) + c.Assert(err, IsNil) + err = bs.Put(asserts.TestOnlyType, a1) + c.Assert(err, IsNil) + + a, err := bs.Get(asserts.TestOnlyType, []string{"k1"}, 0) + c.Assert(err, IsNil) + c.Check(a.Ref().PrimaryKey, DeepEquals, []string{"k1"}) + + r := asserts.MockOptionalPrimaryKey(asserts.TestOnlyType, "opt1", "o1-defl") + defer r() + + a2, err := asserts.Decode([]byte("type: test-only\n" + + "authority-id: auth-id1\n" + + "primary-key: k2\n" + + "marker: a2\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==")) + c.Assert(err, IsNil) + err = bs.Put(asserts.TestOnlyType, a2) + c.Assert(err, IsNil) + a3, err := asserts.Decode([]byte("type: test-only\n" + + "authority-id: auth-id1\n" + + "primary-key: k3\n" + + "opt1: o1-a3\n" + + "marker: a3\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==")) + c.Assert(err, IsNil) + err = bs.Put(asserts.TestOnlyType, a3) + c.Assert(err, IsNil) + + a, err = bs.Get(asserts.TestOnlyType, []string{"k1"}, 0) + c.Assert(err, IsNil) + c.Check(a.Ref().PrimaryKey, DeepEquals, []string{"k1", "o1-defl"}) + c.Check(a.HeaderString("marker"), Equals, "a1") + + a, err = bs.Get(asserts.TestOnlyType, []string{"k1", "o1-defl"}, 0) + c.Assert(err, IsNil) + c.Check(a.Ref().PrimaryKey, DeepEquals, []string{"k1", "o1-defl"}) + c.Check(a.HeaderString("marker"), Equals, "a1") + + a, err = bs.Get(asserts.TestOnlyType, []string{"k2"}, 0) + c.Assert(err, IsNil) + c.Check(a.Ref().PrimaryKey, DeepEquals, []string{"k2", "o1-defl"}) + c.Check(a.HeaderString("marker"), Equals, "a2") + + a, err = bs.Get(asserts.TestOnlyType, []string{"k3", "o1-a3"}, 0) + c.Assert(err, IsNil) + c.Check(a.Ref().PrimaryKey, DeepEquals, []string{"k3", "o1-a3"}) + c.Check(a.HeaderString("marker"), Equals, "a3") + + r2 := asserts.MockOptionalPrimaryKey(asserts.TestOnlyType, "opt2", "o2-defl") + defer r() + + a4, err := asserts.Decode([]byte("type: test-only\n" + + "authority-id: auth-id1\n" + + "primary-key: k4\n" + + "marker: a4\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==")) + c.Assert(err, IsNil) + err = bs.Put(asserts.TestOnlyType, a4) + c.Assert(err, IsNil) + a5, err := asserts.Decode([]byte("type: test-only\n" + + "authority-id: auth-id1\n" + + "primary-key: k3\n" + + "opt2: o2-a5\n" + + "marker: a5\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==")) + c.Assert(err, IsNil) + err = bs.Put(asserts.TestOnlyType, a5) + c.Assert(err, IsNil) + a6, err := asserts.Decode([]byte("type: test-only\n" + + "authority-id: auth-id1\n" + + "primary-key: k5\n" + + "opt1: o1-a6\n" + + "opt2: o2-a6\n" + + "marker: a6\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==")) + c.Assert(err, IsNil) + err = bs.Put(asserts.TestOnlyType, a6) + c.Assert(err, IsNil) + + a, err = bs.Get(asserts.TestOnlyType, []string{"k1"}, 0) + c.Assert(err, IsNil) + c.Check(a.Ref().PrimaryKey, DeepEquals, []string{"k1", "o1-defl", "o2-defl"}) + c.Check(a.HeaderString("marker"), Equals, "a1") + + a, err = bs.Get(asserts.TestOnlyType, []string{"k1", "o1-defl"}, 0) + c.Assert(err, IsNil) + c.Check(a.HeaderString("marker"), Equals, "a1") + + a, err = bs.Get(asserts.TestOnlyType, []string{"k2", "o1-defl", "o2-defl"}, 0) + c.Assert(err, IsNil) + c.Check(a.Ref().PrimaryKey, DeepEquals, []string{"k2", "o1-defl", "o2-defl"}) + c.Check(a.HeaderString("marker"), Equals, "a2") + + a, err = bs.Get(asserts.TestOnlyType, []string{"k3", "o1-a3"}, 0) + c.Assert(err, IsNil) + c.Check(a.Ref().PrimaryKey, DeepEquals, []string{"k3", "o1-a3", "o2-defl"}) + c.Check(a.HeaderString("marker"), Equals, "a3") + + a, err = bs.Get(asserts.TestOnlyType, []string{"k4"}, 0) + c.Assert(err, IsNil) + c.Check(a.Ref().PrimaryKey, DeepEquals, []string{"k4", "o1-defl", "o2-defl"}) + c.Check(a.HeaderString("marker"), Equals, "a4") + + a, err = bs.Get(asserts.TestOnlyType, []string{"k3", "o1-defl", "o2-a5"}, 0) + c.Assert(err, IsNil) + c.Check(a.Ref().PrimaryKey, DeepEquals, []string{"k3", "o1-defl", "o2-a5"}) + c.Check(a.HeaderString("marker"), Equals, "a5") + + a, err = bs.Get(asserts.TestOnlyType, []string{"k5", "o1-a6", "o2-a6"}, 0) + c.Assert(err, IsNil) + c.Check(a.Ref().PrimaryKey, DeepEquals, []string{"k5", "o1-a6", "o2-a6"}) + c.Check(a.HeaderString("marker"), Equals, "a6") + + // revert the previous type definition + r2() + + a, err = bs.Get(asserts.TestOnlyType, []string{"k1"}, 0) + c.Assert(err, IsNil) + c.Check(a.HeaderString("marker"), Equals, "a1") + c.Check(a.Ref().PrimaryKey, DeepEquals, []string{"k1", "o1-defl"}) + a, err = bs.Get(asserts.TestOnlyType, []string{"k1", "o1-defl"}, 0) + c.Assert(err, IsNil) + c.Check(a.HeaderString("marker"), Equals, "a1") + + a, err = bs.Get(asserts.TestOnlyType, []string{"k2", "o1-defl"}, 0) + c.Assert(err, IsNil) + c.Check(a.Ref().PrimaryKey, DeepEquals, []string{"k2", "o1-defl"}) + c.Check(a.HeaderString("marker"), Equals, "a2") + + a, err = bs.Get(asserts.TestOnlyType, []string{"k3", "o1-a3"}, 0) + c.Assert(err, IsNil) + c.Check(a.Ref().PrimaryKey, DeepEquals, []string{"k3", "o1-a3"}) + c.Check(a.HeaderString("marker"), Equals, "a3") + + a, err = bs.Get(asserts.TestOnlyType, []string{"k4"}, 0) + c.Assert(err, IsNil) + c.Check(a.Ref().PrimaryKey, DeepEquals, []string{"k4", "o1-defl"}) + c.Check(a.HeaderString("marker"), Equals, "a4") + + a, err = bs.Get(asserts.TestOnlyType, []string{"k3", "o1-defl"}, 0) + c.Check(err, DeepEquals, &asserts.NotFoundError{ + Type: asserts.TestOnlyType, + }) + c.Check(a, IsNil) + a, err = bs.Get(asserts.TestOnlyType, []string{"k5", "o1-a6"}, 0) + c.Check(err, DeepEquals, &asserts.NotFoundError{ + Type: asserts.TestOnlyType, + }) + c.Check(a, IsNil) + + // revert to initial type definition + r() + a, err = bs.Get(asserts.TestOnlyType, []string{"k1"}, 0) + c.Assert(err, IsNil) + c.Check(a.Ref().PrimaryKey, DeepEquals, []string{"k1"}) + a, err = bs.Get(asserts.TestOnlyType, []string{"k2"}, 0) + c.Assert(err, IsNil) + c.Check(a.Ref().PrimaryKey, DeepEquals, []string{"k2"}) + a, err = bs.Get(asserts.TestOnlyType, []string{"k3"}, 0) + c.Check(err, DeepEquals, &asserts.NotFoundError{ + Type: asserts.TestOnlyType, + }) + c.Check(a, IsNil) + a, err = bs.Get(asserts.TestOnlyType, []string{"k4"}, 0) + c.Assert(err, IsNil) + c.Check(a.Ref().PrimaryKey, DeepEquals, []string{"k4"}) + a, err = bs.Get(asserts.TestOnlyType, []string{"k5"}, 0) + c.Check(err, DeepEquals, &asserts.NotFoundError{ + Type: asserts.TestOnlyType, + }) + c.Check(a, IsNil) +} + +func (fsbss *fsBackstoreSuite) TestOptionalPrimaryKeysSearch(c *C) { + topDir := filepath.Join(c.MkDir(), "asserts-db") + bs, err := asserts.OpenFSBackstore(topDir) + c.Assert(err, IsNil) + + a1, err := asserts.Decode([]byte("type: test-only\n" + + "authority-id: auth-id1\n" + + "primary-key: k1\n" + + "v: x\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==")) + c.Assert(err, IsNil) + err = bs.Put(asserts.TestOnlyType, a1) + c.Assert(err, IsNil) + + r := asserts.MockOptionalPrimaryKey(asserts.TestOnlyType, "opt1", "o1-defl") + defer r() + + a2, err := asserts.Decode([]byte("type: test-only\n" + + "authority-id: auth-id1\n" + + "primary-key: k1\n" + + "opt1: A\n" + + "v: y\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==")) + c.Assert(err, IsNil) + err = bs.Put(asserts.TestOnlyType, a2) + c.Assert(err, IsNil) + + a3, err := asserts.Decode([]byte("type: test-only\n" + + "authority-id: auth-id1\n" + + "primary-key: k2\n" + + "opt1: A\n" + + "v: x\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==")) + c.Assert(err, IsNil) + err = bs.Put(asserts.TestOnlyType, a3) + c.Assert(err, IsNil) + + a4, err := asserts.Decode([]byte("type: test-only\n" + + "authority-id: auth-id1\n" + + "primary-key: k3\n" + + "opt1: B\n" + + "v: y\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==")) + c.Assert(err, IsNil) + err = bs.Put(asserts.TestOnlyType, a4) + c.Assert(err, IsNil) + + a5, err := asserts.Decode([]byte("type: test-only\n" + + "authority-id: auth-id1\n" + + "primary-key: k4\n" + + "opt1: B\n" + + "v: x\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==")) + c.Assert(err, IsNil) + err = bs.Put(asserts.TestOnlyType, a5) + c.Assert(err, IsNil) + + a6, err := asserts.Decode([]byte("type: test-only\n" + + "authority-id: auth-id1\n" + + "primary-key: k3\n" + + "v: y\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==")) + c.Assert(err, IsNil) + err = bs.Put(asserts.TestOnlyType, a6) + c.Assert(err, IsNil) + + var found map[string]string + foundCb := func(a asserts.Assertion) { + if found == nil { + found = make(map[string]string) + } + found[strings.Join(a.Ref().PrimaryKey, "/")] = a.HeaderString("v") + } + + err = bs.Search(asserts.TestOnlyType, map[string]string{ + "primary-key": "k1", + }, foundCb, 0) + c.Assert(err, IsNil) + c.Check(found, DeepEquals, map[string]string{ + "k1/o1-defl": "x", + "k1/A": "y", + }) + + found = nil + err = bs.Search(asserts.TestOnlyType, map[string]string{ + "primary-key": "k3", + "opt1": "o1-defl", + }, foundCb, 0) + c.Assert(err, IsNil) + c.Check(found, DeepEquals, map[string]string{ + "k3/o1-defl": "y", + }) + + found = nil + err = bs.Search(asserts.TestOnlyType, map[string]string{ + "opt1": "o1-defl", + }, foundCb, 0) + c.Assert(err, IsNil) + c.Check(found, DeepEquals, map[string]string{ + "k1/o1-defl": "x", + "k3/o1-defl": "y", + }) + + found = nil + err = bs.Search(asserts.TestOnlyType, map[string]string{ + "opt1": "A", + }, foundCb, 0) + c.Assert(err, IsNil) + c.Check(found, DeepEquals, map[string]string{ + "k1/A": "y", + "k2/A": "x", + }) + + found = nil + err = bs.Search(asserts.TestOnlyType, map[string]string{ + "opt1": "B", + }, foundCb, 0) + c.Assert(err, IsNil) + c.Check(found, DeepEquals, map[string]string{ + "k3/B": "y", + "k4/B": "x", + }) + + found = nil + err = bs.Search(asserts.TestOnlyType, map[string]string{ + "v": "x", + }, foundCb, 0) + c.Assert(err, IsNil) + c.Check(found, DeepEquals, map[string]string{ + "k1/o1-defl": "x", + "k2/A": "x", + "k4/B": "x", + }) + + found = nil + err = bs.Search(asserts.TestOnlyType, map[string]string{ + "v": "y", + }, foundCb, 0) + c.Assert(err, IsNil) + c.Check(found, DeepEquals, map[string]string{ + "k1/A": "y", + "k3/B": "y", + "k3/o1-defl": "y", + }) + + found = nil + err = bs.Search(asserts.TestOnlyType, nil, foundCb, 0) + c.Assert(err, IsNil) + c.Check(found, DeepEquals, map[string]string{ + "k1/o1-defl": "x", + "k1/A": "y", + "k2/A": "x", + "k3/o1-defl": "y", + "k3/B": "y", + "k4/B": "x", + }) + + found = nil + err = bs.Search(asserts.TestOnlyType, map[string]string{ + "primary-key": "k4", + }, foundCb, 0) + c.Assert(err, IsNil) + c.Check(found, DeepEquals, map[string]string{ + "k4/B": "x", + }) + + found = nil + err = bs.Search(asserts.TestOnlyType, map[string]string{ + "primary-key": "k3", + "opt1": "B", + }, foundCb, 0) + c.Assert(err, IsNil) + c.Check(found, DeepEquals, map[string]string{ + "k3/B": "y", + }) + + // revert to initial type definition + r() + + found = nil + err = bs.Search(asserts.TestOnlyType, map[string]string{ + "primary-key": "k1", + }, foundCb, 0) + c.Assert(err, IsNil) + c.Check(found, DeepEquals, map[string]string{ + "k1": "x", + }) + + found = nil + err = bs.Search(asserts.TestOnlyType, map[string]string{ + "primary-key": "k3", + "opt1": "o1-defl", + }, foundCb, 0) + c.Assert(err, IsNil) + // found nothing + c.Check(found, IsNil) + + found = nil + err = bs.Search(asserts.TestOnlyType, map[string]string{ + "v": "x", + }, foundCb, 0) + c.Assert(err, IsNil) + c.Check(found, DeepEquals, map[string]string{ + "k1": "x", + }) + + found = nil + err = bs.Search(asserts.TestOnlyType, map[string]string{ + "v": "y", + }, foundCb, 0) + c.Assert(err, IsNil) + c.Check(found, DeepEquals, map[string]string{ + "k3": "y", + }) + + found = nil + err = bs.Search(asserts.TestOnlyType, nil, foundCb, 0) + c.Assert(err, IsNil) + c.Check(found, DeepEquals, map[string]string{ + "k1": "x", + "k3": "y", + }) +} + +func (fsbss *fsBackstoreSuite) TestOptionalPrimaryKeysSearchTwoOptional(c *C) { + topDir := filepath.Join(c.MkDir(), "asserts-db") + bs, err := asserts.OpenFSBackstore(topDir) + c.Assert(err, IsNil) + + a1, err := asserts.Decode([]byte("type: test-only\n" + + "authority-id: auth-id1\n" + + "primary-key: k1\n" + + "v: x\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==")) + c.Assert(err, IsNil) + err = bs.Put(asserts.TestOnlyType, a1) + c.Assert(err, IsNil) + + a2, err := asserts.Decode([]byte("type: test-only\n" + + "authority-id: auth-id1\n" + + "primary-key: k2\n" + + "v: x\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==")) + c.Assert(err, IsNil) + err = bs.Put(asserts.TestOnlyType, a2) + c.Assert(err, IsNil) + + r := asserts.MockOptionalPrimaryKey(asserts.TestOnlyType, "opt1", "o1-defl") + defer r() + asserts.MockOptionalPrimaryKey(asserts.TestOnlyType, "opt2", "o2-defl") + + a3, err := asserts.Decode([]byte("type: test-only\n" + + "authority-id: auth-id1\n" + + "primary-key: k1\n" + + "opt1: A\n" + + "v: y\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==")) + c.Assert(err, IsNil) + err = bs.Put(asserts.TestOnlyType, a3) + c.Assert(err, IsNil) + + a4, err := asserts.Decode([]byte("type: test-only\n" + + "authority-id: auth-id1\n" + + "primary-key: k2\n" + + "opt2: B\n" + + "v: y\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==")) + c.Assert(err, IsNil) + err = bs.Put(asserts.TestOnlyType, a4) + c.Assert(err, IsNil) + + a5, err := asserts.Decode([]byte("type: test-only\n" + + "authority-id: auth-id1\n" + + "primary-key: k2\n" + + "opt1: A2\n" + + "opt2: B2\n" + + "v: x\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==")) + c.Assert(err, IsNil) + err = bs.Put(asserts.TestOnlyType, a5) + c.Assert(err, IsNil) + + var found map[string]string + foundCb := func(a asserts.Assertion) { + if found == nil { + found = make(map[string]string) + } + found[strings.Join(a.Ref().PrimaryKey, "/")] = a.HeaderString("v") + } + + err = bs.Search(asserts.TestOnlyType, nil, foundCb, 0) + c.Assert(err, IsNil) + c.Check(found, DeepEquals, map[string]string{ + "k1/o1-defl/o2-defl": "x", + "k2/o1-defl/o2-defl": "x", + "k1/A/o2-defl": "y", + "k2/o1-defl/B": "y", + "k2/A2/B2": "x", + }) + + found = nil + err = bs.Search(asserts.TestOnlyType, map[string]string{ + "opt2": "B", + }, foundCb, 0) + c.Assert(err, IsNil) + c.Check(found, DeepEquals, map[string]string{ + "k2/o1-defl/B": "y", + }) + + found = nil + err = bs.Search(asserts.TestOnlyType, map[string]string{ + "opt1": "o1-defl", + "opt2": "B", + }, foundCb, 0) + c.Assert(err, IsNil) + c.Check(found, DeepEquals, map[string]string{ + "k2/o1-defl/B": "y", + }) + + found = nil + err = bs.Search(asserts.TestOnlyType, map[string]string{ + "opt1": "A2", + }, foundCb, 0) + c.Assert(err, IsNil) + c.Check(found, DeepEquals, map[string]string{ + "k2/A2/B2": "x", + }) + +} diff --git a/asserts/fsentryutils.go b/asserts/fsentryutils.go new file mode 100644 index 00000000..e33e9e90 --- /dev/null +++ b/asserts/fsentryutils.go @@ -0,0 +1,74 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2015-2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/snapcore/snapd/osutil" +) + +// utilities to read/write fs entries + +func ensureTop(path string) error { + err := os.MkdirAll(path, 0775) + if err != nil { + return fmt.Errorf("cannot create assert storage root: %v", err) + } + info, err := os.Stat(path) + if err != nil { + return fmt.Errorf("cannot create assert storage root: %v", err) + } + if info.Mode().Perm()&0002 != 0 { + return fmt.Errorf("assert storage root unexpectedly world-writable: %v", path) + } + return nil +} + +func atomicWriteEntry(data []byte, secret bool, top string, subpath ...string) error { + fpath := filepath.Join(top, filepath.Join(subpath...)) + dir := filepath.Dir(fpath) + err := os.MkdirAll(dir, 0775) + if err != nil { + return err + } + fperm := 0664 + if secret { + fperm = 0600 + } + return osutil.AtomicWriteFile(fpath, data, os.FileMode(fperm), 0) +} + +func entryExists(top string, subpath ...string) bool { + fpath := filepath.Join(top, filepath.Join(subpath...)) + return osutil.FileExists(fpath) +} + +func readEntry(top string, subpath ...string) ([]byte, error) { + fpath := filepath.Join(top, filepath.Join(subpath...)) + return os.ReadFile(fpath) +} + +func removeEntry(top string, subpath ...string) error { + fpath := filepath.Join(top, filepath.Join(subpath...)) + return os.Remove(fpath) +} diff --git a/asserts/fskeypairmgr.go b/asserts/fskeypairmgr.go new file mode 100644 index 00000000..91265ef1 --- /dev/null +++ b/asserts/fskeypairmgr.go @@ -0,0 +1,106 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2015-2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "sync" +) + +// the default simple filesystem based keypair manager/backstore + +const ( + privateKeysLayoutVersion = "v1" + privateKeysRoot = "private-keys-" + privateKeysLayoutVersion +) + +type filesystemKeypairManager struct { + top string + mu sync.RWMutex +} + +// OpenFSKeypairManager opens a filesystem backed assertions backstore under path. +func OpenFSKeypairManager(path string) (KeypairManager, error) { + top := filepath.Join(path, privateKeysRoot) + err := ensureTop(top) + if err != nil { + return nil, err + } + return &filesystemKeypairManager{top: top}, nil +} + +var errKeypairAlreadyExists = errors.New("key pair with given key id already exists") + +func (fskm *filesystemKeypairManager) Put(privKey PrivateKey) error { + keyID := privKey.PublicKey().ID() + if entryExists(fskm.top, keyID) { + return errKeypairAlreadyExists + } + encoded, err := encodePrivateKey(privKey) + if err != nil { + return fmt.Errorf("cannot store private key: %v", err) + } + + fskm.mu.Lock() + defer fskm.mu.Unlock() + + err = atomicWriteEntry(encoded, true, fskm.top, keyID) + if err != nil { + return fmt.Errorf("cannot store private key: %v", err) + } + return nil +} + +var errKeypairNotFound = &keyNotFoundError{msg: "cannot find key pair"} + +func (fskm *filesystemKeypairManager) Get(keyID string) (PrivateKey, error) { + fskm.mu.RLock() + defer fskm.mu.RUnlock() + + encoded, err := readEntry(fskm.top, keyID) + if os.IsNotExist(err) { + return nil, errKeypairNotFound + } + if err != nil { + return nil, fmt.Errorf("cannot read key pair: %v", err) + } + privKey, err := decodePrivateKey(encoded) + if err != nil { + return nil, fmt.Errorf("cannot decode key pair: %v", err) + } + return privKey, nil +} + +func (fskm *filesystemKeypairManager) Delete(keyID string) error { + fskm.mu.RLock() + defer fskm.mu.RUnlock() + + err := removeEntry(fskm.top, keyID) + if err != nil { + if os.IsNotExist(err) { + return errKeypairNotFound + } + return err + } + return nil +} diff --git a/asserts/fskeypairmgr_test.go b/asserts/fskeypairmgr_test.go new file mode 100644 index 00000000..fe067357 --- /dev/null +++ b/asserts/fskeypairmgr_test.go @@ -0,0 +1,97 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts_test + +import ( + "os" + "path/filepath" + "syscall" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/asserts" +) + +type fsKeypairMgrSuite struct{} + +var _ = Suite(&fsKeypairMgrSuite{}) + +func (fsbss *fsKeypairMgrSuite) TestOpenOK(c *C) { + // ensure umask is clean when creating the DB dir + oldUmask := syscall.Umask(0) + defer syscall.Umask(oldUmask) + + topDir := filepath.Join(c.MkDir(), "asserts-db") + err := os.MkdirAll(topDir, 0775) + c.Assert(err, IsNil) + + bs, err := asserts.OpenFSKeypairManager(topDir) + c.Check(err, IsNil) + c.Check(bs, NotNil) + + info, err := os.Stat(filepath.Join(topDir, "private-keys-v1")) + c.Assert(err, IsNil) + c.Assert(info.IsDir(), Equals, true) + c.Check(info.Mode().Perm(), Equals, os.FileMode(0775)) +} + +func (fsbss *fsKeypairMgrSuite) TestOpenWorldWritableFail(c *C) { + topDir := filepath.Join(c.MkDir(), "asserts-db") + // make it world-writable + oldUmask := syscall.Umask(0) + os.MkdirAll(filepath.Join(topDir, "private-keys-v1"), 0777) + syscall.Umask(oldUmask) + + bs, err := asserts.OpenFSKeypairManager(topDir) + c.Assert(err, ErrorMatches, "assert storage root unexpectedly world-writable: .*") + c.Check(bs, IsNil) +} + +func (fsbss *fsKeypairMgrSuite) TestDelete(c *C) { + // ensure umask is clean when creating the DB dir + oldUmask := syscall.Umask(0) + defer syscall.Umask(oldUmask) + + topDir := filepath.Join(c.MkDir(), "asserts-db") + err := os.MkdirAll(topDir, 0775) + c.Assert(err, IsNil) + + keypairMgr, err := asserts.OpenFSKeypairManager(topDir) + c.Check(err, IsNil) + + pk1 := testPrivKey1 + keyID := pk1.PublicKey().ID() + err = keypairMgr.Put(pk1) + c.Assert(err, IsNil) + + _, err = keypairMgr.Get(keyID) + c.Assert(err, IsNil) + + err = keypairMgr.Delete(keyID) + c.Assert(err, IsNil) + + err = keypairMgr.Delete(keyID) + c.Check(err, ErrorMatches, "cannot find key pair") + c.Check(asserts.IsKeyNotFound(err), Equals, true) + + _, err = keypairMgr.Get(keyID) + c.Check(err, ErrorMatches, "cannot find key pair") + c.Check(asserts.IsKeyNotFound(err), Equals, true) +} diff --git a/asserts/gpgkeypairmgr.go b/asserts/gpgkeypairmgr.go new file mode 100644 index 00000000..1e5aba6d --- /dev/null +++ b/asserts/gpgkeypairmgr.go @@ -0,0 +1,413 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016-2021 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts + +import ( + "bytes" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "golang.org/x/crypto/openpgp/packet" + + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/strutil" +) + +func ensureGPGHomeDirectory() (string, error) { + real, err := osutil.UserMaybeSudoUser() + if err != nil { + return "", err + } + + uid, gid, err := osutil.UidGid(real) + if err != nil { + return "", err + } + + homedir := os.Getenv("SNAP_GNUPG_HOME") + if homedir == "" { + homedir = filepath.Join(real.HomeDir, ".snap", "gnupg") + } + + if err := osutil.MkdirAllChown(homedir, 0700, uid, gid); err != nil { + return "", err + } + return homedir, nil +} + +// findGPGCommand returns the path to a suitable GnuPG binary to use. +// GnuPG 2 is mainly intended for desktop use, and is hard for us to use +// here: in particular, it's extremely difficult to use it to delete a +// secret key without a pinentry prompt (which would be necessary in our +// test suite). GnuPG 1 is still supported so it's reasonable to continue +// using that for now. +func findGPGCommand() (string, error) { + if path := os.Getenv("SNAP_GNUPG_CMD"); path != "" { + return path, nil + } + + path, err := exec.LookPath("gpg1") + if err != nil { + path, err = exec.LookPath("gpg") + } + return path, err +} + +var gpgBatchYes = false + +func runGPGImpl(input []byte, args ...string) ([]byte, error) { + homedir, err := ensureGPGHomeDirectory() + if err != nil { + return nil, err + } + + // Ensure the gpg-agent knows what tty to talk to to ask for + // the passphrase. This is needed because we drive gpg over + // a pipe and if the agent is not already started it will + // fail to be able to ask for a password. + if os.Getenv("GPG_TTY") == "" { + tty, err := os.Readlink("/proc/self/fd/0") + if err != nil { + return nil, err + } + os.Setenv("GPG_TTY", tty) + } + + general := []string{"--homedir", homedir, "-q", "--no-auto-check-trustdb"} + if gpgBatchYes && strutil.ListContains(args, "--batch") { + general = append(general, "--yes") + } + allArgs := append(general, args...) + + path, err := findGPGCommand() + if err != nil { + return nil, err + } + cmd := exec.Command(path, allArgs...) + var outBuf bytes.Buffer + var errBuf bytes.Buffer + + if len(input) != 0 { + cmd.Stdin = bytes.NewBuffer(input) + } + + cmd.Stdout = &outBuf + cmd.Stderr = &errBuf + + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("%s %s failed: %v (%q)", path, strings.Join(args, " "), err, errBuf.Bytes()) + } + + return outBuf.Bytes(), nil +} + +var runGPG = runGPGImpl + +// A key pair manager backed by a local GnuPG setup. +type GPGKeypairManager struct{} + +func (gkm *GPGKeypairManager) gpg(input []byte, args ...string) ([]byte, error) { + return runGPG(input, args...) +} + +// NewGPGKeypairManager creates a new key pair manager backed by a local GnuPG setup. +// Importing keys through the keypair manager interface is not +// suppored. +// Main purpose is allowing signing using keys from a GPG setup. +func NewGPGKeypairManager() *GPGKeypairManager { + return &GPGKeypairManager{} +} + +func (gkm *GPGKeypairManager) retrieve(fpr string) (PrivateKey, error) { + out, err := gkm.gpg(nil, "--batch", "--export", "--export-options", "export-minimal,export-clean,no-export-attributes", "0x"+fpr) + if err != nil { + return nil, err + } + if len(out) == 0 { + return nil, fmt.Errorf("cannot retrieve key with fingerprint %q in GPG keyring", fpr) + } + + pubKeyBuf := bytes.NewBuffer(out) + privKey, err := newExtPGPPrivateKey(pubKeyBuf, "GPG", func(content []byte) (*packet.Signature, error) { + return gkm.sign(fpr, content) + }) + if err != nil { + return nil, fmt.Errorf("cannot load GPG public key with fingerprint %q: %v", fpr, err) + } + gotFingerprint := privKey.externalID + if gotFingerprint != fpr { + return nil, fmt.Errorf("got wrong public key from GPG, expected fingerprint %q: %s", fpr, gotFingerprint) + } + return privKey, nil +} + +// Walk iterates over all the RSA private keys in the local GPG setup calling the provided callback until this returns an error +func (gkm *GPGKeypairManager) Walk(consider func(privk PrivateKey, fingerprint string, uid string) error) error { + // see GPG source doc/DETAILS + out, err := gkm.gpg(nil, "--batch", "--list-secret-keys", "--fingerprint", "--with-colons", "--fixed-list-mode") + if err != nil { + return err + } + lines := strings.Split(string(out), "\n") + n := len(lines) + if n > 0 && lines[n-1] == "" { + n-- + } + if n == 0 { + return nil + } + lines = lines[:n] + for j := 0; j < n; j++ { + // sec: line + line := lines[j] + if !strings.HasPrefix(line, "sec:") { + continue + } + secFields := strings.Split(line, ":") + if len(secFields) < 5 { + continue + } + if secFields[3] != "1" { // not RSA + continue + } + keyID := secFields[4] + uid := "" + fpr := "" + var privKey PrivateKey + // look for fpr:, uid: lines, order may vary and gpg2.1 + // may springle additional lines in (like gpr:) + Loop: + for k := j + 1; k < n && !strings.HasPrefix(lines[k], "sec:"); k++ { + switch { + case strings.HasPrefix(lines[k], "fpr:"): + fprFields := strings.Split(lines[k], ":") + // extract "Field 10 - User-ID" + // A FPR record stores the fingerprint here. + if len(fprFields) < 10 { + break Loop + } + fpr = fprFields[9] + if !strings.HasSuffix(fpr, keyID) { + break // strange, skip + } + privKey, err = gkm.retrieve(fpr) + if err != nil { + return err + } + case strings.HasPrefix(lines[k], "uid:"): + uidFields := strings.Split(lines[k], ":") + // extract "*** Field 10 - User-ID" + if len(uidFields) < 10 { + break Loop + } + uid = uidFields[9] + } + } + // validity checking + if privKey == nil || uid == "" { + continue + } + // collected it all + err = consider(privKey, fpr, uid) + if err != nil { + return err + } + } + return nil +} + +func (gkm *GPGKeypairManager) Put(privKey PrivateKey) error { + // NOTE: we don't need this initially at least and this keypair mgr is not for general arbitrary usage + return fmt.Errorf("cannot import private key into GPG keyring") +} + +type gpgKeypairInfo struct { + privKey PrivateKey + fingerprint string +} + +var errKeypairNotFoundInGPGKeyring = &keyNotFoundError{msg: "cannot find key pair in GPG keyring"} + +func (gkm *GPGKeypairManager) findByID(keyID string) (*gpgKeypairInfo, error) { + stop := errors.New("stop marker") + var hit *gpgKeypairInfo + match := func(privk PrivateKey, fpr string, uid string) error { + if privk.PublicKey().ID() == keyID { + hit = &gpgKeypairInfo{ + privKey: privk, + fingerprint: fpr, + } + return stop + } + return nil + } + err := gkm.Walk(match) + if err == stop { + return hit, nil + } + if err != nil { + return nil, err + } + return nil, errKeypairNotFoundInGPGKeyring +} + +func (gkm *GPGKeypairManager) Get(keyID string) (PrivateKey, error) { + keyInfo, err := gkm.findByID(keyID) + if err != nil { + return nil, err + } + return keyInfo.privKey, nil +} + +func (gkm *GPGKeypairManager) Delete(keyID string) error { + keyInfo, err := gkm.findByID(keyID) + if err != nil { + return err + } + _, err = gkm.gpg(nil, "--batch", "--delete-secret-and-public-key", "0x"+keyInfo.fingerprint) + if err != nil { + return err + } + return nil +} + +func (gkm *GPGKeypairManager) sign(fingerprint string, content []byte) (*packet.Signature, error) { + out, err := gkm.gpg(content, "--personal-digest-preferences", "SHA512", "--default-key", "0x"+fingerprint, "--detach-sign") + if err != nil { + return nil, fmt.Errorf("cannot sign using GPG: %v", err) + } + + const badSig = "bad GPG produced signature: " + sigpkt, err := packet.Read(bytes.NewBuffer(out)) + if err != nil { + return nil, fmt.Errorf(badSig+"%v", err) + } + + sig, ok := sigpkt.(*packet.Signature) + if !ok { + return nil, fmt.Errorf(badSig+"got %T", sigpkt) + } + + return sig, nil +} + +func (gkm *GPGKeypairManager) findByName(name string) (*gpgKeypairInfo, error) { + stop := errors.New("stop marker") + var hit *gpgKeypairInfo + match := func(privk PrivateKey, fpr string, uid string) error { + if uid == name { + hit = &gpgKeypairInfo{ + privKey: privk, + fingerprint: fpr, + } + return stop + } + return nil + } + err := gkm.Walk(match) + if err == stop { + return hit, nil + } + if err != nil { + return nil, err + } + return nil, errKeypairNotFoundInGPGKeyring +} + +// GetByName looks up a private key by name and returns it. +func (gkm *GPGKeypairManager) GetByName(name string) (PrivateKey, error) { + keyInfo, err := gkm.findByName(name) + if err != nil { + return nil, err + } + return keyInfo.privKey, nil +} + +var generateTemplate = ` +Key-Type: RSA +Key-Length: 4096 +Name-Real: %s +Creation-Date: seconds=%d +Preferences: SHA512 +` + +func (gkm *GPGKeypairManager) parametersForGenerate(passphrase string, name string) string { + fixedCreationTime := v1FixedTimestamp.Unix() + generateParams := fmt.Sprintf(generateTemplate, name, fixedCreationTime) + if passphrase != "" { + generateParams += "Passphrase: " + passphrase + "\n" + } + return generateParams +} + +// Generate creates a new key with the given passphrase and name. +func (gkm *GPGKeypairManager) Generate(passphrase string, name string) error { + _, err := gkm.findByName(name) + if err == nil { + return fmt.Errorf("key named %q already exists in GPG keyring", name) + } + generateParams := gkm.parametersForGenerate(passphrase, name) + _, err = gkm.gpg([]byte(generateParams), "--batch", "--gen-key") + if err != nil { + return err + } + return nil +} + +// Export returns the encoded text of the named public key. +func (gkm *GPGKeypairManager) Export(name string) ([]byte, error) { + keyInfo, err := gkm.findByName(name) + if err != nil { + return nil, err + } + return EncodePublicKey(keyInfo.privKey.PublicKey()) +} + +// DeleteByName removes the named key pair from GnuPG's storage. +func (gkm *GPGKeypairManager) DeleteByName(name string) error { + keyInfo, err := gkm.findByName(name) + if err != nil { + return err + } + _, err = gkm.gpg(nil, "--batch", "--delete-secret-and-public-key", "0x"+keyInfo.fingerprint) + if err != nil { + return err + } + return nil +} + +func (gkm *GPGKeypairManager) List() (res []ExternalKeyInfo, err error) { + collect := func(privk PrivateKey, fpr string, uid string) error { + key := ExternalKeyInfo{ + Name: uid, + ID: privk.PublicKey().ID(), + } + res = append(res, key) + return nil + } + if err := gkm.Walk(collect); err != nil { + return nil, err + } + return res, nil +} diff --git a/asserts/gpgkeypairmgr_test.go b/asserts/gpgkeypairmgr_test.go new file mode 100644 index 00000000..be6258af --- /dev/null +++ b/asserts/gpgkeypairmgr_test.go @@ -0,0 +1,366 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016-2021 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts_test + +import ( + "bytes" + "crypto" + "crypto/rand" + "crypto/rsa" + "fmt" + "os" + "time" + + "golang.org/x/crypto/openpgp/packet" + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/asserts/assertstest" + "github.com/snapcore/snapd/osutil" +) + +type gpgKeypairMgrSuite struct { + homedir string + keypairMgr asserts.KeypairManager +} + +var _ = Suite(&gpgKeypairMgrSuite{}) + +func (gkms *gpgKeypairMgrSuite) SetUpSuite(c *C) { + if !osutil.FileExists("/usr/bin/gpg1") && !osutil.FileExists("/usr/bin/gpg") { + c.Skip("gpg not installed") + } +} + +func (gkms *gpgKeypairMgrSuite) importKey(key string) { + assertstest.GPGImportKey(gkms.homedir, key) +} + +func (gkms *gpgKeypairMgrSuite) SetUpTest(c *C) { + gkms.homedir = c.MkDir() + os.Setenv("SNAP_GNUPG_HOME", gkms.homedir) + gkms.keypairMgr = asserts.NewGPGKeypairManager() + // import test key + gkms.importKey(assertstest.DevKey) +} + +func (gkms *gpgKeypairMgrSuite) TearDownTest(c *C) { + os.Unsetenv("SNAP_GNUPG_HOME") +} + +func (gkms *gpgKeypairMgrSuite) TestGetPublicKeyLooksGood(c *C) { + got, err := gkms.keypairMgr.Get(assertstest.DevKeyID) + c.Assert(err, IsNil) + keyID := got.PublicKey().ID() + c.Check(keyID, Equals, assertstest.DevKeyID) +} + +func (gkms *gpgKeypairMgrSuite) TestGetNotFound(c *C) { + got, err := gkms.keypairMgr.Get("ffffffffffffffff") + c.Check(err, ErrorMatches, `cannot find key pair in GPG keyring`) + c.Check(asserts.IsKeyNotFound(err), Equals, true) + c.Check(got, IsNil) +} + +func (gkms *gpgKeypairMgrSuite) TestGetByNameNotFound(c *C) { + gpgKeypairMgr := gkms.keypairMgr.(*asserts.GPGKeypairManager) + got, err := gpgKeypairMgr.GetByName("missing") + c.Check(err, ErrorMatches, `cannot find key pair in GPG keyring`) + c.Check(asserts.IsKeyNotFound(err), Equals, true) + c.Check(got, IsNil) +} + +func (gkms *gpgKeypairMgrSuite) TestUseInSigning(c *C) { + store := assertstest.NewStoreStack("trusted", nil) + + devKey, err := gkms.keypairMgr.Get(assertstest.DevKeyID) + c.Assert(err, IsNil) + + devAcct := assertstest.NewAccount(store, "devel1", map[string]interface{}{ + "account-id": "dev1-id", + }, "") + devAccKey := assertstest.NewAccountKey(store, devAcct, nil, devKey.PublicKey(), "") + + signDB, err := asserts.OpenDatabase(&asserts.DatabaseConfig{ + KeypairManager: gkms.keypairMgr, + }) + c.Assert(err, IsNil) + + checkDB, err := asserts.OpenDatabase(&asserts.DatabaseConfig{ + Backstore: asserts.NewMemoryBackstore(), + Trusted: store.Trusted, + }) + c.Assert(err, IsNil) + // add store key + err = checkDB.Add(store.StoreAccountKey("")) + c.Assert(err, IsNil) + // enable devel key + err = checkDB.Add(devAcct) + c.Assert(err, IsNil) + err = checkDB.Add(devAccKey) + c.Assert(err, IsNil) + + headers := map[string]interface{}{ + "authority-id": "dev1-id", + "snap-sha3-384": blobSHA3_384, + "snap-id": "snap-id-1", + "grade": "devel", + "snap-size": "1025", + "timestamp": time.Now().Format(time.RFC3339), + } + snapBuild, err := signDB.Sign(asserts.SnapBuildType, headers, nil, assertstest.DevKeyID) + c.Assert(err, IsNil) + + err = checkDB.Check(snapBuild) + c.Check(err, IsNil) +} + +func (gkms *gpgKeypairMgrSuite) TestGetNotUnique(c *C) { + mockGPG := func(prev asserts.GPGRunner, input []byte, args ...string) ([]byte, error) { + if args[1] == "--list-secret-keys" { + return prev(input, args...) + } + c.Assert(args[1], Equals, "--export") + + pk1, err := rsa.GenerateKey(rand.Reader, 512) + c.Assert(err, IsNil) + pk2, err := rsa.GenerateKey(rand.Reader, 512) + c.Assert(err, IsNil) + + buf := new(bytes.Buffer) + err = packet.NewRSAPublicKey(time.Now(), &pk1.PublicKey).Serialize(buf) + c.Assert(err, IsNil) + err = packet.NewRSAPublicKey(time.Now(), &pk2.PublicKey).Serialize(buf) + c.Assert(err, IsNil) + + return buf.Bytes(), nil + } + restore := asserts.MockRunGPG(mockGPG) + defer restore() + + _, err := gkms.keypairMgr.Get(assertstest.DevKeyID) + c.Check(err, ErrorMatches, `cannot load GPG public key with fingerprint "[A-F0-9]+": cannot select exported public key, found many`) +} + +func (gkms *gpgKeypairMgrSuite) TestUseInSigningBrokenSignature(c *C) { + _, rsaPrivKey := assertstest.ReadPrivKey(assertstest.DevKey) + pgpPrivKey := packet.NewRSAPrivateKey(time.Unix(1, 0), rsaPrivKey) + + var breakSig func(sig *packet.Signature, cont []byte) []byte + + mockGPG := func(prev asserts.GPGRunner, input []byte, args ...string) ([]byte, error) { + if args[1] == "--list-secret-keys" || args[1] == "--export" { + return prev(input, args...) + } + n := len(args) + c.Assert(args[n-1], Equals, "--detach-sign") + + sig := new(packet.Signature) + sig.PubKeyAlgo = packet.PubKeyAlgoRSA + sig.Hash = crypto.SHA512 + sig.CreationTime = time.Now() + + // poking to break the signature + cont := breakSig(sig, input) + + h := sig.Hash.New() + h.Write([]byte(cont)) + + err := sig.Sign(h, pgpPrivKey, nil) + c.Assert(err, IsNil) + + buf := new(bytes.Buffer) + sig.Serialize(buf) + return buf.Bytes(), nil + } + restore := asserts.MockRunGPG(mockGPG) + defer restore() + + signDB, err := asserts.OpenDatabase(&asserts.DatabaseConfig{ + KeypairManager: gkms.keypairMgr, + }) + c.Assert(err, IsNil) + + headers := map[string]interface{}{ + "authority-id": "dev1-id", + "snap-sha3-384": blobSHA3_384, + "snap-id": "snap-id-1", + "grade": "devel", + "snap-size": "1025", + "timestamp": time.Now().Format(time.RFC3339), + } + + tests := []struct { + breakSig func(*packet.Signature, []byte) []byte + expectedErr string + }{ + {func(sig *packet.Signature, cont []byte) []byte { + sig.Hash = crypto.SHA1 + return cont + }, "cannot sign assertion: bad GPG produced signature: expected SHA512 digest"}, + {func(sig *packet.Signature, cont []byte) []byte { + return cont[:5] + }, "cannot sign assertion: bad GPG produced signature: it does not verify:.*"}, + } + + for _, t := range tests { + breakSig = t.breakSig + + _, err = signDB.Sign(asserts.SnapBuildType, headers, nil, assertstest.DevKeyID) + c.Check(err, ErrorMatches, t.expectedErr) + } + +} + +func (gkms *gpgKeypairMgrSuite) TestUseInSigningFailure(c *C) { + mockGPG := func(prev asserts.GPGRunner, input []byte, args ...string) ([]byte, error) { + if args[1] == "--list-secret-keys" || args[1] == "--export" { + return prev(input, args...) + } + n := len(args) + c.Assert(args[n-1], Equals, "--detach-sign") + return nil, fmt.Errorf("boom") + } + restore := asserts.MockRunGPG(mockGPG) + defer restore() + + signDB, err := asserts.OpenDatabase(&asserts.DatabaseConfig{ + KeypairManager: gkms.keypairMgr, + }) + c.Assert(err, IsNil) + + headers := map[string]interface{}{ + "authority-id": "dev1-id", + "snap-sha3-384": blobSHA3_384, + "snap-id": "snap-id-1", + "grade": "devel", + "snap-size": "1025", + "timestamp": time.Now().Format(time.RFC3339), + } + + _, err = signDB.Sign(asserts.SnapBuildType, headers, nil, assertstest.DevKeyID) + c.Check(err, ErrorMatches, "cannot sign assertion: cannot sign using GPG: boom") +} + +const shortPrivKey = `-----BEGIN PGP PRIVATE KEY BLOCK----- +Version: GnuPG v1 + +lQOYBFdGO7MBCADltsXglnDQdfBw0yOVpKZdkuvSnJKKn1H72PapgAr7ucLqNBCA +js0kltDTa2LQP4vljiTyoMzOMnex4kXwRPlF+poZIEBHDLT0i/6sJ6mDukss1HBR +GgNpU3y49WTXc8qxFY4clhbuqgQmy6bUmaVoo3Z4z7cqbsCepWfx5y+vJwMYqlo3 +Nb4q2+hTKS/o3yLiYB7/hkEhMZrFrOPR5SM7Tz5y7cpF6ObY+JZIp/MK+LsLWLji +fEX/pcOtSjFdQqbcnhJJscXRERlFQDbc+gNmZYZ2RqdH5o46OliHkGhVDVTiW25A +SqhGfnodypbZ9QAPSRvhLrN64AqEsvRb3I13ABEBAAEAB/9cQKg8Nz6sQUkkDm9C +iCK1/qyNYwro9+3VXj9FOCJxEJuqMemUr4TMVnMcDQrchkC5GnpVJGXLw3HVcwFS +amjPhUKAp7aYsg40DcrjuXP27oiFQvWuZGuNT5WNtCNg8WQr9POjIFWqWIYdTHk9 +9Ux79vW7s/Oj62GY9OWHPSilxpq1MjDKo9CSMbLeWxW+gbDxaD7cK7H/ONcz8bZ7 +pRfEhNIx3mEbWaZpWRrf+dSUx2OJbPGRkeFFMbCNapqftse173BZCwUKsW7RTp2S +w8Vpo2Ky63Jlpz1DpoMDBz2vSH7pzaqAdnziI2r0IKiidajXFfpXJpJ3ICo/QhWj +x1eRBADrI4I99zHeyy+12QMpkDrOu+ahF6/emdsm1FIy88TqeBmLkeXCXKZIpU3c +USnxzm0nPNbOl7Nvf2VdAyeAftyag7t38Cud5MXldv/iY0e6oTKzxgha37yr6oRv +PZ6VGwbkBvWti1HL4yx1QnkHFS6ailR9WiiHr3HaWAklZAsC0QQA+hgOi0V9fMZZ +Y4/iFVRI9k1NK3pl0mP7pVTzbcjVYspLdIPQxPDsHJW0z48g23KOt0vL3yZvxdBx +cfYGqIonAX19aMD5D4bNLx616pZs78DKGlOz6iXDcaib+n/uCNWxd5R/0m/zugrB +qklpyIC/uxx+SmkJqqq378ytfvBMzccD/3Y6m3PM0ZnrIkr4Q7cKi9ao9rvM+J7o +ziMgfnKWedNDxNa4tIVYYGPiXsjxY/ASUyxVjUPbkyCy3ubZrew0zQ9+kQbO/6vB +WAg9ffT9M92QbSDjuxgUiC5GfvlCoDgJtuLRHd0YLDgUCS5nwb+teEsOpiNWEGXc +Tr+5HZO+g6wxT6W0BiAoeHh4KYkBOAQTAQIAIgUCV0Y7swIbLwYLCQgHAwIGFQgC +CQoLBBYCAwECHgECF4AACgkQEYacUJMr9p/i5wf/XbEiAe1+Y/ZNMO8PYnq1Nktk +CbZEfQo+QH/9gJpt4p78YseWeUp14gsULLks3xRojlKNzYkqBpJcP7Ex+hQ3LEp7 +9IVbept5md4uuZcU0GFF42WAYXExd2cuxPv3lmWHOPuN63a/xpp0M2vYDfpt63qi +Tly5/P4+NgpD6vAh8zwRHuBV/0mno/QX6cUCLVxq2v1aOqC9zq9B5sdYKQKjsQBP +NOXCt1wPaINkqiW/8w2KhUl6mL6vhO0Onqu/F7M/YNXitv6Z2NFdFUVBh58UZW3C +2jrc8JeRQ4Qlr1oeHh2loYOdZfxFPxRjhsRTnNKY8UHWLfbeI6lMqxR5G3DS+g== +=kQRo +-----END PGP PRIVATE KEY BLOCK----- +` + +func (gkms *gpgKeypairMgrSuite) TestUseInSigningKeyTooShort(c *C) { + gkms.importKey(shortPrivKey) + privk, _ := assertstest.ReadPrivKey(shortPrivKey) + + signDB, err := asserts.OpenDatabase(&asserts.DatabaseConfig{ + KeypairManager: gkms.keypairMgr, + }) + c.Assert(err, IsNil) + + headers := map[string]interface{}{ + "authority-id": "dev1-id", + "snap-sha3-384": blobSHA3_384, + "snap-id": "snap-id-1", + "grade": "devel", + "snap-size": "1025", + "timestamp": time.Now().Format(time.RFC3339), + } + + _, err = signDB.Sign(asserts.SnapBuildType, headers, nil, privk.PublicKey().ID()) + c.Check(err, ErrorMatches, `cannot sign assertion: signing needs at least a 4096 bits key, got 2048`) +} + +func (gkms *gpgKeypairMgrSuite) TestParametersForGenerate(c *C) { + gpgKeypairMgr := gkms.keypairMgr.(*asserts.GPGKeypairManager) + baseParameters := ` +Key-Type: RSA +Key-Length: 4096 +Name-Real: test-key +Creation-Date: seconds=1451606400 +Preferences: SHA512 +` + + tests := []struct { + passphrase string + extraParameters string + }{ + {"", ""}, + {"secret", "Passphrase: secret\n"}, + } + + for _, test := range tests { + parameters := gpgKeypairMgr.ParametersForGenerate(test.passphrase, "test-key") + c.Check(parameters, Equals, baseParameters+test.extraParameters) + } +} + +func (gkms *gpgKeypairMgrSuite) TestList(c *C) { + gpgKeypairMgr := gkms.keypairMgr.(*asserts.GPGKeypairManager) + + keys, err := gpgKeypairMgr.List() + c.Assert(err, IsNil) + c.Check(keys, HasLen, 1) + c.Check(keys[0].ID, Equals, assertstest.DevKeyID) + c.Check(keys[0].Name, Not(Equals), "") +} + +func (gkms *gpgKeypairMgrSuite) TestDelete(c *C) { + defer asserts.GPGBatchYes()() + + keyID := assertstest.DevKeyID + _, err := gkms.keypairMgr.Get(keyID) + c.Assert(err, IsNil) + + err = gkms.keypairMgr.Delete(keyID) + c.Assert(err, IsNil) + + err = gkms.keypairMgr.Delete(keyID) + c.Check(err, ErrorMatches, `cannot find key.*`) + + _, err = gkms.keypairMgr.Get(keyID) + c.Check(err, ErrorMatches, `cannot find key.*`) +} diff --git a/asserts/header_checks.go b/asserts/header_checks.go new file mode 100644 index 00000000..c9b39157 --- /dev/null +++ b/asserts/header_checks.go @@ -0,0 +1,324 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2015-2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts + +import ( + "crypto" + "encoding/base64" + "fmt" + "regexp" + "strconv" + "strings" + "time" +) + +// common checks used when decoding/assembling assertions + +func checkExistsString(headers map[string]interface{}, name string) (string, error) { + return checkExistsStringWhat(headers, name, "header") +} + +func checkExistsStringWhat(m map[string]interface{}, name, what string) (string, error) { + value, ok := m[name] + if !ok { + return "", fmt.Errorf("%q %s is mandatory", name, what) + } + s, ok := value.(string) + if !ok { + return "", fmt.Errorf("%q %s must be a string", name, what) + } + return s, nil +} + +func checkNotEmptyString(headers map[string]interface{}, name string) (string, error) { + return checkNotEmptyStringWhat(headers, name, "header") +} + +func checkNotEmptyStringWhat(m map[string]interface{}, name, what string) (string, error) { + s, err := checkExistsStringWhat(m, name, what) + if err != nil { + return "", err + } + if len(s) == 0 { + return "", fmt.Errorf("%q %s should not be empty", name, what) + } + return s, nil +} + +func checkOptionalStringWhat(headers map[string]interface{}, name, what string) (string, error) { + value, ok := headers[name] + if !ok { + return "", nil + } + s, ok := value.(string) + if !ok { + return "", fmt.Errorf("%q %s must be a string", name, what) + } + return s, nil +} + +func checkOptionalString(headers map[string]interface{}, name string) (string, error) { + return checkOptionalStringWhat(headers, name, "header") +} + +func checkPrimaryKey(headers map[string]interface{}, primKey string) (string, error) { + value, err := checkNotEmptyString(headers, primKey) + if err != nil { + return "", err + } + if strings.Contains(value, "/") { + return "", fmt.Errorf("%q primary key header cannot contain '/'", primKey) + } + return value, nil +} + +func checkAssertType(assertType *AssertionType) error { + if assertType == nil { + return fmt.Errorf("internal error: assertion type cannot be nil") + } + // validity check against known canonical + validity := typeRegistry[assertType.Name] + switch validity { + case assertType: + // fine, matches canonical + return nil + case nil: + return fmt.Errorf("internal error: unknown assertion type: %q", assertType.Name) + default: + return fmt.Errorf("internal error: unpredefined assertion type for name %q used (unexpected address %p)", assertType.Name, assertType) + } +} + +// use 'defl' default if missing +func checkIntWithDefault(headers map[string]interface{}, name string, defl int) (int, error) { + value, ok := headers[name] + if !ok { + return defl, nil + } + s, ok := value.(string) + if !ok { + return -1, fmt.Errorf("%q header is not an integer: %v", name, value) + } + m, err := atoi(s, "%q %s", name, "header") + if err != nil { + return -1, err + } + return m, nil +} + +func checkInt(headers map[string]interface{}, name string) (int, error) { + return checkIntWhat(headers, name, "header") +} + +func checkIntWhat(headers map[string]interface{}, name, what string) (int, error) { + valueStr, err := checkNotEmptyStringWhat(headers, name, what) + if err != nil { + return -1, err + } + value, err := atoi(valueStr, "%q %s", name, what) + if err != nil { + return -1, err + } + return value, nil +} + +type intSyntaxError string + +func (e intSyntaxError) Error() string { + return string(e) +} + +func atoi(valueStr, whichFmt string, whichArgs ...interface{}) (int, error) { + value, err := strconv.Atoi(valueStr) + if err != nil { + which := fmt.Sprintf(whichFmt, whichArgs...) + if ne, ok := err.(*strconv.NumError); ok && ne.Err == strconv.ErrRange { + return -1, fmt.Errorf("%s is out of range: %v", which, valueStr) + } + return -1, intSyntaxError(fmt.Sprintf("%s is not an integer: %v", which, valueStr)) + } + if prefixZeros(valueStr) { + return -1, fmt.Errorf("%s has invalid prefix zeros: %s", fmt.Sprintf(whichFmt, whichArgs...), valueStr) + } + return value, nil +} + +func prefixZeros(s string) bool { + return strings.HasPrefix(s, "0") && s != "0" +} + +func checkRFC3339Date(headers map[string]interface{}, name string) (time.Time, error) { + return checkRFC3339DateWhat(headers, name, "header") +} + +func checkRFC3339DateWhat(m map[string]interface{}, name, what string) (time.Time, error) { + dateStr, err := checkNotEmptyStringWhat(m, name, what) + if err != nil { + return time.Time{}, err + } + date, err := time.Parse(time.RFC3339, dateStr) + if err != nil { + return time.Time{}, fmt.Errorf("%q %s is not a RFC3339 date: %v", name, what, err) + } + return date, nil +} + +func checkRFC3339DateWithDefaultWhat(m map[string]interface{}, name, what string, defl time.Time) (time.Time, error) { + value, ok := m[name] + if !ok { + return defl, nil + } + dateStr, ok := value.(string) + if !ok { + return time.Time{}, fmt.Errorf("%q %s must be a string", name, what) + } + date, err := time.Parse(time.RFC3339, dateStr) + if err != nil { + return time.Time{}, fmt.Errorf("%q %s is not a RFC3339 date: %v", name, what, err) + } + return date, nil +} + +func checkUint(headers map[string]interface{}, name string, bitSize int) (uint64, error) { + return checkUintWhat(headers, name, bitSize, "header") +} + +func checkUintWhat(headers map[string]interface{}, name string, bitSize int, what string) (uint64, error) { + valueStr, err := checkNotEmptyStringWhat(headers, name, what) + if err != nil { + return 0, err + } + value, err := strconv.ParseUint(valueStr, 10, bitSize) + if err != nil { + if ne, ok := err.(*strconv.NumError); ok && ne.Err == strconv.ErrRange { + return 0, fmt.Errorf("%q %s is out of range: %v", name, what, valueStr) + } + return 0, fmt.Errorf("%q %s is not an unsigned integer: %v", name, what, valueStr) + } + if prefixZeros(valueStr) { + return 0, fmt.Errorf("%q %s has invalid prefix zeros: %s", name, what, valueStr) + } + return value, nil +} + +func checkDigest(headers map[string]interface{}, name string, h crypto.Hash) ([]byte, error) { + return checkDigestWhat(headers, name, h, "header") +} + +func checkDigestWhat(headers map[string]interface{}, name string, h crypto.Hash, what string) ([]byte, error) { + digestStr, err := checkNotEmptyStringWhat(headers, name, what) + if err != nil { + return nil, err + } + b, err := base64.RawURLEncoding.DecodeString(digestStr) + if err != nil { + return nil, fmt.Errorf("%q %s cannot be decoded: %v", name, what, err) + } + if len(b) != h.Size() { + return nil, fmt.Errorf("%q %s does not have the expected bit length: %d", name, what, len(b)*8) + } + + return b, nil +} + +// checkStringListInMap returns the `name` entry in the `m` map as a (possibly nil) `[]string` +// if `m` has an entry for `name` and it isn't a `[]string`, an error is returned +// if pattern is not nil, all the strings must match that pattern, otherwise an error is returned +// `what` is a descriptor, used for error messages +func checkStringListInMap(m map[string]interface{}, name, what string, pattern *regexp.Regexp) ([]string, error) { + value, ok := m[name] + if !ok { + return nil, nil + } + lst, ok := value.([]interface{}) + if !ok { + return nil, fmt.Errorf("%s must be a list of strings", what) + } + if len(lst) == 0 { + return nil, nil + } + res := make([]string, len(lst)) + for i, v := range lst { + s, ok := v.(string) + if !ok { + return nil, fmt.Errorf("%s must be a list of strings", what) + } + if pattern != nil && !pattern.MatchString(s) { + return nil, fmt.Errorf("%s contains an invalid element: %q", what, s) + } + res[i] = s + } + return res, nil +} + +func checkStringList(headers map[string]interface{}, name string) ([]string, error) { + return checkStringListMatches(headers, name, nil) +} + +func checkStringListMatches(headers map[string]interface{}, name string, pattern *regexp.Regexp) ([]string, error) { + return checkStringListInMap(headers, name, fmt.Sprintf("%q header", name), pattern) +} + +func checkStringMatches(headers map[string]interface{}, name string, pattern *regexp.Regexp) (string, error) { + return checkStringMatchesWhat(headers, name, "header", pattern) +} + +func checkStringMatchesWhat(headers map[string]interface{}, name, what string, pattern *regexp.Regexp) (string, error) { + s, err := checkNotEmptyStringWhat(headers, name, what) + if err != nil { + return "", err + } + if !pattern.MatchString(s) { + return "", fmt.Errorf("%q %s contains invalid characters: %q", name, what, s) + } + return s, nil +} + +func checkOptionalBool(headers map[string]interface{}, name string) (bool, error) { + return checkOptionalBoolWhat(headers, name, "header") +} + +func checkOptionalBoolWhat(headers map[string]interface{}, name, what string) (bool, error) { + value, ok := headers[name] + if !ok { + return false, nil + } + s, ok := value.(string) + if !ok || (s != "true" && s != "false") { + return false, fmt.Errorf("%q %s must be 'true' or 'false'", name, what) + } + return s == "true", nil +} + +func checkMap(headers map[string]interface{}, name string) (map[string]interface{}, error) { + return checkMapWhat(headers, name, "header") +} + +func checkMapWhat(m map[string]interface{}, name, what string) (map[string]interface{}, error) { + value, ok := m[name] + if !ok { + return nil, nil + } + mv, ok := value.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("%q %s must be a map", name, what) + } + return mv, nil +} diff --git a/asserts/headers.go b/asserts/headers.go new file mode 100644 index 00000000..5c4db865 --- /dev/null +++ b/asserts/headers.go @@ -0,0 +1,318 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2015 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts + +import ( + "bytes" + "fmt" + "regexp" + "sort" + "strings" + "unicode/utf8" +) + +var ( + nl = []byte("\n") + nlnl = []byte("\n\n") + + // for basic validity checking of header names + headerNameValidity = regexp.MustCompile("^[a-z](?:-?[a-z0-9])*$") +) + +func parseHeaders(head []byte) (map[string]interface{}, error) { + if !utf8.Valid(head) { + return nil, fmt.Errorf("header is not utf8") + } + headers := make(map[string]interface{}) + lines := strings.Split(string(head), "\n") + for i := 0; i < len(lines); { + entry := lines[i] + nameValueSplit := strings.Index(entry, ":") + if nameValueSplit == -1 { + return nil, fmt.Errorf("header entry missing ':' separator: %q", entry) + } + name := entry[:nameValueSplit] + if !headerNameValidity.MatchString(name) { + return nil, fmt.Errorf("invalid header name: %q", name) + } + + consumed := nameValueSplit + 1 + var value interface{} + var err error + value, i, err = parseEntry(consumed, i, lines, 0) + if err != nil { + return nil, err + } + + if _, ok := headers[name]; ok { + return nil, fmt.Errorf("repeated header: %q", name) + } + + headers[name] = value + } + return headers, nil +} + +const ( + commonPrefix = " " + multilinePrefix = " " + listChar = "-" + listPrefix = commonPrefix + listChar +) + +func nestingPrefix(baseIndent int, prefix string) string { + return strings.Repeat(" ", baseIndent) + prefix +} + +func parseEntry(consumedByIntro int, first int, lines []string, baseIndent int) (value interface{}, firstAfter int, err error) { + entry := lines[first] + i := first + 1 + if consumedByIntro == len(entry) { + // multiline values + basePrefix := nestingPrefix(baseIndent, commonPrefix) + if i < len(lines) && strings.HasPrefix(lines[i], basePrefix) { + rest := lines[i][len(basePrefix):] + if strings.HasPrefix(rest, listChar) { + // list + return parseList(i, lines, baseIndent) + } + if len(rest) > 0 && rest[0] != ' ' { + // map + return parseMap(i, lines, baseIndent) + } + } + + return parseMultilineText(i, lines, baseIndent) + } + + // simple one-line value + if entry[consumedByIntro] != ' ' { + return nil, -1, fmt.Errorf("header entry should have a space or newline (for multiline) before value: %q", entry) + } + + return entry[consumedByIntro+1:], i, nil +} + +func parseMultilineText(first int, lines []string, baseIndent int) (value interface{}, firstAfter int, err error) { + size := 0 + i := first + j := i + prefix := nestingPrefix(baseIndent, multilinePrefix) + for j < len(lines) { + iline := lines[j] + if !strings.HasPrefix(iline, prefix) { + break + } + size += len(iline) - len(prefix) + 1 + j++ + } + if j == i { + var cur string + if i == len(lines) { + cur = "EOF" + } else { + cur = fmt.Sprintf("%q", lines[i]) + } + return nil, -1, fmt.Errorf("expected %d chars nesting prefix after multiline introduction %q: %s", len(prefix), lines[i-1], cur) + } + + valueBuf := bytes.NewBuffer(make([]byte, 0, size-1)) + valueBuf.WriteString(lines[i][len(prefix):]) + i++ + for i < j { + valueBuf.WriteByte('\n') + valueBuf.WriteString(lines[i][len(prefix):]) + i++ + } + + return valueBuf.String(), i, nil +} + +func parseList(first int, lines []string, baseIndent int) (value interface{}, firstAfter int, err error) { + lst := []interface{}(nil) + j := first + prefix := nestingPrefix(baseIndent, listPrefix) + for j < len(lines) { + if !strings.HasPrefix(lines[j], prefix) { + return lst, j, nil + } + var v interface{} + var err error + v, j, err = parseEntry(len(prefix), j, lines, baseIndent+len(listPrefix)-1) + if err != nil { + return nil, -1, err + } + lst = append(lst, v) + } + return lst, j, nil +} + +func parseMap(first int, lines []string, baseIndent int) (value interface{}, firstAfter int, err error) { + m := make(map[string]interface{}) + j := first + prefix := nestingPrefix(baseIndent, commonPrefix) + for j < len(lines) { + if !strings.HasPrefix(lines[j], prefix) { + return m, j, nil + } + + entry := lines[j][len(prefix):] + keyValueSplit := strings.Index(entry, ":") + if keyValueSplit == -1 { + return nil, -1, fmt.Errorf("map entry missing ':' separator: %q", entry) + } + key := entry[:keyValueSplit] + if !headerNameValidity.MatchString(key) { + return nil, -1, fmt.Errorf("invalid map entry key: %q", key) + } + + consumed := keyValueSplit + 1 + var value interface{} + var err error + value, j, err = parseEntry(len(prefix)+consumed, j, lines, len(prefix)) + if err != nil { + return nil, -1, err + } + + if _, ok := m[key]; ok { + return nil, -1, fmt.Errorf("repeated map entry: %q", key) + } + + m[key] = value + } + return m, j, nil +} + +// checkHeader checks that the header values are strings, or nested lists or maps with strings as the only scalars +func checkHeader(v interface{}) error { + switch x := v.(type) { + case string: + return nil + case []interface{}: + for _, elem := range x { + err := checkHeader(elem) + if err != nil { + return err + } + } + return nil + case map[string]interface{}: + for _, elem := range x { + err := checkHeader(elem) + if err != nil { + return err + } + } + return nil + default: + return fmt.Errorf("header values must be strings or nested lists or maps with strings as the only scalars: %v", v) + } +} + +// checkHeaders checks that headers are of expected types +func checkHeaders(headers map[string]interface{}) error { + for name, value := range headers { + err := checkHeader(value) + if err != nil { + return fmt.Errorf("header %q: %v", name, err) + } + } + return nil +} + +// copyHeader helps deep copying header values to defend against external mutations +func copyHeader(v interface{}) interface{} { + switch x := v.(type) { + case string: + return x + case []interface{}: + res := make([]interface{}, len(x)) + for i, elem := range x { + res[i] = copyHeader(elem) + } + return res + case map[string]interface{}: + res := make(map[string]interface{}, len(x)) + for name, value := range x { + if value == nil { + continue // normalize nils out + } + res[name] = copyHeader(value) + } + return res + default: + panic(fmt.Sprintf("internal error: encountered unexpected value type copying headers: %v", v)) + } +} + +// copyHeader helps deep copying headers to defend against external mutations +func copyHeaders(headers map[string]interface{}) map[string]interface{} { + return copyHeader(headers).(map[string]interface{}) +} + +func appendEntry(buf *bytes.Buffer, intro string, v interface{}, baseIndent int) { + switch x := v.(type) { + case nil: + return // omit + case string: + buf.WriteByte('\n') + buf.WriteString(intro) + if strings.ContainsRune(x, '\n') { + // multiline value => quote by 4-space indenting + buf.WriteByte('\n') + pfx := nestingPrefix(baseIndent, multilinePrefix) + buf.WriteString(pfx) + x = strings.Replace(x, "\n", "\n"+pfx, -1) + } else { + buf.WriteByte(' ') + } + buf.WriteString(x) + case []interface{}: + if len(x) == 0 { + return // simply omit + } + buf.WriteByte('\n') + buf.WriteString(intro) + pfx := nestingPrefix(baseIndent, listPrefix) + for _, elem := range x { + appendEntry(buf, pfx, elem, baseIndent+len(listPrefix)-1) + } + case map[string]interface{}: + if len(x) == 0 { + return // simply omit + } + buf.WriteByte('\n') + buf.WriteString(intro) + // emit entries sorted by key + keys := make([]string, len(x)) + i := 0 + for key := range x { + keys[i] = key + i++ + } + sort.Strings(keys) + pfx := nestingPrefix(baseIndent, commonPrefix) + for _, key := range keys { + appendEntry(buf, pfx+key+":", x[key], len(pfx)) + } + default: + panic(fmt.Sprintf("internal error: encountered unexpected value type formatting headers: %v", v)) + } +} diff --git a/asserts/headers_test.go b/asserts/headers_test.go new file mode 100644 index 00000000..4907b9c8 --- /dev/null +++ b/asserts/headers_test.go @@ -0,0 +1,396 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2015 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts_test + +import ( + "bytes" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/asserts" +) + +type headersSuite struct{} + +var _ = Suite(&headersSuite{}) + +func (s *headersSuite) TestParseHeadersSimple(c *C) { + m, err := asserts.ParseHeaders([]byte(`foo: 1 +bar: baz`)) + c.Assert(err, IsNil) + c.Check(m, DeepEquals, map[string]interface{}{ + "foo": "1", + "bar": "baz", + }) +} + +func (s *headersSuite) TestParseHeadersMultiline(c *C) { + m, err := asserts.ParseHeaders([]byte(`foo: + abc + +bar: baz`)) + c.Assert(err, IsNil) + c.Check(m, DeepEquals, map[string]interface{}{ + "foo": "abc\n", + "bar": "baz", + }) + + m, err = asserts.ParseHeaders([]byte(`foo: 1 +bar: + baz`)) + c.Assert(err, IsNil) + c.Check(m, DeepEquals, map[string]interface{}{ + "foo": "1", + "bar": "baz", + }) + + m, err = asserts.ParseHeaders([]byte(`foo: 1 +bar: + baz + `)) + c.Assert(err, IsNil) + c.Check(m, DeepEquals, map[string]interface{}{ + "foo": "1", + "bar": "baz\n", + }) + + m, err = asserts.ParseHeaders([]byte(`foo: 1 +bar: + baz + + baz2`)) + c.Assert(err, IsNil) + c.Check(m, DeepEquals, map[string]interface{}{ + "foo": "1", + "bar": "baz\n\nbaz2", + }) +} + +func (s *headersSuite) TestParseHeadersSimpleList(c *C) { + m, err := asserts.ParseHeaders([]byte(`foo: + - x + - y + - z +bar: baz`)) + c.Assert(err, IsNil) + c.Check(m, DeepEquals, map[string]interface{}{ + "foo": []interface{}{"x", "y", "z"}, + "bar": "baz", + }) +} + +func (s *headersSuite) TestParseHeadersListNestedMultiline(c *C) { + m, err := asserts.ParseHeaders([]byte(`foo: + - x + - + y1 + y2 + + - z +bar: baz`)) + c.Assert(err, IsNil) + c.Check(m, DeepEquals, map[string]interface{}{ + "foo": []interface{}{"x", "y1\ny2\n", "z"}, + "bar": "baz", + }) + + m, err = asserts.ParseHeaders([]byte(`bar: baz +foo: + - + - u1 + - u2 + - + y1 + y2 + `)) + c.Assert(err, IsNil) + c.Check(m, DeepEquals, map[string]interface{}{ + "foo": []interface{}{[]interface{}{"u1", "u2"}, "y1\ny2\n"}, + "bar": "baz", + }) +} + +func (s *headersSuite) TestParseHeadersSimpleMap(c *C) { + m, err := asserts.ParseHeaders([]byte(`foo: + x: X + yy: YY + z5: +bar: baz`)) + c.Assert(err, IsNil) + c.Check(m, DeepEquals, map[string]interface{}{ + "foo": map[string]interface{}{ + "x": "X", + "yy": "YY", + "z5": "", + }, + "bar": "baz", + }) +} + +func (s *headersSuite) TestParseHeadersMapNestedMultiline(c *C) { + m, err := asserts.ParseHeaders([]byte(`foo: + x: X + yy: + YY1 + YY2 + u: + - u1 + - u2 +bar: baz`)) + c.Assert(err, IsNil) + c.Check(m, DeepEquals, map[string]interface{}{ + "foo": map[string]interface{}{ + "x": "X", + "yy": "YY1\nYY2", + "u": []interface{}{"u1", "u2"}, + }, + "bar": "baz", + }) + + m, err = asserts.ParseHeaders([]byte(`one: + two: + three: `)) + c.Assert(err, IsNil) + c.Check(m, DeepEquals, map[string]interface{}{ + "one": map[string]interface{}{ + "two": map[string]interface{}{ + "three": "", + }, + }, + }) + + m, err = asserts.ParseHeaders([]byte(`one: + two: + three`)) + c.Assert(err, IsNil) + c.Check(m, DeepEquals, map[string]interface{}{ + "one": map[string]interface{}{ + "two": "three", + }, + }) + + m, err = asserts.ParseHeaders([]byte(`map-within-map: + lev1: + lev2: x`)) + c.Assert(err, IsNil) + c.Check(m, DeepEquals, map[string]interface{}{ + "map-within-map": map[string]interface{}{ + "lev1": map[string]interface{}{ + "lev2": "x", + }, + }, + }) + + m, err = asserts.ParseHeaders([]byte(`list-of-maps: + - + entry: foo + bar: baz + - + entry: bar`)) + c.Assert(err, IsNil) + c.Check(m, DeepEquals, map[string]interface{}{ + "list-of-maps": []interface{}{ + map[string]interface{}{ + "entry": "foo", + "bar": "baz", + }, + map[string]interface{}{ + "entry": "bar", + }, + }, + }) +} + +func (s *headersSuite) TestParseHeadersMapErrors(c *C) { + _, err := asserts.ParseHeaders([]byte(`foo: + x X +bar: baz`)) + c.Check(err, ErrorMatches, `map entry missing ':' separator: "x X"`) + + _, err = asserts.ParseHeaders([]byte(`foo: + 0x: X +bar: baz`)) + c.Check(err, ErrorMatches, `invalid map entry key: "0x"`) + + _, err = asserts.ParseHeaders([]byte(`foo: + a: a + a: b`)) + c.Check(err, ErrorMatches, `repeated map entry: "a"`) +} + +func (s *headersSuite) TestParseHeadersErrors(c *C) { + _, err := asserts.ParseHeaders([]byte(`foo: 1 +bar:baz`)) + c.Check(err, ErrorMatches, `header entry should have a space or newline \(for multiline\) before value: "bar:baz"`) + + _, err = asserts.ParseHeaders([]byte(`foo: + - x + - y + - z +bar: baz`)) + c.Check(err, ErrorMatches, `expected 4 chars nesting prefix after multiline introduction "foo:": " - x"`) + + _, err = asserts.ParseHeaders([]byte(`foo: + - x + - y + - z +bar:`)) + c.Check(err, ErrorMatches, `expected 4 chars nesting prefix after multiline introduction "bar:": EOF`) +} + +func (s *headersSuite) TestAppendEntrySimple(c *C) { + buf := bytes.NewBufferString("start: .") + + asserts.AppendEntry(buf, "bar:", "baz", 0) + + m, err := asserts.ParseHeaders(buf.Bytes()) + c.Assert(err, IsNil) + c.Check(m, DeepEquals, map[string]interface{}{ + "start": ".", + "bar": "baz", + }) +} + +func (s *headersSuite) TestAppendEntryMultiline(c *C) { + multilines := []string{ + "a\n", + "a\nb", + "baz\n baz1\nbaz2", + "baz\n baz1\nbaz2\n", + "baz\n baz1\nbaz2\n\n", + } + + for _, multiline := range multilines { + buf := bytes.NewBufferString("start: .") + + asserts.AppendEntry(buf, "bar:", multiline, 0) + + m, err := asserts.ParseHeaders(buf.Bytes()) + c.Assert(err, IsNil) + c.Check(m, DeepEquals, map[string]interface{}{ + "start": ".", + "bar": multiline, + }) + } +} + +func (s *headersSuite) TestAppendEntrySimpleList(c *C) { + lst := []interface{}{"x", "y", "z"} + + buf := bytes.NewBufferString("start: .") + + asserts.AppendEntry(buf, "bar:", lst, 0) + + m, err := asserts.ParseHeaders(buf.Bytes()) + c.Assert(err, IsNil) + c.Check(m, DeepEquals, map[string]interface{}{ + "start": ".", + "bar": lst, + }) +} + +func (s *headersSuite) TestAppendEntryListNested(c *C) { + lst := []interface{}{"x", "a\nb\n", "", []interface{}{"u1", []interface{}{"w1", "w2"}}} + + buf := bytes.NewBufferString("start: .") + + asserts.AppendEntry(buf, "bar:", lst, 0) + + m, err := asserts.ParseHeaders(buf.Bytes()) + c.Assert(err, IsNil) + c.Check(m, DeepEquals, map[string]interface{}{ + "start": ".", + "bar": lst, + }) +} + +func (s *headersSuite) TestAppendEntrySimpleMap(c *C) { + mp := map[string]interface{}{ + "x": "X", + "yy": "YY", + "z5": "", + } + + buf := bytes.NewBufferString("start: .") + + asserts.AppendEntry(buf, "bar:", mp, 0) + + m, err := asserts.ParseHeaders(buf.Bytes()) + c.Assert(err, IsNil) + c.Check(m, DeepEquals, map[string]interface{}{ + "start": ".", + "bar": mp, + }) +} + +func (s *headersSuite) TestAppendEntryNestedMap(c *C) { + mp := map[string]interface{}{ + "x": "X", + "u": []interface{}{"u1", "u2"}, + "yy": "YY1\nYY2", + "m": map[string]interface{}{"a": "A", "b": map[string]interface{}{"x": "X", "y": "Y"}}, + } + + buf := bytes.NewBufferString("start: .") + + asserts.AppendEntry(buf, "bar:", mp, 0) + + m, err := asserts.ParseHeaders(buf.Bytes()) + c.Assert(err, IsNil) + c.Check(m, DeepEquals, map[string]interface{}{ + "start": ".", + "bar": mp, + }) +} + +func (s *headersSuite) TestAppendEntryOmitting(c *C) { + buf := bytes.NewBufferString("start: .") + + asserts.AppendEntry(buf, "bar:", []interface{}{}, 0) + + m, err := asserts.ParseHeaders(buf.Bytes()) + c.Assert(err, IsNil) + c.Check(m, DeepEquals, map[string]interface{}{ + "start": ".", + }) + + lst := []interface{}{nil, []interface{}{}, "z"} + + buf = bytes.NewBufferString("start: .") + + asserts.AppendEntry(buf, "bar:", lst, 0) + + m, err = asserts.ParseHeaders(buf.Bytes()) + c.Assert(err, IsNil) + c.Check(m, DeepEquals, map[string]interface{}{ + "start": ".", + "bar": []interface{}{"z"}, + }) + + buf = bytes.NewBufferString("start: .") + + asserts.AppendEntry(buf, "bar:", map[string]interface{}{}, 0) + + m, err = asserts.ParseHeaders(buf.Bytes()) + c.Assert(err, IsNil) + c.Check(m, DeepEquals, map[string]interface{}{ + "start": ".", + }) +} diff --git a/asserts/ifacedecls.go b/asserts/ifacedecls.go new file mode 100644 index 00000000..39c906d8 --- /dev/null +++ b/asserts/ifacedecls.go @@ -0,0 +1,1144 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2015-2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts + +import ( + "errors" + "fmt" + "regexp" + "strings" + "unicode" + + "github.com/snapcore/snapd/snap/naming" +) + +// AttrMatchContext has contextual helpers for evaluating attribute constraints. +type AttrMatchContext interface { + PlugAttr(arg string) (interface{}, error) + SlotAttr(arg string) (interface{}, error) +} + +const ( + // feature label for on-store/on-brand/on-model + deviceScopeConstraintsFeature = "device-scope-constraints" + // feature label for plug-names/slot-names constraints + nameConstraintsFeature = "name-constraints" +) + +// AttributeConstraints implements a set of constraints on the attributes of a slot or plug. +type AttributeConstraints struct { + matcher attrMatcher +} + +func (ac *AttributeConstraints) feature(flabel string) bool { + return ac.matcher.feature(flabel) +} + +// compileAttributeConstraints checks and compiles a mapping or list +// from the assertion format into AttributeConstraints. +func compileAttributeConstraints(constraints interface{}) (*AttributeConstraints, error) { + cc := compileContext{ + opts: &compileAttrMatcherOptions{ + allowedOperations: []string{"SLOT", "PLUG"}, + }, + } + matcher, err := compileAttrMatcher(cc, constraints) + if err != nil { + return nil, err + } + return &AttributeConstraints{matcher: matcher}, nil +} + +type fixedAttrMatcher struct { + result error +} + +func (matcher fixedAttrMatcher) feature(flabel string) bool { + return false +} + +func (matcher fixedAttrMatcher) match(apath string, v interface{}, ctx *attrMatchingContext) error { + return matcher.result +} + +var ( + AlwaysMatchAttributes = &AttributeConstraints{matcher: fixedAttrMatcher{nil}} + NeverMatchAttributes = &AttributeConstraints{matcher: fixedAttrMatcher{errors.New("not allowed")}} +) + +// Attrer reflects part of the Attrer interface (see interfaces.Attrer). +type Attrer interface { + Lookup(path string) (interface{}, bool) +} + +// Check checks whether attrs don't match the constraints. +func (c *AttributeConstraints) Check(attrer Attrer, helper AttrMatchContext) error { + return c.matcher.match("", attrer, &attrMatchingContext{ + attrWord: "attribute", + helper: helper, + }) +} + +// SideArityConstraint specifies a constraint for the overall arity of +// the set of connected slots for a given plug or the set of +// connected plugs for a given slot. +// It is used to express parsed slots-per-plug and plugs-per-slot +// constraints. +// See https://forum.snapcraft.io/t/plug-slot-declaration-rules-greedy-plugs/12438 +type SideArityConstraint struct { + // N can be: + // =>1 + // 0 means default and is used only internally during rule + // compilation or on deny- rules where these constraints are + // not applicable + // -1 represents *, that means any (number of) + N int +} + +// Any returns whether this represents the * (any number of) constraint. +func (ac SideArityConstraint) Any() bool { + return ac.N == -1 +} + +func compileSideArityConstraint(context *subruleContext, which string, v interface{}) (SideArityConstraint, error) { + var a SideArityConstraint + if context.installation() || !context.allow() { + return a, fmt.Errorf("%s cannot specify a %s constraint, they apply only to allow-*connection", context, which) + } + x, ok := v.(string) + if !ok || len(x) == 0 { + return a, fmt.Errorf("%s in %s must be an integer >=1 or *", which, context) + } + if x == "*" { + return SideArityConstraint{N: -1}, nil + } + n, err := atoi(x, "%s in %s", which, context) + switch _, syntax := err.(intSyntaxError); { + case err == nil && n < 1: + fallthrough + case syntax: + return a, fmt.Errorf("%s in %s must be an integer >=1 or *", which, context) + case err != nil: + return a, err + } + return SideArityConstraint{N: n}, nil +} + +type sideArityConstraintsHolder interface { + setSlotsPerPlug(SideArityConstraint) + setPlugsPerSlot(SideArityConstraint) + + slotsPerPlug() SideArityConstraint + plugsPerSlot() SideArityConstraint +} + +func normalizeSideArityConstraints(context *subruleContext, c sideArityConstraintsHolder) { + if !context.allow() { + return + } + any := SideArityConstraint{N: -1} + // normalized plugs-per-slot is always * + c.setPlugsPerSlot(any) + slotsPerPlug := c.slotsPerPlug() + if context.autoConnection() { + // auto-connection slots-per-plug can be any or 1 + if !slotsPerPlug.Any() { + c.setSlotsPerPlug(SideArityConstraint{N: 1}) + } + } else { + // connection slots-per-plug can be only any + c.setSlotsPerPlug(any) + } +} + +var ( + sideArityConstraints = []string{"slots-per-plug", "plugs-per-slot"} + sideArityConstraintsSetters = map[string]func(sideArityConstraintsHolder, SideArityConstraint){ + "slots-per-plug": sideArityConstraintsHolder.setSlotsPerPlug, + "plugs-per-slot": sideArityConstraintsHolder.setPlugsPerSlot, + } +) + +// OnClassicConstraint specifies a constraint based whether the system is classic and optional specific distros' sets. +type OnClassicConstraint struct { + Classic bool + SystemIDs []string +} + +type nameMatcher interface { + match(name string, special map[string]string) error +} + +var ( + // validates special name constraints like $INTERFACE + validSpecialNameConstraint = regexp.MustCompile(`^\$[A-Z][A-Z0-9_]*$`) +) + +func compileNameMatcher(whichName string, v interface{}) (nameMatcher, error) { + s, ok := v.(string) + if !ok { + return nil, fmt.Errorf("%s constraint entry must be a regexp or special $ value", whichName) + } + if strings.HasPrefix(s, "$") { + if !validSpecialNameConstraint.MatchString(s) { + return nil, fmt.Errorf("%s constraint entry special value %q is invalid", whichName, s) + } + return specialNameMatcher{special: s}, nil + } + if strings.IndexFunc(s, unicode.IsSpace) != -1 { + return nil, fmt.Errorf("%s constraint entry regexp contains unexpected spaces", whichName) + } + rx, err := regexp.Compile("^(" + s + ")$") + if err != nil { + return nil, fmt.Errorf("cannot compile %s constraint entry %q: %v", whichName, s, err) + } + return regexpNameMatcher{rx}, nil +} + +type regexpNameMatcher struct { + *regexp.Regexp +} + +func (matcher regexpNameMatcher) match(name string, special map[string]string) error { + if !matcher.Regexp.MatchString(name) { + return fmt.Errorf("%q does not match %v", name, matcher.Regexp) + } + return nil +} + +type specialNameMatcher struct { + special string +} + +func (matcher specialNameMatcher) match(name string, special map[string]string) error { + expected := special[matcher.special] + if expected == "" || expected != name { + return fmt.Errorf("%q does not match %v", name, matcher.special) + } + return nil +} + +// NameConstraints implements a set of constraints on the names of slots or plugs. +// See https://forum.snapcraft.io/t/plug-slot-rules-plug-names-slot-names-constraints/12439 +type NameConstraints struct { + matchers []nameMatcher +} + +func compileNameConstraints(whichName string, constraints interface{}) (*NameConstraints, error) { + l, ok := constraints.([]interface{}) + if !ok { + return nil, fmt.Errorf("%s constraints must be a list of regexps and special $ values", whichName) + } + matchers := make([]nameMatcher, 0, len(l)) + for _, nm := range l { + matcher, err := compileNameMatcher(whichName, nm) + if err != nil { + return nil, err + } + matchers = append(matchers, matcher) + } + return &NameConstraints{matchers: matchers}, nil +} + +// Check checks whether name doesn't match the constraints. +func (nc *NameConstraints) Check(whichName, name string, special map[string]string) error { + for _, m := range nc.matchers { + if err := m.match(name, special); err == nil { + return nil + } + } + return fmt.Errorf("%s %q does not match constraints", whichName, name) +} + +// rules + +var ( + validSnapType = regexp.MustCompile(`^(?:core|kernel|gadget|app)$`) + validDistro = regexp.MustCompile(`^[-0-9a-z._]+$`) + validPublisher = regexp.MustCompile(`^(?:[a-z0-9A-Z]{32}|[-a-z0-9]{2,28}|\$[A-Z][A-Z0-9_]*)$`) // account ids look like snap-ids or are nice identifiers, support our own special markers $MARKER + + validIDConstraints = map[string]*regexp.Regexp{ + "slot-snap-type": validSnapType, + "slot-snap-id": naming.ValidSnapID, + "slot-publisher-id": validPublisher, + "plug-snap-type": validSnapType, + "plug-snap-id": naming.ValidSnapID, + "plug-publisher-id": validPublisher, + } +) + +func checkMapOrShortcut(v interface{}) (m map[string]interface{}, invert bool, err error) { + switch x := v.(type) { + case map[string]interface{}: + return x, false, nil + case string: + switch x { + case "true": + return nil, false, nil + case "false": + return nil, true, nil + } + } + return nil, false, errors.New("unexpected type") +} + +type constraintsHolder interface { + setNameConstraints(field string, cstrs *NameConstraints) + setAttributeConstraints(field string, cstrs *AttributeConstraints) + setIDConstraints(field string, cstrs []string) + setOnClassicConstraint(onClassic *OnClassicConstraint) + setDeviceScopeConstraint(deviceScope *DeviceScopeConstraint) +} + +func baseCompileConstraints(context *subruleContext, cDef constraintsDef, target constraintsHolder, nameConstraints, attrConstraints, idConstraints []string) error { + cMap := cDef.cMap + if cMap == nil { + fixed := AlwaysMatchAttributes // "true" + if cDef.invert { // "false" + fixed = NeverMatchAttributes + } + for _, field := range attrConstraints { + target.setAttributeConstraints(field, fixed) + } + return nil + } + defaultUsed := 0 + for _, field := range nameConstraints { + v := cMap[field] + if v != nil { + nc, err := compileNameConstraints(field, v) + if err != nil { + return err + } + target.setNameConstraints(field, nc) + } else { + defaultUsed++ + } + } + for _, field := range attrConstraints { + cstrs := AlwaysMatchAttributes + v := cMap[field] + if v != nil { + var err error + cstrs, err = compileAttributeConstraints(cMap[field]) + if err != nil { + return fmt.Errorf("cannot compile %s in %s: %v", field, context, err) + } + } else { + defaultUsed++ + } + target.setAttributeConstraints(field, cstrs) + } + for _, field := range idConstraints { + lst, err := checkStringListInMap(cMap, field, fmt.Sprintf("%s in %s", field, context), validIDConstraints[field]) + if err != nil { + return err + } + if lst == nil { + defaultUsed++ + } + target.setIDConstraints(field, lst) + } + for _, field := range sideArityConstraints { + v := cMap[field] + if v != nil { + c, err := compileSideArityConstraint(context, field, v) + if err != nil { + return err + } + h, ok := target.(sideArityConstraintsHolder) + if !ok { + return fmt.Errorf("internal error: side arity constraint compiled for unexpected subrule %T", target) + } + sideArityConstraintsSetters[field](h, c) + } else { + defaultUsed++ + } + } + onClassic := cMap["on-classic"] + if onClassic == nil { + defaultUsed++ + } else { + var c *OnClassicConstraint + switch x := onClassic.(type) { + case string: + switch x { + case "true": + c = &OnClassicConstraint{Classic: true} + case "false": + c = &OnClassicConstraint{Classic: false} + } + case []interface{}: + lst, err := checkStringListInMap(cMap, "on-classic", fmt.Sprintf("on-classic in %s", context), validDistro) + if err != nil { + return err + } + c = &OnClassicConstraint{Classic: true, SystemIDs: lst} + } + if c == nil { + return fmt.Errorf("on-classic in %s must be 'true', 'false' or a list of operating system IDs", context) + } + target.setOnClassicConstraint(c) + } + dsc, err := compileDeviceScopeConstraint(cMap, context.String()) + if err != nil { + return err + } + if dsc == nil { + defaultUsed++ + } else { + target.setDeviceScopeConstraint(dsc) + } + // checks whether defaults have been used for everything, which is not + // well-formed + // +1+1 accounts for defaults for missing on-classic plus missing + // on-store/on-brand/on-model + if defaultUsed == len(nameConstraints)+len(attributeConstraints)+len(idConstraints)+len(sideArityConstraints)+1+1 { + return fmt.Errorf("%s must specify at least one of %s, %s, %s, %s, on-classic, on-store, on-brand, on-model", context, strings.Join(nameConstraints, ", "), strings.Join(attrConstraints, ", "), strings.Join(idConstraints, ", "), strings.Join(sideArityConstraints, ", ")) + } + return nil +} + +type rule interface { + setConstraints(field string, cstrs []constraintsHolder) +} + +type constraintsDef struct { + cMap map[string]interface{} + invert bool +} + +// subruleContext carries queryable context information about one the +// {allow,deny}-* subrules that end up compiled as +// Plug|Slot*Constraints. The information includes the parent rule, +// the introductory subrule key ({allow,deny}-*) and which alternative +// it corresponds to if any. +// The information is useful for constraints compilation now that we +// have constraints with different behavior depending on the kind of +// subrule that hosts them (e.g. slots-per-plug, plugs-per-slot). +type subruleContext struct { + // rule is the parent rule context description + rule string + // subrule is the subrule key + subrule string + // alt is which alternative this is (if > 0) + alt int +} + +func (c *subruleContext) String() string { + subctxt := fmt.Sprintf("%s in %s", c.subrule, c.rule) + if c.alt != 0 { + subctxt = fmt.Sprintf("alternative %d of %s", c.alt, subctxt) + } + return subctxt +} + +// allow returns whether the subrule is an allow-* subrule. +func (c *subruleContext) allow() bool { + return strings.HasPrefix(c.subrule, "allow-") +} + +// installation returns whether the subrule is an *-installation subrule. +func (c *subruleContext) installation() bool { + return strings.HasSuffix(c.subrule, "-installation") +} + +// autoConnection returns whether the subrule is an *-auto-connection subrule. +func (c *subruleContext) autoConnection() bool { + return strings.HasSuffix(c.subrule, "-auto-connection") +} + +type subruleCompiler func(context *subruleContext, def constraintsDef) (constraintsHolder, error) + +func baseCompileRule(context string, rule interface{}, target rule, subrules []string, compilers map[string]subruleCompiler, defaultOutcome, invertedOutcome map[string]interface{}) error { + rMap, invert, err := checkMapOrShortcut(rule) + if err != nil { + return fmt.Errorf("%s must be a map or one of the shortcuts 'true' or 'false'", context) + } + if rMap == nil { + rMap = defaultOutcome // "true" + if invert { + rMap = invertedOutcome // "false" + } + } + defaultUsed := 0 + // compile and set subrules + for _, subrule := range subrules { + v := rMap[subrule] + var lst []interface{} + alternatives := false + switch x := v.(type) { + case nil: + v = defaultOutcome[subrule] + defaultUsed++ + case []interface{}: + alternatives = true + lst = x + } + if lst == nil { // v is map or a string, checked below + lst = []interface{}{v} + } + compiler := compilers[subrule] + if compiler == nil { + panic(fmt.Sprintf("no compiler for %s in %s", subrule, context)) + } + alts := make([]constraintsHolder, len(lst)) + for i, alt := range lst { + subctxt := &subruleContext{ + rule: context, + subrule: subrule, + } + if alternatives { + subctxt.alt = i + 1 + } + cMap, invert, err := checkMapOrShortcut(alt) + if err != nil || (cMap == nil && alternatives) { + efmt := "%s must be a map" + if !alternatives { + efmt = "%s must be a map or one of the shortcuts 'true' or 'false'" + } + return fmt.Errorf(efmt, subctxt) + } + + cstrs, err := compiler(subctxt, constraintsDef{ + cMap: cMap, + invert: invert, + }) + if err != nil { + return err + } + alts[i] = cstrs + } + target.setConstraints(subrule, alts) + } + if defaultUsed == len(subrules) { + return fmt.Errorf("%s must specify at least one of %s", context, strings.Join(subrules, ", ")) + } + return nil +} + +// PlugRule holds the rule of what is allowed, wrt installation and +// connection, for a plug of a specific interface for a snap. +type PlugRule struct { + Interface string + + AllowInstallation []*PlugInstallationConstraints + DenyInstallation []*PlugInstallationConstraints + + AllowConnection []*PlugConnectionConstraints + DenyConnection []*PlugConnectionConstraints + + AllowAutoConnection []*PlugConnectionConstraints + DenyAutoConnection []*PlugConnectionConstraints +} + +func (r *PlugRule) feature(flabel string) bool { + for _, cs := range [][]*PlugInstallationConstraints{r.AllowInstallation, r.DenyInstallation} { + for _, c := range cs { + if c.feature(flabel) { + return true + } + } + } + + for _, cs := range [][]*PlugConnectionConstraints{r.AllowConnection, r.DenyConnection, r.AllowAutoConnection, r.DenyAutoConnection} { + for _, c := range cs { + if c.feature(flabel) { + return true + } + } + } + + return false +} + +func castPlugInstallationConstraints(cstrs []constraintsHolder) (res []*PlugInstallationConstraints) { + res = make([]*PlugInstallationConstraints, len(cstrs)) + for i, cstr := range cstrs { + res[i] = cstr.(*PlugInstallationConstraints) + } + return res +} + +func castPlugConnectionConstraints(cstrs []constraintsHolder) (res []*PlugConnectionConstraints) { + res = make([]*PlugConnectionConstraints, len(cstrs)) + for i, cstr := range cstrs { + res[i] = cstr.(*PlugConnectionConstraints) + } + return res +} + +func (r *PlugRule) setConstraints(field string, cstrs []constraintsHolder) { + if len(cstrs) == 0 { + panic(fmt.Sprintf("cannot set PlugRule field %q to empty", field)) + } + switch cstrs[0].(type) { + case *PlugInstallationConstraints: + switch field { + case "allow-installation": + r.AllowInstallation = castPlugInstallationConstraints(cstrs) + return + case "deny-installation": + r.DenyInstallation = castPlugInstallationConstraints(cstrs) + return + } + case *PlugConnectionConstraints: + switch field { + case "allow-connection": + r.AllowConnection = castPlugConnectionConstraints(cstrs) + return + case "deny-connection": + r.DenyConnection = castPlugConnectionConstraints(cstrs) + return + case "allow-auto-connection": + r.AllowAutoConnection = castPlugConnectionConstraints(cstrs) + return + case "deny-auto-connection": + r.DenyAutoConnection = castPlugConnectionConstraints(cstrs) + return + } + } + panic(fmt.Sprintf("cannot set PlugRule field %q with %T elements", field, cstrs[0])) +} + +// PlugInstallationConstraints specifies a set of constraints on an interface plug relevant to the installation of snap. +type PlugInstallationConstraints struct { + PlugSnapTypes []string + PlugSnapIDs []string + + PlugNames *NameConstraints + + PlugAttributes *AttributeConstraints + + OnClassic *OnClassicConstraint + + DeviceScope *DeviceScopeConstraint +} + +func (c *PlugInstallationConstraints) feature(flabel string) bool { + if flabel == deviceScopeConstraintsFeature { + return c.DeviceScope != nil + } + if flabel == nameConstraintsFeature { + return c.PlugNames != nil + } + return c.PlugAttributes.feature(flabel) +} + +func (c *PlugInstallationConstraints) setNameConstraints(field string, cstrs *NameConstraints) { + switch field { + case "plug-names": + c.PlugNames = cstrs + default: + panic("unknown PlugInstallationConstraints field " + field) + } +} + +func (c *PlugInstallationConstraints) setAttributeConstraints(field string, cstrs *AttributeConstraints) { + switch field { + case "plug-attributes": + c.PlugAttributes = cstrs + default: + panic("unknown PlugInstallationConstraints field " + field) + } +} + +func (c *PlugInstallationConstraints) setIDConstraints(field string, cstrs []string) { + switch field { + case "plug-snap-type": + c.PlugSnapTypes = cstrs + case "plug-snap-id": + c.PlugSnapIDs = cstrs + default: + panic("unknown PlugInstallationConstraints field " + field) + } +} + +func (c *PlugInstallationConstraints) setOnClassicConstraint(onClassic *OnClassicConstraint) { + c.OnClassic = onClassic +} + +func (c *PlugInstallationConstraints) setDeviceScopeConstraint(deviceScope *DeviceScopeConstraint) { + c.DeviceScope = deviceScope +} + +func compilePlugInstallationConstraints(context *subruleContext, cDef constraintsDef) (constraintsHolder, error) { + plugInstCstrs := &PlugInstallationConstraints{} + // plug-snap-id is supported here mainly for symmetry with the slot case + // see discussion there + err := baseCompileConstraints(context, cDef, plugInstCstrs, []string{"plug-names"}, []string{"plug-attributes"}, []string{"plug-snap-type", "plug-snap-id"}) + if err != nil { + return nil, err + } + return plugInstCstrs, nil +} + +// PlugConnectionConstraints specfies a set of constraints on an +// interface plug for a snap relevant to its connection or +// auto-connection. +type PlugConnectionConstraints struct { + SlotSnapTypes []string + SlotSnapIDs []string + SlotPublisherIDs []string + + PlugNames *NameConstraints + SlotNames *NameConstraints + + PlugAttributes *AttributeConstraints + SlotAttributes *AttributeConstraints + + // SlotsPerPlug defaults to 1 for auto-connection, can be * (any) + SlotsPerPlug SideArityConstraint + // PlugsPerSlot is always * (any) (for now) + PlugsPerSlot SideArityConstraint + + OnClassic *OnClassicConstraint + + DeviceScope *DeviceScopeConstraint +} + +func (c *PlugConnectionConstraints) feature(flabel string) bool { + if flabel == deviceScopeConstraintsFeature { + return c.DeviceScope != nil + } + if flabel == nameConstraintsFeature { + return c.PlugNames != nil || c.SlotNames != nil + } + return c.PlugAttributes.feature(flabel) || c.SlotAttributes.feature(flabel) +} + +func (c *PlugConnectionConstraints) setNameConstraints(field string, cstrs *NameConstraints) { + switch field { + case "plug-names": + c.PlugNames = cstrs + case "slot-names": + c.SlotNames = cstrs + default: + panic("unknown PlugConnectionConstraints field " + field) + } +} + +func (c *PlugConnectionConstraints) setAttributeConstraints(field string, cstrs *AttributeConstraints) { + switch field { + case "plug-attributes": + c.PlugAttributes = cstrs + case "slot-attributes": + c.SlotAttributes = cstrs + default: + panic("unknown PlugConnectionConstraints field " + field) + } +} + +func (c *PlugConnectionConstraints) setIDConstraints(field string, cstrs []string) { + switch field { + case "slot-snap-type": + c.SlotSnapTypes = cstrs + case "slot-snap-id": + c.SlotSnapIDs = cstrs + case "slot-publisher-id": + c.SlotPublisherIDs = cstrs + default: + panic("unknown PlugConnectionConstraints field " + field) + } +} + +func (c *PlugConnectionConstraints) setSlotsPerPlug(a SideArityConstraint) { + c.SlotsPerPlug = a +} + +func (c *PlugConnectionConstraints) setPlugsPerSlot(a SideArityConstraint) { + c.PlugsPerSlot = a +} + +func (c *PlugConnectionConstraints) slotsPerPlug() SideArityConstraint { + return c.SlotsPerPlug +} + +func (c *PlugConnectionConstraints) plugsPerSlot() SideArityConstraint { + return c.PlugsPerSlot +} + +func (c *PlugConnectionConstraints) setOnClassicConstraint(onClassic *OnClassicConstraint) { + c.OnClassic = onClassic +} + +func (c *PlugConnectionConstraints) setDeviceScopeConstraint(deviceScope *DeviceScopeConstraint) { + c.DeviceScope = deviceScope +} + +var ( + nameConstraints = []string{"plug-names", "slot-names"} + attributeConstraints = []string{"plug-attributes", "slot-attributes"} + plugIDConstraints = []string{"slot-snap-type", "slot-publisher-id", "slot-snap-id"} +) + +func compilePlugConnectionConstraints(context *subruleContext, cDef constraintsDef) (constraintsHolder, error) { + plugConnCstrs := &PlugConnectionConstraints{} + err := baseCompileConstraints(context, cDef, plugConnCstrs, nameConstraints, attributeConstraints, plugIDConstraints) + if err != nil { + return nil, err + } + normalizeSideArityConstraints(context, plugConnCstrs) + return plugConnCstrs, nil +} + +var ( + defaultOutcome = map[string]interface{}{ + "allow-installation": "true", + "allow-connection": "true", + "allow-auto-connection": "true", + "deny-installation": "false", + "deny-connection": "false", + "deny-auto-connection": "false", + } + + invertedOutcome = map[string]interface{}{ + "allow-installation": "false", + "allow-connection": "false", + "allow-auto-connection": "false", + "deny-installation": "true", + "deny-connection": "true", + "deny-auto-connection": "true", + } + + ruleSubrules = []string{"allow-installation", "deny-installation", "allow-connection", "deny-connection", "allow-auto-connection", "deny-auto-connection"} +) + +var plugRuleCompilers = map[string]subruleCompiler{ + "allow-installation": compilePlugInstallationConstraints, + "deny-installation": compilePlugInstallationConstraints, + "allow-connection": compilePlugConnectionConstraints, + "deny-connection": compilePlugConnectionConstraints, + "allow-auto-connection": compilePlugConnectionConstraints, + "deny-auto-connection": compilePlugConnectionConstraints, +} + +func compilePlugRule(interfaceName string, rule interface{}) (*PlugRule, error) { + context := fmt.Sprintf("plug rule for interface %q", interfaceName) + plugRule := &PlugRule{ + Interface: interfaceName, + } + err := baseCompileRule(context, rule, plugRule, ruleSubrules, plugRuleCompilers, defaultOutcome, invertedOutcome) + if err != nil { + return nil, err + } + return plugRule, nil +} + +// SlotRule holds the rule of what is allowed, wrt installation and +// connection, for a slot of a specific interface for a snap. +type SlotRule struct { + Interface string + + AllowInstallation []*SlotInstallationConstraints + DenyInstallation []*SlotInstallationConstraints + + AllowConnection []*SlotConnectionConstraints + DenyConnection []*SlotConnectionConstraints + + AllowAutoConnection []*SlotConnectionConstraints + DenyAutoConnection []*SlotConnectionConstraints +} + +func castSlotInstallationConstraints(cstrs []constraintsHolder) (res []*SlotInstallationConstraints) { + res = make([]*SlotInstallationConstraints, len(cstrs)) + for i, cstr := range cstrs { + res[i] = cstr.(*SlotInstallationConstraints) + } + return res +} + +func (r *SlotRule) feature(flabel string) bool { + for _, cs := range [][]*SlotInstallationConstraints{r.AllowInstallation, r.DenyInstallation} { + for _, c := range cs { + if c.feature(flabel) { + return true + } + } + } + + for _, cs := range [][]*SlotConnectionConstraints{r.AllowConnection, r.DenyConnection, r.AllowAutoConnection, r.DenyAutoConnection} { + for _, c := range cs { + if c.feature(flabel) { + return true + } + } + } + + return false +} + +func castSlotConnectionConstraints(cstrs []constraintsHolder) (res []*SlotConnectionConstraints) { + res = make([]*SlotConnectionConstraints, len(cstrs)) + for i, cstr := range cstrs { + res[i] = cstr.(*SlotConnectionConstraints) + } + return res +} + +func (r *SlotRule) setConstraints(field string, cstrs []constraintsHolder) { + if len(cstrs) == 0 { + panic(fmt.Sprintf("cannot set SlotRule field %q to empty", field)) + } + switch cstrs[0].(type) { + case *SlotInstallationConstraints: + switch field { + case "allow-installation": + r.AllowInstallation = castSlotInstallationConstraints(cstrs) + return + case "deny-installation": + r.DenyInstallation = castSlotInstallationConstraints(cstrs) + return + } + case *SlotConnectionConstraints: + switch field { + case "allow-connection": + r.AllowConnection = castSlotConnectionConstraints(cstrs) + return + case "deny-connection": + r.DenyConnection = castSlotConnectionConstraints(cstrs) + return + case "allow-auto-connection": + r.AllowAutoConnection = castSlotConnectionConstraints(cstrs) + return + case "deny-auto-connection": + r.DenyAutoConnection = castSlotConnectionConstraints(cstrs) + return + } + } + panic(fmt.Sprintf("cannot set SlotRule field %q with %T elements", field, cstrs[0])) +} + +// SlotInstallationConstraints specifies a set of constraints on an +// interface slot relevant to the installation of snap. +type SlotInstallationConstraints struct { + SlotSnapTypes []string + SlotSnapIDs []string + + SlotNames *NameConstraints + + SlotAttributes *AttributeConstraints + + OnClassic *OnClassicConstraint + + DeviceScope *DeviceScopeConstraint +} + +func (c *SlotInstallationConstraints) feature(flabel string) bool { + if flabel == deviceScopeConstraintsFeature { + return c.DeviceScope != nil + } + if flabel == nameConstraintsFeature { + return c.SlotNames != nil + } + return c.SlotAttributes.feature(flabel) +} + +func (c *SlotInstallationConstraints) setNameConstraints(field string, cstrs *NameConstraints) { + switch field { + case "slot-names": + c.SlotNames = cstrs + default: + panic("unknown SlotInstallationConstraints field " + field) + } +} + +func (c *SlotInstallationConstraints) setAttributeConstraints(field string, cstrs *AttributeConstraints) { + switch field { + case "slot-attributes": + c.SlotAttributes = cstrs + default: + panic("unknown SlotInstallationConstraints field " + field) + } +} + +func (c *SlotInstallationConstraints) setIDConstraints(field string, cstrs []string) { + switch field { + case "slot-snap-type": + c.SlotSnapTypes = cstrs + case "slot-snap-id": + c.SlotSnapIDs = cstrs + default: + panic("unknown SlotInstallationConstraints field " + field) + } +} + +func (c *SlotInstallationConstraints) setOnClassicConstraint(onClassic *OnClassicConstraint) { + c.OnClassic = onClassic +} + +func (c *SlotInstallationConstraints) setDeviceScopeConstraint(deviceScope *DeviceScopeConstraint) { + c.DeviceScope = deviceScope +} + +func compileSlotInstallationConstraints(context *subruleContext, cDef constraintsDef) (constraintsHolder, error) { + slotInstCstrs := &SlotInstallationConstraints{} + // slot-snap-id here is mostly useful to restrict a relaxed + // base-declaration slot-snap-type constraint because the latter is used + // also for --dangerous installations. So in rare complex situations + // slot-snap-type might constraint to core and app + // but the intention is really that only system snaps should have the + // slot without a snap-declaration rule, slot-snap-id then can + // be used to limit to the known system snap snap-ids. + // This means we want app-slots to be super-privileged but we have + // slots for the interface on the system snaps as well. + // An example of this is shared-memory. + err := baseCompileConstraints(context, cDef, slotInstCstrs, []string{"slot-names"}, []string{"slot-attributes"}, []string{"slot-snap-type", "slot-snap-id"}) + if err != nil { + return nil, err + } + return slotInstCstrs, nil +} + +// SlotConnectionConstraints specfies a set of constraints on an +// interface slot for a snap relevant to its connection or +// auto-connection. +type SlotConnectionConstraints struct { + // SlotSnapTypes constraints on the slot side for connections + // are only useful in the base-declaration, + // as the snap-declaration is for one given snap with its type. + // So there is no (new) format iteration to cover this. + SlotSnapTypes []string + + PlugSnapTypes []string + PlugSnapIDs []string + PlugPublisherIDs []string + + SlotNames *NameConstraints + PlugNames *NameConstraints + + SlotAttributes *AttributeConstraints + PlugAttributes *AttributeConstraints + + // SlotsPerPlug defaults to 1 for auto-connection, can be * (any) + SlotsPerPlug SideArityConstraint + // PlugsPerSlot is always * (any) (for now) + PlugsPerSlot SideArityConstraint + + OnClassic *OnClassicConstraint + + DeviceScope *DeviceScopeConstraint +} + +func (c *SlotConnectionConstraints) feature(flabel string) bool { + if flabel == deviceScopeConstraintsFeature { + return c.DeviceScope != nil + } + if flabel == nameConstraintsFeature { + return c.PlugNames != nil || c.SlotNames != nil + } + return c.PlugAttributes.feature(flabel) || c.SlotAttributes.feature(flabel) +} + +func (c *SlotConnectionConstraints) setNameConstraints(field string, cstrs *NameConstraints) { + switch field { + case "plug-names": + c.PlugNames = cstrs + case "slot-names": + c.SlotNames = cstrs + default: + panic("unknown SlotConnectionConstraints field " + field) + } +} + +func (c *SlotConnectionConstraints) setAttributeConstraints(field string, cstrs *AttributeConstraints) { + switch field { + case "plug-attributes": + c.PlugAttributes = cstrs + case "slot-attributes": + c.SlotAttributes = cstrs + default: + panic("unknown SlotConnectionConstraints field " + field) + } +} + +func (c *SlotConnectionConstraints) setIDConstraints(field string, cstrs []string) { + switch field { + case "slot-snap-type": + c.SlotSnapTypes = cstrs + case "plug-snap-type": + c.PlugSnapTypes = cstrs + case "plug-snap-id": + c.PlugSnapIDs = cstrs + case "plug-publisher-id": + c.PlugPublisherIDs = cstrs + default: + panic("unknown SlotConnectionConstraints field " + field) + } +} + +var ( + slotIDConstraints = []string{"slot-snap-type", "plug-snap-type", "plug-publisher-id", "plug-snap-id"} +) + +func (c *SlotConnectionConstraints) setSlotsPerPlug(a SideArityConstraint) { + c.SlotsPerPlug = a +} + +func (c *SlotConnectionConstraints) setPlugsPerSlot(a SideArityConstraint) { + c.PlugsPerSlot = a +} + +func (c *SlotConnectionConstraints) slotsPerPlug() SideArityConstraint { + return c.SlotsPerPlug +} + +func (c *SlotConnectionConstraints) plugsPerSlot() SideArityConstraint { + return c.PlugsPerSlot +} + +func (c *SlotConnectionConstraints) setOnClassicConstraint(onClassic *OnClassicConstraint) { + c.OnClassic = onClassic +} + +func (c *SlotConnectionConstraints) setDeviceScopeConstraint(deviceScope *DeviceScopeConstraint) { + c.DeviceScope = deviceScope +} + +func compileSlotConnectionConstraints(context *subruleContext, cDef constraintsDef) (constraintsHolder, error) { + slotConnCstrs := &SlotConnectionConstraints{} + err := baseCompileConstraints(context, cDef, slotConnCstrs, nameConstraints, attributeConstraints, slotIDConstraints) + if err != nil { + return nil, err + } + normalizeSideArityConstraints(context, slotConnCstrs) + return slotConnCstrs, nil +} + +var slotRuleCompilers = map[string]subruleCompiler{ + "allow-installation": compileSlotInstallationConstraints, + "deny-installation": compileSlotInstallationConstraints, + "allow-connection": compileSlotConnectionConstraints, + "deny-connection": compileSlotConnectionConstraints, + "allow-auto-connection": compileSlotConnectionConstraints, + "deny-auto-connection": compileSlotConnectionConstraints, +} + +func compileSlotRule(interfaceName string, rule interface{}) (*SlotRule, error) { + context := fmt.Sprintf("slot rule for interface %q", interfaceName) + slotRule := &SlotRule{ + Interface: interfaceName, + } + err := baseCompileRule(context, rule, slotRule, ruleSubrules, slotRuleCompilers, defaultOutcome, invertedOutcome) + if err != nil { + return nil, err + } + return slotRule, nil +} diff --git a/asserts/ifacedecls_test.go b/asserts/ifacedecls_test.go new file mode 100644 index 00000000..e68ce36d --- /dev/null +++ b/asserts/ifacedecls_test.go @@ -0,0 +1,2204 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2015-2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts_test + +import ( + "fmt" + "regexp" + "strings" + + . "gopkg.in/check.v1" + "gopkg.in/yaml.v2" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/testutil" +) + +var ( + _ = Suite(&attrConstraintsSuite{}) + _ = Suite(&nameConstraintsSuite{}) + _ = Suite(&plugSlotRulesSuite{}) +) + +type attrConstraintsSuite struct { + testutil.BaseTest +} + +type attrerObject map[string]interface{} + +func (o attrerObject) Lookup(path string) (interface{}, bool) { + v, ok := o[path] + return v, ok +} + +func attrs(yml string) *attrerObject { + var attrs map[string]interface{} + err := yaml.Unmarshal([]byte(yml), &attrs) + if err != nil { + panic(err) + } + snapYaml, err := yaml.Marshal(map[string]interface{}{ + "name": "sample", + "plugs": map[string]interface{}{ + "plug": attrs, + }, + }) + if err != nil { + panic(err) + } + + // NOTE: it's important to go through snap yaml here even though we're really interested in Attrs only, + // as InfoFromSnapYaml normalizes yaml values. + info, err := snap.InfoFromSnapYaml(snapYaml) + if err != nil { + panic(err) + } + + ao := attrerObject(info.Plugs["plug"].Attrs) + return &ao +} + +func (s *attrConstraintsSuite) SetUpTest(c *C) { + s.BaseTest.SetUpTest(c) + s.BaseTest.AddCleanup(snap.MockSanitizePlugsSlots(func(snapInfo *snap.Info) {})) +} + +func (s *attrConstraintsSuite) TearDownTest(c *C) { + s.BaseTest.TearDownTest(c) +} + +func (s *attrConstraintsSuite) TestSimple(c *C) { + m, err := asserts.ParseHeaders([]byte(`attrs: + foo: FOO + bar: BAR`)) + c.Assert(err, IsNil) + + cstrs, err := asserts.CompileAttributeConstraints(m["attrs"].(map[string]interface{})) + c.Assert(err, IsNil) + + plug := attrerObject(map[string]interface{}{ + "foo": "FOO", + "bar": "BAR", + "baz": "BAZ", + }) + err = cstrs.Check(plug, nil) + c.Check(err, IsNil) + + plug = attrerObject(map[string]interface{}{ + "foo": "FOO", + "bar": "BAZ", + "baz": "BAZ", + }) + err = cstrs.Check(plug, nil) + c.Check(err, ErrorMatches, `attribute "bar" value "BAZ" does not match \^\(BAR\)\$`) + + plug = attrerObject(map[string]interface{}{ + "foo": "FOO", + "baz": "BAZ", + }) + err = cstrs.Check(plug, nil) + c.Check(err, ErrorMatches, `attribute "bar" has constraints but is unset`) +} + +func (s *attrConstraintsSuite) TestMissingCheck(c *C) { + m, err := asserts.ParseHeaders([]byte(`attrs: + foo: $MISSING`)) + c.Assert(err, IsNil) + + cstrs, err := asserts.CompileAttributeConstraints(m["attrs"].(map[string]interface{})) + c.Assert(err, IsNil) + c.Check(asserts.RuleFeature(cstrs, "dollar-attr-constraints"), Equals, true) +} + +func (s *attrConstraintsSuite) TestNested(c *C) { + m, err := asserts.ParseHeaders([]byte(`attrs: + foo: FOO + bar: + bar1: BAR1 + bar2: BAR2`)) + c.Assert(err, IsNil) + + cstrs, err := asserts.CompileAttributeConstraints(m["attrs"].(map[string]interface{})) + c.Assert(err, IsNil) + + err = cstrs.Check(attrs(` +foo: FOO +bar: + bar1: BAR1 + bar2: BAR2 + bar3: BAR3 +baz: BAZ +`), nil) + c.Check(err, IsNil) + + err = cstrs.Check(attrs(` +foo: FOO +bar: BAZ +baz: BAZ +`), nil) + c.Check(err, ErrorMatches, `attribute "bar" must be a map`) + + err = cstrs.Check(attrs(` +foo: FOO +bar: + bar1: BAR1 + bar2: BAR22 + bar3: BAR3 +baz: BAZ +`), nil) + c.Check(err, ErrorMatches, `attribute "bar\.bar2" value "BAR22" does not match \^\(BAR2\)\$`) + + err = cstrs.Check(attrs(` +foo: FOO +bar: + bar1: BAR1 + bar2: + bar22: true + bar3: BAR3 +baz: BAZ +`), nil) + c.Check(err, ErrorMatches, `attribute "bar\.bar2" must be a scalar or list`) +} + +func (s *attrConstraintsSuite) TestAlternativeMatchingComplex(c *C) { + toMatch := attrs(` +mnt: [{what: "/dev/x*", where: "/foo/*", options: ["rw", "nodev"]}, {what: "/bar/*", where: "/baz/*", options: ["rw", "bind"]}] +`) + + m, err := asserts.ParseHeaders([]byte(`attrs: + mnt: + - + what: /(bar/|dev/x)\* + where: /(foo|baz)/\* + options: rw|bind|nodev`)) + c.Assert(err, IsNil) + + cstrs, err := asserts.CompileAttributeConstraints(m["attrs"].(map[string]interface{})) + c.Assert(err, IsNil) + + err = cstrs.Check(toMatch, nil) + c.Check(err, IsNil) + + m, err = asserts.ParseHeaders([]byte(`attrs: + mnt: + - + what: /dev/x\* + where: /foo/\* + options: + - nodev + - rw + - + what: /bar/\* + where: /baz/\* + options: + - rw + - bind`)) + c.Assert(err, IsNil) + + cstrsExtensive, err := asserts.CompileAttributeConstraints(m["attrs"].(map[string]interface{})) + c.Assert(err, IsNil) + + err = cstrsExtensive.Check(toMatch, nil) + c.Check(err, IsNil) + + // not matching case + m, err = asserts.ParseHeaders([]byte(`attrs: + mnt: + - + what: /dev/x\* + where: /foo/\* + options: + - rw + - + what: /bar/\* + where: /baz/\* + options: + - rw + - bind`)) + c.Assert(err, IsNil) + + cstrsExtensiveNoMatch, err := asserts.CompileAttributeConstraints(m["attrs"].(map[string]interface{})) + c.Assert(err, IsNil) + + err = cstrsExtensiveNoMatch.Check(toMatch, nil) + c.Check(err, ErrorMatches, `no alternative for attribute "mnt\.0" matches: no alternative for attribute "mnt\.0.options\.1" matches:.*`) +} + +func (s *attrConstraintsSuite) TestOtherScalars(c *C) { + m, err := asserts.ParseHeaders([]byte(`attrs: + foo: 1 + bar: true`)) + c.Assert(err, IsNil) + + cstrs, err := asserts.CompileAttributeConstraints(m["attrs"].(map[string]interface{})) + c.Assert(err, IsNil) + + err = cstrs.Check(attrs(` +foo: 1 +bar: true +`), nil) + c.Check(err, IsNil) +} + +func (s *attrConstraintsSuite) TestCompileErrors(c *C) { + _, err := asserts.CompileAttributeConstraints(map[string]interface{}{ + "foo": "[", + }) + c.Check(err, ErrorMatches, `cannot compile "foo" constraint "\[": error parsing regexp:.*`) + + _, err = asserts.CompileAttributeConstraints("FOO") + c.Check(err, ErrorMatches, `first level of non alternative constraints must be a set of key-value contraints`) + + _, err = asserts.CompileAttributeConstraints([]interface{}{"FOO"}) + c.Check(err, ErrorMatches, `first level of non alternative constraints must be a set of key-value contraints`) + + wrongDollarConstraints := []string{ + "$", + "$FOO(a)", + "$SLOT", + "$SLOT()", + } + + for _, wrong := range wrongDollarConstraints { + _, err := asserts.CompileAttributeConstraints(map[string]interface{}{ + "foo": wrong, + }) + c.Check(err, ErrorMatches, fmt.Sprintf(`cannot compile "foo" constraint "%s": not a valid \$SLOT\(\)/\$PLUG\(\) constraint`, regexp.QuoteMeta(wrong))) + + } +} + +type testEvalAttr struct { + comp func(side string, arg string) (interface{}, error) +} + +func (ca testEvalAttr) SlotAttr(arg string) (interface{}, error) { + return ca.comp("slot", arg) +} + +func (ca testEvalAttr) PlugAttr(arg string) (interface{}, error) { + return ca.comp("plug", arg) +} + +func (s *attrConstraintsSuite) TestEvalCheck(c *C) { + m, err := asserts.ParseHeaders([]byte(`attrs: + foo: $SLOT(foo) + bar: $PLUG(bar.baz)`)) + c.Assert(err, IsNil) + + cstrs, err := asserts.CompileAttributeConstraints(m["attrs"].(map[string]interface{})) + c.Assert(err, IsNil) + c.Check(asserts.RuleFeature(cstrs, "dollar-attr-constraints"), Equals, true) + + err = cstrs.Check(attrs(` +foo: foo +bar: bar +`), nil) + c.Check(err, ErrorMatches, `attribute "(foo|bar)" cannot be matched without context`) + + calls := make(map[[2]string]bool) + comp1 := func(op string, arg string) (interface{}, error) { + calls[[2]string{op, arg}] = true + return arg, nil + } + + err = cstrs.Check(attrs(` +foo: foo +bar: bar.baz +`), testEvalAttr{comp1}) + c.Check(err, IsNil) + + c.Check(calls, DeepEquals, map[[2]string]bool{ + {"slot", "foo"}: true, + {"plug", "bar.baz"}: true, + }) + + comp2 := func(op string, arg string) (interface{}, error) { + if op == "plug" { + return nil, fmt.Errorf("boom") + } + return arg, nil + } + + err = cstrs.Check(attrs(` +foo: foo +bar: bar.baz +`), testEvalAttr{comp2}) + c.Check(err, ErrorMatches, `attribute "bar" constraint \$PLUG\(bar\.baz\) cannot be evaluated: boom`) + + comp3 := func(op string, arg string) (interface{}, error) { + if op == "slot" { + return "other-value", nil + } + return arg, nil + } + + err = cstrs.Check(attrs(` +foo: foo +bar: bar.baz +`), testEvalAttr{comp3}) + c.Check(err, ErrorMatches, `attribute "foo" does not match \$SLOT\(foo\): foo != other-value`) +} + +func (s *attrConstraintsSuite) TestNeverMatchAttributeConstraints(c *C) { + c.Check(asserts.NeverMatchAttributes.Check(nil, nil), NotNil) +} + +type nameConstraintsSuite struct{} + +func (s *nameConstraintsSuite) TestCompileErrors(c *C) { + _, err := asserts.CompileNameConstraints("slot-names", "true") + c.Check(err, ErrorMatches, `slot-names constraints must be a list of regexps and special \$ values`) + + _, err = asserts.CompileNameConstraints("slot-names", []interface{}{map[string]interface{}{"foo": "bar"}}) + c.Check(err, ErrorMatches, `slot-names constraint entry must be a regexp or special \$ value`) + + _, err = asserts.CompileNameConstraints("plug-names", []interface{}{"["}) + c.Check(err, ErrorMatches, `cannot compile plug-names constraint entry "\[":.*`) + + _, err = asserts.CompileNameConstraints("plug-names", []interface{}{"$"}) + c.Check(err, ErrorMatches, `plug-names constraint entry special value "\$" is invalid`) + + _, err = asserts.CompileNameConstraints("slot-names", []interface{}{"$12"}) + c.Check(err, ErrorMatches, `slot-names constraint entry special value "\$12" is invalid`) + + _, err = asserts.CompileNameConstraints("plug-names", []interface{}{"a b"}) + c.Check(err, ErrorMatches, `plug-names constraint entry regexp contains unexpected spaces`) +} + +func (s *nameConstraintsSuite) TestCheck(c *C) { + nc, err := asserts.CompileNameConstraints("slot-names", []interface{}{"foo[0-9]", "bar"}) + c.Assert(err, IsNil) + + for _, matching := range []string{"foo0", "foo1", "bar"} { + c.Check(nc.Check("slot name", matching, nil), IsNil) + } + + for _, notMatching := range []string{"baz", "fooo", "foo12"} { + c.Check(nc.Check("slot name", notMatching, nil), ErrorMatches, fmt.Sprintf(`slot name %q does not match constraints`, notMatching)) + } + +} + +func (s *nameConstraintsSuite) TestCheckSpecial(c *C) { + nc, err := asserts.CompileNameConstraints("slot-names", []interface{}{"$INTERFACE"}) + c.Assert(err, IsNil) + + c.Check(nc.Check("slot name", "foo", nil), ErrorMatches, `slot name "foo" does not match constraints`) + c.Check(nc.Check("slot name", "foo", map[string]string{"$INTERFACE": "foo"}), IsNil) + c.Check(nc.Check("slot name", "bar", map[string]string{"$INTERFACE": "foo"}), ErrorMatches, `slot name "bar" does not match constraints`) +} + +type plugSlotRulesSuite struct{} + +func checkAttrs(c *C, attrs *asserts.AttributeConstraints, witness, expected string) { + plug := attrerObject(map[string]interface{}{ + witness: "XYZ", + }) + c.Check(attrs.Check(plug, nil), ErrorMatches, fmt.Sprintf(`attribute "%s".*does not match.*`, witness)) + plug = attrerObject(map[string]interface{}{ + witness: expected, + }) + c.Check(attrs.Check(plug, nil), IsNil) +} + +var ( + sideArityAny = asserts.SideArityConstraint{N: -1} + sideArityOne = asserts.SideArityConstraint{N: 1} +) + +func checkBoolPlugConnConstraints(c *C, subrule string, cstrs []*asserts.PlugConnectionConstraints, always bool) { + expected := asserts.NeverMatchAttributes + if always { + expected = asserts.AlwaysMatchAttributes + } + c.Assert(cstrs, HasLen, 1) + cstrs1 := cstrs[0] + c.Check(cstrs1.PlugAttributes, Equals, expected) + c.Check(cstrs1.SlotAttributes, Equals, expected) + if strings.HasPrefix(subrule, "deny-") { + undef := asserts.SideArityConstraint{} + c.Check(cstrs1.SlotsPerPlug, Equals, undef) + c.Check(cstrs1.PlugsPerSlot, Equals, undef) + } else { + c.Check(cstrs1.PlugsPerSlot, Equals, sideArityAny) + if strings.HasSuffix(subrule, "-auto-connection") { + c.Check(cstrs1.SlotsPerPlug, Equals, sideArityOne) + } else { + c.Check(cstrs1.SlotsPerPlug, Equals, sideArityAny) + } + } + c.Check(cstrs1.SlotSnapIDs, HasLen, 0) + c.Check(cstrs1.SlotPublisherIDs, HasLen, 0) + c.Check(cstrs1.SlotSnapTypes, HasLen, 0) +} + +func checkBoolSlotConnConstraints(c *C, subrule string, cstrs []*asserts.SlotConnectionConstraints, always bool) { + expected := asserts.NeverMatchAttributes + if always { + expected = asserts.AlwaysMatchAttributes + } + c.Assert(cstrs, HasLen, 1) + cstrs1 := cstrs[0] + c.Check(cstrs1.PlugAttributes, Equals, expected) + c.Check(cstrs1.SlotAttributes, Equals, expected) + if strings.HasPrefix(subrule, "deny-") { + undef := asserts.SideArityConstraint{} + c.Check(cstrs1.SlotsPerPlug, Equals, undef) + c.Check(cstrs1.PlugsPerSlot, Equals, undef) + } else { + c.Check(cstrs1.PlugsPerSlot, Equals, sideArityAny) + if strings.HasSuffix(subrule, "-auto-connection") { + c.Check(cstrs1.SlotsPerPlug, Equals, sideArityOne) + } else { + c.Check(cstrs1.SlotsPerPlug, Equals, sideArityAny) + } + } + c.Check(cstrs1.PlugSnapIDs, HasLen, 0) + c.Check(cstrs1.PlugPublisherIDs, HasLen, 0) + c.Check(cstrs1.PlugSnapTypes, HasLen, 0) +} + +func (s *plugSlotRulesSuite) TestCompilePlugRuleAllAllowDenyStanzas(c *C) { + m, err := asserts.ParseHeaders([]byte(`iface: + allow-installation: + plug-attributes: + a1: A1 + deny-installation: + plug-attributes: + a2: A2 + allow-connection: + plug-attributes: + pa3: PA3 + slot-attributes: + sa3: SA3 + deny-connection: + plug-attributes: + pa4: PA4 + slot-attributes: + sa4: SA4 + allow-auto-connection: + plug-attributes: + pa5: PA5 + slot-attributes: + sa5: SA5 + deny-auto-connection: + plug-attributes: + pa6: PA6 + slot-attributes: + sa6: SA6`)) + c.Assert(err, IsNil) + + rule, err := asserts.CompilePlugRule("iface", m["iface"].(map[string]interface{})) + c.Assert(err, IsNil) + + c.Check(rule.Interface, Equals, "iface") + // install subrules + c.Assert(rule.AllowInstallation, HasLen, 1) + checkAttrs(c, rule.AllowInstallation[0].PlugAttributes, "a1", "A1") + c.Assert(rule.DenyInstallation, HasLen, 1) + checkAttrs(c, rule.DenyInstallation[0].PlugAttributes, "a2", "A2") + // connection subrules + c.Assert(rule.AllowConnection, HasLen, 1) + checkAttrs(c, rule.AllowConnection[0].PlugAttributes, "pa3", "PA3") + checkAttrs(c, rule.AllowConnection[0].SlotAttributes, "sa3", "SA3") + c.Assert(rule.DenyConnection, HasLen, 1) + checkAttrs(c, rule.DenyConnection[0].PlugAttributes, "pa4", "PA4") + checkAttrs(c, rule.DenyConnection[0].SlotAttributes, "sa4", "SA4") + // auto-connection subrules + c.Assert(rule.AllowAutoConnection, HasLen, 1) + checkAttrs(c, rule.AllowAutoConnection[0].PlugAttributes, "pa5", "PA5") + checkAttrs(c, rule.AllowAutoConnection[0].SlotAttributes, "sa5", "SA5") + c.Assert(rule.DenyAutoConnection, HasLen, 1) + checkAttrs(c, rule.DenyAutoConnection[0].PlugAttributes, "pa6", "PA6") + checkAttrs(c, rule.DenyAutoConnection[0].SlotAttributes, "sa6", "SA6") +} + +func (s *plugSlotRulesSuite) TestCompilePlugRuleAllAllowDenyOrStanzas(c *C) { + m, err := asserts.ParseHeaders([]byte(`iface: + allow-installation: + - + plug-attributes: + a1: A1 + - + plug-attributes: + a1: A1alt + deny-installation: + - + plug-attributes: + a2: A2 + - + plug-attributes: + a2: A2alt + allow-connection: + - + plug-attributes: + pa3: PA3 + slot-attributes: + sa3: SA3 + - + plug-attributes: + pa3: PA3alt + deny-connection: + - + plug-attributes: + pa4: PA4 + slot-attributes: + sa4: SA4 + - + plug-attributes: + pa4: PA4alt + allow-auto-connection: + - + plug-attributes: + pa5: PA5 + slot-attributes: + sa5: SA5 + - + plug-attributes: + pa5: PA5alt + deny-auto-connection: + - + plug-attributes: + pa6: PA6 + slot-attributes: + sa6: SA6 + - + plug-attributes: + pa6: PA6alt`)) + c.Assert(err, IsNil) + + rule, err := asserts.CompilePlugRule("iface", m["iface"].(map[string]interface{})) + c.Assert(err, IsNil) + + c.Check(rule.Interface, Equals, "iface") + // install subrules + c.Assert(rule.AllowInstallation, HasLen, 2) + checkAttrs(c, rule.AllowInstallation[0].PlugAttributes, "a1", "A1") + checkAttrs(c, rule.AllowInstallation[1].PlugAttributes, "a1", "A1alt") + c.Assert(rule.DenyInstallation, HasLen, 2) + checkAttrs(c, rule.DenyInstallation[0].PlugAttributes, "a2", "A2") + checkAttrs(c, rule.DenyInstallation[1].PlugAttributes, "a2", "A2alt") + // connection subrules + c.Assert(rule.AllowConnection, HasLen, 2) + checkAttrs(c, rule.AllowConnection[0].PlugAttributes, "pa3", "PA3") + checkAttrs(c, rule.AllowConnection[0].SlotAttributes, "sa3", "SA3") + checkAttrs(c, rule.AllowConnection[1].PlugAttributes, "pa3", "PA3alt") + c.Assert(rule.DenyConnection, HasLen, 2) + checkAttrs(c, rule.DenyConnection[0].PlugAttributes, "pa4", "PA4") + checkAttrs(c, rule.DenyConnection[0].SlotAttributes, "sa4", "SA4") + checkAttrs(c, rule.DenyConnection[1].PlugAttributes, "pa4", "PA4alt") + // auto-connection subrules + c.Assert(rule.AllowAutoConnection, HasLen, 2) + checkAttrs(c, rule.AllowAutoConnection[0].PlugAttributes, "pa5", "PA5") + checkAttrs(c, rule.AllowAutoConnection[0].SlotAttributes, "sa5", "SA5") + checkAttrs(c, rule.AllowAutoConnection[1].PlugAttributes, "pa5", "PA5alt") + c.Assert(rule.DenyAutoConnection, HasLen, 2) + checkAttrs(c, rule.DenyAutoConnection[0].PlugAttributes, "pa6", "PA6") + checkAttrs(c, rule.DenyAutoConnection[0].SlotAttributes, "sa6", "SA6") + checkAttrs(c, rule.DenyAutoConnection[1].PlugAttributes, "pa6", "PA6alt") +} + +func (s *plugSlotRulesSuite) TestCompilePlugRuleShortcutTrue(c *C) { + rule, err := asserts.CompilePlugRule("iface", "true") + c.Assert(err, IsNil) + + c.Check(rule.Interface, Equals, "iface") + // install subrules + c.Assert(rule.AllowInstallation, HasLen, 1) + c.Check(rule.AllowInstallation[0].PlugAttributes, Equals, asserts.AlwaysMatchAttributes) + c.Assert(rule.DenyInstallation, HasLen, 1) + c.Check(rule.DenyInstallation[0].PlugAttributes, Equals, asserts.NeverMatchAttributes) + // connection subrules + checkBoolPlugConnConstraints(c, "allow-connection", rule.AllowConnection, true) + checkBoolPlugConnConstraints(c, "deny-connection", rule.DenyConnection, false) + // auto-connection subrules + checkBoolPlugConnConstraints(c, "allow-auto-connection", rule.AllowAutoConnection, true) + checkBoolPlugConnConstraints(c, "deny-auto-connection", rule.DenyAutoConnection, false) +} + +func (s *plugSlotRulesSuite) TestCompilePlugRuleShortcutFalse(c *C) { + rule, err := asserts.CompilePlugRule("iface", "false") + c.Assert(err, IsNil) + + // install subrules + c.Assert(rule.AllowInstallation, HasLen, 1) + c.Check(rule.AllowInstallation[0].PlugAttributes, Equals, asserts.NeverMatchAttributes) + c.Assert(rule.DenyInstallation, HasLen, 1) + c.Check(rule.DenyInstallation[0].PlugAttributes, Equals, asserts.AlwaysMatchAttributes) + // connection subrules + checkBoolPlugConnConstraints(c, "allow-connection", rule.AllowConnection, false) + checkBoolPlugConnConstraints(c, "deny-connection", rule.DenyConnection, true) + // auto-connection subrules + checkBoolPlugConnConstraints(c, "allow-auto-connection", rule.AllowAutoConnection, false) + checkBoolPlugConnConstraints(c, "deny-auto-connection", rule.DenyAutoConnection, true) +} + +func (s *plugSlotRulesSuite) TestCompilePlugRuleDefaults(c *C) { + rule, err := asserts.CompilePlugRule("iface", map[string]interface{}{ + "deny-auto-connection": "true", + }) + c.Assert(err, IsNil) + + // everything follows the defaults... + + // install subrules + c.Assert(rule.AllowInstallation, HasLen, 1) + c.Check(rule.AllowInstallation[0].PlugAttributes, Equals, asserts.AlwaysMatchAttributes) + c.Assert(rule.DenyInstallation, HasLen, 1) + c.Check(rule.DenyInstallation[0].PlugAttributes, Equals, asserts.NeverMatchAttributes) + // connection subrules + checkBoolPlugConnConstraints(c, "allow-connection", rule.AllowConnection, true) + checkBoolPlugConnConstraints(c, "deny-connection", rule.DenyConnection, false) + // auto-connection subrules + checkBoolPlugConnConstraints(c, "allow-auto-connection", rule.AllowAutoConnection, true) + // ... but deny-auto-connection is on + checkBoolPlugConnConstraints(c, "deny-auto-connection", rule.DenyAutoConnection, true) +} + +func (s *plugSlotRulesSuite) TestCompilePlugRuleInstalationConstraintsIDConstraints(c *C) { + rule, err := asserts.CompilePlugRule("iface", map[string]interface{}{ + "allow-installation": map[string]interface{}{ + "plug-snap-type": []interface{}{"core", "kernel", "gadget", "app"}, + "plug-snap-id": []interface{}{"snapidsnapidsnapidsnapidsnapid01", "snapidsnapidsnapidsnapidsnapid02"}, + }, + }) + c.Assert(err, IsNil) + + c.Assert(rule.AllowInstallation, HasLen, 1) + cstrs := rule.AllowInstallation[0] + c.Check(cstrs.PlugSnapTypes, DeepEquals, []string{"core", "kernel", "gadget", "app"}) + c.Check(cstrs.PlugSnapIDs, DeepEquals, []string{"snapidsnapidsnapidsnapidsnapid01", "snapidsnapidsnapidsnapidsnapid02"}) +} + +func (s *plugSlotRulesSuite) TestCompilePlugRuleInstallationConstraintsOnClassic(c *C) { + m, err := asserts.ParseHeaders([]byte(`iface: + allow-installation: true`)) + c.Assert(err, IsNil) + + rule, err := asserts.CompilePlugRule("iface", m["iface"].(map[string]interface{})) + c.Assert(err, IsNil) + + c.Check(rule.AllowInstallation[0].OnClassic, IsNil) + + m, err = asserts.ParseHeaders([]byte(`iface: + allow-installation: + on-classic: false`)) + c.Assert(err, IsNil) + + rule, err = asserts.CompilePlugRule("iface", m["iface"].(map[string]interface{})) + c.Assert(err, IsNil) + + c.Check(rule.AllowInstallation[0].OnClassic, DeepEquals, &asserts.OnClassicConstraint{}) + + m, err = asserts.ParseHeaders([]byte(`iface: + allow-installation: + on-classic: true`)) + c.Assert(err, IsNil) + + rule, err = asserts.CompilePlugRule("iface", m["iface"].(map[string]interface{})) + c.Assert(err, IsNil) + + c.Check(rule.AllowInstallation[0].OnClassic, DeepEquals, &asserts.OnClassicConstraint{Classic: true}) + + m, err = asserts.ParseHeaders([]byte(`iface: + allow-installation: + on-classic: + - ubuntu + - debian`)) + c.Assert(err, IsNil) + + rule, err = asserts.CompilePlugRule("iface", m["iface"].(map[string]interface{})) + c.Assert(err, IsNil) + + c.Check(rule.AllowInstallation[0].OnClassic, DeepEquals, &asserts.OnClassicConstraint{Classic: true, SystemIDs: []string{"ubuntu", "debian"}}) +} + +func (s *plugSlotRulesSuite) TestCompilePlugRuleInstallationConstraintsDeviceScope(c *C) { + m, err := asserts.ParseHeaders([]byte(`iface: + allow-installation: true`)) + c.Assert(err, IsNil) + + rule, err := asserts.CompilePlugRule("iface", m["iface"].(map[string]interface{})) + c.Assert(err, IsNil) + + c.Check(rule.AllowInstallation[0].DeviceScope, IsNil) + + tests := []struct { + rule string + expected asserts.DeviceScopeConstraint + }{ + {`iface: + allow-installation: + on-store: + - my-store`, asserts.DeviceScopeConstraint{Store: []string{"my-store"}}}, + {`iface: + allow-installation: + on-store: + - my-store + - other-store`, asserts.DeviceScopeConstraint{Store: []string{"my-store", "other-store"}}}, + {`iface: + allow-installation: + on-brand: + - my-brand + - s9zGdwb16ysLeRW6nRivwZS5r9puP8JT`, asserts.DeviceScopeConstraint{Brand: []string{"my-brand", "s9zGdwb16ysLeRW6nRivwZS5r9puP8JT"}}}, + {`iface: + allow-installation: + on-model: + - my-brand/bar + - s9zGdwb16ysLeRW6nRivwZS5r9puP8JT/baz`, asserts.DeviceScopeConstraint{Model: []string{"my-brand/bar", "s9zGdwb16ysLeRW6nRivwZS5r9puP8JT/baz"}}}, + {`iface: + allow-installation: + on-store: + - store1 + - store2 + on-brand: + - my-brand + on-model: + - my-brand/bar + - s9zGdwb16ysLeRW6nRivwZS5r9puP8JT/baz`, asserts.DeviceScopeConstraint{ + Store: []string{"store1", "store2"}, + Brand: []string{"my-brand"}, + Model: []string{"my-brand/bar", "s9zGdwb16ysLeRW6nRivwZS5r9puP8JT/baz"}}}, + } + + for _, t := range tests { + m, err = asserts.ParseHeaders([]byte(t.rule)) + c.Assert(err, IsNil) + + rule, err = asserts.CompilePlugRule("iface", m["iface"].(map[string]interface{})) + c.Assert(err, IsNil) + + c.Check(rule.AllowInstallation[0].DeviceScope, DeepEquals, &t.expected) + } +} + +func (s *plugSlotRulesSuite) TestCompilePlugRuleInstallationConstraintsPlugNames(c *C) { + m, err := asserts.ParseHeaders([]byte(`iface: + allow-installation: true`)) + c.Assert(err, IsNil) + + rule, err := asserts.CompilePlugRule("iface", m["iface"].(map[string]interface{})) + c.Assert(err, IsNil) + + c.Check(rule.AllowInstallation[0].PlugNames, IsNil) + + tests := []struct { + rule string + matching []string + notMatching []string + }{ + {`iface: + allow-installation: + plug-names: + - foo`, []string{"foo"}, []string{"bar"}}, + {`iface: + allow-installation: + plug-names: + - foo + - bar`, []string{"foo", "bar"}, []string{"baz"}}, + {`iface: + allow-installation: + plug-names: + - foo[0-9] + - bar`, []string{"foo0", "foo1", "bar"}, []string{"baz", "fooo", "foo12"}}, + } + for _, t := range tests { + m, err = asserts.ParseHeaders([]byte(t.rule)) + c.Assert(err, IsNil) + + rule, err = asserts.CompilePlugRule("iface", m["iface"].(map[string]interface{})) + c.Assert(err, IsNil) + + for _, matching := range t.matching { + c.Check(rule.AllowInstallation[0].PlugNames.Check("plug name", matching, nil), IsNil) + } + for _, notMatching := range t.notMatching { + c.Check(rule.AllowInstallation[0].PlugNames.Check("plug name", notMatching, nil), NotNil) + } + } +} + +func (s *plugSlotRulesSuite) TestCompilePlugRuleConnectionConstraintsIDConstraints(c *C) { + rule, err := asserts.CompilePlugRule("iface", map[string]interface{}{ + "allow-connection": map[string]interface{}{ + "slot-snap-type": []interface{}{"core", "kernel", "gadget", "app"}, + "slot-snap-id": []interface{}{"snapidsnapidsnapidsnapidsnapid01", "snapidsnapidsnapidsnapidsnapid02"}, + "slot-publisher-id": []interface{}{"pubidpubidpubidpubidpubidpubid09", "canonical", "$SAME"}, + }, + }) + c.Assert(err, IsNil) + + c.Assert(rule.AllowConnection, HasLen, 1) + cstrs := rule.AllowConnection[0] + c.Check(cstrs.SlotSnapTypes, DeepEquals, []string{"core", "kernel", "gadget", "app"}) + c.Check(cstrs.SlotSnapIDs, DeepEquals, []string{"snapidsnapidsnapidsnapidsnapid01", "snapidsnapidsnapidsnapidsnapid02"}) + c.Check(cstrs.SlotPublisherIDs, DeepEquals, []string{"pubidpubidpubidpubidpubidpubid09", "canonical", "$SAME"}) + +} + +func (s *plugSlotRulesSuite) TestCompilePlugRuleConnectionConstraintsOnClassic(c *C) { + m, err := asserts.ParseHeaders([]byte(`iface: + allow-connection: true`)) + c.Assert(err, IsNil) + + rule, err := asserts.CompilePlugRule("iface", m["iface"].(map[string]interface{})) + c.Assert(err, IsNil) + + c.Check(rule.AllowConnection[0].OnClassic, IsNil) + + m, err = asserts.ParseHeaders([]byte(`iface: + allow-connection: + on-classic: false`)) + c.Assert(err, IsNil) + + rule, err = asserts.CompilePlugRule("iface", m["iface"].(map[string]interface{})) + c.Assert(err, IsNil) + + c.Check(rule.AllowConnection[0].OnClassic, DeepEquals, &asserts.OnClassicConstraint{}) + + m, err = asserts.ParseHeaders([]byte(`iface: + allow-connection: + on-classic: true`)) + c.Assert(err, IsNil) + + rule, err = asserts.CompilePlugRule("iface", m["iface"].(map[string]interface{})) + c.Assert(err, IsNil) + + c.Check(rule.AllowConnection[0].OnClassic, DeepEquals, &asserts.OnClassicConstraint{Classic: true}) + + m, err = asserts.ParseHeaders([]byte(`iface: + allow-connection: + on-classic: + - ubuntu + - debian`)) + c.Assert(err, IsNil) + + rule, err = asserts.CompilePlugRule("iface", m["iface"].(map[string]interface{})) + c.Assert(err, IsNil) + + c.Check(rule.AllowConnection[0].OnClassic, DeepEquals, &asserts.OnClassicConstraint{Classic: true, SystemIDs: []string{"ubuntu", "debian"}}) +} + +func (s *plugSlotRulesSuite) TestCompilePlugRuleConnectionConstraintsDeviceScope(c *C) { + m, err := asserts.ParseHeaders([]byte(`iface: + allow-connection: true`)) + c.Assert(err, IsNil) + + rule, err := asserts.CompilePlugRule("iface", m["iface"].(map[string]interface{})) + c.Assert(err, IsNil) + + c.Check(rule.AllowInstallation[0].DeviceScope, IsNil) + + tests := []struct { + rule string + expected asserts.DeviceScopeConstraint + }{ + {`iface: + allow-connection: + on-store: + - my-store`, asserts.DeviceScopeConstraint{Store: []string{"my-store"}}}, + {`iface: + allow-connection: + on-store: + - my-store + - other-store`, asserts.DeviceScopeConstraint{Store: []string{"my-store", "other-store"}}}, + {`iface: + allow-connection: + on-brand: + - my-brand + - s9zGdwb16ysLeRW6nRivwZS5r9puP8JT`, asserts.DeviceScopeConstraint{Brand: []string{"my-brand", "s9zGdwb16ysLeRW6nRivwZS5r9puP8JT"}}}, + {`iface: + allow-connection: + on-model: + - my-brand/bar + - s9zGdwb16ysLeRW6nRivwZS5r9puP8JT/baz`, asserts.DeviceScopeConstraint{Model: []string{"my-brand/bar", "s9zGdwb16ysLeRW6nRivwZS5r9puP8JT/baz"}}}, + {`iface: + allow-connection: + on-store: + - store1 + - store2 + on-brand: + - my-brand + on-model: + - my-brand/bar + - s9zGdwb16ysLeRW6nRivwZS5r9puP8JT/baz`, asserts.DeviceScopeConstraint{ + Store: []string{"store1", "store2"}, + Brand: []string{"my-brand"}, + Model: []string{"my-brand/bar", "s9zGdwb16ysLeRW6nRivwZS5r9puP8JT/baz"}}}, + } + + for _, t := range tests { + m, err = asserts.ParseHeaders([]byte(t.rule)) + c.Assert(err, IsNil) + + rule, err = asserts.CompilePlugRule("iface", m["iface"].(map[string]interface{})) + c.Assert(err, IsNil) + + c.Check(rule.AllowConnection[0].DeviceScope, DeepEquals, &t.expected) + } +} + +func (s *plugSlotRulesSuite) TestCompilePlugRuleConnectionConstraintsPlugNamesSlotNames(c *C) { + m, err := asserts.ParseHeaders([]byte(`iface: + allow-connection: true`)) + c.Assert(err, IsNil) + + rule, err := asserts.CompilePlugRule("iface", m["iface"].(map[string]interface{})) + c.Assert(err, IsNil) + + c.Check(rule.AllowConnection[0].PlugNames, IsNil) + c.Check(rule.AllowConnection[0].SlotNames, IsNil) + + tests := []struct { + rule string + matching []string + notMatching []string + }{ + {`iface: + allow-connection: + plug-names: + - Pfoo + slot-names: + - Sfoo`, []string{"foo"}, []string{"bar"}}, + {`iface: + allow-connection: + plug-names: + - Pfoo + - Pbar + slot-names: + - Sfoo + - Sbar`, []string{"foo", "bar"}, []string{"baz"}}, + {`iface: + allow-connection: + plug-names: + - Pfoo[0-9] + - Pbar + slot-names: + - Sfoo[0-9] + - Sbar`, []string{"foo0", "foo1", "bar"}, []string{"baz", "fooo", "foo12"}}, + } + for _, t := range tests { + m, err = asserts.ParseHeaders([]byte(t.rule)) + c.Assert(err, IsNil) + + rule, err = asserts.CompilePlugRule("iface", m["iface"].(map[string]interface{})) + c.Assert(err, IsNil) + + for _, matching := range t.matching { + c.Check(rule.AllowConnection[0].PlugNames.Check("plug name", "P"+matching, nil), IsNil) + + c.Check(rule.AllowConnection[0].SlotNames.Check("slot name", "S"+matching, nil), IsNil) + } + + for _, notMatching := range t.notMatching { + c.Check(rule.AllowConnection[0].SlotNames.Check("plug name", "P"+notMatching, nil), NotNil) + + c.Check(rule.AllowConnection[0].SlotNames.Check("slot name", "S"+notMatching, nil), NotNil) + } + } +} + +func (s *plugSlotRulesSuite) TestCompilePlugRuleConnectionConstraintsSideArityConstraints(c *C) { + m, err := asserts.ParseHeaders([]byte(`iface: + allow-auto-connection: true`)) + c.Assert(err, IsNil) + + rule, err := asserts.CompilePlugRule("iface", m["iface"].(map[string]interface{})) + c.Assert(err, IsNil) + + // defaults + c.Check(rule.AllowAutoConnection[0].SlotsPerPlug, Equals, asserts.SideArityConstraint{N: 1}) + c.Check(rule.AllowAutoConnection[0].PlugsPerSlot.Any(), Equals, true) + + c.Check(rule.AllowConnection[0].SlotsPerPlug.Any(), Equals, true) + c.Check(rule.AllowConnection[0].PlugsPerSlot.Any(), Equals, true) + + // test that the arity constraints get normalized away to any + // under allow-connection + // see https://forum.snapcraft.io/t/plug-slot-declaration-rules-greedy-plugs/12438 + allowConnTests := []string{ + `iface: + allow-connection: + slots-per-plug: 1 + plugs-per-slot: 2`, + `iface: + allow-connection: + slots-per-plug: * + plugs-per-slot: 1`, + `iface: + allow-connection: + slots-per-plug: 2 + plugs-per-slot: *`, + } + + for _, t := range allowConnTests { + m, err = asserts.ParseHeaders([]byte(t)) + c.Assert(err, IsNil) + + rule, err = asserts.CompilePlugRule("iface", m["iface"].(map[string]interface{})) + c.Assert(err, IsNil) + + c.Check(rule.AllowConnection[0].SlotsPerPlug.Any(), Equals, true) + c.Check(rule.AllowConnection[0].PlugsPerSlot.Any(), Equals, true) + } + + // test that under allow-auto-connection: + // slots-per-plug can be * (any) or otherwise gets normalized to 1 + // plugs-per-slot gets normalized to any (*) + // see https://forum.snapcraft.io/t/plug-slot-declaration-rules-greedy-plugs/12438 + allowAutoConnTests := []struct { + rule string + slotsPerPlug asserts.SideArityConstraint + }{ + {`iface: + allow-auto-connection: + slots-per-plug: 1 + plugs-per-slot: 2`, sideArityOne}, + {`iface: + allow-auto-connection: + slots-per-plug: * + plugs-per-slot: 1`, sideArityAny}, + {`iface: + allow-auto-connection: + slots-per-plug: 2 + plugs-per-slot: *`, sideArityOne}, + } + + for _, t := range allowAutoConnTests { + m, err = asserts.ParseHeaders([]byte(t.rule)) + c.Assert(err, IsNil) + + rule, err = asserts.CompilePlugRule("iface", m["iface"].(map[string]interface{})) + c.Assert(err, IsNil) + + c.Check(rule.AllowAutoConnection[0].SlotsPerPlug, Equals, t.slotsPerPlug) + c.Check(rule.AllowAutoConnection[0].PlugsPerSlot.Any(), Equals, true) + } +} + +func (s *plugSlotRulesSuite) TestCompilePlugRuleConnectionConstraintsAttributesDefault(c *C) { + rule, err := asserts.CompilePlugRule("iface", map[string]interface{}{ + "allow-connection": map[string]interface{}{ + "slot-snap-id": []interface{}{"snapidsnapidsnapidsnapidsnapid01"}, + }, + }) + c.Assert(err, IsNil) + + // attributes default to always matching here + cstrs := rule.AllowConnection[0] + c.Check(cstrs.PlugAttributes, Equals, asserts.AlwaysMatchAttributes) + c.Check(cstrs.SlotAttributes, Equals, asserts.AlwaysMatchAttributes) +} + +func (s *plugSlotRulesSuite) TestCompilePlugRuleErrors(c *C) { + tests := []struct { + stanza string + err string + }{ + {`iface: foo`, `plug rule for interface "iface" must be a map or one of the shortcuts 'true' or 'false'`}, + {`iface: + - allow`, `plug rule for interface "iface" must be a map or one of the shortcuts 'true' or 'false'`}, + {`iface: + allow-installation: foo`, `allow-installation in plug rule for interface "iface" must be a map or one of the shortcuts 'true' or 'false'`}, + {`iface: + deny-installation: foo`, `deny-installation in plug rule for interface "iface" must be a map or one of the shortcuts 'true' or 'false'`}, + {`iface: + allow-connection: foo`, `allow-connection in plug rule for interface "iface" must be a map or one of the shortcuts 'true' or 'false'`}, + {`iface: + allow-connection: + - foo`, `alternative 1 of allow-connection in plug rule for interface "iface" must be a map`}, + {`iface: + allow-connection: + - true`, `alternative 1 of allow-connection in plug rule for interface "iface" must be a map`}, + {`iface: + allow-installation: + plug-attributes: + a1: [`, `cannot compile plug-attributes in allow-installation in plug rule for interface "iface": cannot compile "a1" constraint .*`}, + {`iface: + allow-connection: + slot-attributes: + a2: [`, `cannot compile slot-attributes in allow-connection in plug rule for interface "iface": cannot compile "a2" constraint .*`}, + {`iface: + allow-connection: + slot-snap-id: + - + foo: 1`, `slot-snap-id in allow-connection in plug rule for interface "iface" must be a list of strings`}, + {`iface: + allow-connection: + slot-snap-id: + - foo`, `slot-snap-id in allow-connection in plug rule for interface "iface" contains an invalid element: "foo"`}, + {`iface: + allow-connection: + slot-snap-type: + - foo`, `slot-snap-type in allow-connection in plug rule for interface "iface" contains an invalid element: "foo"`}, + {`iface: + allow-connection: + slot-snap-type: + - xapp`, `slot-snap-type in allow-connection in plug rule for interface "iface" contains an invalid element: "xapp"`}, + {`iface: + allow-connection: + slot-snap-ids: + - foo`, `allow-connection in plug rule for interface "iface" must specify at least one of plug-names, slot-names, plug-attributes, slot-attributes, slot-snap-type, slot-publisher-id, slot-snap-id, slots-per-plug, plugs-per-slot, on-classic, on-store, on-brand, on-model`}, + {`iface: + deny-connection: + slot-snap-ids: + - foo`, `deny-connection in plug rule for interface "iface" must specify at least one of plug-names, slot-names, plug-attributes, slot-attributes, slot-snap-type, slot-publisher-id, slot-snap-id, slots-per-plug, plugs-per-slot, on-classic, on-store, on-brand, on-model`}, + {`iface: + allow-auto-connection: + slot-snap-ids: + - foo`, `allow-auto-connection in plug rule for interface "iface" must specify at least one of plug-names, slot-names, plug-attributes, slot-attributes, slot-snap-type, slot-publisher-id, slot-snap-id, slots-per-plug, plugs-per-slot, on-classic, on-store, on-brand, on-model`}, + {`iface: + deny-auto-connection: + slot-snap-ids: + - foo`, `deny-auto-connection in plug rule for interface "iface" must specify at least one of plug-names, slot-names, plug-attributes, slot-attributes, slot-snap-type, slot-publisher-id, slot-snap-id, slots-per-plug, plugs-per-slot, on-classic, on-store, on-brand, on-model`}, + {`iface: + allow-connect: true`, `plug rule for interface "iface" must specify at least one of allow-installation, deny-installation, allow-connection, deny-connection, allow-auto-connection, deny-auto-connection`}, + {`iface: + allow-installation: + on-store: true`, `on-store in allow-installation in plug rule for interface \"iface\" must be a list of strings`}, + {`iface: + allow-installation: + on-store: store1`, `on-store in allow-installation in plug rule for interface \"iface\" must be a list of strings`}, + {`iface: + allow-installation: + on-store: + - zoom!`, `on-store in allow-installation in plug rule for interface \"iface\" contains an invalid element: \"zoom!\"`}, + {`iface: + allow-connection: + on-brand: true`, `on-brand in allow-connection in plug rule for interface \"iface\" must be a list of strings`}, + {`iface: + allow-connection: + on-brand: brand1`, `on-brand in allow-connection in plug rule for interface \"iface\" must be a list of strings`}, + {`iface: + allow-connection: + on-brand: + - zoom!`, `on-brand in allow-connection in plug rule for interface \"iface\" contains an invalid element: \"zoom!\"`}, + {`iface: + allow-auto-connection: + on-model: true`, `on-model in allow-auto-connection in plug rule for interface \"iface\" must be a list of strings`}, + {`iface: + allow-auto-connection: + on-model: foo/bar`, `on-model in allow-auto-connection in plug rule for interface \"iface\" must be a list of strings`}, + {`iface: + allow-auto-connection: + on-model: + - foo/!qz`, `on-model in allow-auto-connection in plug rule for interface \"iface\" contains an invalid element: \"foo/!qz"`}, + {`iface: + allow-installation: + slots-per-plug: 1`, `allow-installation in plug rule for interface "iface" cannot specify a slots-per-plug constraint, they apply only to allow-\*connection`}, + {`iface: + deny-connection: + slots-per-plug: 1`, `deny-connection in plug rule for interface "iface" cannot specify a slots-per-plug constraint, they apply only to allow-\*connection`}, + {`iface: + allow-auto-connection: + plugs-per-slot: any`, `plugs-per-slot in allow-auto-connection in plug rule for interface "iface" must be an integer >=1 or \*`}, + {`iface: + allow-auto-connection: + slots-per-plug: 00`, `slots-per-plug in allow-auto-connection in plug rule for interface "iface" has invalid prefix zeros: 00`}, + {`iface: + allow-auto-connection: + slots-per-plug: 99999999999999999999`, `slots-per-plug in allow-auto-connection in plug rule for interface "iface" is out of range: 99999999999999999999`}, + {`iface: + allow-auto-connection: + slots-per-plug: 0`, `slots-per-plug in allow-auto-connection in plug rule for interface "iface" must be an integer >=1 or \*`}, + {`iface: + allow-auto-connection: + slots-per-plug: + what: 1`, `slots-per-plug in allow-auto-connection in plug rule for interface "iface" must be an integer >=1 or \*`}, + {`iface: + allow-auto-connection: + plug-names: true`, `plug-names constraints must be a list of regexps and special \$ values`}, + {`iface: + allow-auto-connection: + slot-names: true`, `slot-names constraints must be a list of regexps and special \$ values`}, + } + + for _, t := range tests { + m, err := asserts.ParseHeaders([]byte(t.stanza)) + c.Assert(err, IsNil, Commentf(t.stanza)) + + _, err = asserts.CompilePlugRule("iface", m["iface"]) + c.Check(err, ErrorMatches, t.err, Commentf(t.stanza)) + } +} + +var ( + deviceScopeConstrs = map[string][]interface{}{ + "on-store": {"store"}, + "on-brand": {"brand"}, + "on-model": {"brand/model"}, + } +) + +func (s *plugSlotRulesSuite) TestPlugRuleFeatures(c *C) { + combos := []struct { + subrule string + constraintsPrefixes []string + }{ + {"allow-installation", []string{"plug-"}}, + {"deny-installation", []string{"plug-"}}, + {"allow-connection", []string{"plug-", "slot-"}}, + {"deny-connection", []string{"plug-", "slot-"}}, + {"allow-auto-connection", []string{"plug-", "slot-"}}, + {"deny-auto-connection", []string{"plug-", "slot-"}}, + } + + for _, combo := range combos { + for _, attrConstrPrefix := range combo.constraintsPrefixes { + attrConstraintMap := map[string]interface{}{ + "a": "ATTR", + "other": []interface{}{"x", "y"}, + } + ruleMap := map[string]interface{}{ + combo.subrule: map[string]interface{}{ + attrConstrPrefix + "attributes": attrConstraintMap, + }, + } + + rule, err := asserts.CompilePlugRule("iface", ruleMap) + c.Assert(err, IsNil) + c.Check(asserts.RuleFeature(rule, "dollar-attr-constraints"), Equals, false, Commentf("%v", ruleMap)) + + c.Check(asserts.RuleFeature(rule, "device-scope-constraints"), Equals, false, Commentf("%v", ruleMap)) + c.Check(asserts.RuleFeature(rule, "name-constraints"), Equals, false, Commentf("%v", ruleMap)) + + attrConstraintMap["a"] = "$MISSING" + rule, err = asserts.CompilePlugRule("iface", ruleMap) + c.Assert(err, IsNil) + c.Check(asserts.RuleFeature(rule, "dollar-attr-constraints"), Equals, true, Commentf("%v", ruleMap)) + + // covers also alternation + attrConstraintMap["a"] = []interface{}{"$SLOT(a)"} + rule, err = asserts.CompilePlugRule("iface", ruleMap) + c.Assert(err, IsNil) + c.Check(asserts.RuleFeature(rule, "dollar-attr-constraints"), Equals, true, Commentf("%v", ruleMap)) + + c.Check(asserts.RuleFeature(rule, "device-scope-constraints"), Equals, false, Commentf("%v", ruleMap)) + c.Check(asserts.RuleFeature(rule, "name-constraints"), Equals, false, Commentf("%v", ruleMap)) + + } + + for deviceScopeConstr, value := range deviceScopeConstrs { + ruleMap := map[string]interface{}{ + combo.subrule: map[string]interface{}{ + deviceScopeConstr: value, + }, + } + + rule, err := asserts.CompilePlugRule("iface", ruleMap) + c.Assert(err, IsNil) + c.Check(asserts.RuleFeature(rule, "device-scope-constraints"), Equals, true, Commentf("%v", ruleMap)) + } + + for _, nameConstrPrefix := range combo.constraintsPrefixes { + ruleMap := map[string]interface{}{ + combo.subrule: map[string]interface{}{ + nameConstrPrefix + "names": []interface{}{"foo"}, + }, + } + + rule, err := asserts.CompilePlugRule("iface", ruleMap) + c.Assert(err, IsNil) + c.Check(asserts.RuleFeature(rule, "name-constraints"), Equals, true, Commentf("%v", ruleMap)) + } + } +} + +func (s *plugSlotRulesSuite) TestCompileSlotRuleAllAllowDenyStanzas(c *C) { + m, err := asserts.ParseHeaders([]byte(`iface: + allow-installation: + slot-attributes: + a1: A1 + deny-installation: + slot-attributes: + a2: A2 + allow-connection: + plug-attributes: + pa3: PA3 + slot-attributes: + sa3: SA3 + deny-connection: + plug-attributes: + pa4: PA4 + slot-attributes: + sa4: SA4 + allow-auto-connection: + plug-attributes: + pa5: PA5 + slot-attributes: + sa5: SA5 + deny-auto-connection: + plug-attributes: + pa6: PA6 + slot-attributes: + sa6: SA6`)) + c.Assert(err, IsNil) + + rule, err := asserts.CompileSlotRule("iface", m["iface"].(map[string]interface{})) + c.Assert(err, IsNil) + + c.Check(rule.Interface, Equals, "iface") + // install subrules + c.Assert(rule.AllowInstallation, HasLen, 1) + checkAttrs(c, rule.AllowInstallation[0].SlotAttributes, "a1", "A1") + c.Assert(rule.DenyInstallation, HasLen, 1) + checkAttrs(c, rule.DenyInstallation[0].SlotAttributes, "a2", "A2") + // connection subrules + c.Assert(rule.AllowConnection, HasLen, 1) + checkAttrs(c, rule.AllowConnection[0].PlugAttributes, "pa3", "PA3") + checkAttrs(c, rule.AllowConnection[0].SlotAttributes, "sa3", "SA3") + c.Assert(rule.DenyConnection, HasLen, 1) + checkAttrs(c, rule.DenyConnection[0].PlugAttributes, "pa4", "PA4") + checkAttrs(c, rule.DenyConnection[0].SlotAttributes, "sa4", "SA4") + // auto-connection subrules + c.Assert(rule.AllowAutoConnection, HasLen, 1) + checkAttrs(c, rule.AllowAutoConnection[0].PlugAttributes, "pa5", "PA5") + checkAttrs(c, rule.AllowAutoConnection[0].SlotAttributes, "sa5", "SA5") + c.Assert(rule.DenyAutoConnection, HasLen, 1) + checkAttrs(c, rule.DenyAutoConnection[0].PlugAttributes, "pa6", "PA6") + checkAttrs(c, rule.DenyAutoConnection[0].SlotAttributes, "sa6", "SA6") +} + +func (s *plugSlotRulesSuite) TestCompileSlotRuleAllAllowDenyOrStanzas(c *C) { + m, err := asserts.ParseHeaders([]byte(`iface: + allow-installation: + - + slot-attributes: + a1: A1 + - + slot-attributes: + a1: A1alt + deny-installation: + - + slot-attributes: + a2: A2 + - + slot-attributes: + a2: A2alt + allow-connection: + - + plug-attributes: + pa3: PA3 + slot-attributes: + sa3: SA3 + - + slot-attributes: + sa3: SA3alt + deny-connection: + - + plug-attributes: + pa4: PA4 + slot-attributes: + sa4: SA4 + - + slot-attributes: + sa4: SA4alt + allow-auto-connection: + - + plug-attributes: + pa5: PA5 + slot-attributes: + sa5: SA5 + - + slot-attributes: + sa5: SA5alt + deny-auto-connection: + - + plug-attributes: + pa6: PA6 + slot-attributes: + sa6: SA6 + - + slot-attributes: + sa6: SA6alt`)) + c.Assert(err, IsNil) + + rule, err := asserts.CompileSlotRule("iface", m["iface"].(map[string]interface{})) + c.Assert(err, IsNil) + + c.Check(rule.Interface, Equals, "iface") + // install subrules + c.Assert(rule.AllowInstallation, HasLen, 2) + checkAttrs(c, rule.AllowInstallation[0].SlotAttributes, "a1", "A1") + checkAttrs(c, rule.AllowInstallation[1].SlotAttributes, "a1", "A1alt") + c.Assert(rule.DenyInstallation, HasLen, 2) + checkAttrs(c, rule.DenyInstallation[0].SlotAttributes, "a2", "A2") + checkAttrs(c, rule.DenyInstallation[1].SlotAttributes, "a2", "A2alt") + // connection subrules + c.Assert(rule.AllowConnection, HasLen, 2) + checkAttrs(c, rule.AllowConnection[0].PlugAttributes, "pa3", "PA3") + checkAttrs(c, rule.AllowConnection[0].SlotAttributes, "sa3", "SA3") + checkAttrs(c, rule.AllowConnection[1].SlotAttributes, "sa3", "SA3alt") + c.Assert(rule.DenyConnection, HasLen, 2) + checkAttrs(c, rule.DenyConnection[0].PlugAttributes, "pa4", "PA4") + checkAttrs(c, rule.DenyConnection[0].SlotAttributes, "sa4", "SA4") + checkAttrs(c, rule.DenyConnection[1].SlotAttributes, "sa4", "SA4alt") + // auto-connection subrules + c.Assert(rule.AllowAutoConnection, HasLen, 2) + checkAttrs(c, rule.AllowAutoConnection[0].PlugAttributes, "pa5", "PA5") + checkAttrs(c, rule.AllowAutoConnection[0].SlotAttributes, "sa5", "SA5") + checkAttrs(c, rule.AllowAutoConnection[1].SlotAttributes, "sa5", "SA5alt") + c.Assert(rule.DenyAutoConnection, HasLen, 2) + checkAttrs(c, rule.DenyAutoConnection[0].PlugAttributes, "pa6", "PA6") + checkAttrs(c, rule.DenyAutoConnection[0].SlotAttributes, "sa6", "SA6") + checkAttrs(c, rule.DenyAutoConnection[1].SlotAttributes, "sa6", "SA6alt") +} + +func (s *plugSlotRulesSuite) TestCompileSlotRuleShortcutTrue(c *C) { + rule, err := asserts.CompileSlotRule("iface", "true") + c.Assert(err, IsNil) + + c.Check(rule.Interface, Equals, "iface") + // install subrules + c.Assert(rule.AllowInstallation, HasLen, 1) + c.Check(rule.AllowInstallation[0].SlotAttributes, Equals, asserts.AlwaysMatchAttributes) + c.Assert(rule.DenyInstallation, HasLen, 1) + c.Check(rule.DenyInstallation[0].SlotAttributes, Equals, asserts.NeverMatchAttributes) + // connection subrules + checkBoolSlotConnConstraints(c, "allow-connection", rule.AllowConnection, true) + checkBoolSlotConnConstraints(c, "deny-connection", rule.DenyConnection, false) + // auto-connection subrules + checkBoolSlotConnConstraints(c, "allow-auto-connection", rule.AllowAutoConnection, true) + checkBoolSlotConnConstraints(c, "deny-auto-connection", rule.DenyAutoConnection, false) +} + +func (s *plugSlotRulesSuite) TestCompileSlotRuleShortcutFalse(c *C) { + rule, err := asserts.CompileSlotRule("iface", "false") + c.Assert(err, IsNil) + + // install subrules + c.Assert(rule.AllowInstallation, HasLen, 1) + c.Check(rule.AllowInstallation[0].SlotAttributes, Equals, asserts.NeverMatchAttributes) + c.Assert(rule.DenyInstallation, HasLen, 1) + c.Check(rule.DenyInstallation[0].SlotAttributes, Equals, asserts.AlwaysMatchAttributes) + // connection subrules + checkBoolSlotConnConstraints(c, "allwo-connection", rule.AllowConnection, false) + checkBoolSlotConnConstraints(c, "deny-connection", rule.DenyConnection, true) + // auto-connection subrules + checkBoolSlotConnConstraints(c, "allow-auto-connection", rule.AllowAutoConnection, false) + checkBoolSlotConnConstraints(c, "deny-auto-connection", rule.DenyAutoConnection, true) +} + +func (s *plugSlotRulesSuite) TestCompileSlotRuleDefaults(c *C) { + rule, err := asserts.CompileSlotRule("iface", map[string]interface{}{ + "deny-auto-connection": "true", + }) + c.Assert(err, IsNil) + + // everything follows the defaults... + + // install subrules + c.Assert(rule.AllowInstallation, HasLen, 1) + c.Check(rule.AllowInstallation[0].SlotAttributes, Equals, asserts.AlwaysMatchAttributes) + c.Assert(rule.DenyInstallation, HasLen, 1) + c.Check(rule.DenyInstallation[0].SlotAttributes, Equals, asserts.NeverMatchAttributes) + // connection subrules + checkBoolSlotConnConstraints(c, "allow-connection", rule.AllowConnection, true) + checkBoolSlotConnConstraints(c, "deny-connection", rule.DenyConnection, false) + // auto-connection subrules + checkBoolSlotConnConstraints(c, "allow-auto-connection", rule.AllowAutoConnection, true) + // ... but deny-auto-connection is on + checkBoolSlotConnConstraints(c, "deny-auto-connection", rule.DenyAutoConnection, true) +} + +func (s *plugSlotRulesSuite) TestCompileSlotRuleInstallationConstraintsIDConstraints(c *C) { + rule, err := asserts.CompileSlotRule("iface", map[string]interface{}{ + "allow-installation": map[string]interface{}{ + "slot-snap-type": []interface{}{"core", "kernel", "gadget", "app"}, + "slot-snap-id": []interface{}{"snapidsnapidsnapidsnapidsnapid01", "snapidsnapidsnapidsnapidsnapid02"}, + }, + }) + c.Assert(err, IsNil) + + c.Assert(rule.AllowInstallation, HasLen, 1) + cstrs := rule.AllowInstallation[0] + c.Check(cstrs.SlotSnapTypes, DeepEquals, []string{"core", "kernel", "gadget", "app"}) + c.Check(cstrs.SlotSnapIDs, DeepEquals, []string{"snapidsnapidsnapidsnapidsnapid01", "snapidsnapidsnapidsnapidsnapid02"}) +} + +func (s *plugSlotRulesSuite) TestCompileSlotRuleInstallationConstraintsOnClassic(c *C) { + m, err := asserts.ParseHeaders([]byte(`iface: + allow-installation: true`)) + c.Assert(err, IsNil) + + rule, err := asserts.CompileSlotRule("iface", m["iface"].(map[string]interface{})) + c.Assert(err, IsNil) + + c.Check(rule.AllowInstallation[0].OnClassic, IsNil) + + m, err = asserts.ParseHeaders([]byte(`iface: + allow-installation: + on-classic: false`)) + c.Assert(err, IsNil) + + rule, err = asserts.CompileSlotRule("iface", m["iface"].(map[string]interface{})) + c.Assert(err, IsNil) + + c.Check(rule.AllowInstallation[0].OnClassic, DeepEquals, &asserts.OnClassicConstraint{}) + + m, err = asserts.ParseHeaders([]byte(`iface: + allow-installation: + on-classic: true`)) + c.Assert(err, IsNil) + + rule, err = asserts.CompileSlotRule("iface", m["iface"].(map[string]interface{})) + c.Assert(err, IsNil) + + c.Check(rule.AllowInstallation[0].OnClassic, DeepEquals, &asserts.OnClassicConstraint{Classic: true}) + + m, err = asserts.ParseHeaders([]byte(`iface: + allow-installation: + on-classic: + - ubuntu + - debian`)) + c.Assert(err, IsNil) + + rule, err = asserts.CompileSlotRule("iface", m["iface"].(map[string]interface{})) + c.Assert(err, IsNil) + + c.Check(rule.AllowInstallation[0].OnClassic, DeepEquals, &asserts.OnClassicConstraint{Classic: true, SystemIDs: []string{"ubuntu", "debian"}}) +} + +func (s *plugSlotRulesSuite) TestCompileSlotRuleInstallationConstraintsDeviceScope(c *C) { + m, err := asserts.ParseHeaders([]byte(`iface: + allow-installation: true`)) + c.Assert(err, IsNil) + + rule, err := asserts.CompileSlotRule("iface", m["iface"].(map[string]interface{})) + c.Assert(err, IsNil) + + c.Check(rule.AllowInstallation[0].DeviceScope, IsNil) + + tests := []struct { + rule string + expected asserts.DeviceScopeConstraint + }{ + {`iface: + allow-installation: + on-store: + - my-store`, asserts.DeviceScopeConstraint{Store: []string{"my-store"}}}, + {`iface: + allow-installation: + on-store: + - my-store + - other-store`, asserts.DeviceScopeConstraint{Store: []string{"my-store", "other-store"}}}, + {`iface: + allow-installation: + on-brand: + - my-brand + - s9zGdwb16ysLeRW6nRivwZS5r9puP8JT`, asserts.DeviceScopeConstraint{Brand: []string{"my-brand", "s9zGdwb16ysLeRW6nRivwZS5r9puP8JT"}}}, + {`iface: + allow-installation: + on-model: + - my-brand/bar + - s9zGdwb16ysLeRW6nRivwZS5r9puP8JT/baz`, asserts.DeviceScopeConstraint{Model: []string{"my-brand/bar", "s9zGdwb16ysLeRW6nRivwZS5r9puP8JT/baz"}}}, + {`iface: + allow-installation: + on-store: + - store1 + - store2 + on-brand: + - my-brand + on-model: + - my-brand/bar + - s9zGdwb16ysLeRW6nRivwZS5r9puP8JT/baz`, asserts.DeviceScopeConstraint{ + Store: []string{"store1", "store2"}, + Brand: []string{"my-brand"}, + Model: []string{"my-brand/bar", "s9zGdwb16ysLeRW6nRivwZS5r9puP8JT/baz"}}}, + } + + for _, t := range tests { + m, err = asserts.ParseHeaders([]byte(t.rule)) + c.Assert(err, IsNil) + + rule, err = asserts.CompileSlotRule("iface", m["iface"].(map[string]interface{})) + c.Assert(err, IsNil) + + c.Check(rule.AllowInstallation[0].DeviceScope, DeepEquals, &t.expected) + } +} + +func (s *plugSlotRulesSuite) TestCompileSlotRuleInstallationConstraintsSlotNames(c *C) { + m, err := asserts.ParseHeaders([]byte(`iface: + allow-installation: true`)) + c.Assert(err, IsNil) + + rule, err := asserts.CompileSlotRule("iface", m["iface"].(map[string]interface{})) + c.Assert(err, IsNil) + + c.Check(rule.AllowInstallation[0].SlotNames, IsNil) + + tests := []struct { + rule string + matching []string + notMatching []string + }{ + {`iface: + allow-installation: + slot-names: + - foo`, []string{"foo"}, []string{"bar"}}, + {`iface: + allow-installation: + slot-names: + - foo + - bar`, []string{"foo", "bar"}, []string{"baz"}}, + {`iface: + allow-installation: + slot-names: + - foo[0-9] + - bar`, []string{"foo0", "foo1", "bar"}, []string{"baz", "fooo", "foo12"}}, + } + for _, t := range tests { + m, err = asserts.ParseHeaders([]byte(t.rule)) + c.Assert(err, IsNil) + + rule, err = asserts.CompileSlotRule("iface", m["iface"].(map[string]interface{})) + c.Assert(err, IsNil) + + for _, matching := range t.matching { + c.Check(rule.AllowInstallation[0].SlotNames.Check("slot name", matching, nil), IsNil) + } + for _, notMatching := range t.notMatching { + c.Check(rule.AllowInstallation[0].SlotNames.Check("slot name", notMatching, nil), NotNil) + } + } +} + +func (s *plugSlotRulesSuite) TestCompileSlotRuleConnectionConstraintsIDConstraints(c *C) { + rule, err := asserts.CompileSlotRule("iface", map[string]interface{}{ + "allow-connection": map[string]interface{}{ + "slot-snap-type": []interface{}{"core"}, + "plug-snap-type": []interface{}{"core", "kernel", "gadget", "app"}, + "plug-snap-id": []interface{}{"snapidsnapidsnapidsnapidsnapid01", "snapidsnapidsnapidsnapidsnapid02"}, + "plug-publisher-id": []interface{}{"pubidpubidpubidpubidpubidpubid09", "canonical", "$SAME"}, + }, + }) + c.Assert(err, IsNil) + + c.Assert(rule.AllowConnection, HasLen, 1) + cstrs := rule.AllowConnection[0] + c.Check(cstrs.SlotSnapTypes, DeepEquals, []string{"core"}) + c.Check(cstrs.PlugSnapTypes, DeepEquals, []string{"core", "kernel", "gadget", "app"}) + c.Check(cstrs.PlugSnapIDs, DeepEquals, []string{"snapidsnapidsnapidsnapidsnapid01", "snapidsnapidsnapidsnapidsnapid02"}) + c.Check(cstrs.PlugPublisherIDs, DeepEquals, []string{"pubidpubidpubidpubidpubidpubid09", "canonical", "$SAME"}) +} + +func (s *plugSlotRulesSuite) TestCompileSlotRuleConnectionConstraintsOnClassic(c *C) { + m, err := asserts.ParseHeaders([]byte(`iface: + allow-connection: true`)) + c.Assert(err, IsNil) + + rule, err := asserts.CompileSlotRule("iface", m["iface"].(map[string]interface{})) + c.Assert(err, IsNil) + + c.Check(rule.AllowConnection[0].OnClassic, IsNil) + + m, err = asserts.ParseHeaders([]byte(`iface: + allow-connection: + on-classic: false`)) + c.Assert(err, IsNil) + + rule, err = asserts.CompileSlotRule("iface", m["iface"].(map[string]interface{})) + c.Assert(err, IsNil) + + c.Check(rule.AllowConnection[0].OnClassic, DeepEquals, &asserts.OnClassicConstraint{}) + + m, err = asserts.ParseHeaders([]byte(`iface: + allow-connection: + on-classic: true`)) + c.Assert(err, IsNil) + + rule, err = asserts.CompileSlotRule("iface", m["iface"].(map[string]interface{})) + c.Assert(err, IsNil) + + c.Check(rule.AllowConnection[0].OnClassic, DeepEquals, &asserts.OnClassicConstraint{Classic: true}) + + m, err = asserts.ParseHeaders([]byte(`iface: + allow-connection: + on-classic: + - ubuntu + - debian`)) + c.Assert(err, IsNil) + + rule, err = asserts.CompileSlotRule("iface", m["iface"].(map[string]interface{})) + c.Assert(err, IsNil) + + c.Check(rule.AllowConnection[0].OnClassic, DeepEquals, &asserts.OnClassicConstraint{Classic: true, SystemIDs: []string{"ubuntu", "debian"}}) +} + +func (s *plugSlotRulesSuite) TestCompileSlotRuleConnectionConstraintsDeviceScope(c *C) { + m, err := asserts.ParseHeaders([]byte(`iface: + allow-connection: true`)) + c.Assert(err, IsNil) + + rule, err := asserts.CompileSlotRule("iface", m["iface"].(map[string]interface{})) + c.Assert(err, IsNil) + + c.Check(rule.AllowConnection[0].DeviceScope, IsNil) + + tests := []struct { + rule string + expected asserts.DeviceScopeConstraint + }{ + {`iface: + allow-connection: + on-store: + - my-store`, asserts.DeviceScopeConstraint{Store: []string{"my-store"}}}, + {`iface: + allow-connection: + on-store: + - my-store + - other-store`, asserts.DeviceScopeConstraint{Store: []string{"my-store", "other-store"}}}, + {`iface: + allow-connection: + on-brand: + - my-brand + - s9zGdwb16ysLeRW6nRivwZS5r9puP8JT`, asserts.DeviceScopeConstraint{Brand: []string{"my-brand", "s9zGdwb16ysLeRW6nRivwZS5r9puP8JT"}}}, + {`iface: + allow-connection: + on-model: + - my-brand/bar + - s9zGdwb16ysLeRW6nRivwZS5r9puP8JT/baz`, asserts.DeviceScopeConstraint{Model: []string{"my-brand/bar", "s9zGdwb16ysLeRW6nRivwZS5r9puP8JT/baz"}}}, + {`iface: + allow-connection: + on-store: + - store1 + - store2 + on-brand: + - my-brand + on-model: + - my-brand/bar + - s9zGdwb16ysLeRW6nRivwZS5r9puP8JT/baz`, asserts.DeviceScopeConstraint{ + Store: []string{"store1", "store2"}, + Brand: []string{"my-brand"}, + Model: []string{"my-brand/bar", "s9zGdwb16ysLeRW6nRivwZS5r9puP8JT/baz"}}}, + } + + for _, t := range tests { + m, err = asserts.ParseHeaders([]byte(t.rule)) + c.Assert(err, IsNil) + + rule, err = asserts.CompileSlotRule("iface", m["iface"].(map[string]interface{})) + c.Assert(err, IsNil) + + c.Check(rule.AllowConnection[0].DeviceScope, DeepEquals, &t.expected) + } +} + +func (s *plugSlotRulesSuite) TestCompileSlotRuleConnectionConstraintsPlugNamesSlotNames(c *C) { + m, err := asserts.ParseHeaders([]byte(`iface: + allow-connection: true`)) + c.Assert(err, IsNil) + + rule, err := asserts.CompileSlotRule("iface", m["iface"].(map[string]interface{})) + c.Assert(err, IsNil) + + c.Check(rule.AllowConnection[0].PlugNames, IsNil) + c.Check(rule.AllowConnection[0].SlotNames, IsNil) + + tests := []struct { + rule string + matching []string + notMatching []string + }{ + {`iface: + allow-connection: + plug-names: + - Pfoo + slot-names: + - Sfoo`, []string{"foo"}, []string{"bar"}}, + {`iface: + allow-connection: + plug-names: + - Pfoo + - Pbar + slot-names: + - Sfoo + - Sbar`, []string{"foo", "bar"}, []string{"baz"}}, + {`iface: + allow-connection: + plug-names: + - Pfoo[0-9] + - Pbar + slot-names: + - Sfoo[0-9] + - Sbar`, []string{"foo0", "foo1", "bar"}, []string{"baz", "fooo", "foo12"}}, + } + for _, t := range tests { + m, err = asserts.ParseHeaders([]byte(t.rule)) + c.Assert(err, IsNil) + + rule, err = asserts.CompileSlotRule("iface", m["iface"].(map[string]interface{})) + c.Assert(err, IsNil) + + for _, matching := range t.matching { + c.Check(rule.AllowConnection[0].PlugNames.Check("plug name", "P"+matching, nil), IsNil) + + c.Check(rule.AllowConnection[0].SlotNames.Check("slot name", "S"+matching, nil), IsNil) + } + + for _, notMatching := range t.notMatching { + c.Check(rule.AllowConnection[0].SlotNames.Check("plug name", "P"+notMatching, nil), NotNil) + + c.Check(rule.AllowConnection[0].SlotNames.Check("slot name", "S"+notMatching, nil), NotNil) + } + } +} + +func (s *plugSlotRulesSuite) TestCompileSlotRuleConnectionConstraintsSideArityConstraints(c *C) { + m, err := asserts.ParseHeaders([]byte(`iface: + allow-auto-connection: true`)) + c.Assert(err, IsNil) + + rule, err := asserts.CompileSlotRule("iface", m["iface"].(map[string]interface{})) + c.Assert(err, IsNil) + + // defaults + c.Check(rule.AllowAutoConnection[0].SlotsPerPlug, Equals, asserts.SideArityConstraint{N: 1}) + c.Check(rule.AllowAutoConnection[0].PlugsPerSlot.Any(), Equals, true) + + c.Check(rule.AllowConnection[0].SlotsPerPlug.Any(), Equals, true) + c.Check(rule.AllowConnection[0].PlugsPerSlot.Any(), Equals, true) + + // test that the arity constraints get normalized away to any + // under allow-connection + // see https://forum.snapcraft.io/t/plug-slot-declaration-rules-greedy-plugs/12438 + allowConnTests := []string{ + `iface: + allow-connection: + slots-per-plug: 1 + plugs-per-slot: 2`, + `iface: + allow-connection: + slots-per-plug: * + plugs-per-slot: 1`, + `iface: + allow-connection: + slots-per-plug: 2 + plugs-per-slot: *`, + } + + for _, t := range allowConnTests { + m, err = asserts.ParseHeaders([]byte(t)) + c.Assert(err, IsNil) + + rule, err = asserts.CompileSlotRule("iface", m["iface"].(map[string]interface{})) + c.Assert(err, IsNil) + + c.Check(rule.AllowConnection[0].SlotsPerPlug.Any(), Equals, true) + c.Check(rule.AllowConnection[0].PlugsPerSlot.Any(), Equals, true) + } + + // test that under allow-auto-connection: + // slots-per-plug can be * (any) or otherwise gets normalized to 1 + // plugs-per-slot gets normalized to any (*) + // see https://forum.snapcraft.io/t/plug-slot-declaration-rules-greedy-plugs/12438 + allowAutoConnTests := []struct { + rule string + slotsPerPlug asserts.SideArityConstraint + }{ + {`iface: + allow-auto-connection: + slots-per-plug: 1 + plugs-per-slot: 2`, sideArityOne}, + {`iface: + allow-auto-connection: + slots-per-plug: * + plugs-per-slot: 1`, sideArityAny}, + {`iface: + allow-auto-connection: + slots-per-plug: 2 + plugs-per-slot: *`, sideArityOne}, + } + + for _, t := range allowAutoConnTests { + m, err = asserts.ParseHeaders([]byte(t.rule)) + c.Assert(err, IsNil) + + rule, err = asserts.CompileSlotRule("iface", m["iface"].(map[string]interface{})) + c.Assert(err, IsNil) + + c.Check(rule.AllowAutoConnection[0].SlotsPerPlug, Equals, t.slotsPerPlug) + c.Check(rule.AllowAutoConnection[0].PlugsPerSlot.Any(), Equals, true) + } +} + +func (s *plugSlotRulesSuite) TestCompileSlotRuleErrors(c *C) { + tests := []struct { + stanza string + err string + }{ + {`iface: foo`, `slot rule for interface "iface" must be a map or one of the shortcuts 'true' or 'false'`}, + {`iface: + - allow`, `slot rule for interface "iface" must be a map or one of the shortcuts 'true' or 'false'`}, + {`iface: + allow-installation: foo`, `allow-installation in slot rule for interface "iface" must be a map or one of the shortcuts 'true' or 'false'`}, + {`iface: + deny-installation: foo`, `deny-installation in slot rule for interface "iface" must be a map or one of the shortcuts 'true' or 'false'`}, + {`iface: + allow-connection: foo`, `allow-connection in slot rule for interface "iface" must be a map or one of the shortcuts 'true' or 'false'`}, + {`iface: + allow-connection: + - foo`, `alternative 1 of allow-connection in slot rule for interface "iface" must be a map`}, + {`iface: + allow-connection: + - true`, `alternative 1 of allow-connection in slot rule for interface "iface" must be a map`}, + {`iface: + allow-installation: + slot-attributes: + a1: [`, `cannot compile slot-attributes in allow-installation in slot rule for interface "iface": cannot compile "a1" constraint .*`}, + {`iface: + allow-connection: + plug-attributes: + a2: [`, `cannot compile plug-attributes in allow-connection in slot rule for interface "iface": cannot compile "a2" constraint .*`}, + {`iface: + allow-connection: + plug-snap-id: + - + foo: 1`, `plug-snap-id in allow-connection in slot rule for interface "iface" must be a list of strings`}, + {`iface: + allow-connection: + plug-snap-id: + - foo`, `plug-snap-id in allow-connection in slot rule for interface "iface" contains an invalid element: "foo"`}, + {`iface: + allow-connection: + slot-snap-type: + - foo`, `slot-snap-type in allow-connection in slot rule for interface "iface" contains an invalid element: "foo"`}, + {`iface: + allow-connection: + plug-snap-type: + - foo`, `plug-snap-type in allow-connection in slot rule for interface "iface" contains an invalid element: "foo"`}, + {`iface: + allow-connection: + plug-snap-type: + - xapp`, `plug-snap-type in allow-connection in slot rule for interface "iface" contains an invalid element: "xapp"`}, + {`iface: + allow-connection: + on-classic: + x: 1`, `on-classic in allow-connection in slot rule for interface \"iface\" must be 'true', 'false' or a list of operating system IDs`}, + {`iface: + allow-connection: + on-classic: + - zoom!`, `on-classic in allow-connection in slot rule for interface \"iface\" contains an invalid element: \"zoom!\"`}, + {`iface: + allow-connection: + plug-snap-ids: + - foo`, `allow-connection in slot rule for interface "iface" must specify at least one of plug-names, slot-names, plug-attributes, slot-attributes, slot-snap-type, plug-snap-type, plug-publisher-id, plug-snap-id, slots-per-plug, plugs-per-slot, on-classic, on-store, on-brand, on-model`}, + {`iface: + deny-connection: + plug-snap-ids: + - foo`, `deny-connection in slot rule for interface "iface" must specify at least one of plug-names, slot-names, plug-attributes, slot-attributes, slot-snap-type, plug-snap-type, plug-publisher-id, plug-snap-id, slots-per-plug, plugs-per-slot, on-classic, on-store, on-brand, on-model`}, + {`iface: + allow-auto-connection: + plug-snap-ids: + - foo`, `allow-auto-connection in slot rule for interface "iface" must specify at least one of plug-names, slot-names, plug-attributes, slot-attributes, slot-snap-type, plug-snap-type, plug-publisher-id, plug-snap-id, slots-per-plug, plugs-per-slot, on-classic, on-store, on-brand, on-model`}, + {`iface: + deny-auto-connection: + plug-snap-ids: + - foo`, `deny-auto-connection in slot rule for interface "iface" must specify at least one of plug-names, slot-names, plug-attributes, slot-attributes, slot-snap-type, plug-snap-type, plug-publisher-id, plug-snap-id, slots-per-plug, plugs-per-slot, on-classic, on-store, on-brand, on-model`}, + {`iface: + allow-connect: true`, `slot rule for interface "iface" must specify at least one of allow-installation, deny-installation, allow-connection, deny-connection, allow-auto-connection, deny-auto-connection`}, + {`iface: + allow-installation: + on-store: true`, `on-store in allow-installation in slot rule for interface \"iface\" must be a list of strings`}, + {`iface: + allow-installation: + on-store: store1`, `on-store in allow-installation in slot rule for interface \"iface\" must be a list of strings`}, + {`iface: + allow-installation: + on-store: + - zoom!`, `on-store in allow-installation in slot rule for interface \"iface\" contains an invalid element: \"zoom!\"`}, + {`iface: + allow-connection: + on-brand: true`, `on-brand in allow-connection in slot rule for interface \"iface\" must be a list of strings`}, + {`iface: + allow-connection: + on-brand: brand1`, `on-brand in allow-connection in slot rule for interface \"iface\" must be a list of strings`}, + {`iface: + allow-connection: + on-brand: + - zoom!`, `on-brand in allow-connection in slot rule for interface \"iface\" contains an invalid element: \"zoom!\"`}, + {`iface: + allow-auto-connection: + on-model: true`, `on-model in allow-auto-connection in slot rule for interface \"iface\" must be a list of strings`}, + {`iface: + allow-auto-connection: + on-model: foo/bar`, `on-model in allow-auto-connection in slot rule for interface \"iface\" must be a list of strings`}, + {`iface: + allow-auto-connection: + on-model: + - foo//bar`, `on-model in allow-auto-connection in slot rule for interface \"iface\" contains an invalid element: \"foo//bar"`}, + {`iface: + allow-installation: + slots-per-plug: 1`, `allow-installation in slot rule for interface "iface" cannot specify a slots-per-plug constraint, they apply only to allow-\*connection`}, + {`iface: + deny-auto-connection: + slots-per-plug: 1`, `deny-auto-connection in slot rule for interface "iface" cannot specify a slots-per-plug constraint, they apply only to allow-\*connection`}, + {`iface: + allow-auto-connection: + plugs-per-slot: any`, `plugs-per-slot in allow-auto-connection in slot rule for interface "iface" must be an integer >=1 or \*`}, + {`iface: + allow-auto-connection: + slots-per-plug: 00`, `slots-per-plug in allow-auto-connection in slot rule for interface "iface" has invalid prefix zeros: 00`}, + {`iface: + allow-auto-connection: + slots-per-plug: 99999999999999999999`, `slots-per-plug in allow-auto-connection in slot rule for interface "iface" is out of range: 99999999999999999999`}, + {`iface: + allow-auto-connection: + slots-per-plug: 0`, `slots-per-plug in allow-auto-connection in slot rule for interface "iface" must be an integer >=1 or \*`}, + {`iface: + allow-auto-connection: + slots-per-plug: + what: 1`, `slots-per-plug in allow-auto-connection in slot rule for interface "iface" must be an integer >=1 or \*`}, + {`iface: + allow-auto-connection: + plug-names: true`, `plug-names constraints must be a list of regexps and special \$ values`}, + {`iface: + allow-auto-connection: + slot-names: true`, `slot-names constraints must be a list of regexps and special \$ values`}, + } + + for _, t := range tests { + m, err := asserts.ParseHeaders([]byte(t.stanza)) + c.Assert(err, IsNil, Commentf(t.stanza)) + _, err = asserts.CompileSlotRule("iface", m["iface"]) + c.Check(err, ErrorMatches, t.err, Commentf(t.stanza)) + } +} + +func (s *plugSlotRulesSuite) TestSlotRuleFeatures(c *C) { + combos := []struct { + subrule string + constraintsPrefixes []string + }{ + {"allow-installation", []string{"slot-"}}, + {"deny-installation", []string{"slot-"}}, + {"allow-connection", []string{"plug-", "slot-"}}, + {"deny-connection", []string{"plug-", "slot-"}}, + {"allow-auto-connection", []string{"plug-", "slot-"}}, + {"deny-auto-connection", []string{"plug-", "slot-"}}, + } + + for _, combo := range combos { + for _, attrConstrPrefix := range combo.constraintsPrefixes { + attrConstraintMap := map[string]interface{}{ + "a": "ATTR", + } + ruleMap := map[string]interface{}{ + combo.subrule: map[string]interface{}{ + attrConstrPrefix + "attributes": attrConstraintMap, + }, + } + + rule, err := asserts.CompileSlotRule("iface", ruleMap) + c.Assert(err, IsNil) + c.Check(asserts.RuleFeature(rule, "dollar-attr-constraints"), Equals, false, Commentf("%v", ruleMap)) + + c.Check(asserts.RuleFeature(rule, "device-scope-constraints"), Equals, false, Commentf("%v", ruleMap)) + + attrConstraintMap["a"] = "$PLUG(a)" + rule, err = asserts.CompileSlotRule("iface", ruleMap) + c.Assert(err, IsNil) + c.Check(asserts.RuleFeature(rule, "dollar-attr-constraints"), Equals, true, Commentf("%v", ruleMap)) + + c.Check(asserts.RuleFeature(rule, "device-scope-constraints"), Equals, false, Commentf("%v", ruleMap)) + } + + for deviceScopeConstr, value := range deviceScopeConstrs { + ruleMap := map[string]interface{}{ + combo.subrule: map[string]interface{}{ + deviceScopeConstr: value, + }, + } + + rule, err := asserts.CompileSlotRule("iface", ruleMap) + c.Assert(err, IsNil) + c.Check(asserts.RuleFeature(rule, "device-scope-constraints"), Equals, true, Commentf("%v", ruleMap)) + } + + for _, nameConstrPrefix := range combo.constraintsPrefixes { + ruleMap := map[string]interface{}{ + combo.subrule: map[string]interface{}{ + nameConstrPrefix + "names": []interface{}{"foo"}, + }, + } + + rule, err := asserts.CompileSlotRule("iface", ruleMap) + c.Assert(err, IsNil) + c.Check(asserts.RuleFeature(rule, "name-constraints"), Equals, true, Commentf("%v", ruleMap)) + } + + } +} + +func (s *plugSlotRulesSuite) TestValidOnStoreBrandModel(c *C) { + // more extensive testing is now done in deviceScopeConstraintSuite + tests := []struct { + constr string + value string + valid bool + }{ + {"on-store", "", false}, + {"on-store", "foo", true}, + {"on-store", "F_o-O88", true}, + {"on-store", "foo!", false}, + {"on-brand", "", false}, + // custom set brands (length 2-28) + {"on-brand", "dwell", true}, + {"on-brand", "Dwell", false}, + {"on-brand", "dwell-88", true}, + {"on-brand", "dwell_88", false}, + {"on-brand", "0123456789012345678901234567", true}, + // snappy id brands (fixed length 32) + {"on-brand", "01234567890123456789012345678", false}, + {"on-brand", "01234567890123456789012345678901", true}, + {"on-model", "", false}, + {"on-model", "dwell/dwell1", true}, + {"on-model", "dwell", false}, + {"on-model", "dwell/", false}, + } + + check := func(constr, value string, valid bool) { + ruleMap := map[string]interface{}{ + "allow-auto-connection": map[string]interface{}{ + constr: []interface{}{value}, + }, + } + + _, err := asserts.CompilePlugRule("iface", ruleMap) + if valid { + c.Check(err, IsNil, Commentf("%v", ruleMap)) + } else { + c.Check(err, ErrorMatches, fmt.Sprintf(`%s in allow-auto-connection in plug rule for interface "iface" contains an invalid element: %q`, constr, value), Commentf("%v", ruleMap)) + } + } + + for _, t := range tests { + check(t.constr, t.value, t.valid) + + if t.constr == "on-brand" { + // reuse and double check all brands also in the context of on-model! + + check("on-model", t.value+"/foo", t.valid) + } + } +} diff --git a/asserts/info/main.go b/asserts/info/main.go new file mode 100644 index 00000000..09a2adca --- /dev/null +++ b/asserts/info/main.go @@ -0,0 +1,37 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +// info produces information about assertions to include in /usr/lib/snapd/info. +package main + +import ( + "encoding/json" + "fmt" + + "github.com/snapcore/snapd/asserts" +) + +func main() { + maxFormats := asserts.MaxSupportedFormats(1) + b, err := json.Marshal(maxFormats) + if err != nil { + panic(fmt.Sprintf("cannot json marshal asserts info: %v", err)) + } + fmt.Printf("SNAPD_ASSERTS_FORMATS='%s'\n", b) +} diff --git a/asserts/internal/grouping.go b/asserts/internal/grouping.go new file mode 100644 index 00000000..ad66ea53 --- /dev/null +++ b/asserts/internal/grouping.go @@ -0,0 +1,286 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package internal + +import ( + "bytes" + "encoding/base64" + "encoding/binary" + "fmt" + "sort" +) + +// Groupings maintain labels to identify membership to one or more groups. +// Labels are implemented as subsets of integers from 0 +// up to an excluded maximum, where the integers represent the groups. +// Assumptions: +// - most labels are for one group or very few +// - a few labels are sparse with more groups in them +// - very few comprise the universe of all groups +type Groupings struct { + n uint + maxGroup uint16 + bitsetThreshold uint16 +} + +// NewGroupings creates a new Groupings supporting labels for membership +// to up n groups. n must be a positive multiple of 16 and <=65536. +func NewGroupings(n int) (*Groupings, error) { + if n <= 0 || n > 65536 { + return nil, fmt.Errorf("n=%d groups is outside of valid range (0, 65536]", n) + } + if n%16 != 0 { + return nil, fmt.Errorf("n=%d groups is not a multiple of 16", n) + } + return &Groupings{n: uint(n), bitsetThreshold: uint16(n / 16)}, nil +} + +// N returns up to how many groups are supported. +// That is the value that was passed to NewGroupings. +func (gr *Groupings) N() int { + return int(gr.n) +} + +// WithinRange checks whether group is within the admissible range for +// labeling otherwise it returns an error. +func (gr *Groupings) WithinRange(group uint16) error { + if uint(group) >= gr.n { + return fmt.Errorf("group exceeds admissible maximum: %d >= %d", group, gr.n) + } + return nil +} + +type Grouping struct { + size uint16 + elems []uint16 +} + +func (g Grouping) Copy() Grouping { + elems2 := make([]uint16, len(g.elems), cap(g.elems)) + copy(elems2[:], g.elems[:]) + g.elems = elems2 + return g +} + +// search locates group among the sorted Grouping elements, it returns: +// - true if found +// - false if not found +// - the index at which group should be inserted to keep the +// elements sorted if not found and the bit-set representation is not in use +func (gr *Groupings) search(g *Grouping, group uint16) (found bool, j uint16) { + if g.size > gr.bitsetThreshold { + return bitsetContains(g, group), 0 + } + j = uint16(sort.Search(int(g.size), func(i int) bool { return g.elems[i] >= group })) + if j < g.size && g.elems[j] == group { + return true, 0 + } + return false, j +} + +func bitsetContains(g *Grouping, group uint16) bool { + return (g.elems[group/16] & (1 << (group % 16))) != 0 +} + +// AddTo adds the given group to the grouping. +func (gr *Groupings) AddTo(g *Grouping, group uint16) error { + if err := gr.WithinRange(group); err != nil { + return err + } + if group > gr.maxGroup { + gr.maxGroup = group + } + if g.size == 0 { + g.size = 1 + g.elems = []uint16{group} + return nil + } + found, j := gr.search(g, group) + if found { + return nil + } + newsize := g.size + 1 + if newsize > gr.bitsetThreshold { + // switching to a bit-set representation after the size point + // where the space cost is the same, the representation uses + // bitsetThreshold-many 16-bits words stored in elems. + // We don't always use the bit-set representation because + // * we expect small groupings and iteration to be common, + // iteration is more costly over the bit-set representation + // * serialization matches more or less what we do in memory, + // so again is more efficient for small groupings in the + // extensive representation. + if g.size == gr.bitsetThreshold { + prevelems := g.elems + g.elems = make([]uint16, gr.bitsetThreshold) + for _, e := range prevelems { + bitsetAdd(g, e) + } + } + g.size = newsize + bitsetAdd(g, group) + return nil + } + var newelems []uint16 + if int(g.size) == cap(g.elems) { + newelems = make([]uint16, newsize, cap(g.elems)*2) + copy(newelems, g.elems[:j]) + } else { + newelems = g.elems[:newsize] + } + if j < g.size { + copy(newelems[j+1:], g.elems[j:]) + } + // inserting new group at j index keeping the elements sorted + newelems[j] = group + g.size = newsize + g.elems = newelems + return nil +} + +func bitsetAdd(g *Grouping, group uint16) { + g.elems[group/16] |= 1 << (group % 16) +} + +// Contains returns whether the given group is a member of the grouping. +func (gr *Groupings) Contains(g *Grouping, group uint16) bool { + found, _ := gr.search(g, group) + return found +} + +// Serialize produces a string encoding the given integers. +func Serialize(elems []uint16) string { + b := bytes.NewBuffer(make([]byte, 0, len(elems)*2)) + binary.Write(b, binary.LittleEndian, elems) + return base64.RawURLEncoding.EncodeToString(b.Bytes()) +} + +// Serialize produces a string representing the grouping label. +func (gr *Groupings) Serialize(g *Grouping) string { + // groupings are serialized as: + // * the actual element groups if there are up to + // bitsetThreshold elements: elems[0], elems[1], ... + // * otherwise the number of elements, followed by the bitset + // representation comprised of bitsetThreshold-many 16-bits words + // (stored using elems as well) + if g.size > gr.bitsetThreshold { + return gr.bitsetSerialize(g) + } + return Serialize(g.elems) +} + +func (gr *Groupings) bitsetSerialize(g *Grouping) string { + b := bytes.NewBuffer(make([]byte, 0, (gr.bitsetThreshold+1)*2)) + binary.Write(b, binary.LittleEndian, g.size) + binary.Write(b, binary.LittleEndian, g.elems) + return base64.RawURLEncoding.EncodeToString(b.Bytes()) +} + +const errSerializedLabelFmt = "invalid serialized grouping label: %v" + +// Deserialize reconstructs a grouping out of the serialized label. +func (gr *Groupings) Deserialize(label string) (*Grouping, error) { + b, err := base64.RawURLEncoding.DecodeString(label) + if err != nil { + return nil, fmt.Errorf(errSerializedLabelFmt, err) + } + if len(b)%2 != 0 { + return nil, fmt.Errorf(errSerializedLabelFmt, "not divisible into 16-bits words") + } + m := len(b) / 2 + var g Grouping + if m == int(gr.bitsetThreshold+1) { + // deserialize number of elements + bitset representation + // comprising bitsetThreshold-many 16-bits words + return gr.bitsetDeserialize(&g, b) + } + if m > int(gr.bitsetThreshold) { + return nil, fmt.Errorf(errSerializedLabelFmt, "too large") + } + g.size = uint16(m) + esz := uint16(1) + for esz < g.size { + esz *= 2 + } + g.elems = make([]uint16, g.size, esz) + binary.Read(bytes.NewBuffer(b), binary.LittleEndian, g.elems) + for i, e := range g.elems { + if e > gr.maxGroup { + return nil, fmt.Errorf(errSerializedLabelFmt, "element larger than maximum group") + } + if i > 0 && g.elems[i-1] >= e { + return nil, fmt.Errorf(errSerializedLabelFmt, "not sorted") + } + } + return &g, nil +} + +func (gr *Groupings) bitsetDeserialize(g *Grouping, b []byte) (*Grouping, error) { + buf := bytes.NewBuffer(b) + binary.Read(buf, binary.LittleEndian, &g.size) + if g.size > gr.maxGroup+1 { + return nil, fmt.Errorf(errSerializedLabelFmt, "bitset size cannot be possibly larger than maximum group plus 1") + } + if g.size <= gr.bitsetThreshold { + // should not have used a bitset repr for so few elements + return nil, fmt.Errorf(errSerializedLabelFmt, "bitset for too few elements") + } + g.elems = make([]uint16, gr.bitsetThreshold) + binary.Read(buf, binary.LittleEndian, g.elems) + return g, nil +} + +// Iter iterates over the groups in the grouping and calls f with each of +// them. If f returns an error Iter immediately returns with it. +func (gr *Groupings) Iter(g *Grouping, f func(group uint16) error) error { + if g.size > gr.bitsetThreshold { + return gr.bitsetIter(g, f) + } + for _, e := range g.elems { + if err := f(e); err != nil { + return err + } + } + return nil +} + +func (gr *Groupings) bitsetIter(g *Grouping, f func(group uint16) error) error { + c := g.size + for i := uint16(0); i <= gr.maxGroup/16; i++ { + w := g.elems[i] + if w == 0 { + continue + } + for j := uint16(0); w != 0; j++ { + if w&1 != 0 { + if err := f(i*16 + j); err != nil { + return err + } + c-- + if c == 0 { + // found all elements + return nil + } + } + w >>= 1 + } + } + return nil +} diff --git a/asserts/internal/grouping_test.go b/asserts/internal/grouping_test.go new file mode 100644 index 00000000..1a6633fc --- /dev/null +++ b/asserts/internal/grouping_test.go @@ -0,0 +1,713 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package internal_test + +import ( + "encoding/base64" + "errors" + "testing" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/asserts/internal" +) + +func TestInternal(t *testing.T) { TestingT(t) } + +type groupingsSuite struct{} + +var _ = Suite(&groupingsSuite{}) + +func (s *groupingsSuite) TestNewGroupings(c *C) { + tests := []struct { + n int + err string + }{ + {-10, `n=-10 groups is outside of valid range \(0, 65536\]`}, + {0, `n=0 groups is outside of valid range \(0, 65536\]`}, + {9, "n=9 groups is not a multiple of 16"}, + {16, ""}, + {255, "n=255 groups is not a multiple of 16"}, + {256, ""}, + {1024, ""}, + {65536, ""}, + {65537, `n=65537 groups is outside of valid range \(0, 65536\]`}, + } + + for _, t := range tests { + comm := Commentf("%d", t.n) + gr, err := internal.NewGroupings(t.n) + if t.err == "" { + c.Check(err, IsNil, comm) + c.Check(gr, NotNil, comm) + c.Check(gr.N(), Equals, t.n) + } else { + c.Check(gr, IsNil, comm) + c.Check(err, ErrorMatches, t.err, comm) + } + } +} + +func (s *groupingsSuite) TestAddToAndContains(c *C) { + var g internal.Grouping + + gr, err := internal.NewGroupings(16) + c.Assert(err, IsNil) + + err = gr.AddTo(&g, 1) + c.Assert(err, IsNil) + err = gr.AddTo(&g, 3) + c.Assert(err, IsNil) + err = gr.AddTo(&g, 0) + c.Assert(err, IsNil) + err = gr.AddTo(&g, 4) + c.Assert(err, IsNil) + err = gr.AddTo(&g, 2) + c.Assert(err, IsNil) + + for i := uint16(0); i < 5; i++ { + c.Check(gr.Contains(&g, i), Equals, true) + } + + c.Check(gr.Contains(&g, 5), Equals, false) +} + +func (s *groupingsSuite) TestOutsideRange(c *C) { + var g internal.Grouping + + gr, err := internal.NewGroupings(16) + c.Assert(err, IsNil) + + // validity + err = gr.AddTo(&g, 15) + c.Assert(err, IsNil) + + err = gr.AddTo(&g, 16) + c.Check(err, ErrorMatches, "group exceeds admissible maximum: 16 >= 16") + + err = gr.AddTo(&g, 99) + c.Check(err, ErrorMatches, "group exceeds admissible maximum: 99 >= 16") +} + +func (s *groupingsSuite) TestSerializeLabel(c *C) { + var g internal.Grouping + + gr, err := internal.NewGroupings(128) + c.Assert(err, IsNil) + + err = gr.AddTo(&g, 1) + c.Assert(err, IsNil) + err = gr.AddTo(&g, 3) + c.Assert(err, IsNil) + err = gr.AddTo(&g, 0) + c.Assert(err, IsNil) + err = gr.AddTo(&g, 4) + c.Assert(err, IsNil) + err = gr.AddTo(&g, 2) + c.Assert(err, IsNil) + + l := gr.Serialize(&g) + + g1, err := gr.Deserialize(l) + c.Check(err, IsNil) + + c.Check(g1, DeepEquals, &g) +} + +func (s *groupingsSuite) TestDeserializeLabelErrors(c *C) { + var g internal.Grouping + + gr, err := internal.NewGroupings(64) + c.Assert(err, IsNil) + + err = gr.AddTo(&g, 0) + c.Assert(err, IsNil) + err = gr.AddTo(&g, 1) + c.Assert(err, IsNil) + err = gr.AddTo(&g, 2) + c.Assert(err, IsNil) + err = gr.AddTo(&g, 3) + c.Assert(err, IsNil) + err = gr.AddTo(&g, 4) + c.Assert(err, IsNil) + + const errPrefix = "invalid serialized grouping label: " + + invalidLabels := []struct { + invalid, errSuffix string + }{ + // not base64 + {"\x0a\x02\xf4", `illegal base64 data.*`}, + // wrong length + {base64.RawURLEncoding.EncodeToString([]byte{1}), `not divisible into 16-bits words`}, + // not a known group + {internal.Serialize([]uint16{5}), `element larger than maximum group`}, + // not in order + {internal.Serialize([]uint16{0, 2, 1}), `not sorted`}, + // bitset: too many words + {internal.Serialize([]uint16{0, 0, 0, 0, 0, 0}), `too large`}, + // bitset: larger than maxgroup + {internal.Serialize([]uint16{6, 0, 0, 0, 0}), `bitset size cannot be possibly larger than maximum group plus 1`}, + // bitset: grouping size is too small + {internal.Serialize([]uint16{0, 0, 0, 0, 0}), `bitset for too few elements`}, + {internal.Serialize([]uint16{1, 0, 0, 0, 0}), `bitset for too few elements`}, + {internal.Serialize([]uint16{4, 0, 0, 0, 0}), `bitset for too few elements`}, + } + + for _, il := range invalidLabels { + _, err := gr.Deserialize(il.invalid) + c.Check(err, ErrorMatches, errPrefix+il.errSuffix) + } +} + +func (s *groupingsSuite) TestIter(c *C) { + var g internal.Grouping + + gr, err := internal.NewGroupings(128) + c.Assert(err, IsNil) + + err = gr.AddTo(&g, 1) + c.Assert(err, IsNil) + err = gr.AddTo(&g, 3) + c.Assert(err, IsNil) + err = gr.AddTo(&g, 0) + c.Assert(err, IsNil) + err = gr.AddTo(&g, 4) + c.Assert(err, IsNil) + err = gr.AddTo(&g, 2) + c.Assert(err, IsNil) + + elems := []uint16{} + f := func(group uint16) error { + elems = append(elems, group) + return nil + } + + err = gr.Iter(&g, f) + c.Assert(err, IsNil) + c.Check(elems, DeepEquals, []uint16{0, 1, 2, 3, 4}) +} + +func (s *groupingsSuite) TestIterError(c *C) { + var g internal.Grouping + + gr, err := internal.NewGroupings(32) + c.Assert(err, IsNil) + + err = gr.AddTo(&g, 1) + c.Assert(err, IsNil) + err = gr.AddTo(&g, 3) + c.Assert(err, IsNil) + + errBoom := errors.New("boom") + n := 0 + f := func(group uint16) error { + n++ + return errBoom + } + + err = gr.Iter(&g, f) + c.Check(err, Equals, errBoom) + c.Check(n, Equals, 1) +} + +func (s *groupingsSuite) TestRepeated(c *C) { + var g internal.Grouping + + gr, err := internal.NewGroupings(64) + c.Assert(err, IsNil) + + err = gr.AddTo(&g, 1) + c.Assert(err, IsNil) + err = gr.AddTo(&g, 0) + c.Assert(err, IsNil) + err = gr.AddTo(&g, 2) + c.Assert(err, IsNil) + + err = gr.AddTo(&g, 1) + c.Assert(err, IsNil) + err = gr.AddTo(&g, 0) + c.Assert(err, IsNil) + + elems := []uint16{} + f := func(group uint16) error { + elems = append(elems, group) + return nil + } + + err = gr.Iter(&g, f) + c.Assert(err, IsNil) + c.Check(elems, DeepEquals, []uint16{0, 1, 2}) +} + +func (s *groupingsSuite) TestCopy(c *C) { + var g internal.Grouping + + gr, err := internal.NewGroupings(16) + c.Assert(err, IsNil) + + err = gr.AddTo(&g, 1) + c.Assert(err, IsNil) + err = gr.AddTo(&g, 3) + c.Assert(err, IsNil) + err = gr.AddTo(&g, 0) + c.Assert(err, IsNil) + err = gr.AddTo(&g, 4) + c.Assert(err, IsNil) + err = gr.AddTo(&g, 2) + c.Assert(err, IsNil) + + g2 := g.Copy() + c.Check(g2, DeepEquals, g) + + err = gr.AddTo(&g2, 7) + c.Assert(err, IsNil) + + c.Check(gr.Contains(&g, 7), Equals, false) + c.Check(gr.Contains(&g2, 7), Equals, true) + + c.Check(g2, Not(DeepEquals), g) +} +func (s *groupingsSuite) TestBitsetSerializeAndIterSimple(c *C) { + gr, err := internal.NewGroupings(32) + c.Assert(err, IsNil) + + var elems []uint16 + f := func(group uint16) error { + elems = append(elems, group) + return nil + } + + var g internal.Grouping + err = gr.AddTo(&g, 1) + c.Assert(err, IsNil) + err = gr.AddTo(&g, 5) + c.Assert(err, IsNil) + err = gr.AddTo(&g, 17) + c.Assert(err, IsNil) + err = gr.AddTo(&g, 24) + c.Assert(err, IsNil) + + l := gr.Serialize(&g) + c.Check(l, DeepEquals, + internal.Serialize([]uint16{4, + uint16(1<<1 | 1<<5), + uint16(1<<(17-16) | 1<<(24-16)), + })) + + err = gr.Iter(&g, f) + c.Assert(err, IsNil) + c.Check(elems, DeepEquals, []uint16{1, 5, 17, 24}) +} + +func (s *groupingsSuite) TestBitSet(c *C) { + var g internal.Grouping + + gr, err := internal.NewGroupings(64) + c.Assert(err, IsNil) + + for i := uint16(0); i < 64; i++ { + err := gr.AddTo(&g, i) + c.Assert(err, IsNil) + c.Check(gr.Contains(&g, i), Equals, true) + + l := gr.Serialize(&g) + + switch i { + case 4: + c.Check(l, Equals, internal.Serialize([]uint16{5, 0x1f, 0, 0, 0})) + case 15: + c.Check(l, Equals, internal.Serialize([]uint16{16, 0xffff, 0, 0, 0})) + case 16: + c.Check(l, Equals, internal.Serialize([]uint16{17, 0xffff, 0x1, 0, 0})) + case 63: + c.Check(l, Equals, internal.Serialize([]uint16{64, 0xffff, 0xffff, 0xffff, 0xffff})) + } + + g1, err := gr.Deserialize(l) + c.Check(err, IsNil) + + c.Check(g1, DeepEquals, &g) + } + + for i := uint16(63); ; i-- { + err := gr.AddTo(&g, i) + c.Assert(err, IsNil) + c.Check(gr.Contains(&g, i), Equals, true) + if i == 0 { + break + } + + l := gr.Serialize(&g) + + g1, err := gr.Deserialize(l) + c.Check(err, IsNil) + + c.Check(g1, DeepEquals, &g) + } +} + +func (s *groupingsSuite) TestBitsetIter(c *C) { + gr, err := internal.NewGroupings(32) + c.Assert(err, IsNil) + + var elems []uint16 + f := func(group uint16) error { + elems = append(elems, group) + return nil + } + + for i := uint16(2); i < 32; i++ { + var g internal.Grouping + + err := gr.AddTo(&g, i-2) + c.Assert(err, IsNil) + err = gr.AddTo(&g, i-1) + c.Assert(err, IsNil) + err = gr.AddTo(&g, i) + c.Assert(err, IsNil) + + err = gr.Iter(&g, f) + c.Assert(err, IsNil) + c.Check(elems, DeepEquals, []uint16{i - 2, i - 1, i}) + + elems = nil + } + + var g internal.Grouping + for i := uint16(0); i < 32; i++ { + err = gr.AddTo(&g, i) + c.Assert(err, IsNil) + } + + err = gr.Iter(&g, f) + c.Assert(err, IsNil) + c.Check(elems, HasLen, 32) +} + +func (s *groupingsSuite) TestBitsetIterError(c *C) { + gr, err := internal.NewGroupings(16) + c.Assert(err, IsNil) + + var g internal.Grouping + + err = gr.AddTo(&g, 0) + c.Assert(err, IsNil) + err = gr.AddTo(&g, 1) + c.Assert(err, IsNil) + + errBoom := errors.New("boom") + n := 0 + f := func(group uint16) error { + n++ + return errBoom + } + + err = gr.Iter(&g, f) + c.Check(err, Equals, errBoom) + c.Check(n, Equals, 1) +} + +func BenchmarkIterBaseline(b *testing.B) { + b.StopTimer() + + n := 0 + f := func(group uint16) error { + n++ + return nil + } + + b.StartTimer() + for i := 0; i < b.N; i++ { + n = 0 + for j := uint16(0); j < 64; j++ { + f(j) + } + if n != 64 { + b.FailNow() + } + } +} + +func BenchmarkIter4Elems(b *testing.B) { + b.StopTimer() + + gr, err := internal.NewGroupings(64) + if err != nil { + b.FailNow() + } + + n := 0 + f := func(group uint16) error { + n++ + return nil + } + + var g internal.Grouping + gr.AddTo(&g, 1) + gr.AddTo(&g, 5) + gr.AddTo(&g, 17) + gr.AddTo(&g, 24) + + b.StartTimer() + for i := 0; i < b.N; i++ { + n = 0 + gr.Iter(&g, f) + if n != 4 { + b.FailNow() + } + } +} + +func BenchmarkIterBitset5Elems(b *testing.B) { + b.StopTimer() + + gr, err := internal.NewGroupings(64) + if err != nil { + b.FailNow() + } + + n := 0 + f := func(group uint16) error { + n++ + return nil + } + + var g internal.Grouping + gr.AddTo(&g, 1) + gr.AddTo(&g, 5) + gr.AddTo(&g, 17) + gr.AddTo(&g, 24) + gr.AddTo(&g, 33) + + b.StartTimer() + for i := 0; i < b.N; i++ { + n = 0 + gr.Iter(&g, f) + if n != 5 { + b.FailNow() + } + } +} + +func BenchmarkIterBitsetEmptyStretches(b *testing.B) { + b.StopTimer() + + gr, err := internal.NewGroupings(64) + if err != nil { + b.FailNow() + } + + n := 0 + f := func(group uint16) error { + n++ + return nil + } + + var g internal.Grouping + gr.AddTo(&g, 0) + gr.AddTo(&g, 15) + gr.AddTo(&g, 16) + gr.AddTo(&g, 31) + gr.AddTo(&g, 32) + + b.StartTimer() + for i := 0; i < b.N; i++ { + n = 0 + gr.Iter(&g, f) + if n != 5 { + b.FailNow() + } + } +} + +func BenchmarkIterBitsetEven(b *testing.B) { + b.StopTimer() + + gr, err := internal.NewGroupings(64) + if err != nil { + b.FailNow() + } + + n := 0 + f := func(group uint16) error { + n++ + return nil + } + + var g internal.Grouping + for i := 0; i <= 63; i += 2 { + gr.AddTo(&g, uint16(i)) + } + + b.StartTimer() + for i := 0; i < b.N; i++ { + n = 0 + gr.Iter(&g, f) + if n != 32 { + b.FailNow() + } + } +} + +func BenchmarkIterBitsetOdd(b *testing.B) { + b.StopTimer() + + gr, err := internal.NewGroupings(64) + if err != nil { + b.FailNow() + } + + n := 0 + f := func(group uint16) error { + n++ + return nil + } + + var g internal.Grouping + for i := 1; i <= 63; i += 2 { + gr.AddTo(&g, uint16(i)) + } + + b.StartTimer() + for i := 0; i < b.N; i++ { + n = 0 + gr.Iter(&g, f) + if n != 32 { + b.FailNow() + } + } +} + +func BenchmarkIterBitset0Inc3(b *testing.B) { + b.StopTimer() + + gr, err := internal.NewGroupings(64) + if err != nil { + b.FailNow() + } + + n := 0 + f := func(group uint16) error { + n++ + return nil + } + + var g internal.Grouping + for i := 0; i <= 63; i += 3 { + gr.AddTo(&g, uint16(i)) + } + + b.StartTimer() + for i := 0; i < b.N; i++ { + n = 0 + gr.Iter(&g, f) + if n != 22 { + b.FailNow() + } + } +} + +func BenchmarkIterBitset1Inc3(b *testing.B) { + b.StopTimer() + + gr, err := internal.NewGroupings(64) + if err != nil { + b.FailNow() + } + + n := 0 + f := func(group uint16) error { + n++ + return nil + } + + var g internal.Grouping + for i := 1; i <= 63; i += 3 { + gr.AddTo(&g, uint16(i)) + } + + b.StartTimer() + for i := 0; i < b.N; i++ { + n = 0 + gr.Iter(&g, f) + if n != 21 { + b.FailNow() + } + } +} + +func BenchmarkIterBitset0Inc4(b *testing.B) { + b.StopTimer() + + gr, err := internal.NewGroupings(64) + if err != nil { + b.FailNow() + } + + n := 0 + f := func(group uint16) error { + n++ + return nil + } + + var g internal.Grouping + for i := 0; i <= 63; i += 4 { + gr.AddTo(&g, uint16(i)) + } + + b.StartTimer() + for i := 0; i < b.N; i++ { + n = 0 + gr.Iter(&g, f) + if n != 16 { + b.FailNow() + } + } +} + +func BenchmarkIterBitsetComplete(b *testing.B) { + b.StopTimer() + + gr, err := internal.NewGroupings(64) + if err != nil { + b.FailNow() + } + + n := 0 + f := func(group uint16) error { + n++ + return nil + } + + var g internal.Grouping + for i := 0; i <= 63; i++ { + gr.AddTo(&g, uint16(i)) + } + + b.StartTimer() + for i := 0; i < b.N; i++ { + n = 0 + gr.Iter(&g, f) + if n != 64 { + b.FailNow() + } + } +} diff --git a/asserts/membackstore.go b/asserts/membackstore.go new file mode 100644 index 00000000..a23a72d3 --- /dev/null +++ b/asserts/membackstore.go @@ -0,0 +1,303 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016-2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts + +import ( + "errors" + "fmt" + "sort" + "strconv" + "sync" +) + +type memoryBackstore struct { + top memBSBranch + mu sync.RWMutex +} + +type memBSNode interface { + put(assertType *AssertionType, key []string, assert Assertion) error + get(key []string, maxFormat int) (Assertion, error) + search(hint []string, found func(Assertion), maxFormat int) + sequenceMemberAfter(prefix []string, after, maxFormat int) (Assertion, error) +} + +type memBSBranch map[string]memBSNode + +type memBSLeaf map[string]map[int]Assertion + +type memBSSeqLeaf struct { + memBSLeaf + sequence []int +} + +func (br memBSBranch) put(assertType *AssertionType, key []string, assert Assertion) error { + key0 := key[0] + down := br[key0] + if down == nil { + if len(key) > 2 { + down = make(memBSBranch) + } else { + leaf := make(memBSLeaf) + if assertType.SequenceForming() { + down = &memBSSeqLeaf{memBSLeaf: leaf} + } else { + down = leaf + } + } + br[key0] = down + } + return down.put(assertType, key[1:], assert) +} + +func (leaf memBSLeaf) cur(key0 string, maxFormat int) (a Assertion) { + for formatnum, a1 := range leaf[key0] { + if formatnum <= maxFormat { + if a == nil || a1.Revision() > a.Revision() { + a = a1 + } + } + } + return a +} + +func (leaf memBSLeaf) put(assertType *AssertionType, key []string, assert Assertion) error { + key0 := key[0] + cur := leaf.cur(key0, assertType.MaxSupportedFormat()) + if cur != nil { + rev := assert.Revision() + curRev := cur.Revision() + if curRev >= rev { + return &RevisionError{Current: curRev, Used: rev} + } + } + if _, ok := leaf[key0]; !ok { + leaf[key0] = make(map[int]Assertion) + } + leaf[key0][assert.Format()] = assert + return nil +} + +func (leaf *memBSSeqLeaf) put(assertType *AssertionType, key []string, assert Assertion) error { + if err := leaf.memBSLeaf.put(assertType, key, assert); err != nil { + return err + } + if len(leaf.memBSLeaf) != len(leaf.sequence) { + seqnum := assert.(SequenceMember).Sequence() + inspos := sort.SearchInts(leaf.sequence, seqnum) + n := len(leaf.sequence) + leaf.sequence = append(leaf.sequence, seqnum) + if inspos != n { + copy(leaf.sequence[inspos+1:n+1], leaf.sequence[inspos:n]) + leaf.sequence[inspos] = seqnum + } + } + return nil +} + +// errNotFound is used internally by backends, it is converted to the richer +// NotFoundError only at their public interface boundary +var errNotFound = errors.New("assertion not found") + +func (br memBSBranch) get(key []string, maxFormat int) (Assertion, error) { + key0 := key[0] + down := br[key0] + if down == nil { + return nil, errNotFound + } + return down.get(key[1:], maxFormat) +} + +func (leaf memBSLeaf) get(key []string, maxFormat int) (Assertion, error) { + key0 := key[0] + cur := leaf.cur(key0, maxFormat) + if cur == nil { + return nil, errNotFound + } + return cur, nil +} + +func (br memBSBranch) search(hint []string, found func(Assertion), maxFormat int) { + hint0 := hint[0] + if hint0 == "" { + for _, down := range br { + down.search(hint[1:], found, maxFormat) + } + return + } + down := br[hint0] + if down != nil { + down.search(hint[1:], found, maxFormat) + } +} + +func (leaf memBSLeaf) search(hint []string, found func(Assertion), maxFormat int) { + hint0 := hint[0] + if hint0 == "" { + for key := range leaf { + cand := leaf.cur(key, maxFormat) + if cand != nil { + found(cand) + } + } + return + } + + cur := leaf.cur(hint0, maxFormat) + if cur != nil { + found(cur) + } +} + +func (br memBSBranch) sequenceMemberAfter(prefix []string, after, maxFormat int) (Assertion, error) { + prefix0 := prefix[0] + down := br[prefix0] + if down == nil { + return nil, errNotFound + } + return down.sequenceMemberAfter(prefix[1:], after, maxFormat) +} + +func (left memBSLeaf) sequenceMemberAfter(prefix []string, after, maxFormat int) (Assertion, error) { + panic("internal error: unexpected sequenceMemberAfter on memBSLeaf") +} + +func (leaf *memBSSeqLeaf) sequenceMemberAfter(prefix []string, after, maxFormat int) (Assertion, error) { + n := len(leaf.sequence) + dir := 1 + var start int + if after == -1 { + // search for the latest in sequence compatible with + // maxFormat: consider all sequence numbers in + // sequence backward + dir = -1 + start = n - 1 + } else { + // search for the first in sequence with sequence number + // > after and compatible with maxFormat + start = sort.SearchInts(leaf.sequence, after) + if start == n { + // nothing + return nil, errNotFound + } + if leaf.sequence[start] == after { + // skip after itself + start += 1 + } + } + for j := start; j >= 0 && j < n; j += dir { + seqkey := strconv.Itoa(leaf.sequence[j]) + cur := leaf.cur(seqkey, maxFormat) + if cur != nil { + return cur, nil + } + } + return nil, errNotFound +} + +// NewMemoryBackstore creates a memory backed assertions backstore. +func NewMemoryBackstore() Backstore { + return &memoryBackstore{ + top: make(memBSBranch), + } +} + +func (mbs *memoryBackstore) Put(assertType *AssertionType, assert Assertion) error { + mbs.mu.Lock() + defer mbs.mu.Unlock() + + internalKey := make([]string, 1, 1+len(assertType.PrimaryKey)) + internalKey[0] = assertType.Name + internalKey = append(internalKey, assert.Ref().PrimaryKey...) + + err := mbs.top.put(assertType, internalKey, assert) + return err +} + +func (mbs *memoryBackstore) Get(assertType *AssertionType, key []string, maxFormat int) (Assertion, error) { + mbs.mu.RLock() + defer mbs.mu.RUnlock() + + n := len(assertType.PrimaryKey) + if len(key) > n { + return nil, fmt.Errorf("internal error: Backstore.Get given a key longer than expected for %q: %v", assertType.Name, key) + } + + internalKey := make([]string, 1+len(assertType.PrimaryKey)) + internalKey[0] = assertType.Name + copy(internalKey[1:], key) + if len(key) < n { + for kopt := len(key); kopt < n; kopt++ { + defl := assertType.OptionalPrimaryKeyDefaults[assertType.PrimaryKey[kopt]] + if defl == "" { + return nil, fmt.Errorf("internal error: Backstore.Get given a key missing mandatory elements for %q: %v", assertType.Name, key) + } + internalKey[kopt+1] = defl + } + } + + a, err := mbs.top.get(internalKey, maxFormat) + if err == errNotFound { + return nil, &NotFoundError{Type: assertType} + } + return a, err +} + +func (mbs *memoryBackstore) Search(assertType *AssertionType, headers map[string]string, foundCb func(Assertion), maxFormat int) error { + mbs.mu.RLock() + defer mbs.mu.RUnlock() + + hint := make([]string, 1+len(assertType.PrimaryKey)) + hint[0] = assertType.Name + for i, name := range assertType.PrimaryKey { + hint[1+i] = headers[name] + } + + candCb := func(a Assertion) { + if searchMatch(a, headers) { + foundCb(a) + } + } + + mbs.top.search(hint, candCb, maxFormat) + return nil +} + +func (mbs *memoryBackstore) SequenceMemberAfter(assertType *AssertionType, sequenceKey []string, after, maxFormat int) (SequenceMember, error) { + if !assertType.SequenceForming() { + panic(fmt.Sprintf("internal error: SequenceMemberAfter on non sequence-forming assertion type %q", assertType.Name)) + } + if len(sequenceKey) != len(assertType.PrimaryKey)-1 { + return nil, fmt.Errorf("internal error: SequenceMemberAfter's sequence key argument length must be exactly 1 less than the assertion type primary key") + } + + mbs.mu.RLock() + defer mbs.mu.RUnlock() + + internalPrefix := make([]string, len(assertType.PrimaryKey)) + internalPrefix[0] = assertType.Name + copy(internalPrefix[1:], sequenceKey) + + a, err := mbs.top.sequenceMemberAfter(internalPrefix, after, maxFormat) + if err == errNotFound { + return nil, &NotFoundError{Type: assertType} + } + return a.(SequenceMember), err +} diff --git a/asserts/membackstore_test.go b/asserts/membackstore_test.go new file mode 100644 index 00000000..3f092d5e --- /dev/null +++ b/asserts/membackstore_test.go @@ -0,0 +1,640 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016-2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts_test + +import ( + "strings" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/asserts" +) + +type memBackstoreSuite struct { + bs asserts.Backstore + a asserts.Assertion +} + +var _ = Suite(&memBackstoreSuite{}) + +func (mbss *memBackstoreSuite) SetUpTest(c *C) { + mbss.bs = asserts.NewMemoryBackstore() + + encoded := "type: test-only\n" + + "authority-id: auth-id1\n" + + "primary-key: foo\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==" + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + mbss.a = a +} + +func (mbss *memBackstoreSuite) TestPutAndGet(c *C) { + err := mbss.bs.Put(asserts.TestOnlyType, mbss.a) + c.Assert(err, IsNil) + + a, err := mbss.bs.Get(asserts.TestOnlyType, []string{"foo"}, 0) + c.Assert(err, IsNil) + + c.Check(a, Equals, mbss.a) +} + +func (mbss *memBackstoreSuite) TestGetNotFound(c *C) { + a, err := mbss.bs.Get(asserts.TestOnlyType, []string{"foo"}, 0) + c.Assert(err, DeepEquals, &asserts.NotFoundError{ + Type: asserts.TestOnlyType, + // Headers can be omitted by Backstores + }) + c.Check(a, IsNil) + + err = mbss.bs.Put(asserts.TestOnlyType, mbss.a) + c.Assert(err, IsNil) + + a, err = mbss.bs.Get(asserts.TestOnlyType, []string{"bar"}, 0) + c.Assert(err, DeepEquals, &asserts.NotFoundError{ + Type: asserts.TestOnlyType, + }) + c.Check(a, IsNil) +} + +func (mbss *memBackstoreSuite) TestPutNotNewer(c *C) { + err := mbss.bs.Put(asserts.TestOnlyType, mbss.a) + c.Assert(err, IsNil) + + err = mbss.bs.Put(asserts.TestOnlyType, mbss.a) + c.Check(err, ErrorMatches, "revision 0 is already the current revision") +} + +func (mbss *memBackstoreSuite) TestSearch(c *C) { + encoded := "type: test-only\n" + + "authority-id: auth-id1\n" + + "primary-key: one\n" + + "other: other1\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==" + a1, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + + encoded = "type: test-only\n" + + "authority-id: auth-id1\n" + + "primary-key: two\n" + + "other: other2\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==" + a2, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + + err = mbss.bs.Put(asserts.TestOnlyType, a1) + c.Assert(err, IsNil) + err = mbss.bs.Put(asserts.TestOnlyType, a2) + c.Assert(err, IsNil) + + found := map[string]asserts.Assertion{} + cb := func(a asserts.Assertion) { + found[a.HeaderString("primary-key")] = a + } + err = mbss.bs.Search(asserts.TestOnlyType, nil, cb, 0) + c.Assert(err, IsNil) + c.Check(found, HasLen, 2) + + found = map[string]asserts.Assertion{} + err = mbss.bs.Search(asserts.TestOnlyType, map[string]string{ + "primary-key": "one", + }, cb, 0) + c.Assert(err, IsNil) + c.Check(found, DeepEquals, map[string]asserts.Assertion{ + "one": a1, + }) + + found = map[string]asserts.Assertion{} + err = mbss.bs.Search(asserts.TestOnlyType, map[string]string{ + "other": "other2", + }, cb, 0) + c.Assert(err, IsNil) + c.Check(found, DeepEquals, map[string]asserts.Assertion{ + "two": a2, + }) + + found = map[string]asserts.Assertion{} + err = mbss.bs.Search(asserts.TestOnlyType, map[string]string{ + "primary-key": "two", + "other": "other1", + }, cb, 0) + c.Assert(err, IsNil) + c.Check(found, HasLen, 0) +} + +func (mbss *memBackstoreSuite) TestSearch2Levels(c *C) { + encoded := "type: test-only-2\n" + + "authority-id: auth-id1\n" + + "pk1: a\n" + + "pk2: x\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==" + aAX, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + + encoded = "type: test-only-2\n" + + "authority-id: auth-id1\n" + + "pk1: b\n" + + "pk2: x\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==" + aBX, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + + err = mbss.bs.Put(asserts.TestOnly2Type, aAX) + c.Assert(err, IsNil) + err = mbss.bs.Put(asserts.TestOnly2Type, aBX) + c.Assert(err, IsNil) + + found := map[string]asserts.Assertion{} + cb := func(a asserts.Assertion) { + found[a.HeaderString("pk1")+":"+a.HeaderString("pk2")] = a + } + err = mbss.bs.Search(asserts.TestOnly2Type, map[string]string{ + "pk2": "x", + }, cb, 0) + c.Assert(err, IsNil) + c.Check(found, HasLen, 2) +} + +func (mbss *memBackstoreSuite) TestPutOldRevision(c *C) { + bs := asserts.NewMemoryBackstore() + + // Create two revisions of assertion. + a0, err := asserts.Decode([]byte("type: test-only\n" + + "authority-id: auth-id1\n" + + "primary-key: foo\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==")) + c.Assert(err, IsNil) + a1, err := asserts.Decode([]byte("type: test-only\n" + + "authority-id: auth-id1\n" + + "primary-key: foo\n" + + "revision: 1\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==")) + c.Assert(err, IsNil) + + // Put newer revision, follwed by old revision. + err = bs.Put(asserts.TestOnlyType, a1) + c.Assert(err, IsNil) + err = bs.Put(asserts.TestOnlyType, a0) + + c.Check(err, ErrorMatches, `revision 0 is older than current revision 1`) + c.Check(err, DeepEquals, &asserts.RevisionError{Current: 1, Used: 0}) +} + +func (mbss *memBackstoreSuite) TestGetFormat(c *C) { + bs := asserts.NewMemoryBackstore() + + af0, err := asserts.Decode([]byte("type: test-only\n" + + "authority-id: auth-id1\n" + + "primary-key: foo\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==")) + c.Assert(err, IsNil) + af1, err := asserts.Decode([]byte("type: test-only\n" + + "authority-id: auth-id1\n" + + "primary-key: foo\n" + + "format: 1\n" + + "revision: 1\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==")) + c.Assert(err, IsNil) + af2, err := asserts.Decode([]byte("type: test-only\n" + + "authority-id: auth-id1\n" + + "primary-key: zoo\n" + + "format: 2\n" + + "revision: 22\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==")) + c.Assert(err, IsNil) + + err = bs.Put(asserts.TestOnlyType, af0) + c.Assert(err, IsNil) + err = bs.Put(asserts.TestOnlyType, af1) + c.Assert(err, IsNil) + + a, err := bs.Get(asserts.TestOnlyType, []string{"foo"}, 1) + c.Assert(err, IsNil) + c.Check(a.Revision(), Equals, 1) + + a, err = bs.Get(asserts.TestOnlyType, []string{"foo"}, 0) + c.Assert(err, IsNil) + c.Check(a.Revision(), Equals, 0) + + a, err = bs.Get(asserts.TestOnlyType, []string{"zoo"}, 0) + c.Assert(err, FitsTypeOf, &asserts.NotFoundError{}) + c.Check(a, IsNil) + + err = bs.Put(asserts.TestOnlyType, af2) + c.Assert(err, IsNil) + + a, err = bs.Get(asserts.TestOnlyType, []string{"zoo"}, 1) + c.Assert(err, FitsTypeOf, &asserts.NotFoundError{}) + c.Check(a, IsNil) + + a, err = bs.Get(asserts.TestOnlyType, []string{"zoo"}, 2) + c.Assert(err, IsNil) + c.Check(a.Revision(), Equals, 22) +} + +func (mbss *memBackstoreSuite) TestSearchFormat(c *C) { + bs := asserts.NewMemoryBackstore() + + af0, err := asserts.Decode([]byte("type: test-only-2\n" + + "authority-id: auth-id1\n" + + "pk1: foo\n" + + "pk2: bar\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==")) + c.Assert(err, IsNil) + af1, err := asserts.Decode([]byte("type: test-only-2\n" + + "authority-id: auth-id1\n" + + "pk1: foo\n" + + "pk2: bar\n" + + "format: 1\n" + + "revision: 1\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==")) + c.Assert(err, IsNil) + + af2, err := asserts.Decode([]byte("type: test-only-2\n" + + "authority-id: auth-id1\n" + + "pk1: foo\n" + + "pk2: baz\n" + + "format: 2\n" + + "revision: 1\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==")) + c.Assert(err, IsNil) + + err = bs.Put(asserts.TestOnly2Type, af0) + c.Assert(err, IsNil) + + queries := []map[string]string{ + {"pk1": "foo", "pk2": "bar"}, + {"pk1": "foo"}, + {"pk2": "bar"}, + } + + for _, q := range queries { + var a asserts.Assertion + foundCb := func(a1 asserts.Assertion) { + a = a1 + } + err := bs.Search(asserts.TestOnly2Type, q, foundCb, 1) + c.Assert(err, IsNil) + c.Check(a.Revision(), Equals, 0) + } + + err = bs.Put(asserts.TestOnly2Type, af1) + c.Assert(err, IsNil) + + for _, q := range queries { + var a asserts.Assertion + foundCb := func(a1 asserts.Assertion) { + a = a1 + } + err := bs.Search(asserts.TestOnly2Type, q, foundCb, 1) + c.Assert(err, IsNil) + c.Check(a.Revision(), Equals, 1) + + err = bs.Search(asserts.TestOnly2Type, q, foundCb, 0) + c.Assert(err, IsNil) + c.Check(a.Revision(), Equals, 0) + } + + err = bs.Put(asserts.TestOnly2Type, af2) + c.Assert(err, IsNil) + + var as []asserts.Assertion + foundCb := func(a1 asserts.Assertion) { + as = append(as, a1) + } + err = bs.Search(asserts.TestOnly2Type, map[string]string{ + "pk1": "foo", + }, foundCb, 1) // will not find af2 + c.Assert(err, IsNil) + c.Check(as, HasLen, 1) + c.Check(as[0].Revision(), Equals, 1) + +} + +func (mbss *memBackstoreSuite) TestPutSequence(c *C) { + bs := asserts.NewMemoryBackstore() + + sq1f0, err := asserts.Decode([]byte("type: test-only-seq\n" + + "authority-id: auth-id1\n" + + "n: s1\n" + + "sequence: 1\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==")) + c.Assert(err, IsNil) + + sq2f0, err := asserts.Decode([]byte("type: test-only-seq\n" + + "authority-id: auth-id1\n" + + "n: s1\n" + + "sequence: 2\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==")) + c.Assert(err, IsNil) + + sq2f1, err := asserts.Decode([]byte("type: test-only-seq\n" + + "authority-id: auth-id1\n" + + "format: 1\n" + + "n: s1\n" + + "sequence: 2\n" + + "revision: 1\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==")) + c.Assert(err, IsNil) + + sq3f1, err := asserts.Decode([]byte("type: test-only-seq\n" + + "authority-id: auth-id1\n" + + "format: 1\n" + + "n: s1\n" + + "sequence: 3\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==")) + c.Assert(err, IsNil) + + for _, a := range []asserts.Assertion{sq3f1, sq1f0, sq2f0, sq2f1} { + err = bs.Put(asserts.TestOnlySeqType, a) + c.Assert(err, IsNil) + } + + a, err := bs.Get(asserts.TestOnlySeqType, []string{"s1", "1"}, 0) + c.Assert(err, IsNil) + c.Check(a.(asserts.SequenceMember).Sequence(), Equals, 1) + c.Check(a.Format(), Equals, 0) + + a, err = bs.Get(asserts.TestOnlySeqType, []string{"s1", "2"}, 0) + c.Assert(err, IsNil) + c.Check(a.(asserts.SequenceMember).Sequence(), Equals, 2) + c.Check(a.Format(), Equals, 0) + + a, err = bs.Get(asserts.TestOnlySeqType, []string{"s1", "2"}, 1) + c.Assert(err, IsNil) + c.Check(a.(asserts.SequenceMember).Sequence(), Equals, 2) + c.Check(a.Format(), Equals, 1) + + a, err = bs.Get(asserts.TestOnlySeqType, []string{"s1", "3"}, 0) + c.Assert(err, DeepEquals, &asserts.NotFoundError{ + Type: asserts.TestOnlySeqType, + }) + c.Check(a, IsNil) + + a, err = bs.Get(asserts.TestOnlySeqType, []string{"s1", "3"}, 1) + c.Assert(err, IsNil) + c.Check(a.(asserts.SequenceMember).Sequence(), Equals, 3) + c.Check(a.Format(), Equals, 1) + + err = bs.Put(asserts.TestOnlySeqType, sq2f0) + c.Check(err, DeepEquals, &asserts.RevisionError{Current: 1, Used: 0}) +} + +func (mbss *memBackstoreSuite) TestSequenceMemberAfter(c *C) { + bs := asserts.NewMemoryBackstore() + + other1, err := asserts.Decode([]byte("type: test-only-seq\n" + + "authority-id: auth-id1\n" + + "n: other\n" + + "sequence: 1\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==")) + c.Assert(err, IsNil) + + sq1f0, err := asserts.Decode([]byte("type: test-only-seq\n" + + "authority-id: auth-id1\n" + + "n: s1\n" + + "sequence: 1\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==")) + c.Assert(err, IsNil) + + sq2f0, err := asserts.Decode([]byte("type: test-only-seq\n" + + "authority-id: auth-id1\n" + + "n: s1\n" + + "sequence: 2\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==")) + c.Assert(err, IsNil) + + sq2f1, err := asserts.Decode([]byte("type: test-only-seq\n" + + "authority-id: auth-id1\n" + + "format: 1\n" + + "n: s1\n" + + "sequence: 2\n" + + "revision: 1\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==")) + c.Assert(err, IsNil) + + sq3f1, err := asserts.Decode([]byte("type: test-only-seq\n" + + "authority-id: auth-id1\n" + + "format: 1\n" + + "n: s1\n" + + "sequence: 3\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==")) + c.Assert(err, IsNil) + + sq3f2, err := asserts.Decode([]byte("type: test-only-seq\n" + + "authority-id: auth-id1\n" + + "format: 2\n" + + "n: s1\n" + + "sequence: 3\n" + + "revision: 1\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==")) + c.Assert(err, IsNil) + + for _, a := range []asserts.Assertion{other1, sq1f0, sq2f0, sq2f1, sq3f1, sq3f2} { + err = bs.Put(asserts.TestOnlySeqType, a) + c.Assert(err, IsNil) + } + + seqKey := []string{"s1"} + tests := []struct { + after int + maxFormat int + sequence int + format int + revision int + }{ + {after: 0, maxFormat: 0, sequence: 1, format: 0, revision: 0}, + {after: 0, maxFormat: 2, sequence: 1, format: 0, revision: 0}, + {after: 1, maxFormat: 0, sequence: 2, format: 0, revision: 0}, + {after: 1, maxFormat: 1, sequence: 2, format: 1, revision: 1}, + {after: 1, maxFormat: 2, sequence: 2, format: 1, revision: 1}, + {after: 2, maxFormat: 0, sequence: -1}, + {after: 2, maxFormat: 1, sequence: 3, format: 1, revision: 0}, + {after: 2, maxFormat: 2, sequence: 3, format: 2, revision: 1}, + {after: 3, maxFormat: 0, sequence: -1}, + {after: 3, maxFormat: 2, sequence: -1}, + {after: 4, maxFormat: 2, sequence: -1}, + {after: -1, maxFormat: 0, sequence: 2, format: 0, revision: 0}, + {after: -1, maxFormat: 1, sequence: 3, format: 1, revision: 0}, + {after: -1, maxFormat: 2, sequence: 3, format: 2, revision: 1}, + } + + for _, t := range tests { + a, err := bs.SequenceMemberAfter(asserts.TestOnlySeqType, seqKey, t.after, t.maxFormat) + if t.sequence == -1 { + c.Check(err, DeepEquals, &asserts.NotFoundError{ + Type: asserts.TestOnlySeqType, + }) + } else { + c.Assert(err, IsNil) + c.Assert(a.HeaderString("n"), Equals, "s1") + c.Check(a.Sequence(), Equals, t.sequence) + c.Check(a.Format(), Equals, t.format) + c.Check(a.Revision(), Equals, t.revision) + } + } + + _, err = bs.SequenceMemberAfter(asserts.TestOnlySeqType, []string{"s2"}, -1, 2) + c.Check(err, DeepEquals, &asserts.NotFoundError{ + Type: asserts.TestOnlySeqType, + }) +} + +func (mbss *memBackstoreSuite) TestOptionalPrimaryKeys(c *C) { + r := asserts.MockOptionalPrimaryKey(asserts.TestOnlyType, "opt1", "o1-defl") + defer r() + bs := mbss.bs + + a1, err := asserts.Decode([]byte("type: test-only\n" + + "authority-id: auth-id1\n" + + "primary-key: k1\n" + + "marker: a1\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==")) + c.Assert(err, IsNil) + err = bs.Put(asserts.TestOnlyType, a1) + c.Assert(err, IsNil) + + a2, err := asserts.Decode([]byte("type: test-only\n" + + "authority-id: auth-id1\n" + + "primary-key: k2\n" + + "opt1: A\n" + + "marker: a2\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==")) + c.Assert(err, IsNil) + err = bs.Put(asserts.TestOnlyType, a2) + c.Assert(err, IsNil) + + a, err := bs.Get(asserts.TestOnlyType, []string{"k1"}, 0) + c.Assert(err, IsNil) + c.Check(a.Ref().PrimaryKey, DeepEquals, []string{"k1", "o1-defl"}) + c.Check(a.HeaderString("marker"), Equals, "a1") + + a, err = bs.Get(asserts.TestOnlyType, []string{"k1", "o1-defl"}, 0) + c.Assert(err, IsNil) + c.Check(a.Ref().PrimaryKey, DeepEquals, []string{"k1", "o1-defl"}) + c.Check(a.HeaderString("marker"), Equals, "a1") + + a, err = bs.Get(asserts.TestOnlyType, []string{"k2", "A"}, 0) + c.Assert(err, IsNil) + c.Check(a.Ref().PrimaryKey, DeepEquals, []string{"k2", "A"}) + c.Check(a.HeaderString("marker"), Equals, "a2") + + a, err = bs.Get(asserts.TestOnlyType, []string{"k2"}, 0) + c.Check(err, DeepEquals, &asserts.NotFoundError{ + Type: asserts.TestOnlyType, + }) + c.Check(a, IsNil) + + a, err = bs.Get(asserts.TestOnlyType, []string{}, 0) + c.Check(err, ErrorMatches, `internal error: Backstore.Get given a key missing mandatory elements for "test-only":.*`) + c.Check(a, IsNil) + + var found map[string]string + cb := func(a asserts.Assertion) { + if found == nil { + found = make(map[string]string) + } + found[strings.Join(a.Ref().PrimaryKey, "/")] = a.HeaderString("marker") + + } + err = mbss.bs.Search(asserts.TestOnlyType, nil, cb, 0) + c.Assert(err, IsNil) + c.Check(found, DeepEquals, map[string]string{ + "k1/o1-defl": "a1", + "k2/A": "a2", + }) + + found = nil + err = mbss.bs.Search(asserts.TestOnlyType, map[string]string{ + "primary-key": "k1", + }, cb, 0) + c.Assert(err, IsNil) + c.Check(found, DeepEquals, map[string]string{ + "k1/o1-defl": "a1", + }) + + found = nil + err = mbss.bs.Search(asserts.TestOnlyType, map[string]string{ + "opt1": "o1-defl", + }, cb, 0) + c.Assert(err, IsNil) + c.Check(found, DeepEquals, map[string]string{ + "k1/o1-defl": "a1", + }) + + found = nil + err = mbss.bs.Search(asserts.TestOnlyType, map[string]string{ + "opt1": "A", + }, cb, 0) + c.Assert(err, IsNil) + c.Check(found, DeepEquals, map[string]string{ + "k2/A": "a2", + }) +} diff --git a/asserts/memkeypairmgr.go b/asserts/memkeypairmgr.go new file mode 100644 index 00000000..75555f81 --- /dev/null +++ b/asserts/memkeypairmgr.go @@ -0,0 +1,71 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2015-2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts + +import ( + "sync" +) + +type memoryKeypairManager struct { + pairs map[string]PrivateKey + mu sync.RWMutex +} + +// NewMemoryKeypairManager creates a new key pair manager with a memory backstore. +func NewMemoryKeypairManager() KeypairManager { + return &memoryKeypairManager{ + pairs: make(map[string]PrivateKey), + } +} + +func (mkm *memoryKeypairManager) Put(privKey PrivateKey) error { + mkm.mu.Lock() + defer mkm.mu.Unlock() + + keyID := privKey.PublicKey().ID() + if mkm.pairs[keyID] != nil { + return errKeypairAlreadyExists + } + mkm.pairs[keyID] = privKey + return nil +} + +func (mkm *memoryKeypairManager) Get(keyID string) (PrivateKey, error) { + mkm.mu.RLock() + defer mkm.mu.RUnlock() + + privKey := mkm.pairs[keyID] + if privKey == nil { + return nil, errKeypairNotFound + } + return privKey, nil +} + +func (mkm *memoryKeypairManager) Delete(keyID string) error { + mkm.mu.RLock() + defer mkm.mu.RUnlock() + + _, ok := mkm.pairs[keyID] + if !ok { + return errKeypairNotFound + } + delete(mkm.pairs, keyID) + return nil +} diff --git a/asserts/memkeypairmgr_test.go b/asserts/memkeypairmgr_test.go new file mode 100644 index 00000000..cd3e544e --- /dev/null +++ b/asserts/memkeypairmgr_test.go @@ -0,0 +1,94 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2015-2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts_test + +import ( + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/asserts" +) + +type memKeypairMgtSuite struct { + keypairMgr asserts.KeypairManager +} + +var _ = Suite(&memKeypairMgtSuite{}) + +func (mkms *memKeypairMgtSuite) SetUpTest(c *C) { + mkms.keypairMgr = asserts.NewMemoryKeypairManager() +} + +func (mkms *memKeypairMgtSuite) TestPutAndGet(c *C) { + pk1 := testPrivKey1 + keyID := pk1.PublicKey().ID() + err := mkms.keypairMgr.Put(pk1) + c.Assert(err, IsNil) + + got, err := mkms.keypairMgr.Get(keyID) + c.Assert(err, IsNil) + c.Assert(got, NotNil) + c.Check(got.PublicKey().ID(), Equals, pk1.PublicKey().ID()) +} + +func (mkms *memKeypairMgtSuite) TestPutAlreadyExists(c *C) { + pk1 := testPrivKey1 + err := mkms.keypairMgr.Put(pk1) + c.Assert(err, IsNil) + + err = mkms.keypairMgr.Put(pk1) + c.Check(err, ErrorMatches, "key pair with given key id already exists") +} + +func (mkms *memKeypairMgtSuite) TestGetNotFound(c *C) { + pk1 := testPrivKey1 + keyID := pk1.PublicKey().ID() + + got, err := mkms.keypairMgr.Get(keyID) + c.Check(got, IsNil) + c.Check(err, ErrorMatches, "cannot find key pair") + c.Check(asserts.IsKeyNotFound(err), Equals, true) + + err = mkms.keypairMgr.Put(pk1) + c.Assert(err, IsNil) + + got, err = mkms.keypairMgr.Get(keyID + "x") + c.Check(got, IsNil) + c.Check(err, ErrorMatches, "cannot find key pair") + c.Check(asserts.IsKeyNotFound(err), Equals, true) +} + +func (mkms *memKeypairMgtSuite) TestDelete(c *C) { + pk1 := testPrivKey1 + keyID := pk1.PublicKey().ID() + err := mkms.keypairMgr.Put(pk1) + c.Assert(err, IsNil) + + _, err = mkms.keypairMgr.Get(keyID) + c.Assert(err, IsNil) + + err = mkms.keypairMgr.Delete(keyID) + c.Assert(err, IsNil) + + err = mkms.keypairMgr.Delete(keyID) + c.Check(err, ErrorMatches, "cannot find key pair") + + _, err = mkms.keypairMgr.Get(keyID) + c.Check(err, ErrorMatches, "cannot find key pair") +} diff --git a/asserts/model.go b/asserts/model.go new file mode 100644 index 00000000..5333bcd6 --- /dev/null +++ b/asserts/model.go @@ -0,0 +1,1221 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016-2023 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts + +import ( + "fmt" + "regexp" + "strings" + "time" + + "github.com/snapcore/snapd/release" + "github.com/snapcore/snapd/snap/channel" + "github.com/snapcore/snapd/snap/naming" + "github.com/snapcore/snapd/strutil" +) + +// ModelComponent holds details for components specified by a model assertion. +type ModelComponent struct { + // Presence can be optional or required + Presence string + // Modes is an optional list of modes, which must be a subset + // of the ones for the snap + Modes []string +} + +// TODO: for ModelSnap +// * consider moving snap.Type out of snap and using it in ModelSnap +// but remember assertions use "core" (never "os") for TypeOS +// * consider having a first-class Presence type + +// ModelSnap holds the details about a snap specified by a model assertion. +type ModelSnap struct { + Name string + SnapID string + // SnapType is one of: app|base|gadget|kernel|core, default is app + SnapType string + // Modes in which the snap must be made available + Modes []string + // DefaultChannel is the initial tracking channel, + // default is latest/stable in an extended model + DefaultChannel string + // PinnedTrack is a pinned track for the snap, if set DefaultChannel + // cannot be set at the same time (Core 18 models feature) + PinnedTrack string + // Presence is one of: required|optional + Presence string + // Classic indicates that this classic snap is intentionally + // included in a classic model + Classic bool + // Components is a map of component names to ModelComponent + Components map[string]ModelComponent +} + +// SnapName implements naming.SnapRef. +func (s *ModelSnap) SnapName() string { + return s.Name +} + +// ID implements naming.SnapRef. +func (s *ModelSnap) ID() string { + return s.SnapID +} + +type modelSnaps struct { + snapd *ModelSnap + base *ModelSnap + gadget *ModelSnap + kernel *ModelSnap + snapsNoEssential []*ModelSnap +} + +func (ms *modelSnaps) list() (allSnaps []*ModelSnap, requiredWithEssentialSnaps []naming.SnapRef, numEssentialSnaps int) { + addSnap := func(snap *ModelSnap, essentialSnap int) { + if snap == nil { + return + } + numEssentialSnaps += essentialSnap + allSnaps = append(allSnaps, snap) + if snap.Presence == "required" { + requiredWithEssentialSnaps = append(requiredWithEssentialSnaps, snap) + } + } + + addSnap(ms.snapd, 1) + addSnap(ms.kernel, 1) + addSnap(ms.base, 1) + addSnap(ms.gadget, 1) + for _, snap := range ms.snapsNoEssential { + addSnap(snap, 0) + } + return allSnaps, requiredWithEssentialSnaps, numEssentialSnaps +} + +var ( + essentialSnapModes = []string{"run", "ephemeral"} + defaultModes = []string{"run"} +) + +func checkExtendedSnaps(extendedSnaps interface{}, base string, grade ModelGrade, modelIsClassic bool) (*modelSnaps, error) { + const wrongHeaderType = `"snaps" header must be a list of maps` + + entries, ok := extendedSnaps.([]interface{}) + if !ok { + return nil, fmt.Errorf(wrongHeaderType) + } + + var modelSnaps modelSnaps + seen := make(map[string]bool, len(entries)) + seenIDs := make(map[string]string, len(entries)) + + for _, entry := range entries { + snap, ok := entry.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf(wrongHeaderType) + } + modelSnap, err := checkModelSnap(snap, base, grade, modelIsClassic) + if err != nil { + return nil, err + } + + if seen[modelSnap.Name] { + return nil, fmt.Errorf("cannot list the same snap %q multiple times", modelSnap.Name) + } + seen[modelSnap.Name] = true + // at this time we do not support parallel installing + // from model/seed + if snapID := modelSnap.SnapID; snapID != "" { + if underName := seenIDs[snapID]; underName != "" { + return nil, fmt.Errorf("cannot specify the same snap id %q multiple times, specified for snaps %q and %q", snapID, underName, modelSnap.Name) + } + seenIDs[snapID] = modelSnap.Name + } + + switch { + case modelSnap.SnapType == "snapd": + // TODO: allow to be explicit only in grade: dangerous? + if modelSnaps.snapd != nil { + return nil, fmt.Errorf("cannot specify multiple snapd snaps: %q and %q", modelSnaps.snapd.Name, modelSnap.Name) + } + modelSnaps.snapd = modelSnap + case modelSnap.SnapType == "kernel": + if modelSnaps.kernel != nil { + return nil, fmt.Errorf("cannot specify multiple kernel snaps: %q and %q", modelSnaps.kernel.Name, modelSnap.Name) + } + modelSnaps.kernel = modelSnap + case modelSnap.SnapType == "gadget": + if modelSnaps.gadget != nil { + return nil, fmt.Errorf("cannot specify multiple gadget snaps: %q and %q", modelSnaps.gadget.Name, modelSnap.Name) + } + modelSnaps.gadget = modelSnap + case modelSnap.Name == base: + if modelSnap.SnapType != "base" { + return nil, fmt.Errorf(`boot base %q must specify type "base", not %q`, base, modelSnap.SnapType) + } + modelSnaps.base = modelSnap + } + + if !isEssentialSnap(modelSnap.Name, modelSnap.SnapType, base) { + modelSnaps.snapsNoEssential = append(modelSnaps.snapsNoEssential, modelSnap) + } + } + + return &modelSnaps, nil +} + +var ( + validSnapTypes = []string{"app", "base", "gadget", "kernel", "core", "snapd"} + validSnapMode = regexp.MustCompile("^[a-z][-a-z]+$") + validSnapPresences = []string{"required", "optional"} +) + +func isEssentialSnap(snapName, snapType, modelBase string) bool { + switch snapType { + case "snapd", "kernel", "gadget": + return true + } + if snapName == modelBase { + return true + } + return false +} + +func checkModesForSnap(snap map[string]interface{}, isEssential bool, what string) ([]string, error) { + modes, err := checkStringListInMap(snap, "modes", fmt.Sprintf("%q %s", "modes", what), + validSnapMode) + if err != nil { + return nil, err + } + if isEssential { + if len(modes) != 0 { + return nil, fmt.Errorf("essential snaps are always available, cannot specify modes %s", what) + } + modes = essentialSnapModes + } + + if len(modes) == 0 { + modes = defaultModes + } + + return modes, nil +} + +func checkModelSnap(snap map[string]interface{}, modelBase string, grade ModelGrade, modelIsClassic bool) (*ModelSnap, error) { + name, err := checkNotEmptyStringWhat(snap, "name", "of snap") + if err != nil { + return nil, err + } + if err := naming.ValidateSnap(name); err != nil { + return nil, fmt.Errorf("invalid snap name %q", name) + } + + what := fmt.Sprintf("of snap %q", name) + + var snapID string + _, ok := snap["id"] + if ok { + var err error + snapID, err = checkStringMatchesWhat(snap, "id", what, naming.ValidSnapID) + if err != nil { + return nil, err + } + } else { + // snap ids are optional with grade dangerous to allow working + // with local/not pushed yet to the store snaps + if grade != ModelDangerous { + return nil, fmt.Errorf(`"id" %s is mandatory for %s grade model`, what, grade) + } + } + + typ, err := checkOptionalStringWhat(snap, "type", what) + if err != nil { + return nil, err + } + if typ == "" { + typ = "app" + } + if !strutil.ListContains(validSnapTypes, typ) { + return nil, fmt.Errorf("type of snap %q must be one of %s", name, strings.Join(validSnapTypes, "|")) + } + + presence, err := checkOptionalStringWhat(snap, "presence", what) + if err != nil { + return nil, err + } + if presence != "" && !strutil.ListContains(validSnapPresences, presence) { + return nil, fmt.Errorf("presence of snap %q must be one of required|optional", name) + } + essential := isEssentialSnap(name, typ, modelBase) + if essential && presence != "" { + return nil, fmt.Errorf("essential snaps are always available, cannot specify presence for snap %q", name) + } + if presence == "" { + presence = "required" + } + + modes, err := checkModesForSnap(snap, essential, what) + if err != nil { + return nil, err + } + + defaultChannel, err := checkOptionalStringWhat(snap, "default-channel", what) + if err != nil { + return nil, err + } + if defaultChannel == "" { + defaultChannel = "latest/stable" + } + defCh, err := channel.ParseVerbatim(defaultChannel, "-") + if err != nil { + return nil, fmt.Errorf("invalid default channel for snap %q: %v", name, err) + } + if defCh.Track == "" { + return nil, fmt.Errorf("default channel for snap %q must specify a track", name) + } + + isClassic, err := checkOptionalBoolWhat(snap, "classic", what) + if err != nil { + return nil, err + } + if isClassic && !modelIsClassic { + return nil, fmt.Errorf("snap %q cannot be classic in non-classic model", name) + } + if isClassic && typ != "app" { + return nil, fmt.Errorf("snap %q cannot be classic with type %q instead of app", name, typ) + } + if isClassic && (len(modes) != 1 || modes[0] != "run") { + return nil, fmt.Errorf("classic snap %q not allowed outside of run mode: %v", + name, modes) + } + + components, err := checkComponentsForMaps(snap, modes, what) + if err != nil { + return nil, err + } + + return &ModelSnap{ + Name: name, + SnapID: snapID, + SnapType: typ, + Modes: modes, // can be empty + DefaultChannel: defaultChannel, + Presence: presence, // can be empty + Classic: isClassic, + Components: components, // can be empty + }, nil +} + +// This is what we expect for components: +/** +snaps: + - name: + ... + presence: "optional"|"required" # optional, defaults to "required" + modes: [] # list of modes + components: # optional + : + presence: "optional"|"required" + modes: [] # list of modes, optional + # must be a subset of snap modes + # defaults to the same modes + # as the snap + : "required"|"optional" # presence, shortcut syntax +**/ +func checkComponentsForMaps(m map[string]interface{}, validModes []string, what string) (map[string]ModelComponent, error) { + const compsField = "components" + value, ok := m[compsField] + if !ok { + return nil, nil + } + comps, ok := value.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("%q %s must be a map from strings to components", + compsField, what) + } + + res := make(map[string]ModelComponent, len(comps)) + for name, comp := range comps { + // Name of component follows the same rules as snap components + if err := naming.ValidateSnap(name); err != nil { + return nil, fmt.Errorf("invalid component name %s", name) + } + + // "comp: required|optional" case + compWhat := fmt.Sprintf("of component %q %s", name, what) + presence, ok := comp.(string) + if ok { + if !strutil.ListContains(validSnapPresences, presence) { + return nil, fmt.Errorf("presence %s must be one of required|optional", compWhat) + } + res[name] = ModelComponent{Presence: presence, + Modes: append([]string(nil), validModes...)} + continue + } + + // try map otherwise + compFields, ok := comp.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("%s must be a map of strings to components or one of required|optional", + compWhat) + } + // Error out if unexpected entry + for key := range compFields { + if !strutil.ListContains([]string{"presence", "modes"}, key) { + return nil, fmt.Errorf("entry %q %s is unknown", key, compWhat) + } + } + presence, err := checkNotEmptyStringWhat(compFields, "presence", compWhat) + if err != nil { + return nil, err + } + if !strutil.ListContains(validSnapPresences, presence) { + return nil, fmt.Errorf("presence %s must be one of required|optional", compWhat) + } + modes, err := checkStringListInMap(compFields, "modes", + fmt.Sprintf("modes %s", compWhat), validSnapMode) + if err != nil { + return nil, err + } + if len(modes) == 0 { + modes = append([]string(nil), validModes...) + } else { + for _, m := range modes { + if !strutil.ListContains(validModes, m) { + return nil, fmt.Errorf("mode %q %s is incompatible with the snap modes", m, compWhat) + } + } + } + res[name] = ModelComponent{Presence: presence, Modes: modes} + } + + return res, nil +} + +// unextended case support + +func checkSnapWithTrack(headers map[string]interface{}, which string) (*ModelSnap, error) { + _, ok := headers[which] + if !ok { + return nil, nil + } + value, ok := headers[which].(string) + if !ok { + return nil, fmt.Errorf(`%q header must be a string`, which) + } + l := strings.SplitN(value, "=", 2) + + name := l[0] + track := "" + if err := validateSnapName(name, which); err != nil { + return nil, err + } + if len(l) > 1 { + track = l[1] + if strings.Count(track, "/") != 0 { + return nil, fmt.Errorf(`%q channel selector must be a track name only`, which) + } + channelRisks := []string{"stable", "candidate", "beta", "edge"} + if strutil.ListContains(channelRisks, track) { + return nil, fmt.Errorf(`%q channel selector must be a track name`, which) + } + } + + return &ModelSnap{ + Name: name, + SnapType: which, + Modes: defaultModes, + PinnedTrack: track, + Presence: "required", + }, nil +} + +func validateSnapName(name string, headerName string) error { + if err := naming.ValidateSnap(name); err != nil { + return fmt.Errorf("invalid snap name in %q header: %s", headerName, name) + } + return nil +} + +func checkRequiredSnap(name string, headerName string, snapType string) (*ModelSnap, error) { + if err := validateSnapName(name, headerName); err != nil { + return nil, err + } + + return &ModelSnap{ + Name: name, + SnapType: snapType, + Modes: defaultModes, + Presence: "required", + }, nil +} + +// ModelGrade characterizes the security of the model which then +// controls related policy. +type ModelGrade string + +const ( + ModelGradeUnset ModelGrade = "unset" + // ModelSecured implies mandatory full disk encryption and secure boot. + ModelSecured ModelGrade = "secured" + // ModelSigned implies all seed snaps are signed and mentioned + // in the model, i.e. no unasserted or extra snaps. + ModelSigned ModelGrade = "signed" + // ModelDangerous allows unasserted snaps and extra snaps. + ModelDangerous ModelGrade = "dangerous" +) + +// StorageSafety characterizes the requested storage safety of +// the model which then controls what encryption is used +type StorageSafety string + +const ( + StorageSafetyUnset StorageSafety = "unset" + // StorageSafetyEncrypted implies mandatory full disk encryption. + StorageSafetyEncrypted StorageSafety = "encrypted" + // StorageSafetyPreferEncrypted implies full disk + // encryption when the system supports it. + StorageSafetyPreferEncrypted StorageSafety = "prefer-encrypted" + // StorageSafetyPreferUnencrypted implies no full disk + // encryption by default even if the system supports + // encryption. + StorageSafetyPreferUnencrypted StorageSafety = "prefer-unencrypted" +) + +var validStorageSafeties = []string{string(StorageSafetyEncrypted), string(StorageSafetyPreferEncrypted), string(StorageSafetyPreferUnencrypted)} + +var validModelGrades = []string{string(ModelSecured), string(ModelSigned), string(ModelDangerous)} + +// gradeToCode encodes grades into 32 bits, trying to be slightly future-proof: +// - lower 16 bits are reserved +// - in the higher bits use the sequence 1, 8, 16 to have some space +// to possibly add new grades in between +var gradeToCode = map[ModelGrade]uint32{ + ModelGradeUnset: 0, + ModelDangerous: 0x10000, + ModelSigned: 0x80000, + ModelSecured: 0x100000, + // reserved by secboot to measure classic models + // "ClassicModelGradeMask": 0x80000000 +} + +// Code returns a bit representation of the grade, for example for +// measuring it in a full disk encryption implementation. +func (mg ModelGrade) Code() uint32 { + code, ok := gradeToCode[mg] + if !ok { + panic(fmt.Sprintf("unknown model grade: %s", mg)) + } + return code +} + +type ModelValidationSetMode string + +const ( + ModelValidationSetModePreferEnforced ModelValidationSetMode = "prefer-enforce" + ModelValidationSetModeEnforced ModelValidationSetMode = "enforce" +) + +var validModelValidationSetModes = []string{ + string(ModelValidationSetModePreferEnforced), + string(ModelValidationSetModeEnforced), +} + +// ModelValidationSet represents a reference to a validation set assertion. +// The structure also describes how the validation set will be applied +// to the device, and whether the validation set should be pinned to +// a specific sequence. +type ModelValidationSet struct { + // AccountID is the account ID the validation set originates from. + // If this was not explicitly set in the stanza, this will instead + // be set to the brand ID. + AccountID string + // Name is the name of the validation set from the account ID. + Name string + // Sequence, if non-zero, specifies that the validation set should be + // pinned at this sequence number. + Sequence int + // Mode is the enforcement mode the validation set should be applied with. + Mode ModelValidationSetMode +} + +// SequenceKey returns the sequence key for this validation set. +func (mvs *ModelValidationSet) SequenceKey() string { + return vsSequenceKey(release.Series, mvs.AccountID, mvs.Name) +} + +func (mvs *ModelValidationSet) AtSequence() *AtSequence { + return &AtSequence{ + Type: ValidationSetType, + SequenceKey: []string{release.Series, mvs.AccountID, mvs.Name}, + Sequence: mvs.Sequence, + Pinned: mvs.Sequence > 0, + Revision: RevisionNotKnown, + } +} + +// Model holds a model assertion, which is a statement by a brand +// about the properties of a device model. +type Model struct { + assertionBase + classic bool + + baseSnap *ModelSnap + gadgetSnap *ModelSnap + kernelSnap *ModelSnap + + grade ModelGrade + + storageSafety StorageSafety + + allSnaps []*ModelSnap + // consumers of this info should care only about snap identity => + // snapRef + requiredWithEssentialSnaps []naming.SnapRef + numEssentialSnaps int + + validationSets []*ModelValidationSet + + serialAuthority []string + sysUserAuthority []string + preseedAuthority []string + timestamp time.Time +} + +// BrandID returns the brand identifier. Same as the authority id. +func (mod *Model) BrandID() string { + return mod.HeaderString("brand-id") +} + +// Model returns the model name identifier. +func (mod *Model) Model() string { + return mod.HeaderString("model") +} + +// DisplayName returns the human-friendly name of the model or +// falls back to Model if this was not set. +func (mod *Model) DisplayName() string { + display := mod.HeaderString("display-name") + if display == "" { + return mod.Model() + } + return display +} + +// Series returns the series of the core software the model uses. +func (mod *Model) Series() string { + return mod.HeaderString("series") +} + +// Classic returns whether the model is a classic system. +func (mod *Model) Classic() bool { + return mod.classic +} + +// Distribution returns the linux distro specified in the model. +func (mod *Model) Distribution() string { + return mod.HeaderString("distribution") +} + +// Architecture returns the architecture the model is based on. +func (mod *Model) Architecture() string { + return mod.HeaderString("architecture") +} + +// Grade returns the stability grade of the model. Will be ModelGradeUnset +// for Core 16/18 models. +func (mod *Model) Grade() ModelGrade { + return mod.grade +} + +// StorageSafety returns the storage safety for the model. Will be +// StorageSafetyUnset for Core 16/18 models. +func (mod *Model) StorageSafety() StorageSafety { + return mod.storageSafety +} + +// GadgetSnap returns the details of the gadget snap the model uses. +func (mod *Model) GadgetSnap() *ModelSnap { + return mod.gadgetSnap +} + +// Gadget returns the gadget snap the model uses. +func (mod *Model) Gadget() string { + if mod.gadgetSnap == nil { + return "" + } + return mod.gadgetSnap.Name +} + +// GadgetTrack returns the gadget track the model uses. +// XXX this should go away +func (mod *Model) GadgetTrack() string { + if mod.gadgetSnap == nil { + return "" + } + return mod.gadgetSnap.PinnedTrack +} + +// KernelSnap returns the details of the kernel snap the model uses. +func (mod *Model) KernelSnap() *ModelSnap { + return mod.kernelSnap +} + +// Kernel returns the kernel snap the model uses. +// XXX this should go away +func (mod *Model) Kernel() string { + if mod.kernelSnap == nil { + return "" + } + return mod.kernelSnap.Name +} + +// KernelTrack returns the kernel track the model uses. +// XXX this should go away +func (mod *Model) KernelTrack() string { + if mod.kernelSnap == nil { + return "" + } + return mod.kernelSnap.PinnedTrack +} + +// Base returns the base snap the model uses. +func (mod *Model) Base() string { + return mod.HeaderString("base") +} + +// BaseSnap returns the details of the base snap the model uses. +func (mod *Model) BaseSnap() *ModelSnap { + return mod.baseSnap +} + +// Store returns the snap store the model uses. +func (mod *Model) Store() string { + return mod.HeaderString("store") +} + +// RequiredNoEssentialSnaps returns the snaps that must be installed at all times and cannot be removed for this model, excluding the essential snaps (gadget, kernel, boot base, snapd). +func (mod *Model) RequiredNoEssentialSnaps() []naming.SnapRef { + return mod.requiredWithEssentialSnaps[mod.numEssentialSnaps:] +} + +// RequiredWithEssentialSnaps returns the snaps that must be installed at all times and cannot be removed for this model, including any essential snaps (gadget, kernel, boot base, snapd). +func (mod *Model) RequiredWithEssentialSnaps() []naming.SnapRef { + return mod.requiredWithEssentialSnaps +} + +// EssentialSnaps returns all essential snaps explicitly mentioned by +// the model. +// They are always returned according to this order with some skipped +// if not mentioned: snapd, kernel, boot base, gadget. +func (mod *Model) EssentialSnaps() []*ModelSnap { + return mod.allSnaps[:mod.numEssentialSnaps] +} + +// SnapsWithoutEssential returns all the snaps listed by the model +// without any of the essential snaps (as returned by EssentialSnaps). +// They are returned in the order of mention by the model. +func (mod *Model) SnapsWithoutEssential() []*ModelSnap { + return mod.allSnaps[mod.numEssentialSnaps:] +} + +// AllSnaps returns all the snaps listed by the model, across all modes. +// Essential snaps are at the front of the slice, followed by the non-essential +// snaps. The essential snaps follow the same order as returned by +// EssentialSnaps. The non-essential snaps are returned in the order they are +// mentioned in the model. +func (mod *Model) AllSnaps() []*ModelSnap { + return mod.allSnaps +} + +// ValidationSets returns all the validation-sets listed by the model. +func (mod *Model) ValidationSets() []*ModelValidationSet { + return mod.validationSets +} + +// SerialAuthority returns the authority ids that are accepted as +// signers for serial assertions for this model. It always includes the +// brand of the model. +func (mod *Model) SerialAuthority() []string { + return mod.serialAuthority +} + +// SystemUserAuthority returns the authority ids that are accepted as +// signers of system-user assertions for this model. Empty list means +// any, otherwise it always includes the brand of the model. +func (mod *Model) SystemUserAuthority() []string { + return mod.sysUserAuthority +} + +// PreseedAuthority returns the authority ids that are accepted as +// signers of the preseed binary blob for this model. It always includes the +// brand of the model. +func (mod *Model) PreseedAuthority() []string { + return mod.preseedAuthority +} + +// Timestamp returns the time when the model assertion was issued. +func (mod *Model) Timestamp() time.Time { + return mod.timestamp +} + +// Implement further consistency checks. +func (mod *Model) checkConsistency(db RODatabase, acck *AccountKey) error { + // TODO: double check trust level of authority depending on class and possibly allowed-modes + return nil +} + +// expected interface is implemented +var _ consistencyChecker = (*Model)(nil) + +// limit model to only lowercase for now +var validModel = regexp.MustCompile("^[a-zA-Z0-9](?:-?[a-zA-Z0-9])*$") + +func checkModel(headers map[string]interface{}) (string, error) { + s, err := checkStringMatches(headers, "model", validModel) + if err != nil { + return "", err + } + + // TODO: support the concept of case insensitive/preserving string headers + if strings.ToLower(s) != s { + return "", fmt.Errorf(`"model" header cannot contain uppercase letters`) + } + return s, nil +} + +func checkAuthorityMatchesBrand(a Assertion) error { + typeName := a.Type().Name + authorityID := a.AuthorityID() + brand := a.HeaderString("brand-id") + if brand != authorityID { + return fmt.Errorf("authority-id and brand-id must match, %s assertions are expected to be signed by the brand: %q != %q", typeName, authorityID, brand) + } + return nil +} + +func checkOptionalAuthority(headers map[string]interface{}, name string, brandID string, acceptsWildcard bool) ([]string, error) { + ids := []string{brandID} + v, ok := headers[name] + if !ok { + return ids, nil + } + switch x := v.(type) { + case string: + if acceptsWildcard && x == "*" { + return nil, nil + } + case []interface{}: + lst, err := checkStringListMatches(headers, name, validAccountID) + if err == nil { + if !strutil.ListContains(lst, brandID) { + lst = append(ids, lst...) + } + return lst, nil + } + } + + if acceptsWildcard { + return nil, fmt.Errorf("%q header must be '*' or a list of account ids", name) + } else { + return nil, fmt.Errorf("%q header must be a list of account ids", name) + } +} + +func checkOptionalSerialAuthority(headers map[string]interface{}, brandID string) ([]string, error) { + const acceptsWildcard = false + return checkOptionalAuthority(headers, "serial-authority", brandID, acceptsWildcard) +} + +func checkOptionalSystemUserAuthority(headers map[string]interface{}, brandID string) ([]string, error) { + const acceptsWildcard = true + return checkOptionalAuthority(headers, "system-user-authority", brandID, acceptsWildcard) +} + +func checkOptionalPreseedAuthority(headers map[string]interface{}, brandID string) ([]string, error) { + const acceptsWildcard = false + return checkOptionalAuthority(headers, "preseed-authority", brandID, acceptsWildcard) +} + +func checkModelValidationSetAccountID(headers map[string]interface{}, what, brandID string) (string, error) { + accountID, err := checkOptionalStringWhat(headers, "account-id", what) + if err != nil { + return "", err + } + + // default to brand ID if account ID is not provided + if accountID == "" { + return brandID, nil + } + return accountID, nil +} + +// checkOptionalModelValidationSetSequence reads the optional 'sequence' member, if +// not set, returns 0 as this means unpinned. Unfortunately we are not able +// to reuse `checkSequence` as it operates inside different parameters. +func checkOptionalModelValidationSetSequence(headers map[string]interface{}, what string) (int, error) { + // Default to 0 when the sequence header is not present + if _, ok := headers["sequence"]; !ok { + return 0, nil + } + + seq, err := checkIntWhat(headers, "sequence", what) + if err != nil { + return 0, err + } + + // If sequence is provided, only accept positive values above 0 + if seq <= 0 { + return 0, fmt.Errorf("\"sequence\" %s must be larger than 0 or left unspecified (meaning tracking latest)", what) + } + return seq, nil +} + +func checkModelValidationSetMode(headers map[string]interface{}, what string) (ModelValidationSetMode, error) { + modeStr, err := checkNotEmptyStringWhat(headers, "mode", what) + if err != nil { + return "", err + } + + if modeStr != "" && !strutil.ListContains(validModelValidationSetModes, modeStr) { + return "", fmt.Errorf("\"mode\" %s must be %s, not %q", what, strings.Join(validModelValidationSetModes, "|"), modeStr) + } + return ModelValidationSetMode(modeStr), nil +} + +func checkModelValidationSet(headers map[string]interface{}, brandID string) (*ModelValidationSet, error) { + name, err := checkStringMatchesWhat(headers, "name", "of validation-set", validValidationSetName) + if err != nil { + return nil, err + } + + what := fmt.Sprintf("of validation-set %q", name) + accountID, err := checkModelValidationSetAccountID(headers, what, brandID) + if err != nil { + return nil, err + } + + what = fmt.Sprintf("of validation-set \"%s/%s\"", accountID, name) + seq, err := checkOptionalModelValidationSetSequence(headers, what) + if err != nil { + return nil, err + } + + mode, err := checkModelValidationSetMode(headers, what) + if err != nil { + return nil, err + } + + return &ModelValidationSet{ + AccountID: accountID, + Name: name, + Sequence: seq, + Mode: mode, + }, nil +} + +func checkOptionalModelValidationSets(headers map[string]interface{}, brandID string) ([]*ModelValidationSet, error) { + valSets, ok := headers["validation-sets"] + if !ok { + return nil, nil + } + + entries, ok := valSets.([]interface{}) + if !ok { + return nil, fmt.Errorf(`"validation-sets" must be a list of validation sets`) + } + + vss := make([]*ModelValidationSet, len(entries)) + seen := make(map[string]bool, len(entries)) + for i, entry := range entries { + data, ok := entry.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf(`entry in "validation-sets" is not a valid validation-set`) + } + + vs, err := checkModelValidationSet(data, brandID) + if err != nil { + return nil, err + } + vsKey := fmt.Sprintf("%s/%s", vs.AccountID, vs.Name) + if seen[vsKey] { + return nil, fmt.Errorf("cannot add validation set %q twice", vsKey) + } + + vss[i] = vs + seen[vsKey] = true + } + return vss, nil +} + +var ( + modelMandatory = []string{"architecture", "gadget", "kernel"} + extendedMandatory = []string{"architecture", "base"} + extendedSnapsConflicting = []string{"gadget", "kernel", "required-snaps"} + classicModelOptional = []string{"architecture", "gadget"} + + // The distribution header must be a valid ID according to + // https://www.freedesktop.org/software/systemd/man/os-release.html#ID= + validDistribution = regexp.MustCompile(`^[a-z0-9._-]*$`) +) + +func assembleModel(assert assertionBase) (Assertion, error) { + err := checkAuthorityMatchesBrand(&assert) + if err != nil { + return nil, err + } + + _, err = checkModel(assert.headers) + if err != nil { + return nil, err + } + + classic, err := checkOptionalBool(assert.headers, "classic") + if err != nil { + return nil, err + } + + // Core 20 extended snaps header + extendedSnaps, extended := assert.headers["snaps"] + if extended { + for _, conflicting := range extendedSnapsConflicting { + if _, ok := assert.headers[conflicting]; ok { + return nil, fmt.Errorf("cannot specify separate %q header once using the extended snaps header", conflicting) + } + } + } else { + if _, ok := assert.headers["grade"]; ok { + return nil, fmt.Errorf("cannot specify a grade for model without the extended snaps header") + } + if _, ok := assert.headers["storage-safety"]; ok { + return nil, fmt.Errorf("cannot specify storage-safety for model without the extended snaps header") + } + } + + if classic && !extended { + if _, ok := assert.headers["kernel"]; ok { + return nil, fmt.Errorf("cannot specify a kernel with a non-extended classic model") + } + if _, ok := assert.headers["base"]; ok { + return nil, fmt.Errorf("cannot specify a base with a non-extended classic model") + } + } + + // distribution mandatory for classic with extended snaps, not + // allowed otherwise. + if classic && extended { + _, err := checkStringMatches(assert.headers, "distribution", validDistribution) + if err != nil { + return nil, fmt.Errorf("%v, see distribution ID in os-release spec", err) + } + } else if _, ok := assert.headers["distribution"]; ok { + return nil, fmt.Errorf("cannot specify distribution for model unless it is classic and has an extended snaps header") + } + + checker := checkNotEmptyString + toCheck := modelMandatory + if extended { + toCheck = extendedMandatory + } else if classic { + checker = checkOptionalString + toCheck = classicModelOptional + } + + for _, h := range toCheck { + if _, err := checker(assert.headers, h); err != nil { + return nil, err + } + } + + // base, if provided, must be a valid snap name too + var baseSnap *ModelSnap + base, err := checkOptionalString(assert.headers, "base") + if err != nil { + return nil, err + } + if base != "" { + baseSnap, err = checkRequiredSnap(base, "base", "base") + if err != nil { + return nil, err + } + } + + // store is optional but must be a string, defaults to the ubuntu store + if _, err = checkOptionalString(assert.headers, "store"); err != nil { + return nil, err + } + + // display-name is optional but must be a string + if _, err = checkOptionalString(assert.headers, "display-name"); err != nil { + return nil, err + } + + var modSnaps *modelSnaps + grade := ModelGradeUnset + storageSafety := StorageSafetyUnset + if extended { + gradeStr, err := checkOptionalString(assert.headers, "grade") + if err != nil { + return nil, err + } + if gradeStr != "" && !strutil.ListContains(validModelGrades, gradeStr) { + return nil, fmt.Errorf("grade for model must be %s, not %q", strings.Join(validModelGrades, "|"), gradeStr) + } + grade = ModelSigned + if gradeStr != "" { + grade = ModelGrade(gradeStr) + } + + storageSafetyStr, err := checkOptionalString(assert.headers, "storage-safety") + if err != nil { + return nil, err + } + if storageSafetyStr != "" && !strutil.ListContains(validStorageSafeties, storageSafetyStr) { + return nil, fmt.Errorf("storage-safety for model must be %s, not %q", strings.Join(validStorageSafeties, "|"), storageSafetyStr) + } + if storageSafetyStr != "" { + storageSafety = StorageSafety(storageSafetyStr) + } else { + if grade == ModelSecured { + storageSafety = StorageSafetyEncrypted + } else { + storageSafety = StorageSafetyPreferEncrypted + } + } + + if grade == ModelSecured && storageSafety != StorageSafetyEncrypted { + return nil, fmt.Errorf(`secured grade model must not have storage-safety overridden, only "encrypted" is valid`) + } + + modSnaps, err = checkExtendedSnaps(extendedSnaps, base, grade, classic) + if err != nil { + return nil, err + } + hasKernel := modSnaps.kernel != nil + hasGadget := modSnaps.gadget != nil + if !classic { + if !hasGadget { + return nil, fmt.Errorf(`one "snaps" header entry must specify the model gadget`) + } + if !hasKernel { + return nil, fmt.Errorf(`one "snaps" header entry must specify the model kernel`) + } + } else { + if hasKernel && !hasGadget { + return nil, fmt.Errorf("cannot specify a kernel in an extended classic model without a model gadget") + } + } + + if modSnaps.base == nil { + // complete with defaults, + // the assumption is that base names are very stable + // essentially fixed + modSnaps.base = baseSnap + snapID := naming.WellKnownSnapID(modSnaps.base.Name) + if snapID == "" && grade != ModelDangerous { + return nil, fmt.Errorf(`cannot specify not well-known base %q without a corresponding "snaps" header entry`, modSnaps.base.Name) + } + modSnaps.base.SnapID = snapID + modSnaps.base.Modes = essentialSnapModes + modSnaps.base.DefaultChannel = "latest/stable" + } + } else { + modSnaps = &modelSnaps{ + base: baseSnap, + } + // kernel/gadget must be valid snap names and can have (optional) tracks + // - validate those + modSnaps.kernel, err = checkSnapWithTrack(assert.headers, "kernel") + if err != nil { + return nil, err + } + modSnaps.gadget, err = checkSnapWithTrack(assert.headers, "gadget") + if err != nil { + return nil, err + } + + // required snap must be valid snap names + reqSnaps, err := checkStringList(assert.headers, "required-snaps") + if err != nil { + return nil, err + } + for _, name := range reqSnaps { + reqSnap, err := checkRequiredSnap(name, "required-snaps", "") + if err != nil { + return nil, err + } + modSnaps.snapsNoEssential = append(modSnaps.snapsNoEssential, reqSnap) + } + } + + brandID := assert.HeaderString("brand-id") + + serialAuthority, err := checkOptionalSerialAuthority(assert.headers, brandID) + if err != nil { + return nil, err + } + + sysUserAuthority, err := checkOptionalSystemUserAuthority(assert.headers, brandID) + if err != nil { + return nil, err + } + + preseedAuthority, err := checkOptionalPreseedAuthority(assert.headers, brandID) + if err != nil { + return nil, err + } + + timestamp, err := checkRFC3339Date(assert.headers, "timestamp") + if err != nil { + return nil, err + } + + allSnaps, requiredWithEssentialSnaps, numEssentialSnaps := modSnaps.list() + + valSets, err := checkOptionalModelValidationSets(assert.headers, brandID) + if err != nil { + return nil, err + } + + // NB: + // * core is not supported at this time, it defaults to ubuntu-core + // in prepare-image until rename and/or introduction of the header. + // * some form of allowed-modes, class are postponed, + // + // prepare-image takes care of not allowing them for now + + // ignore extra headers and non-empty body for future compatibility + return &Model{ + assertionBase: assert, + classic: classic, + baseSnap: modSnaps.base, + gadgetSnap: modSnaps.gadget, + kernelSnap: modSnaps.kernel, + grade: grade, + storageSafety: storageSafety, + allSnaps: allSnaps, + requiredWithEssentialSnaps: requiredWithEssentialSnaps, + numEssentialSnaps: numEssentialSnaps, + validationSets: valSets, + serialAuthority: serialAuthority, + sysUserAuthority: sysUserAuthority, + preseedAuthority: preseedAuthority, + timestamp: timestamp, + }, nil +} diff --git a/asserts/model_test.go b/asserts/model_test.go new file mode 100644 index 00000000..b17611f3 --- /dev/null +++ b/asserts/model_test.go @@ -0,0 +1,1733 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016-2023 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts_test + +import ( + "fmt" + "strings" + "time" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/release" + "github.com/snapcore/snapd/snap/naming" + "github.com/snapcore/snapd/testutil" +) + +type modelSuite struct { + ts time.Time + tsLine string +} + +var ( + _ = Suite(&modelSuite{}) +) + +func (mods *modelSuite) SetUpSuite(c *C) { + mods.ts = time.Now().Truncate(time.Second).UTC() + mods.tsLine = "timestamp: " + mods.ts.Format(time.RFC3339) + "\n" +} + +const ( + reqSnaps = "required-snaps:\n - foo\n - bar\n" + sysUserAuths = "system-user-authority: *\n" + serialAuths = "serial-authority:\n - generic\n" + preseedAuths = "preseed-authority:\n - preseed-delegate\n" +) + +const ( + modelExample = "type: model\n" + + "authority-id: brand-id1\n" + + "series: 16\n" + + "brand-id: brand-id1\n" + + "model: baz-3000\n" + + "display-name: Baz 3000\n" + + "architecture: amd64\n" + + "gadget: brand-gadget\n" + + "base: core18\n" + + "kernel: baz-linux\n" + + "store: brand-store\n" + + serialAuths + + sysUserAuths + + reqSnaps + + preseedAuths + + "TSLINE" + + "body-length: 0\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==" + + classicModelExample = "type: model\n" + + "authority-id: brand-id1\n" + + "series: 16\n" + + "brand-id: brand-id1\n" + + "model: baz-3000\n" + + "display-name: Baz 3000\n" + + "classic: true\n" + + "architecture: amd64\n" + + "gadget: brand-gadget\n" + + "store: brand-store\n" + + reqSnaps + + "TSLINE" + + "body-length: 0\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==" + + core20ModelExample = `type: model +authority-id: brand-id1 +series: 16 +brand-id: brand-id1 +model: baz-3000 +display-name: Baz 3000 +architecture: amd64 +system-user-authority: * +base: core20 +store: brand-store +snaps: + - + name: baz-linux + id: bazlinuxidididididididididididid + type: kernel + default-channel: 20 + - + name: brand-gadget + id: brandgadgetdidididididididididid + type: gadget + - + name: other-base + id: otherbasedididididididididididid + type: base + modes: + - run + presence: required + - + name: nm + id: nmididididididididididididididid + modes: + - ephemeral + - run + default-channel: 1.0 + - + name: myapp + id: myappdididididididididididididid + type: app + default-channel: 2.0 + - + name: myappopt + id: myappoptidididididididididididid + type: app + presence: optional +OTHERgrade: secured +storage-safety: encrypted +` + "TSLINE" + + "body-length: 0\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==" + + classicModelWithSnapsExample = `type: model +authority-id: brand-id1 +series: 16 +brand-id: brand-id1 +model: baz-3000 +display-name: Baz 3000 +architecture: amd64 +system-user-authority: * +base: core20 +classic: true +distribution: ubuntu +store: brand-store +snaps: + - + name: baz-linux + id: bazlinuxidididididididididididid + type: kernel + default-channel: 20 + - + name: brand-gadget + id: brandgadgetdidididididididididid + type: gadget + - + name: other-base + id: otherbasedididididididididididid + type: base + modes: + - run + presence: required + - + name: nm + id: nmididididididididididididididid + modes: + - ephemeral + - run + default-channel: 1.0 + - + name: myapp + id: myappdididididididididididididid + type: app + default-channel: 2.0 + - + name: myappopt + id: myappoptidididididididididididid + type: app + presence: optional + classic: true +OTHERgrade: secured +storage-safety: encrypted +` + "TSLINE" + + "body-length: 0\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==" + + coreModelWithComponentsExample = `type: model +authority-id: brand-id1 +series: 16 +brand-id: brand-id1 +model: baz-3000 +display-name: Baz 3000 +architecture: amd64 +system-user-authority: * +base: core24 +store: brand-store +snaps: + - + name: baz-linux + id: bazlinuxidididididididididididid + type: kernel + default-channel: 20 + - + name: brand-gadget + id: brandgadgetdidididididididididid + type: gadget + - + name: other-base + id: otherbasedididididididididididid + type: base + presence: required + - + name: nm + id: nmididididididididididididididid + modes: + - ephemeral + - run + default-channel: 1.0 + components: + comp1: + presence: optional + modes: + - ephemeral + comp2: required + - + name: myapp + id: myappdididididididididididididid + type: app + default-channel: 2.0 + presence: optional + modes: + - ephemeral + - run + components: + comp1: + presence: optional + modes: + - ephemeral + - run + comp2: required + - + name: myappopt + id: myappoptidididididididididididid + type: app + presence: required + components: + comp1: + presence: optional + comp2: required +OTHERgrade: secured +storage-safety: encrypted +` + "TSLINE" + + "body-length: 0\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==" +) + +func (mods *modelSuite) TestDecodeOK(c *C) { + encoded := strings.Replace(modelExample, "TSLINE", mods.tsLine, 1) + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + c.Check(a.Type(), Equals, asserts.ModelType) + model := a.(*asserts.Model) + c.Check(model.AuthorityID(), Equals, "brand-id1") + c.Check(model.Timestamp(), Equals, mods.ts) + c.Check(model.Series(), Equals, "16") + c.Check(model.BrandID(), Equals, "brand-id1") + c.Check(model.Model(), Equals, "baz-3000") + c.Check(model.DisplayName(), Equals, "Baz 3000") + c.Check(model.Architecture(), Equals, "amd64") + c.Check(model.GadgetSnap(), DeepEquals, &asserts.ModelSnap{ + Name: "brand-gadget", + SnapType: "gadget", + Modes: []string{"run"}, + Presence: "required", + }) + c.Check(model.Gadget(), Equals, "brand-gadget") + c.Check(model.GadgetTrack(), Equals, "") + c.Check(model.KernelSnap(), DeepEquals, &asserts.ModelSnap{ + Name: "baz-linux", + SnapType: "kernel", + Modes: []string{"run"}, + Presence: "required", + }) + c.Check(model.Kernel(), Equals, "baz-linux") + c.Check(model.KernelTrack(), Equals, "") + c.Check(model.Base(), Equals, "core18") + c.Check(model.BaseSnap(), DeepEquals, &asserts.ModelSnap{ + Name: "core18", + SnapType: "base", + Modes: []string{"run"}, + Presence: "required", + }) + c.Check(model.Store(), Equals, "brand-store") + c.Check(model.Grade(), Equals, asserts.ModelGradeUnset) + c.Check(model.StorageSafety(), Equals, asserts.StorageSafetyUnset) + essentialSnaps := model.EssentialSnaps() + c.Check(essentialSnaps, DeepEquals, []*asserts.ModelSnap{ + model.KernelSnap(), + model.BaseSnap(), + model.GadgetSnap(), + }) + snaps := model.SnapsWithoutEssential() + c.Check(snaps, DeepEquals, []*asserts.ModelSnap{ + { + Name: "foo", + Modes: []string{"run"}, + Presence: "required", + }, + { + Name: "bar", + Modes: []string{"run"}, + Presence: "required", + }, + }) + // essential snaps included + reqSnaps := naming.NewSnapSet(model.RequiredWithEssentialSnaps()) + for _, e := range essentialSnaps { + c.Check(reqSnaps.Contains(e), Equals, true) + } + for _, s := range snaps { + c.Check(reqSnaps.Contains(s), Equals, true) + } + c.Check(reqSnaps.Size(), Equals, len(essentialSnaps)+len(snaps)) + // essential snaps excluded + noEssential := naming.NewSnapSet(model.RequiredNoEssentialSnaps()) + for _, e := range essentialSnaps { + c.Check(noEssential.Contains(e), Equals, false) + } + for _, s := range snaps { + c.Check(noEssential.Contains(s), Equals, true) + } + c.Check(noEssential.Size(), Equals, len(snaps)) + + c.Check(model.SystemUserAuthority(), HasLen, 0) + c.Check(model.SerialAuthority(), DeepEquals, []string{"brand-id1", "generic"}) + c.Check(model.PreseedAuthority(), DeepEquals, []string{"brand-id1", "preseed-delegate"}) +} + +func (mods *modelSuite) TestDecodeStoreIsOptional(c *C) { + withTimestamp := strings.Replace(modelExample, "TSLINE", mods.tsLine, 1) + encoded := strings.Replace(withTimestamp, "store: brand-store\n", "store: \n", 1) + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + model := a.(*asserts.Model) + c.Check(model.Store(), Equals, "") + + encoded = strings.Replace(withTimestamp, "store: brand-store\n", "", 1) + a, err = asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + model = a.(*asserts.Model) + c.Check(model.Store(), Equals, "") +} + +func (mods *modelSuite) TestDecodeBaseIsOptional(c *C) { + withTimestamp := strings.Replace(modelExample, "TSLINE", mods.tsLine, 1) + encoded := strings.Replace(withTimestamp, "base: core18\n", "base: \n", 1) + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + model := a.(*asserts.Model) + c.Check(model.Base(), Equals, "") + + encoded = strings.Replace(withTimestamp, "base: core18\n", "", 1) + a, err = asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + model = a.(*asserts.Model) + c.Check(model.Base(), Equals, "") + c.Check(model.BaseSnap(), IsNil) +} + +func (mods *modelSuite) TestDecodeDisplayNameIsOptional(c *C) { + withTimestamp := strings.Replace(modelExample, "TSLINE", mods.tsLine, 1) + encoded := strings.Replace(withTimestamp, "display-name: Baz 3000\n", "display-name: \n", 1) + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + model := a.(*asserts.Model) + // optional but we fallback to Model + c.Check(model.DisplayName(), Equals, "baz-3000") + + encoded = strings.Replace(withTimestamp, "display-name: Baz 3000\n", "", 1) + a, err = asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + model = a.(*asserts.Model) + // optional but we fallback to Model + c.Check(model.DisplayName(), Equals, "baz-3000") +} + +func (mods *modelSuite) TestDecodeRequiredSnapsAreOptional(c *C) { + withTimestamp := strings.Replace(modelExample, "TSLINE", mods.tsLine, 1) + encoded := strings.Replace(withTimestamp, reqSnaps, "", 1) + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + model := a.(*asserts.Model) + c.Check(model.RequiredNoEssentialSnaps(), HasLen, 0) +} + +func (mods *modelSuite) TestDecodeValidatesSnapNames(c *C) { + withTimestamp := strings.Replace(modelExample, "TSLINE", mods.tsLine, 1) + encoded := strings.Replace(withTimestamp, reqSnaps, "required-snaps:\n - foo_bar\n - bar\n", 1) + a, err := asserts.Decode([]byte(encoded)) + c.Assert(a, IsNil) + c.Assert(err, ErrorMatches, `assertion model: invalid snap name in "required-snaps" header: foo_bar`) + + encoded = strings.Replace(withTimestamp, reqSnaps, "required-snaps:\n - foo\n - bar-;;''\n", 1) + a, err = asserts.Decode([]byte(encoded)) + c.Assert(a, IsNil) + c.Assert(err, ErrorMatches, `assertion model: invalid snap name in "required-snaps" header: bar-;;''`) + + encoded = strings.Replace(withTimestamp, "kernel: baz-linux\n", "kernel: baz-linux_instance\n", 1) + a, err = asserts.Decode([]byte(encoded)) + c.Assert(a, IsNil) + c.Assert(err, ErrorMatches, `assertion model: invalid snap name in "kernel" header: baz-linux_instance`) + + encoded = strings.Replace(withTimestamp, "gadget: brand-gadget\n", "gadget: brand-gadget_instance\n", 1) + a, err = asserts.Decode([]byte(encoded)) + c.Assert(a, IsNil) + c.Assert(err, ErrorMatches, `assertion model: invalid snap name in "gadget" header: brand-gadget_instance`) + + encoded = strings.Replace(withTimestamp, "base: core18\n", "base: core18_instance\n", 1) + a, err = asserts.Decode([]byte(encoded)) + c.Assert(a, IsNil) + c.Assert(err, ErrorMatches, `assertion model: invalid snap name in "base" header: core18_instance`) +} + +func (mods modelSuite) TestDecodeValidSnapNames(c *C) { + // reuse test cases for snap.ValidateName() + + withTimestamp := strings.Replace(modelExample, "TSLINE", mods.tsLine, 1) + + validNames := []string{ + "aa", "aaa", "aaaa", + "a-a", "aa-a", "a-aa", "a-b-c", + "a0", "a-0", "a-0a", + "01game", "1-or-2", + // a regexp stresser + "u-94903713687486543234157734673284536758", + } + for _, name := range validNames { + encoded := strings.Replace(withTimestamp, "kernel: baz-linux\n", fmt.Sprintf("kernel: %s\n", name), 1) + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + model := a.(*asserts.Model) + c.Check(model.Kernel(), Equals, name) + } + invalidNames := []string{ + // name cannot be empty, never reaches snap name validation + "", + // too short (min 2 chars) + "a", + // names cannot be too long + "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "xxxxxxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxx", + "1111111111111111111111111111111111111111x", + "x1111111111111111111111111111111111111111", + "x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x", + // a regexp stresser + "u-9490371368748654323415773467328453675-", + // dashes alone are not a name + "-", "--", + // double dashes in a name are not allowed + "a--a", + // name should not end with a dash + "a-", + // name cannot have any spaces in it + "a ", " a", "a a", + // a number alone is not a name + "0", "123", + // identifier must be plain ASCII + "日本語", "한글", "ру́сский язы́к", + // instance names are invalid too + "foo_bar", "x_1", + } + for _, name := range invalidNames { + encoded := strings.Replace(withTimestamp, "kernel: baz-linux\n", fmt.Sprintf("kernel: %s\n", name), 1) + a, err := asserts.Decode([]byte(encoded)) + c.Assert(a, IsNil) + if name != "" { + c.Assert(err, ErrorMatches, `assertion model: invalid snap name in "kernel" header: .*`) + } else { + c.Assert(err, ErrorMatches, `assertion model: "kernel" header should not be empty`) + } + } +} + +func (mods *modelSuite) TestDecodeSerialAuthorityIsOptional(c *C) { + withTimestamp := strings.Replace(modelExample, "TSLINE", mods.tsLine, 1) + encoded := strings.Replace(withTimestamp, serialAuths, "", 1) + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + model := a.(*asserts.Model) + // the default is just to accept the brand itself + c.Check(model.SerialAuthority(), DeepEquals, []string{"brand-id1"}) + + encoded = strings.Replace(withTimestamp, serialAuths, "serial-authority:\n - foo\n - bar\n", 1) + a, err = asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + model = a.(*asserts.Model) + // the brand is always added implicitly + c.Check(model.SerialAuthority(), DeepEquals, []string{"brand-id1", "foo", "bar"}) +} + +func (mods *modelSuite) TestDecodeSystemUserAuthorityIsOptional(c *C) { + withTimestamp := strings.Replace(modelExample, "TSLINE", mods.tsLine, 1) + encoded := strings.Replace(withTimestamp, sysUserAuths, "", 1) + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + model := a.(*asserts.Model) + // the default is just to accept the brand itself + c.Check(model.SystemUserAuthority(), DeepEquals, []string{"brand-id1"}) + + encoded = strings.Replace(withTimestamp, sysUserAuths, "system-user-authority:\n - foo\n - bar\n", 1) + a, err = asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + model = a.(*asserts.Model) + // the brand is always added implicitly, it can always sign + // a new revision of the model anyway + c.Check(model.SystemUserAuthority(), DeepEquals, []string{"brand-id1", "foo", "bar"}) +} + +func (mods *modelSuite) TestDecodePreseedAuthorityIsOptional(c *C) { + withTimestamp := strings.Replace(modelExample, "TSLINE", mods.tsLine, 1) + encoded := strings.Replace(withTimestamp, preseedAuths, "", 1) + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + model := a.(*asserts.Model) + // the default is just to accept the brand itself + c.Check(model.PreseedAuthority(), DeepEquals, []string{"brand-id1"}) + + encoded = strings.Replace(withTimestamp, preseedAuths, "preseed-authority:\n - foo\n - bar\n", 1) + a, err = asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + model = a.(*asserts.Model) + // the brand is always added implicitly + c.Check(model.PreseedAuthority(), DeepEquals, []string{"brand-id1", "foo", "bar"}) +} + +func (mods *modelSuite) TestDecodeKernelTrack(c *C) { + withTimestamp := strings.Replace(modelExample, "TSLINE", mods.tsLine, 1) + encoded := strings.Replace(withTimestamp, "kernel: baz-linux\n", "kernel: baz-linux=18\n", 1) + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + model := a.(*asserts.Model) + c.Check(model.KernelSnap(), DeepEquals, &asserts.ModelSnap{ + Name: "baz-linux", + SnapType: "kernel", + Modes: []string{"run"}, + PinnedTrack: "18", + Presence: "required", + }) + c.Check(model.Kernel(), Equals, "baz-linux") + c.Check(model.KernelTrack(), Equals, "18") +} + +func (mods *modelSuite) TestDecodeGadgetTrack(c *C) { + withTimestamp := strings.Replace(modelExample, "TSLINE", mods.tsLine, 1) + encoded := strings.Replace(withTimestamp, "gadget: brand-gadget\n", "gadget: brand-gadget=18\n", 1) + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + model := a.(*asserts.Model) + c.Check(model.GadgetSnap(), DeepEquals, &asserts.ModelSnap{ + Name: "brand-gadget", + SnapType: "gadget", + Modes: []string{"run"}, + PinnedTrack: "18", + Presence: "required", + }) + c.Check(model.Gadget(), Equals, "brand-gadget") + c.Check(model.GadgetTrack(), Equals, "18") +} + +const ( + modelErrPrefix = "assertion model: " +) + +func (mods *modelSuite) TestDecodeInvalid(c *C) { + encoded := strings.Replace(modelExample, "TSLINE", mods.tsLine, 1) + + invalidTests := []struct{ original, invalid, expectedErr string }{ + {"series: 16\n", "", `"series" header is mandatory`}, + {"series: 16\n", "series: \n", `"series" header should not be empty`}, + {"brand-id: brand-id1\n", "", `"brand-id" header is mandatory`}, + {"brand-id: brand-id1\n", "brand-id: \n", `"brand-id" header should not be empty`}, + {"brand-id: brand-id1\n", "brand-id: random\n", `authority-id and brand-id must match, model assertions are expected to be signed by the brand: "brand-id1" != "random"`}, + {"model: baz-3000\n", "", `"model" header is mandatory`}, + {"model: baz-3000\n", "model: \n", `"model" header should not be empty`}, + {"model: baz-3000\n", "model: baz/3000\n", `"model" primary key header cannot contain '/'`}, + // lift this restriction at a later point + {"model: baz-3000\n", "model: BAZ-3000\n", `"model" header cannot contain uppercase letters`}, + {"display-name: Baz 3000\n", "display-name:\n - xyz\n", `"display-name" header must be a string`}, + {"architecture: amd64\n", "", `"architecture" header is mandatory`}, + {"architecture: amd64\n", "architecture: \n", `"architecture" header should not be empty`}, + {"gadget: brand-gadget\n", "", `"gadget" header is mandatory`}, + {"gadget: brand-gadget\n", "gadget: \n", `"gadget" header should not be empty`}, + {"gadget: brand-gadget\n", "gadget: brand-gadget=x/x/x\n", `"gadget" channel selector must be a track name only`}, + {"gadget: brand-gadget\n", "gadget: brand-gadget=stable\n", `"gadget" channel selector must be a track name`}, + {"gadget: brand-gadget\n", "gadget: brand-gadget=18/beta\n", `"gadget" channel selector must be a track name only`}, + {"gadget: brand-gadget\n", "gadget:\n - xyz \n", `"gadget" header must be a string`}, + {"kernel: baz-linux\n", "", `"kernel" header is mandatory`}, + {"kernel: baz-linux\n", "kernel: \n", `"kernel" header should not be empty`}, + {"kernel: baz-linux\n", "kernel: baz-linux=x/x/x\n", `"kernel" channel selector must be a track name only`}, + {"kernel: baz-linux\n", "kernel: baz-linux=stable\n", `"kernel" channel selector must be a track name`}, + {"kernel: baz-linux\n", "kernel: baz-linux=18/beta\n", `"kernel" channel selector must be a track name only`}, + {"kernel: baz-linux\n", "kernel:\n - xyz \n", `"kernel" header must be a string`}, + {"base: core18\n", "base:\n - xyz \n", `"base" header must be a string`}, + {"store: brand-store\n", "store:\n - xyz\n", `"store" header must be a string`}, + {mods.tsLine, "", `"timestamp" header is mandatory`}, + {mods.tsLine, "timestamp: \n", `"timestamp" header should not be empty`}, + {mods.tsLine, "timestamp: 12:30\n", `"timestamp" header is not a RFC3339 date: .*`}, + {reqSnaps, "required-snaps: foo\n", `"required-snaps" header must be a list of strings`}, + {reqSnaps, "required-snaps:\n -\n - nested\n", `"required-snaps" header must be a list of strings`}, + {serialAuths, "serial-authority:\n a: 1\n", `"serial-authority" header must be a list of account ids`}, + {serialAuths, "serial-authority:\n - 5_6\n", `"serial-authority" header must be a list of account ids`}, + {serialAuths, "serial-authority: *\n", `"serial-authority" header must be a list of account ids`}, + {sysUserAuths, "system-user-authority:\n a: 1\n", `"system-user-authority" header must be '\*' or a list of account ids`}, + {sysUserAuths, "system-user-authority:\n - 5_6\n", `"system-user-authority" header must be '\*' or a list of account ids`}, + {preseedAuths, "preseed-authority:\n a: 1\n", `"preseed-authority" header must be a list of account ids`}, + {preseedAuths, "preseed-authority:\n - 5_6\n", `"preseed-authority" header must be a list of account ids`}, + {preseedAuths, "preseed-authority: *\n", `"preseed-authority" header must be a list of account ids`}, + {reqSnaps, "grade: dangerous\n", `cannot specify a grade for model without the extended snaps header`}, + } + + for _, test := range invalidTests { + invalid := strings.Replace(encoded, test.original, test.invalid, 1) + _, err := asserts.Decode([]byte(invalid)) + c.Check(err, ErrorMatches, modelErrPrefix+test.expectedErr) + } +} + +func (mods *modelSuite) TestModelCheck(c *C) { + ex, err := asserts.Decode([]byte(strings.Replace(modelExample, "TSLINE", mods.tsLine, 1))) + c.Assert(err, IsNil) + + storeDB, db := makeStoreAndCheckDB(c) + brandDB := setup3rdPartySigning(c, "brand-id1", storeDB, db) + + headers := ex.Headers() + headers["brand-id"] = brandDB.AuthorityID + headers["timestamp"] = time.Now().Format(time.RFC3339) + model, err := brandDB.Sign(asserts.ModelType, headers, nil, "") + c.Assert(err, IsNil) + + err = db.Check(model) + c.Assert(err, IsNil) +} + +func (mods *modelSuite) TestModelCheckInconsistentTimestamp(c *C) { + ex, err := asserts.Decode([]byte(strings.Replace(modelExample, "TSLINE", mods.tsLine, 1))) + c.Assert(err, IsNil) + + storeDB, db := makeStoreAndCheckDB(c) + brandDB := setup3rdPartySigning(c, "brand-id1", storeDB, db) + + headers := ex.Headers() + headers["brand-id"] = brandDB.AuthorityID + headers["timestamp"] = "2011-01-01T14:00:00Z" + model, err := brandDB.Sign(asserts.ModelType, headers, nil, "") + c.Assert(err, IsNil) + + err = db.Check(model) + c.Assert(err, ErrorMatches, `model assertion timestamp "2011-01-01 14:00:00 \+0000 UTC" outside of signing key validity \(key valid since.*\)`) +} + +func (mods *modelSuite) TestClassicDecodeOK(c *C) { + encoded := strings.Replace(classicModelExample, "TSLINE", mods.tsLine, 1) + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + c.Check(a.Type(), Equals, asserts.ModelType) + model := a.(*asserts.Model) + c.Check(model.AuthorityID(), Equals, "brand-id1") + c.Check(model.Timestamp(), Equals, mods.ts) + c.Check(model.Series(), Equals, "16") + c.Check(model.BrandID(), Equals, "brand-id1") + c.Check(model.Model(), Equals, "baz-3000") + c.Check(model.DisplayName(), Equals, "Baz 3000") + c.Check(model.Classic(), Equals, true) + c.Check(model.Distribution(), Equals, "") + c.Check(model.Architecture(), Equals, "amd64") + c.Check(model.GadgetSnap(), DeepEquals, &asserts.ModelSnap{ + Name: "brand-gadget", + SnapType: "gadget", + Modes: []string{"run"}, + Presence: "required", + }) + c.Check(model.Gadget(), Equals, "brand-gadget") + c.Check(model.KernelSnap(), IsNil) + c.Check(model.Kernel(), Equals, "") + c.Check(model.KernelTrack(), Equals, "") + c.Check(model.Base(), Equals, "") + c.Check(model.BaseSnap(), IsNil) + c.Check(model.Store(), Equals, "brand-store") + essentialSnaps := model.EssentialSnaps() + c.Check(essentialSnaps, DeepEquals, []*asserts.ModelSnap{ + model.GadgetSnap(), + }) + snaps := model.SnapsWithoutEssential() + c.Check(snaps, DeepEquals, []*asserts.ModelSnap{ + { + Name: "foo", + Modes: []string{"run"}, + Presence: "required", + }, + { + Name: "bar", + Modes: []string{"run"}, + Presence: "required", + }, + }) + // gadget included + reqSnaps := naming.NewSnapSet(model.RequiredWithEssentialSnaps()) + for _, e := range essentialSnaps { + c.Check(reqSnaps.Contains(e), Equals, true) + } + for _, s := range snaps { + c.Check(reqSnaps.Contains(s), Equals, true) + } + c.Check(reqSnaps.Size(), Equals, len(essentialSnaps)+len(snaps)) + // gadget excluded + noEssential := naming.NewSnapSet(model.RequiredNoEssentialSnaps()) + for _, e := range essentialSnaps { + c.Check(noEssential.Contains(e), Equals, false) + } + for _, s := range snaps { + c.Check(noEssential.Contains(s), Equals, true) + } + c.Check(noEssential.Size(), Equals, len(snaps)) +} + +func (mods *modelSuite) TestClassicDecodeInvalid(c *C) { + encoded := strings.Replace(classicModelExample, "TSLINE", mods.tsLine, 1) + + invalidTests := []struct{ original, invalid, expectedErr string }{ + {"classic: true\n", "classic: foo\n", `"classic" header must be 'true' or 'false'`}, + {"architecture: amd64\n", "architecture:\n - foo\n", `"architecture" header must be a string`}, + {"gadget: brand-gadget\n", "gadget:\n - foo\n", `"gadget" header must be a string`}, + {"gadget: brand-gadget\n", "kernel: brand-kernel\n", `cannot specify a kernel with a non-extended classic model`}, + {"gadget: brand-gadget\n", "base: some-base\n", `cannot specify a base with a non-extended classic model`}, + {"gadget: brand-gadget\n", "gadget:\n - xyz\n", `"gadget" header must be a string`}, + } + + for _, test := range invalidTests { + invalid := strings.Replace(encoded, test.original, test.invalid, 1) + _, err := asserts.Decode([]byte(invalid)) + c.Check(err, ErrorMatches, modelErrPrefix+test.expectedErr) + } +} + +func (mods *modelSuite) TestClassicDecodeGadgetAndArchOptional(c *C) { + encoded := strings.Replace(classicModelExample, "TSLINE", mods.tsLine, 1) + encoded = strings.Replace(encoded, "gadget: brand-gadget\n", "", 1) + encoded = strings.Replace(encoded, "architecture: amd64\n", "", 1) + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + c.Check(a.Type(), Equals, asserts.ModelType) + model := a.(*asserts.Model) + c.Check(model.Classic(), Equals, true) + c.Check(model.Distribution(), Equals, "") + c.Check(model.Architecture(), Equals, "") + c.Check(model.GadgetSnap(), IsNil) + c.Check(model.Gadget(), Equals, "") + c.Check(model.GadgetTrack(), Equals, "") +} + +func (mods *modelSuite) TestWithSnapsDecodeOK(c *C) { + tt := []struct { + modelRaw string + isClassic bool + }{ + {modelRaw: core20ModelExample, isClassic: false}, + {modelRaw: classicModelWithSnapsExample, isClassic: true}, + } + + for _, t := range tt { + mods.testWithSnapsDecodeOK(c, t.modelRaw, t.isClassic) + } +} + +func (mods *modelSuite) testWithSnapsDecodeOK(c *C, modelRaw string, isClassic bool) { + encoded := strings.Replace(modelRaw, "TSLINE", mods.tsLine, 1) + encoded = strings.Replace(encoded, "OTHER", "", 1) + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + c.Check(a.Type(), Equals, asserts.ModelType) + model := a.(*asserts.Model) + c.Check(model.AuthorityID(), Equals, "brand-id1") + c.Check(model.Timestamp(), Equals, mods.ts) + c.Check(model.Series(), Equals, "16") + c.Check(model.BrandID(), Equals, "brand-id1") + c.Check(model.Model(), Equals, "baz-3000") + c.Check(model.DisplayName(), Equals, "Baz 3000") + c.Check(model.Architecture(), Equals, "amd64") + c.Check(model.Classic(), Equals, isClassic) + if isClassic { + c.Check(model.Distribution(), Equals, "ubuntu") + } else { + c.Check(model.Distribution(), Equals, "") + } + c.Check(model.GadgetSnap(), DeepEquals, &asserts.ModelSnap{ + Name: "brand-gadget", + SnapID: "brandgadgetdidididididididididid", + SnapType: "gadget", + Modes: []string{"run", "ephemeral"}, + DefaultChannel: "latest/stable", + Presence: "required", + }) + c.Check(model.Gadget(), Equals, "brand-gadget") + c.Check(model.GadgetTrack(), Equals, "") + c.Check(model.KernelSnap(), DeepEquals, &asserts.ModelSnap{ + Name: "baz-linux", + SnapID: "bazlinuxidididididididididididid", + SnapType: "kernel", + Modes: []string{"run", "ephemeral"}, + DefaultChannel: "20", + Presence: "required", + }) + c.Check(model.Kernel(), Equals, "baz-linux") + c.Check(model.KernelTrack(), Equals, "") + c.Check(model.Base(), Equals, "core20") + c.Check(model.BaseSnap(), DeepEquals, &asserts.ModelSnap{ + Name: "core20", + SnapID: naming.WellKnownSnapID("core20"), + SnapType: "base", + Modes: []string{"run", "ephemeral"}, + DefaultChannel: "latest/stable", + Presence: "required", + }) + c.Check(model.Store(), Equals, "brand-store") + c.Check(model.Grade(), Equals, asserts.ModelSecured) + c.Check(model.StorageSafety(), Equals, asserts.StorageSafetyEncrypted) + essentialSnaps := model.EssentialSnaps() + c.Check(essentialSnaps, DeepEquals, []*asserts.ModelSnap{ + model.KernelSnap(), + model.BaseSnap(), + model.GadgetSnap(), + }) + snaps := model.SnapsWithoutEssential() + c.Check(snaps, DeepEquals, []*asserts.ModelSnap{ + { + Name: "other-base", + SnapID: "otherbasedididididididididididid", + SnapType: "base", + Modes: []string{"run"}, + DefaultChannel: "latest/stable", + Presence: "required", + }, + { + Name: "nm", + SnapID: "nmididididididididididididididid", + SnapType: "app", + Modes: []string{"ephemeral", "run"}, + DefaultChannel: "1.0", + Presence: "required", + }, + { + Name: "myapp", + SnapID: "myappdididididididididididididid", + SnapType: "app", + Modes: []string{"run"}, + DefaultChannel: "2.0", + Presence: "required", + }, + { + Name: "myappopt", + SnapID: "myappoptidididididididididididid", + SnapType: "app", + Modes: []string{"run"}, + DefaultChannel: "latest/stable", + Presence: "optional", + Classic: isClassic, + }, + }) + // essential snaps included + reqSnaps := naming.NewSnapSet(model.RequiredWithEssentialSnaps()) + for _, e := range essentialSnaps { + c.Check(reqSnaps.Contains(e), Equals, true) + } + for _, s := range snaps { + c.Check(reqSnaps.Contains(s), Equals, s.Presence == "required") + } + c.Check(reqSnaps.Size(), Equals, len(essentialSnaps)+len(snaps)-1) + // essential snaps excluded + noEssential := naming.NewSnapSet(model.RequiredNoEssentialSnaps()) + for _, e := range essentialSnaps { + c.Check(noEssential.Contains(e), Equals, false) + } + for _, s := range snaps { + c.Check(noEssential.Contains(s), Equals, s.Presence == "required") + } + c.Check(noEssential.Size(), Equals, len(snaps)-1) + + c.Check(model.SystemUserAuthority(), HasLen, 0) + c.Check(model.SerialAuthority(), DeepEquals, []string{"brand-id1"}) + c.Check(model.PreseedAuthority(), DeepEquals, []string{"brand-id1"}) +} + +func (mods *modelSuite) TestCore20ExplictBootBase(c *C) { + encoded := strings.Replace(core20ModelExample, "TSLINE", mods.tsLine, 1) + encoded = strings.Replace(encoded, "OTHER", ` - + name: core20 + id: core20ididididididididididididid + type: base + default-channel: latest/candidate +`, 1) + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + c.Check(a.Type(), Equals, asserts.ModelType) + model := a.(*asserts.Model) + c.Check(model.BaseSnap(), DeepEquals, &asserts.ModelSnap{ + Name: "core20", + SnapID: "core20ididididididididididididid", + SnapType: "base", + Modes: []string{"run", "ephemeral"}, + DefaultChannel: "latest/candidate", + Presence: "required", + }) +} + +func (mods *modelSuite) TestCore20ExplictSnapd(c *C) { + encoded := strings.Replace(core20ModelExample, "TSLINE", mods.tsLine, 1) + encoded = strings.Replace(encoded, "OTHER", ` - + name: snapd + id: snapdidididididididididididididd + type: snapd + default-channel: latest/edge +`, 1) + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + c.Check(a.Type(), Equals, asserts.ModelType) + model := a.(*asserts.Model) + snapdSnap := model.EssentialSnaps()[0] + c.Check(snapdSnap, DeepEquals, &asserts.ModelSnap{ + Name: "snapd", + SnapID: "snapdidididididididididididididd", + SnapType: "snapd", + Modes: []string{"run", "ephemeral"}, + DefaultChannel: "latest/edge", + Presence: "required", + }) +} + +func (mods *modelSuite) TestCore20GradeOptionalDefaultSigned(c *C) { + encoded := strings.Replace(core20ModelExample, "TSLINE", mods.tsLine, 1) + encoded = strings.Replace(encoded, "OTHER", "", 1) + encoded = strings.Replace(encoded, "grade: secured\n", "", 1) + + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + c.Check(a.Type(), Equals, asserts.ModelType) + model := a.(*asserts.Model) + c.Check(model.Grade(), Equals, asserts.ModelSigned) +} + +func (mods *modelSuite) TestCore20ValidGrades(c *C) { + encoded := strings.Replace(core20ModelExample, "TSLINE", mods.tsLine, 1) + encoded = strings.Replace(encoded, "OTHER", "", 1) + + for _, grade := range []string{"signed", "secured", "dangerous"} { + ex := strings.Replace(encoded, "grade: secured\n", fmt.Sprintf("grade: %s\n", grade), 1) + a, err := asserts.Decode([]byte(ex)) + c.Assert(err, IsNil) + c.Check(a.Type(), Equals, asserts.ModelType) + model := a.(*asserts.Model) + c.Check(string(model.Grade()), Equals, grade) + } +} + +func (mods *modelSuite) TestModelGradeCode(c *C) { + for i, grade := range []asserts.ModelGrade{asserts.ModelGradeUnset, asserts.ModelDangerous, asserts.ModelSigned, asserts.ModelSecured} { + // unset is represented as zero + code := 0 + if i > 0 { + // have some space between grades to add new ones + n := (i - 1) * 8 + if n == 0 { + n = 1 // dangerous + } + // lower 16 bits are reserved + code = n << 16 + } + c.Check(grade.Code(), Equals, uint32(code)) + } +} + +func (mods *modelSuite) TestCore20GradeDangerous(c *C) { + encoded := strings.Replace(core20ModelExample, "TSLINE", mods.tsLine, 1) + encoded = strings.Replace(encoded, "OTHER", "", 1) + encoded = strings.Replace(encoded, "grade: secured\n", "grade: dangerous\n", 1) + // snap ids are optional with grade dangerous to allow working + // with local/not pushed yet to the store snaps + encoded = strings.Replace(encoded, " id: myappdididididididididididididid\n", "", 1) + encoded = strings.Replace(encoded, " id: brandgadgetdidididididididididid\n", "", 1) + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + c.Check(a.Type(), Equals, asserts.ModelType) + model := a.(*asserts.Model) + c.Check(model.Grade(), Equals, asserts.ModelDangerous) + snaps := model.SnapsWithoutEssential() + c.Check(snaps[len(snaps)-2], DeepEquals, &asserts.ModelSnap{ + Name: "myapp", + SnapType: "app", + Modes: []string{"run"}, + DefaultChannel: "2.0", + Presence: "required", + }) +} + +func (mods *modelSuite) TestCore20ValidStorageSafety(c *C) { + encoded := strings.Replace(core20ModelExample, "TSLINE", mods.tsLine, 1) + encoded = strings.Replace(encoded, "OTHER", "", 1) + encoded = strings.Replace(encoded, "grade: secured\n", "grade: signed\n", 1) + + for _, tc := range []struct { + ss asserts.StorageSafety + sss string + }{ + {asserts.StorageSafetyPreferEncrypted, "prefer-encrypted"}, + {asserts.StorageSafetyPreferUnencrypted, "prefer-unencrypted"}, + {asserts.StorageSafetyEncrypted, "encrypted"}, + } { + ex := strings.Replace(encoded, "storage-safety: encrypted\n", fmt.Sprintf("storage-safety: %s\n", tc.sss), 1) + a, err := asserts.Decode([]byte(ex)) + c.Assert(err, IsNil) + c.Check(a.Type(), Equals, asserts.ModelType) + model := a.(*asserts.Model) + c.Check(model.StorageSafety(), Equals, tc.ss) + } +} + +func (mods *modelSuite) TestCore20DefaultStorageSafetySecured(c *C) { + encoded := strings.Replace(core20ModelExample, "TSLINE", mods.tsLine, 1) + encoded = strings.Replace(encoded, "OTHER", "", 1) + ex := strings.Replace(encoded, "storage-safety: encrypted\n", "", 1) + + a, err := asserts.Decode([]byte(ex)) + c.Assert(err, IsNil) + c.Check(a.Type(), Equals, asserts.ModelType) + model := a.(*asserts.Model) + c.Check(model.StorageSafety(), Equals, asserts.StorageSafetyEncrypted) +} + +func (mods *modelSuite) TestCore20DefaultStorageSafetySignedDangerous(c *C) { + encoded := strings.Replace(core20ModelExample, "TSLINE", mods.tsLine, 1) + encoded = strings.Replace(encoded, "OTHER", "", 1) + encoded = strings.Replace(encoded, "storage-safety: encrypted\n", "", 1) + + for _, grade := range []string{"dangerous", "signed"} { + ex := strings.Replace(encoded, "grade: secured\n", fmt.Sprintf("grade: %s\n", grade), 1) + a, err := asserts.Decode([]byte(ex)) + c.Assert(err, IsNil) + c.Check(a.Type(), Equals, asserts.ModelType) + model := a.(*asserts.Model) + c.Check(model.StorageSafety(), Equals, asserts.StorageSafetyPreferEncrypted) + } +} + +func (mods *modelSuite) TestWithSnapsDecodeInvalid(c *C) { + tt := []struct { + modelRaw string + isClassic bool + }{ + {modelRaw: core20ModelExample, isClassic: false}, + {modelRaw: classicModelWithSnapsExample, isClassic: true}, + } + + for _, t := range tt { + mods.testWithSnapsDecodeInvalid(c, t.modelRaw, t.isClassic) + } +} + +func (mods *modelSuite) testWithSnapsDecodeInvalid(c *C, modelRaw string, isClassic bool) { + encoded := strings.Replace(modelRaw, "TSLINE", mods.tsLine, 1) + + snapsStanza := encoded[strings.Index(encoded, "snaps:"):strings.Index(encoded, "grade:")] + + invalidTests := []struct{ original, invalid, expectedErr string }{ + {"base: core20\n", "", `"base" header is mandatory`}, + {"base: core20\n", "base: alt-base\n", `cannot specify not well-known base "alt-base" without a corresponding "snaps" header entry`}, + {snapsStanza, "snaps: snap\n", `"snaps" header must be a list of maps`}, + {snapsStanza, "snaps:\n - snap\n", `"snaps" header must be a list of maps`}, + {"name: myapp\n", "other: 1\n", `"name" of snap is mandatory`}, + {"name: myapp\n", "name: myapp_2\n", `invalid snap name "myapp_2"`}, + {"id: myappdididididididididididididid\n", "id: 2\n", `"id" of snap "myapp" contains invalid characters: "2"`}, + {" id: myappdididididididididididididid\n", "", `"id" of snap "myapp" is mandatory for secured grade model`}, + {"type: gadget\n", "type:\n - g\n", `"type" of snap "brand-gadget" must be a string`}, + {"type: app\n", "type: thing\n", `"type" of snap "myappopt" must be one of must be one of app|base|gadget|kernel|core|snapd`}, + {"modes:\n - run\n", "modes: run\n", `"modes" of snap "other-base" must be a list of strings`}, + {"default-channel: 20\n", "default-channel: edge\n", `default channel for snap "baz-linux" must specify a track`}, + {"default-channel: 2.0\n", "default-channel:\n - x\n", `"default-channel" of snap "myapp" must be a string`}, + {"default-channel: 2.0\n", "default-channel: 2.0/xyz/z\n", `invalid default channel for snap "myapp": invalid risk in channel name: 2.0/xyz/z`}, + {"presence: optional\n", "presence:\n - opt\n", `"presence" of snap "myappopt" must be a string`}, + {"presence: optional\n", "presence: no\n", `"presence" of snap "myappopt" must be one of must be one of required|optional`}, + {"OTHER", " -\n name: myapp\n id: myappdididididididididididididid\n", `cannot list the same snap "myapp" multiple times`}, + {"OTHER", " -\n name: myapp2\n id: myappdididididididididididididid\n", `cannot specify the same snap id "myappdididididididididididididid" multiple times, specified for snaps "myapp" and "myapp2"`}, + {"OTHER", " -\n name: kernel2\n id: kernel2didididididididididididid\n type: kernel\n", `cannot specify multiple kernel snaps: "baz-linux" and "kernel2"`}, + {"OTHER", " -\n name: gadget2\n id: gadget2didididididididididididid\n type: gadget\n", `cannot specify multiple gadget snaps: "brand-gadget" and "gadget2"`}, + {"type: gadget\n", "type: gadget\n presence: required\n", `essential snaps are always available, cannot specify presence for snap "brand-gadget"`}, + {"type: gadget\n", "type: gadget\n modes:\n - run\n", `essential snaps are always available, cannot specify modes of snap "brand-gadget"`}, + {"type: kernel\n", "type: kernel\n presence: required\n", `essential snaps are always available, cannot specify presence for snap "baz-linux"`}, + {"OTHER", " -\n name: core20\n id: core20ididididididididididididid\n type: base\n presence: optional\n", `essential snaps are always available, cannot specify presence for snap "core20"`}, + {"OTHER", " -\n name: core20\n id: core20ididididididididididididid\n type: app\n", `boot base "core20" must specify type "base", not "app"`}, + {"OTHER", "kernel: foo\n", `cannot specify separate "kernel" header once using the extended snaps header`}, + {"OTHER", "gadget: foo\n", `cannot specify separate "gadget" header once using the extended snaps header`}, + {"OTHER", "required-snaps:\n - foo\n", `cannot specify separate "required-snaps" header once using the extended snaps header`}, + {"grade: secured\n", "grade: foo\n", `grade for model must be secured|signed|dangerous`}, + {"storage-safety: encrypted\n", "storage-safety: foo\n", `storage-safety for model must be encrypted\|prefer-encrypted\|prefer-unencrypted, not "foo"`}, + {"storage-safety: encrypted\n", "storage-safety: prefer-unencrypted\n", `secured grade model must not have storage-safety overridden, only "encrypted" is valid`}, + } + if isClassic { + classicInvalid := []struct{ original, invalid, expectedErr string }{ + {"distribution: ubuntu\n", "", `"distribution" header is mandatory, see distribution ID in os-release spec`}, + {"distribution: ubuntu\n", "distribution: Ubuntu\n", `"distribution" header contains invalid characters: "Ubuntu", see distribution ID in os-release spec`}, + {"distribution: ubuntu\n", "distribution: *buntu\n", `"distribution" header contains invalid characters: "\*buntu", see distribution ID in os-release spec`}, + {"type: gadget\n", "type: app\n", `cannot specify a kernel in an extended classic model without a model gadget`}, + {" classic: true\n", " classic: what", `"classic" of snap "myappopt" must be 'true' or 'false'`}, + {"OTHER", ` modes: + - ephemeral + - run +`, `classic snap "myappopt" not allowed outside of run mode: \[ephemeral run\]`}, + {"OTHER", ` modes: + - install +`, `classic snap "myappopt" not allowed outside of run mode: \[install\]`}, + {` type: app + presence: optional +`, ` type: base + presence: optional +`, `snap "myappopt" cannot be classic with type "base" instead of app`}, + {"\nclassic: true\ndistribution: ubuntu\n", "\nclassic: false\n", `snap "myappopt" cannot be classic in non-classic model`}, + } + invalidTests = append(invalidTests, classicInvalid...) + } else { + coreInvalid := []struct{ original, invalid, expectedErr string }{ + {"OTHER", "distribution: ubuntu\n", `cannot specify distribution for model unless it is classic and has an extended snaps header`}, + {"type: gadget\n", "type: app\n", `one "snaps" header entry must specify the model gadget`}, + {"type: kernel\n", "type: app\n", `one "snaps" header entry must specify the model kernel`}, + } + invalidTests = append(invalidTests, coreInvalid...) + } + for _, test := range invalidTests { + invalid := strings.Replace(encoded, test.original, test.invalid, 1) + invalid = strings.Replace(invalid, "OTHER", "", 1) + _, err := asserts.Decode([]byte(invalid)) + c.Check(err, ErrorMatches, modelErrPrefix+test.expectedErr) + } +} + +func (mods *modelSuite) TestClassicWithSnapsMinimalDecodeOK(c *C) { + // XXX support also omitting the base? + encoded := strings.Replace(classicModelWithSnapsExample, "TSLINE", mods.tsLine, 1) + encoded = strings.Replace(encoded, "OTHER", "", 1) + tests := []struct { + originalFrag string + changedFrag string + hasGadget bool + }{ + // no kernel and no gadget + {` + - + name: baz-linux + id: bazlinuxidididididididididididid + type: kernel + default-channel: 20 + - + name: brand-gadget + id: brandgadgetdidididididididididid + type: gadget`, "", false}, + // no kernel but a gadget + {` + - + name: baz-linux + id: bazlinuxidididididididididididid + type: kernel + default-channel: 20`, "", true}, + } + + for _, t := range tests { + minimal := strings.Replace(encoded, t.originalFrag, t.changedFrag, 1) + a, err := asserts.Decode([]byte(minimal)) + c.Assert(err, IsNil) + c.Check(a.Type(), Equals, asserts.ModelType) + model := a.(*asserts.Model) + c.Check(model.Architecture(), Equals, "amd64") + c.Check(model.Classic(), Equals, true) + c.Check(model.Distribution(), Equals, "ubuntu") + c.Check(model.Base(), Equals, "core20") + // no kernel + c.Check(model.KernelSnap(), IsNil) + c.Check(model.Kernel(), Equals, "") + c.Check(model.KernelTrack(), Equals, "") + c.Check(model.BaseSnap(), DeepEquals, &asserts.ModelSnap{ + Name: "core20", + SnapID: naming.WellKnownSnapID("core20"), + SnapType: "base", + Modes: []string{"run", "ephemeral"}, + DefaultChannel: "latest/stable", + Presence: "required", + }) + expectedEssSnap := []*asserts.ModelSnap{ + model.BaseSnap(), + } + if t.hasGadget { + c.Check(model.GadgetSnap(), DeepEquals, &asserts.ModelSnap{ + Name: "brand-gadget", + SnapID: "brandgadgetdidididididididididid", + SnapType: "gadget", + Modes: []string{"run", "ephemeral"}, + DefaultChannel: "latest/stable", + Presence: "required", + }) + c.Check(model.Gadget(), Equals, "brand-gadget") + c.Check(model.GadgetTrack(), Equals, "") + expectedEssSnap = append(expectedEssSnap, model.GadgetSnap()) + } else { + c.Check(model.GadgetSnap(), IsNil) + c.Check(model.Gadget(), Equals, "") + c.Check(model.GadgetTrack(), Equals, "") + } + c.Check(model.Grade(), Equals, asserts.ModelSecured) + c.Check(model.StorageSafety(), Equals, asserts.StorageSafetyEncrypted) + essentialSnaps := model.EssentialSnaps() + c.Check(essentialSnaps, DeepEquals, expectedEssSnap) + snaps := model.SnapsWithoutEssential() + c.Check(snaps, DeepEquals, []*asserts.ModelSnap{ + { + Name: "other-base", + SnapID: "otherbasedididididididididididid", + SnapType: "base", + Modes: []string{"run"}, + DefaultChannel: "latest/stable", + Presence: "required", + }, + { + Name: "nm", + SnapID: "nmididididididididididididididid", + SnapType: "app", + Modes: []string{"ephemeral", "run"}, + DefaultChannel: "1.0", + Presence: "required", + }, + { + Name: "myapp", + SnapID: "myappdididididididididididididid", + SnapType: "app", + Modes: []string{"run"}, + DefaultChannel: "2.0", + Presence: "required", + }, + { + Name: "myappopt", + SnapID: "myappoptidididididididididididid", + SnapType: "app", + Modes: []string{"run"}, + DefaultChannel: "latest/stable", + Presence: "optional", + Classic: true, + }, + }) + // essential snaps included + reqSnaps := naming.NewSnapSet(model.RequiredWithEssentialSnaps()) + for _, e := range essentialSnaps { + c.Check(reqSnaps.Contains(e), Equals, true) + } + for _, s := range snaps { + c.Check(reqSnaps.Contains(s), Equals, s.Presence == "required") + } + c.Check(reqSnaps.Size(), Equals, len(essentialSnaps)+len(snaps)-1) + // essential snaps excluded + noEssential := naming.NewSnapSet(model.RequiredNoEssentialSnaps()) + for _, e := range essentialSnaps { + c.Check(noEssential.Contains(e), Equals, false) + } + for _, s := range snaps { + c.Check(noEssential.Contains(s), Equals, s.Presence == "required") + } + c.Check(noEssential.Size(), Equals, len(snaps)-1) + } +} + +func (mods *modelSuite) TestModelValidationSetAtSequence(c *C) { + mvs := &asserts.ModelValidationSet{ + AccountID: "test", + Name: "set", + Mode: asserts.ModelValidationSetModeEnforced, + } + c.Check(mvs.AtSequence(), DeepEquals, &asserts.AtSequence{ + Type: asserts.ValidationSetType, + SequenceKey: []string{release.Series, "test", "set"}, + Sequence: 0, + Pinned: false, + Revision: asserts.RevisionNotKnown, + }) +} + +func (mods *modelSuite) TestModelValidationSetAtSequenceNoSequence(c *C) { + mvs := &asserts.ModelValidationSet{ + AccountID: "test", + Name: "set", + Sequence: 1, + Mode: asserts.ModelValidationSetModeEnforced, + } + c.Check(mvs.AtSequence(), DeepEquals, &asserts.AtSequence{ + Type: asserts.ValidationSetType, + SequenceKey: []string{release.Series, "test", "set"}, + Sequence: 1, + Pinned: true, + Revision: asserts.RevisionNotKnown, + }) +} + +func (mods *modelSuite) TestValidationSetsDecodeInvalid(c *C) { + encoded := strings.Replace(core20ModelExample, "TSLINE", mods.tsLine, 1) + tests := []struct { + frag string + expectedErr string + }{ + // invalid format 1 + {`validation-sets: 12395 +`, "assertion model: \"validation-sets\" must be a list of validation sets"}, + // invalid format 2 + {`validation-sets: + - test +`, "assertion model: entry in \"validation-sets\" is not a valid validation-set"}, + // missing name + {`validation-sets: + - + mode: prefer-enforce +`, "assertion model: \"name\" of validation-set is mandatory"}, + // account-id not a valid string + {`validation-sets: + - + account-id: + - 1 + name: my-set + mode: enforce +`, "assertion model: \"account-id\" of validation-set \"my-set\" must be a string"}, + // missing mode + {`validation-sets: + - + name: my-set +`, "assertion model: \"mode\" of validation-set \"brand-id1/my-set\" is mandatory"}, + // invalid value in mode + {`validation-sets: + - + account-id: developer1 + name: my-set + sequence: 10 + mode: hello +`, "assertion model: \"mode\" of validation-set \"brand-id1/my-set\" must be prefer-enforce|enforce, not \"hello\""}, + // sequence number invalid (not an integer) + {`validation-sets: + - + account-id: developer1 + sequence: foo + name: my-set + mode: enforce +`, "assertion model: \"sequence\" of validation-set \"developer1/my-set\" is not an integer: foo"}, + // sequence number invalid (below) + {`validation-sets: + - + account-id: developer1 + sequence: -1 + name: my-set + mode: enforce +`, "assertion model: \"sequence\" of validation-set \"developer1/my-set\" must be larger than 0 or left unspecified \\(meaning tracking latest\\)"}, + // sequence number invalid (0 is not allowed) + {`validation-sets: + - + account-id: developer1 + sequence: 0 + name: my-set + mode: enforce +`, "assertion model: \"sequence\" of validation-set \"developer1/my-set\" must be larger than 0 or left unspecified \\(meaning tracking latest\\)"}, + // duplicate validation-set + {`validation-sets: + - + account-id: developer1 + name: my-set + mode: prefer-enforce + - + account-id: developer1 + name: my-set + mode: enforce +`, "assertion model: cannot add validation set \"developer1/my-set\" twice"}, + } + + for _, t := range tests { + data := strings.Replace(encoded, "OTHER", t.frag, 1) + _, err := asserts.Decode([]byte(data)) + c.Check(err, ErrorMatches, t.expectedErr) + } +} + +func (mods *modelSuite) TestValidationSetsDecodeOK(c *C) { + encoded := strings.Replace(core20ModelExample, "TSLINE", mods.tsLine, 1) + tests := []struct { + frag string + expected []*asserts.ModelValidationSet + }{ + // brand validation-set, this should instead use the brand specified + // by the core20ModelExample, as account-id is not set + {`validation-sets: + - + name: my-set + mode: prefer-enforce +`, + []*asserts.ModelValidationSet{ + { + AccountID: "brand-id1", + Name: "my-set", + Mode: asserts.ModelValidationSetModePreferEnforced, + }, + }}, + // pinned set + {`validation-sets: + - + account-id: developer1 + name: my-set + sequence: 10 + mode: enforce +`, + []*asserts.ModelValidationSet{ + { + AccountID: "developer1", + Name: "my-set", + Sequence: 10, + Mode: asserts.ModelValidationSetModeEnforced, + }, + }}, + // unpinned set + {`validation-sets: + - + account-id: developer1 + name: my-set + mode: prefer-enforce +`, + []*asserts.ModelValidationSet{ + { + AccountID: "developer1", + Name: "my-set", + Mode: asserts.ModelValidationSetModePreferEnforced, + }, + }}, + } + + for _, t := range tests { + data := strings.Replace(encoded, "OTHER", t.frag, 1) + a, err := asserts.Decode([]byte(data)) + c.Assert(err, IsNil) + c.Check(a.Type(), Equals, asserts.ModelType) + model := a.(*asserts.Model) + c.Check(model.Architecture(), Equals, "amd64") + c.Check(model.Classic(), Equals, false) + c.Check(model.Base(), Equals, "core20") + c.Check(model.ValidationSets(), DeepEquals, t.expected) + } +} + +func (mods *modelSuite) TestModelValidationSetSequenceKey(c *C) { + mvs := &asserts.ModelValidationSet{ + AccountID: "test", + Name: "set", + Sequence: 1, + Mode: asserts.ModelValidationSetModeEnforced, + } + + c.Check(mvs.SequenceKey(), Equals, "16/test/set") +} + +func (mods *modelSuite) TestAllSnaps(c *C) { + encoded := strings.Replace(core20ModelExample, "TSLINE", mods.tsLine, 1) + encoded = strings.Replace(encoded, "OTHER", "", 1) + + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + c.Check(a.Type(), Equals, asserts.ModelType) + + model := a.(*asserts.Model) + + allSnaps := append([]*asserts.ModelSnap(nil), model.EssentialSnaps()...) + + // make sure that we have essential snaps to compare to + c.Assert(len(allSnaps), testutil.IntGreaterThan, 0) + + essentialLen := len(allSnaps) + + allSnaps = append(allSnaps, model.SnapsWithoutEssential()...) + + // same here, make sure that we have non-essential snaps to compare to + c.Assert(len(allSnaps), testutil.IntGreaterThan, essentialLen) + + c.Check(model.AllSnaps(), DeepEquals, allSnaps) +} + +func (mods *modelSuite) TestDecodeWithComponentsOK(c *C) { + encoded := strings.Replace(coreModelWithComponentsExample, "TSLINE", mods.tsLine, 1) + encoded = strings.Replace(encoded, "OTHER", "", 1) + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + + c.Check(a.Type(), Equals, asserts.ModelType) + model := a.(*asserts.Model) + c.Check(model.AuthorityID(), Equals, "brand-id1") + c.Check(model.Timestamp(), Equals, mods.ts) + c.Check(model.Series(), Equals, "16") + c.Check(model.BrandID(), Equals, "brand-id1") + c.Check(model.Model(), Equals, "baz-3000") + c.Check(model.DisplayName(), Equals, "Baz 3000") + c.Check(model.Architecture(), Equals, "amd64") + c.Check(model.GadgetSnap(), DeepEquals, &asserts.ModelSnap{ + Name: "brand-gadget", + SnapID: "brandgadgetdidididididididididid", + SnapType: "gadget", + Modes: []string{"run", "ephemeral"}, + Presence: "required", + DefaultChannel: "latest/stable", + }) + c.Check(model.Gadget(), Equals, "brand-gadget") + c.Check(model.GadgetTrack(), Equals, "") + c.Check(model.KernelSnap(), DeepEquals, &asserts.ModelSnap{ + Name: "baz-linux", + SnapID: "bazlinuxidididididididididididid", + SnapType: "kernel", + Modes: []string{"run", "ephemeral"}, + Presence: "required", + DefaultChannel: "20", + }) + c.Check(model.Kernel(), Equals, "baz-linux") + c.Check(model.KernelTrack(), Equals, "") + c.Check(model.Base(), Equals, "core24") + c.Check(model.BaseSnap(), DeepEquals, &asserts.ModelSnap{ + Name: "core24", + SnapID: "dwTAh7MZZ01zyriOZErqd1JynQLiOGvM", + SnapType: "base", + Modes: []string{"run", "ephemeral"}, + Presence: "required", + DefaultChannel: "latest/stable", + }) + c.Check(model.Store(), Equals, "brand-store") + c.Check(model.Grade(), Equals, asserts.ModelSecured) + c.Check(model.StorageSafety(), Equals, asserts.StorageSafetyEncrypted) + essentialSnaps := model.EssentialSnaps() + c.Check(essentialSnaps, DeepEquals, []*asserts.ModelSnap{ + model.KernelSnap(), + model.BaseSnap(), + model.GadgetSnap(), + }) + snaps := model.SnapsWithoutEssential() + c.Check(snaps, DeepEquals, []*asserts.ModelSnap{ + { + Name: "other-base", + SnapID: "otherbasedididididididididididid", + SnapType: "base", + Modes: []string{"run"}, + DefaultChannel: "latest/stable", + Presence: "required", + }, + { + Name: "nm", + SnapID: "nmididididididididididididididid", + SnapType: "app", + Modes: []string{"ephemeral", "run"}, + DefaultChannel: "1.0", + Presence: "required", + Components: map[string]asserts.ModelComponent{ + "comp1": { + Presence: "optional", + Modes: []string{"ephemeral"}, + }, + "comp2": { + Presence: "required", + Modes: []string{"ephemeral", "run"}, + }, + }, + }, + { + Name: "myapp", + SnapID: "myappdididididididididididididid", + SnapType: "app", + Modes: []string{"ephemeral", "run"}, + DefaultChannel: "2.0", + Presence: "optional", + Components: map[string]asserts.ModelComponent{ + "comp1": { + Presence: "optional", + Modes: []string{"ephemeral", "run"}, + }, + "comp2": { + Presence: "required", + Modes: []string{"ephemeral", "run"}, + }, + }, + }, + { + Name: "myappopt", + SnapID: "myappoptidididididididididididid", + SnapType: "app", + DefaultChannel: "latest/stable", + Presence: "required", + Modes: []string{"run"}, + Components: map[string]asserts.ModelComponent{ + "comp1": { + Presence: "optional", + Modes: []string{"run"}, + }, + "comp2": { + Presence: "required", + Modes: []string{"run"}, + }, + }, + }, + }) + + c.Check(model.SystemUserAuthority(), HasLen, 0) + c.Check(model.SerialAuthority(), DeepEquals, []string{"brand-id1"}) + c.Check(model.PreseedAuthority(), DeepEquals, []string{"brand-id1"}) +} + +func (mods *modelSuite) TestDecodeWithComponentsBadPresence1(c *C) { + encoded := strings.Replace(coreModelWithComponentsExample, "TSLINE", mods.tsLine, 1) + encoded = strings.Replace(encoded, "OTHER", ` - + name: somesnap + id: somesnapidididididididididididid + type: app + presence: required + components: + comp1: badpresenceval +`, 1) + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err.Error(), Equals, `assertion model: presence of component "comp1" of snap "somesnap" must be one of required|optional`) + c.Assert(a, IsNil) +} + +func (mods *modelSuite) TestDecodeWithComponentsBadPresence2(c *C) { + encoded := strings.Replace(coreModelWithComponentsExample, "TSLINE", mods.tsLine, 1) + encoded = strings.Replace(encoded, "OTHER", ` - + name: somesnap + id: somesnapidididididididididididid + type: app + presence: required + components: + comp1: + presence: badpresenceval +`, 1) + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err.Error(), Equals, `assertion model: presence of component "comp1" of snap "somesnap" must be one of required|optional`) + c.Assert(a, IsNil) +} + +func (mods *modelSuite) TestDecodeWithComponentsBadMode(c *C) { + encoded := strings.Replace(coreModelWithComponentsExample, "TSLINE", mods.tsLine, 1) + encoded = strings.Replace(encoded, "OTHER", ` - + name: somesnap + id: somesnapidididididididididididid + type: app + presence: required + modes: + - run + components: + comp1: + presence: required + modes: + - ephemeral +`, 1) + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err.Error(), Equals, `assertion model: mode "ephemeral" of component "comp1" of snap "somesnap" is incompatible with the snap modes`) + c.Assert(a, IsNil) +} + +func (mods *modelSuite) TestDecodeWithComponentsBadContent(c *C) { + for i, tc := range []struct { + compsEntry string + errMsg string + }{ + {` components: + - comp1 + - comp2 +`, + `assertion model: "components" of snap "somesnap" must be a map from strings to components`}, + {` components: + comp_1: required +`, + `parsing assertion headers: invalid map entry key: "comp_1"`}, + {` components: + comp1: + presence: required + other: something +`, + `assertion model: entry "other" of component "comp1" of snap "somesnap" is unknown`}, + {` components: + comp1: + modes: + - run +`, + `assertion model: "presence" of component "comp1" of snap "somesnap" is mandatory`, + }, + {` components: + comp1: + presence: required + modes: + - foomode +`, + `assertion model: mode "foomode" of component "comp1" of snap "somesnap" is incompatible with the snap modes`, + }, + } { + c.Logf("test %d: %q", i, tc.compsEntry) + encoded := strings.Replace(coreModelWithComponentsExample, "TSLINE", mods.tsLine, 1) + encoded = strings.Replace(encoded, "OTHER", ` - + name: somesnap + id: somesnapidididididididididididid + type: app + presence: required + modes: + - run +`+tc.compsEntry, 1) + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err.Error(), Equals, tc.errMsg) + c.Assert(a, IsNil) + } +} diff --git a/asserts/pool.go b/asserts/pool.go new file mode 100644 index 00000000..5d1d1246 --- /dev/null +++ b/asserts/pool.go @@ -0,0 +1,1022 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020-2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts + +import ( + "errors" + "fmt" + + "github.com/snapcore/snapd/asserts/internal" +) + +// A Grouping identifies opaquely a grouping of assertions. +// Pool uses it to label the intersection between a set of groups. +type Grouping string + +// A pool helps holding and tracking a set of assertions and their +// prerequisites as they need to be updated or resolved. The +// assertions can be organized in groups. Failure can be tracked +// isolated to groups, conversely any error related to a single group +// alone will stop any work to resolve it. Independent assertions +// should not be grouped. Assertions and prerequisites that are part +// of more than one group are tracked properly only once. +// +// Typical usage involves specifying the initial assertions needing to +// be resolved or updated using AddUnresolved and AddToUpdate. +// AddUnresolvedSequence and AddSequenceToUpdate exist parallel to +// AddUnresolved/AddToUpdate to handle sequence-forming assertions, +// which cannot be used with the latter. +// At this point ToResolve can be called to get them organized in +// groupings ready for fetching. Fetched assertions can then be provided +// with Add or AddBatch. Because these can have prerequisites calling +// ToResolve and fetching needs to be repeated until ToResolve's +// result is empty. Between any two ToResolve invocations but after +// any Add or AddBatch AddUnresolved/AddToUpdate can also be used +// again. +// +// V +// | +// /-> AddUnresolved, AddToUpdate +// | | +// | V +// |------> ToResolve -> empty? done +// | | +// | V +// \ __________ Add +// +// If errors prevent from fulfilling assertions from a ToResolve, +// AddError and AddGroupingError can be used to report the errors so +// that they can be associated with groups. +// +// All the resolved assertions in a Pool from groups not in error can +// be committed to a destination database with CommitTo. +type Pool struct { + groundDB RODatabase + + numbering map[string]uint16 + groupings *internal.Groupings + + unresolved map[string]unresolvedAssertRecord + unresolvedSequences map[string]unresolvedAssertRecord + prerequisites map[string]unresolvedAssertRecord + + bs Backstore + unchanged map[string]bool + + groups map[uint16]*groupRec + + curPhase poolPhase +} + +// NewPool creates a new Pool, groundDB is used to resolve trusted and +// predefined assertions and to provide the current revision for +// assertions to update and their prerequisites. Up to n groups can be +// used to organize the assertions. +func NewPool(groundDB RODatabase, n int) *Pool { + groupings, err := internal.NewGroupings(n) + if err != nil { + panic(fmt.Sprintf("NewPool: %v", err)) + } + return &Pool{ + groundDB: groundDB, + numbering: make(map[string]uint16), + groupings: groupings, + unresolved: make(map[string]unresolvedAssertRecord), + unresolvedSequences: make(map[string]unresolvedAssertRecord), + prerequisites: make(map[string]unresolvedAssertRecord), + bs: NewMemoryBackstore(), + unchanged: make(map[string]bool), + groups: make(map[uint16]*groupRec), + } +} + +func (p *Pool) groupNum(group string) (gnum uint16, err error) { + if gnum, ok := p.numbering[group]; ok { + return gnum, nil + } + gnum = uint16(len(p.numbering)) + if err = p.groupings.WithinRange(gnum); err != nil { + return 0, err + } + p.numbering[group] = gnum + return gnum, nil +} + +func (p *Pool) ensureGroup(group string) (gnum uint16, err error) { + gnum, err = p.groupNum(group) + if err != nil { + return 0, err + } + if gRec := p.groups[gnum]; gRec == nil { + p.groups[gnum] = &groupRec{ + name: group, + } + } + return gnum, nil +} + +// Singleton returns a grouping containing only the given group. +// It is useful mainly for tests and to drive Add are AddBatch when the +// server is pushing assertions (instead of the usual pull scenario). +func (p *Pool) Singleton(group string) (Grouping, error) { + gnum, err := p.ensureGroup(group) + if err != nil { + return Grouping(""), nil + } + + var grouping internal.Grouping + p.groupings.AddTo(&grouping, gnum) + return Grouping(p.groupings.Serialize(&grouping)), nil +} + +type unresolvedAssertRecord interface { + isAssertionNewer(a Assertion) bool + groupingPtr() *internal.Grouping + label() Grouping + isRevisionNotKnown() bool + error() error +} + +// An unresolvedRec tracks a single unresolved assertion until it is +// resolved or there is an error doing so. The field 'grouping' will +// grow to contain all the groups requiring this assertion while it +// is unresolved. +type unresolvedRec struct { + at *AtRevision + grouping internal.Grouping + + serializedLabel Grouping + + err error +} + +func (u *unresolvedRec) isAssertionNewer(a Assertion) bool { + return a.Revision() > u.at.Revision +} + +func (u *unresolvedRec) groupingPtr() *internal.Grouping { + return &u.grouping +} + +func (u *unresolvedRec) label() Grouping { + return u.serializedLabel +} + +func (u *unresolvedRec) isRevisionNotKnown() bool { + return u.at.Revision == RevisionNotKnown +} + +func (u *unresolvedRec) error() error { + return u.err +} + +func (u *unresolvedRec) exportTo(r map[Grouping][]*AtRevision, gr *internal.Groupings) { + serLabel := Grouping(gr.Serialize(&u.grouping)) + // remember serialized label + u.serializedLabel = serLabel + r[serLabel] = append(r[serLabel], u.at) +} + +func (u *unresolvedRec) merge(at *AtRevision, gnum uint16, gr *internal.Groupings) { + gr.AddTo(&u.grouping, gnum) + // assume we want to resolve/update wrt the highest revision + if at.Revision > u.at.Revision { + u.at.Revision = at.Revision + } +} + +type unresolvedSeqRec struct { + at *AtSequence + grouping internal.Grouping + + serializedLabel Grouping + + err error +} + +func (u *unresolvedSeqRec) groupingPtr() *internal.Grouping { + return &u.grouping +} + +func (u *unresolvedSeqRec) label() Grouping { + return u.serializedLabel +} + +func (u *unresolvedSeqRec) isAssertionNewer(a Assertion) bool { + seqf, ok := a.(SequenceMember) + if !ok { + // This should never happen because resolveWith() compares correct types. + panic(fmt.Sprintf("internal error: cannot compare assertion %v with unresolved sequence-forming assertion (wrong type)", a.Ref())) + } + if u.at.Pinned { + return seqf.Sequence() == u.at.Sequence && a.Revision() > u.at.Revision + } + // not pinned + if seqf.Sequence() == u.at.Sequence { + return a.Revision() > u.at.Revision + } + return seqf.Sequence() > u.at.Sequence +} + +func (u *unresolvedSeqRec) isRevisionNotKnown() bool { + return u.at.Revision == RevisionNotKnown +} + +func (u *unresolvedSeqRec) error() error { + return u.err +} + +func (u *unresolvedSeqRec) exportTo(r map[Grouping][]*AtSequence, gr *internal.Groupings) { + serLabel := Grouping(gr.Serialize(&u.grouping)) + // remember serialized label + u.serializedLabel = serLabel + r[serLabel] = append(r[serLabel], u.at) +} + +// A groupRec keeps track of all the resolved assertions in a group +// or whether the group should be considered in error (err != nil). +type groupRec struct { + name string + err error + resolved []Ref +} + +func (gRec *groupRec) hasErr() bool { + return gRec.err != nil +} + +func (gRec *groupRec) setErr(e error) { + if gRec.err == nil { + gRec.err = e + } +} + +func (gRec *groupRec) markResolved(ref *Ref) (marked bool) { + if gRec.hasErr() { + return false + } + gRec.resolved = append(gRec.resolved, *ref) + return true +} + +// markResolved marks the assertion referenced by ref as resolved +// in all the groups in grouping, except those already in error. +func (p *Pool) markResolved(grouping *internal.Grouping, resolved *Ref) (marked bool) { + p.groupings.Iter(grouping, func(gnum uint16) error { + if p.groups[gnum].markResolved(resolved) { + marked = true + } + return nil + }) + return marked +} + +// setErr marks all the groups in grouping as in error with error err +// except those already in error. +func (p *Pool) setErr(grouping *internal.Grouping, err error) { + p.groupings.Iter(grouping, func(gnum uint16) error { + p.groups[gnum].setErr(err) + return nil + }) +} + +func (p *Pool) isPredefined(ref *Ref) (bool, error) { + _, err := ref.Resolve(p.groundDB.FindPredefined) + if err == nil { + return true, nil + } + if !errors.Is(err, &NotFoundError{}) { + return false, err + } + return false, nil +} + +func (p *Pool) isResolved(ref *Ref) (bool, error) { + if p.unchanged[ref.Unique()] { + return true, nil + } + _, err := p.bs.Get(ref.Type, ref.PrimaryKey, ref.Type.MaxSupportedFormat()) + if err == nil { + return true, nil + } + if !errors.Is(err, &NotFoundError{}) { + return false, err + } + return false, nil +} + +func (p *Pool) curRevision(ref *Ref) (int, error) { + a, err := ref.Resolve(p.groundDB.Find) + if err != nil && !errors.Is(err, &NotFoundError{}) { + return 0, err + } + if err == nil { + return a.Revision(), nil + } + return RevisionNotKnown, nil +} + +func (p *Pool) curSeqRevision(seq *AtSequence) (int, error) { + a, err := seq.Resolve(p.groundDB.Find) + if err != nil && !errors.Is(err, &NotFoundError{}) { + return 0, err + } + if err == nil { + return a.Revision(), nil + } + return RevisionNotKnown, nil +} + +type poolPhase int + +const ( + poolPhaseAddUnresolved = iota + poolPhaseAdd +) + +func (p *Pool) phase(ph poolPhase) error { + if ph == p.curPhase { + return nil + } + if ph == poolPhaseAdd { + return fmt.Errorf("internal error: cannot switch to Pool add phase without invoking ToResolve first") + } + // ph == poolPhaseAddUnresolved + p.unresolvedBookkeeping() + p.curPhase = poolPhaseAddUnresolved + return nil +} + +// AddUnresolved adds the assertion referenced by unresolved +// AtRevision to the Pool as unresolved and as required by the given group. +// Usually unresolved.Revision will have been set to RevisionNotKnown. +func (p *Pool) AddUnresolved(unresolved *AtRevision, group string) error { + if unresolved.Type.SequenceForming() { + return fmt.Errorf("internal error: AddUnresolved requested for sequence-forming assertion") + } + + if err := p.phase(poolPhaseAddUnresolved); err != nil { + return err + } + gnum, err := p.ensureGroup(group) + if err != nil { + return err + } + u := *unresolved + ok, err := p.isPredefined(&u.Ref) + if err != nil { + return err + } + if ok { + // predefined, nothing to do + return nil + } + return p.addUnresolved(&u, gnum) +} + +// AddUnresolvedSequence adds the assertion referenced by unresolved +// AtSequence to the Pool as unresolved and as required by the given group. +// Usually unresolved.Revision will have been set to RevisionNotKnown. +// Given sequence can only be added once to the Pool. +func (p *Pool) AddUnresolvedSequence(unresolved *AtSequence, group string) error { + if err := p.phase(poolPhaseAddUnresolved); err != nil { + return err + } + if p.unresolvedSequences[unresolved.Unique()] != nil { + return fmt.Errorf("internal error: sequence %v is already being resolved", unresolved.SequenceKey) + } + gnum, err := p.ensureGroup(group) + if err != nil { + return err + } + u := *unresolved + p.addUnresolvedSeq(&u, gnum) + return nil +} + +func (p *Pool) addUnresolved(unresolved *AtRevision, gnum uint16) error { + ok, err := p.isResolved(&unresolved.Ref) + if err != nil { + return err + } + if ok { + // We assume that either the resolving of + // prerequisites for the already resolved assertion in + // progress has succeeded or will. If that's not the + // case we will fail at CommitTo time. We could + // instead recurse into its prerequisites again but the + // complexity isn't clearly worth it. + // See TestParallelPartialResolutionFailure + // Mark this as resolved in the group. + p.groups[gnum].markResolved(&unresolved.Ref) + return nil + } + uniq := unresolved.Ref.Unique() + var u unresolvedAssertRecord + if u = p.unresolved[uniq]; u == nil { + u = &unresolvedRec{ + at: unresolved, + } + p.unresolved[uniq] = u + } + + urec := u.(*unresolvedRec) + urec.merge(unresolved, gnum, p.groupings) + return nil +} + +func (p *Pool) addUnresolvedSeq(unresolved *AtSequence, gnum uint16) error { + uniq := unresolved.Unique() + u := &unresolvedSeqRec{ + at: unresolved, + } + p.unresolvedSequences[uniq] = u + return p.groupings.AddTo(&u.grouping, gnum) +} + +// ToResolve returns all the currently unresolved assertions in the +// Pool, organized in opaque groupings based on which set of groups +// requires each of them. +// At the next ToResolve any unresolved assertion with not known +// revision that was not added via Add or AddBatch will result in all +// groups requiring it being in error with ErrUnresolved. +// Conversely, the remaining unresolved assertions originally added +// via AddToUpdate will be assumed to still be at their current +// revisions. +func (p *Pool) ToResolve() (map[Grouping][]*AtRevision, map[Grouping][]*AtSequence, error) { + if p.curPhase == poolPhaseAdd { + p.unresolvedBookkeeping() + } else { + p.curPhase = poolPhaseAdd + } + atr := make(map[Grouping][]*AtRevision) + for _, ur := range p.unresolved { + u := ur.(*unresolvedRec) + if u.at.Revision == RevisionNotKnown { + rev, err := p.curRevision(&u.at.Ref) + if err != nil { + return nil, nil, err + } + if rev != RevisionNotKnown { + u.at.Revision = rev + } + } + u.exportTo(atr, p.groupings) + } + + ats := make(map[Grouping][]*AtSequence) + for _, u := range p.unresolvedSequences { + seq := u.(*unresolvedSeqRec) + if seq.at.Revision == RevisionNotKnown { + rev, err := p.curSeqRevision(seq.at) + if err != nil { + return nil, nil, err + } + if rev != RevisionNotKnown { + seq.at.Revision = rev + } + } + seq.exportTo(ats, p.groupings) + } + return atr, ats, nil +} + +func (p *Pool) addPrerequisite(pref *Ref, g *internal.Grouping) error { + uniq := pref.Unique() + u := p.unresolved[uniq] + at := &AtRevision{ + Ref: *pref, + Revision: RevisionNotKnown, + } + if u == nil { + u = p.prerequisites[uniq] + } + if u != nil { + gr := p.groupings + gr.Iter(g, func(gnum uint16) error { + urec := u.(*unresolvedRec) + urec.merge(at, gnum, gr) + return nil + }) + return nil + } + ok, err := p.isPredefined(pref) + if err != nil { + return err + } + if ok { + // nothing to do + return nil + } + ok, err = p.isResolved(pref) + if err != nil { + return err + } + if ok { + // nothing to do, it is anyway implied + return nil + } + p.prerequisites[uniq] = &unresolvedRec{ + at: at, + grouping: g.Copy(), + } + return nil +} + +func (p *Pool) add(a Assertion, g *internal.Grouping) error { + if err := p.bs.Put(a.Type(), a); err != nil { + if revErr, ok := err.(*RevisionError); ok { + if revErr.Current >= a.Revision() { + // we already got something more recent + return nil + } + } + + return err + } + for _, pref := range a.Prerequisites() { + if err := p.addPrerequisite(pref, g); err != nil { + return err + } + } + keyRef := &Ref{ + Type: AccountKeyType, + PrimaryKey: []string{a.SignKeyID()}, + } + return p.addPrerequisite(keyRef, g) +} + +func (p *Pool) resolveWith(unresolved map[string]unresolvedAssertRecord, uniq string, u unresolvedAssertRecord, a Assertion, extrag *internal.Grouping) (ok bool, err error) { + if u.isAssertionNewer(a) { + if extrag == nil { + extrag = u.groupingPtr() + } else { + p.groupings.Iter(u.groupingPtr(), func(gnum uint16) error { + p.groupings.AddTo(extrag, gnum) + return nil + }) + } + ref := a.Ref() + if p.markResolved(extrag, ref) { + // remove from tracking - + // remove u from unresolved only if the assertion + // is added to the resolved backstore; + // otherwise it might resurface as unresolved; + // it will be ultimately handled in + // unresolvedBookkeeping if it stays around + delete(unresolved, uniq) + if err := p.add(a, extrag); err != nil { + p.setErr(extrag, err) + return false, err + } + } + } + return true, nil +} + +// Add adds the given assertion associated with the given grouping to the +// Pool as resolved in all the groups requiring it. +// Any not already resolved prerequisites of the assertion will +// be implicitly added as unresolved and required by all of those groups. +// The grouping will usually have been associated with the assertion +// in a ToResolve's result. Otherwise the union of all groups +// requiring the assertion plus the groups in grouping will be considered. +// The latter is mostly relevant in scenarios where the server is pushing +// assertions. +// If an error is returned it refers to an immediate or local error. +// Errors related to the assertions are associated with the relevant groups +// and can be retrieved with Err, in which case ok is set to false. +func (p *Pool) Add(a Assertion, grouping Grouping) (ok bool, err error) { + if err := p.phase(poolPhaseAdd); err != nil { + return false, err + } + + if !a.SupportedFormat() { + e := &UnsupportedFormatError{Ref: a.Ref(), Format: a.Format()} + p.AddGroupingError(e, grouping) + return false, nil + } + + return p.addToGrouping(a, grouping, p.groupings.Deserialize) +} + +func (p *Pool) addToGrouping(a Assertion, grouping Grouping, deserializeGrouping func(string) (*internal.Grouping, error)) (ok bool, err error) { + var uniq string + ref := a.Ref() + var u unresolvedAssertRecord + var extrag *internal.Grouping + var unresolved map[string]unresolvedAssertRecord + + if !ref.Type.SequenceForming() { + uniq = ref.Unique() + if u = p.unresolved[uniq]; u != nil { + unresolved = p.unresolved + } else if u = p.prerequisites[uniq]; u != nil { + unresolved = p.prerequisites + } else { + ok, err := p.isPredefined(a.Ref()) + if err != nil { + return false, err + } + if ok { + // nothing to do + return true, nil + } + // a is not tracked as unresolved in any way so far, + // this is an atypical scenario where something gets + // pushed but we still want to add it to the resolved + // lists of the relevant groups; in case it is + // actually already resolved most of resolveWith below will + // be a nop + rec := &unresolvedRec{ + at: a.At(), + } + rec.at.Revision = RevisionNotKnown + u = rec + } + } else { + atseq := AtSequence{ + Type: ref.Type, + SequenceKey: ref.PrimaryKey[:len(ref.PrimaryKey)-1], + } + uniq = atseq.Unique() + if u = p.unresolvedSequences[uniq]; u != nil { + unresolved = p.unresolvedSequences + } else { + // note: sequence-forming assertions are never prerequisites. + at := a.At() + // a is not tracked as unresolved in any way so far, + // this is an atypical scenario where something gets + // pushed but we still want to add it to the resolved + // lists of the relevant groups; in case it is + // actually already resolved most of resolveWith below will + // be a nop + rec := &unresolvedSeqRec{ + at: &AtSequence{ + Type: a.Type(), + SequenceKey: at.PrimaryKey[:len(at.PrimaryKey)-1], + }, + } + rec.at.Revision = RevisionNotKnown + u = rec + } + } + + if u.label() != grouping { + var err error + extrag, err = deserializeGrouping(string(grouping)) + if err != nil { + return false, err + } + } + + return p.resolveWith(unresolved, uniq, u, a, extrag) +} + +// AddBatch adds all the assertions in the Batch to the Pool, +// associated with the given grouping and as resolved in all the +// groups requiring them. It is equivalent to using Add on each of them. +// If an error is returned it refers to an immediate or local error. +// Errors related to the assertions are associated with the relevant groups +// and can be retrieved with Err, in which case ok set to false. +func (p *Pool) AddBatch(b *Batch, grouping Grouping) (ok bool, err error) { + if err := p.phase(poolPhaseAdd); err != nil { + return false, err + } + + // b dealt with unsupported formats already + + // deserialize grouping if needed only once + var cachedGrouping *internal.Grouping + deser := func(_ string) (*internal.Grouping, error) { + if cachedGrouping != nil { + // do a copy as addToGrouping and resolveWith + // might add to their input + g := cachedGrouping.Copy() + return &g, nil + } + var err error + cachedGrouping, err = p.groupings.Deserialize(string(grouping)) + return cachedGrouping, err + } + + inError := false + for _, a := range b.added { + ok, err := p.addToGrouping(a, grouping, deser) + if err != nil { + return false, err + } + if !ok { + inError = true + } + } + + return !inError, nil +} + +var ( + ErrUnresolved = errors.New("unresolved assertion") + ErrUnknownPoolGroup = errors.New("unknown pool group") +) + +// unresolvedBookkeeping processes any left over unresolved assertions +// since the last ToResolve invocation and intervening calls to Add/AddBatch, +// - they were either marked as in error which will be propagated +// to all groups requiring them +// - simply unresolved, which will be propagated to groups requiring them +// as ErrUnresolved +// - unchanged (update case) +// +// unresolvedBookkeeping will also promote any recorded prerequisites +// into actively unresolved, as long as not all the groups requiring them +// are in error. +func (p *Pool) unresolvedBookkeeping() { + // any left over unresolved are either: + // * in error + // * unchanged + // * or unresolved + processUnresolved := func(unresolved map[string]unresolvedAssertRecord) { + for uniq, ur := range unresolved { + e := ur.error() + if e == nil { + if ur.isRevisionNotKnown() { + e = ErrUnresolved + } else { + // unchanged + p.unchanged[uniq] = true + } + } + if e != nil { + p.setErr(ur.groupingPtr(), e) + } + delete(unresolved, uniq) + } + } + processUnresolved(p.unresolved) + processUnresolved(p.unresolvedSequences) + + // prerequisites will become the new unresolved but drop them + // if all their groups are in error + for uniq, pr := range p.prerequisites { + prereq := pr.(*unresolvedRec) + useful := false + p.groupings.Iter(&prereq.grouping, func(gnum uint16) error { + if !p.groups[gnum].hasErr() { + useful = true + } + return nil + }) + if !useful { + delete(p.prerequisites, uniq) + continue + } + } + + // prerequisites become the new unresolved, the emptied + // unresolved is used for prerequisites in the next round + p.unresolved, p.prerequisites = p.prerequisites, p.unresolved +} + +// Err returns the error for group if group is in error, nil otherwise. +func (p *Pool) Err(group string) error { + gnum, err := p.groupNum(group) + if err != nil { + return err + } + gRec := p.groups[gnum] + if gRec == nil { + return ErrUnknownPoolGroup + } + return gRec.err +} + +// Errors returns a mapping of groups in error to their errors. +func (p *Pool) Errors() map[string]error { + res := make(map[string]error) + for _, gRec := range p.groups { + if err := gRec.err; err != nil { + res[gRec.name] = err + } + } + if len(res) == 0 { + return nil + } + return res +} + +// AddError associates error e with the unresolved assertion. +// The error will be propagated to all the affected groups at +// the next ToResolve. +func (p *Pool) AddError(e error, ref *Ref) error { + if err := p.phase(poolPhaseAdd); err != nil { + return err + } + uniq := ref.Unique() + if u := p.unresolved[uniq]; u != nil && u.(*unresolvedRec).err == nil { + u.(*unresolvedRec).err = e + } + return nil +} + +// AddSequenceError associates error e with the unresolved sequence-forming +// assertion. +// The error will be propagated to all the affected groups at +// the next ToResolve. +func (p *Pool) AddSequenceError(e error, atSeq *AtSequence) error { + if err := p.phase(poolPhaseAdd); err != nil { + return err + } + uniq := atSeq.Unique() + if u := p.unresolvedSequences[uniq]; u != nil && u.(*unresolvedSeqRec).err == nil { + u.(*unresolvedSeqRec).err = e + } + return nil +} + +// AddGroupingError puts all the groups of grouping in error, with error e. +func (p *Pool) AddGroupingError(e error, grouping Grouping) error { + if err := p.phase(poolPhaseAdd); err != nil { + return err + } + + g, err := p.groupings.Deserialize(string(grouping)) + if err != nil { + return err + } + + p.setErr(g, e) + return nil +} + +// AddToUpdate adds the assertion referenced by toUpdate and all its +// prerequisites to the Pool as unresolved and as required by the +// given group. It is assumed that the assertion is currently in the +// ground database of the Pool, otherwise this will error. +// The current revisions of the assertion and its prerequisites will +// be recorded and only higher revisions will then resolve them, +// otherwise if ultimately unresolved they will be assumed to still be +// at their current ones. +func (p *Pool) AddToUpdate(toUpdate *Ref, group string) error { + if toUpdate.Type.SequenceForming() { + return fmt.Errorf("internal error: AddToUpdate requested for sequence-forming assertion") + } + if err := p.phase(poolPhaseAddUnresolved); err != nil { + return err + } + gnum, err := p.ensureGroup(group) + if err != nil { + return err + } + retrieve := func(ref *Ref) (Assertion, error) { + return ref.Resolve(p.groundDB.Find) + } + add := func(a Assertion) error { + return p.addUnresolved(a.At(), gnum) + } + f := NewFetcher(p.groundDB, retrieve, add) + if err := f.Fetch(toUpdate); err != nil { + return err + } + return nil +} + +// AddSequenceToUpdate adds the assertion referenced by toUpdate and all its +// prerequisites to the Pool as unresolved and as required by the +// given group. It is assumed that the assertion is currently in the +// ground database of the Pool, otherwise this will error. +// The current revisions of the assertion and its prerequisites will +// be recorded and only higher revisions will then resolve them, +// otherwise if ultimately unresolved they will be assumed to still be +// at their current ones. If toUpdate is pinned, then it will be resolved +// to the highest revision with same sequence point (toUpdate.Sequence). +func (p *Pool) AddSequenceToUpdate(toUpdate *AtSequence, group string) error { + if err := p.phase(poolPhaseAddUnresolved); err != nil { + return err + } + if toUpdate.Sequence <= 0 { + return fmt.Errorf("internal error: sequence to update must have a sequence number set") + } + if p.unresolvedSequences[toUpdate.Unique()] != nil { + return fmt.Errorf("internal error: sequence %v is already being resolved", toUpdate.SequenceKey) + } + gnum, err := p.ensureGroup(group) + if err != nil { + return err + } + retrieve := func(ref *Ref) (Assertion, error) { + return ref.Resolve(p.groundDB.Find) + } + retrieveSeq := func(seq *AtSequence) (Assertion, error) { + return seq.Resolve(p.groundDB.Find) + } + add := func(a Assertion) error { + if !a.Type().SequenceForming() { + return p.addUnresolved(a.At(), gnum) + } + // sequence forming assertions are never predefined, so no check for it. + // final add corresponding to toUpdate itself. + u := *toUpdate + u.Revision = a.Revision() + return p.addUnresolvedSeq(&u, gnum) + } + f := NewSequenceFormingFetcher(p.groundDB, retrieve, retrieveSeq, add) + if err := f.FetchSequence(toUpdate); err != nil { + return err + } + return nil +} + +// CommitTo adds the assertions from groups without errors to the +// given assertion database. Commit errors can be retrieved via Err +// per group. An error is returned directly only if CommitTo is called +// with possible pending unresolved assertions. +func (p *Pool) CommitTo(db *Database) error { + if p.curPhase == poolPhaseAddUnresolved { + return fmt.Errorf("internal error: cannot commit Pool during add unresolved phase") + } + p.unresolvedBookkeeping() + + retrieve := func(ref *Ref) (Assertion, error) { + a, err := p.bs.Get(ref.Type, ref.PrimaryKey, ref.Type.MaxSupportedFormat()) + if errors.Is(err, &NotFoundError{}) { + // fallback to pre-existing assertions + a, err = ref.Resolve(db.Find) + } + if err != nil { + return nil, resolveError("cannot resolve prerequisite assertion: %s", ref, err) + } + return a, nil + } + save := func(a Assertion) error { + err := db.Add(a) + if IsUnaccceptedUpdate(err) { + // unsupported format case is handled before. + // be idempotent, db has already the same or + // newer. + return nil + } + return err + } + +NextGroup: + for _, gRec := range p.groups { + if gRec.hasErr() { + // already in error, ignore + continue + } + // TODO: try to reuse fetcher + f := NewFetcher(db, retrieve, save) + for i := range gRec.resolved { + if err := f.Fetch(&gRec.resolved[i]); err != nil { + gRec.setErr(err) + continue NextGroup + } + } + } + + return nil +} + +// ClearGroups clears the pool in terms of information associated with groups +// while preserving information about already resolved or unchanged assertions. +// It is useful for reusing a pool once the maximum of usable groups +// that was set with NewPool has been exhausted. Group errors must be +// queried before calling it otherwise they are lost. It is an error +// to call it when there are still pending unresolved assertions in +// the pool. +func (p *Pool) ClearGroups() error { + if len(p.unresolved) != 0 || len(p.prerequisites) != 0 { + return fmt.Errorf("internal error: trying to clear groups of asserts.Pool with pending unresolved or prerequisites") + } + + p.numbering = make(map[string]uint16) + // use a fresh Groupings as well so that max group tracking starts + // from scratch. + // NewGroupings cannot fail on a value accepted by it previously + p.groupings, _ = internal.NewGroupings(p.groupings.N()) + p.groups = make(map[uint16]*groupRec) + p.curPhase = poolPhaseAdd + return nil +} + +// Backstore returns the memory backstore of this pool. +func (p *Pool) Backstore() Backstore { + return p.bs +} diff --git a/asserts/pool_test.go b/asserts/pool_test.go new file mode 100644 index 00000000..0a9521c4 --- /dev/null +++ b/asserts/pool_test.go @@ -0,0 +1,1744 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020-2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts_test + +import ( + "errors" + "sort" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/asserts/assertstest" + "github.com/snapcore/snapd/testutil" +) + +type poolSuite struct { + testutil.BaseTest + + hub *assertstest.StoreStack + dev1Acct *asserts.Account + dev2Acct *asserts.Account + + decl1 *asserts.TestOnlyDecl + decl1_1 *asserts.TestOnlyDecl + rev1_1111 *asserts.TestOnlyRev + rev1_3333 *asserts.TestOnlyRev + + decl2 *asserts.TestOnlyDecl + rev2_2222 *asserts.TestOnlyRev + + seq1_1111r5 *asserts.TestOnlySeq + seq1_1111r6 *asserts.TestOnlySeq + seq2_1111r7 *asserts.TestOnlySeq + seq3_1111r5 *asserts.TestOnlySeq + + db *asserts.Database +} + +var _ = Suite(&poolSuite{}) + +func (s *poolSuite) SetUpTest(c *C) { + s.BaseTest.SetUpTest(c) + + s.hub = assertstest.NewStoreStack("hub", nil) + s.dev1Acct = assertstest.NewAccount(s.hub, "developer1", map[string]interface{}{ + "account-id": "developer1", + }, "") + s.dev2Acct = assertstest.NewAccount(s.hub, "developer2", map[string]interface{}{ + "account-id": "developer2", + }, "") + + a, err := s.hub.Sign(asserts.TestOnlyDeclType, map[string]interface{}{ + "id": "one", + "dev-id": "developer1", + }, nil, "") + c.Assert(err, IsNil) + s.decl1 = a.(*asserts.TestOnlyDecl) + + a, err = s.hub.Sign(asserts.TestOnlyDeclType, map[string]interface{}{ + "id": "one", + "dev-id": "developer1", + "revision": "1", + }, nil, "") + c.Assert(err, IsNil) + s.decl1_1 = a.(*asserts.TestOnlyDecl) + + a, err = s.hub.Sign(asserts.TestOnlyDeclType, map[string]interface{}{ + "id": "two", + "dev-id": "developer2", + }, nil, "") + c.Assert(err, IsNil) + s.decl2 = a.(*asserts.TestOnlyDecl) + + a, err = s.hub.Sign(asserts.TestOnlyRevType, map[string]interface{}{ + "h": "1111", + "id": "one", + "dev-id": "developer1", + }, nil, "") + c.Assert(err, IsNil) + s.rev1_1111 = a.(*asserts.TestOnlyRev) + + a, err = s.hub.Sign(asserts.TestOnlyRevType, map[string]interface{}{ + "h": "3333", + "id": "one", + "dev-id": "developer1", + }, nil, "") + c.Assert(err, IsNil) + s.rev1_3333 = a.(*asserts.TestOnlyRev) + + a, err = s.hub.Sign(asserts.TestOnlyRevType, map[string]interface{}{ + "h": "2222", + "id": "two", + "dev-id": "developer2", + }, nil, "") + c.Assert(err, IsNil) + s.rev2_2222 = a.(*asserts.TestOnlyRev) + + // sequence-forming + + a, err = s.hub.Sign(asserts.TestOnlySeqType, map[string]interface{}{ + "n": "1111", + "sequence": "1", + "id": "one", + "dev-id": "developer1", + "revision": "5", + }, nil, "") + c.Assert(err, IsNil) + s.seq1_1111r5 = a.(*asserts.TestOnlySeq) + + a, err = s.hub.Sign(asserts.TestOnlySeqType, map[string]interface{}{ + "n": "1111", + "sequence": "1", + "id": "one", + "dev-id": "developer1", + "revision": "6", + }, nil, "") + c.Assert(err, IsNil) + s.seq1_1111r6 = a.(*asserts.TestOnlySeq) + + a, err = s.hub.Sign(asserts.TestOnlySeqType, map[string]interface{}{ + "n": "1111", + "sequence": "2", + "id": "one", + "dev-id": "developer1", + "revision": "7", + }, nil, "") + c.Assert(err, IsNil) + s.seq2_1111r7 = a.(*asserts.TestOnlySeq) + + a, err = s.hub.Sign(asserts.TestOnlySeqType, map[string]interface{}{ + "n": "1111", + "sequence": "3", + "id": "one", + "dev-id": "developer1", + "revision": "4", + }, nil, "") + c.Assert(err, IsNil) + s.seq3_1111r5 = a.(*asserts.TestOnlySeq) + + db, err := asserts.OpenDatabase(&asserts.DatabaseConfig{ + Backstore: asserts.NewMemoryBackstore(), + Trusted: s.hub.Trusted, + }) + c.Assert(err, IsNil) + s.db = db +} + +func (s *poolSuite) TestAddUnresolved(c *C) { + pool := asserts.NewPool(s.db, 64) + + at1 := &asserts.AtRevision{ + Ref: asserts.Ref{Type: asserts.TestOnlyRevType, PrimaryKey: []string{"1111"}}, + Revision: asserts.RevisionNotKnown, + } + err := pool.AddUnresolved(at1, "for_one") // group num: 0 + c.Assert(err, IsNil) + + toResolve, toResolveSeq, err := pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, DeepEquals, map[asserts.Grouping][]*asserts.AtRevision{ + asserts.MakePoolGrouping(0): {at1}, + }) + c.Check(toResolveSeq, HasLen, 0) +} + +func (s *poolSuite) TestAddUnresolvedPredefined(c *C) { + pool := asserts.NewPool(s.db, 64) + + at := s.hub.TrustedAccount.At() + at.Revision = asserts.RevisionNotKnown + err := pool.AddUnresolved(at, "for_one") + c.Assert(err, IsNil) + + // nothing to resolve + toResolve, toResolveSeq, err := pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, HasLen, 0) + c.Check(toResolveSeq, HasLen, 0) +} + +func (s *poolSuite) TestAddUnresolvedGrouping(c *C) { + pool := asserts.NewPool(s.db, 64) + + storeKeyAt := s.hub.StoreAccountKey("").At() + + pool.AddUnresolved(storeKeyAt, "for_two") // group num: 0 + pool.AddUnresolved(storeKeyAt, "for_one") // group num: 1 + + toResolve, toResolveSeq, err := pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, DeepEquals, map[asserts.Grouping][]*asserts.AtRevision{ + asserts.MakePoolGrouping(0, 1): {storeKeyAt}, + }) + c.Check(toResolveSeq, HasLen, 0) +} + +func (s *poolSuite) TestAddUnresolvedDup(c *C) { + pool := asserts.NewPool(s.db, 64) + + storeKeyAt := s.hub.StoreAccountKey("").At() + + pool.AddUnresolved(storeKeyAt, "for_one") // group num: 0 + pool.AddUnresolved(storeKeyAt, "for_one") // group num: 0 + + toResolve, toResolveSeq, err := pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, DeepEquals, map[asserts.Grouping][]*asserts.AtRevision{ + asserts.MakePoolGrouping(0): {storeKeyAt}, + }) + c.Check(toResolveSeq, HasLen, 0) +} + +type byAtRevision []*asserts.AtRevision + +func (ats byAtRevision) Len() int { + return len(ats) +} + +func (ats byAtRevision) Less(i, j int) bool { + return ats[i].Ref.Unique() < ats[j].Ref.Unique() +} + +func (ats byAtRevision) Swap(i, j int) { + ats[i], ats[j] = ats[j], ats[i] +} + +func sortToResolve(toResolve map[asserts.Grouping][]*asserts.AtRevision) { + for _, ats := range toResolve { + sort.Sort(byAtRevision(ats)) + } +} + +func (s *poolSuite) TestFetch(c *C) { + pool := asserts.NewPool(s.db, 64) + + at1111 := &asserts.AtRevision{ + Ref: asserts.Ref{Type: asserts.TestOnlyRevType, PrimaryKey: []string{"1111"}}, + Revision: asserts.RevisionNotKnown, + } + err := pool.AddUnresolved(at1111, "for_one") + c.Assert(err, IsNil) + + toResolve, toResolveSeq, err := pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, DeepEquals, map[asserts.Grouping][]*asserts.AtRevision{ + asserts.MakePoolGrouping(0): {at1111}, + }) + c.Check(toResolveSeq, HasLen, 0) + + ok, err := pool.Add(s.rev1_1111, asserts.MakePoolGrouping(0)) + c.Assert(err, IsNil) + c.Assert(ok, Equals, true) + + toResolve, toResolveSeq, err = pool.ToResolve() + c.Assert(err, IsNil) + sortToResolve(toResolve) + dev1AcctAt := s.dev1Acct.At() + dev1AcctAt.Revision = asserts.RevisionNotKnown + decl1At := s.decl1.At() + decl1At.Revision = asserts.RevisionNotKnown + storeKeyAt := s.hub.StoreAccountKey("").At() + storeKeyAt.Revision = asserts.RevisionNotKnown + c.Check(toResolve, DeepEquals, map[asserts.Grouping][]*asserts.AtRevision{ + asserts.MakePoolGrouping(0): {storeKeyAt, dev1AcctAt, decl1At}, + }) + c.Check(toResolveSeq, HasLen, 0) + + c.Check(pool.Err("for_one"), IsNil) +} + +func (s *poolSuite) TestFetchSequenceForming(c *C) { + pool := asserts.NewPool(s.db, 64) + + // revision and sequence not set + atseq := &asserts.AtSequence{ + Type: asserts.TestOnlySeqType, + SequenceKey: []string{"1111"}, + Revision: asserts.RevisionNotKnown, + } + err := pool.AddUnresolvedSequence(atseq, "for_one") + c.Assert(err, IsNil) + + toResolve, toResolveSeq, err := pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, HasLen, 0) + c.Check(toResolveSeq, DeepEquals, map[asserts.Grouping][]*asserts.AtSequence{ + asserts.MakePoolGrouping(0): {atseq}, + }) + + // resolve + ok, err := pool.Add(s.seq1_1111r5, asserts.MakePoolGrouping(0)) + c.Assert(err, IsNil) + c.Assert(ok, Equals, true) + + toResolve, toResolveSeq, err = pool.ToResolve() + c.Assert(err, IsNil) + + storeKeyAt := s.hub.StoreAccountKey("").At() + storeKeyAt.Revision = asserts.RevisionNotKnown + c.Check(toResolveSeq, HasLen, 0) + c.Check(toResolve, DeepEquals, map[asserts.Grouping][]*asserts.AtRevision{ + asserts.MakePoolGrouping(0): {storeKeyAt}, + }) + + ok, err = pool.Add(s.hub.StoreAccountKey(""), asserts.MakePoolGrouping(0)) + c.Assert(err, IsNil) + c.Assert(ok, Equals, true) + + c.Check(pool.Err("for_one"), IsNil) + + err = pool.CommitTo(s.db) + c.Check(err, IsNil) + c.Assert(pool.Err("for_one"), IsNil) +} + +func (s *poolSuite) TestCompleteFetch(c *C) { + pool := asserts.NewPool(s.db, 64) + + at1111 := &asserts.AtRevision{ + Ref: asserts.Ref{Type: asserts.TestOnlyRevType, PrimaryKey: []string{"1111"}}, + Revision: asserts.RevisionNotKnown, + } + err := pool.AddUnresolved(at1111, "for_one") + c.Assert(err, IsNil) + + toResolve, toResolveSeq, err := pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, DeepEquals, map[asserts.Grouping][]*asserts.AtRevision{ + asserts.MakePoolGrouping(0): {at1111}, + }) + c.Check(toResolveSeq, HasLen, 0) + + ok, err := pool.Add(s.rev1_1111, asserts.MakePoolGrouping(0)) + c.Assert(err, IsNil) + c.Assert(ok, Equals, true) + + toResolve, toResolveSeq, err = pool.ToResolve() + c.Assert(err, IsNil) + sortToResolve(toResolve) + dev1AcctAt := s.dev1Acct.At() + dev1AcctAt.Revision = asserts.RevisionNotKnown + decl1At := s.decl1.At() + decl1At.Revision = asserts.RevisionNotKnown + storeKey := s.hub.StoreAccountKey("") + storeKeyAt := storeKey.At() + storeKeyAt.Revision = asserts.RevisionNotKnown + c.Check(toResolve, DeepEquals, map[asserts.Grouping][]*asserts.AtRevision{ + asserts.MakePoolGrouping(0): {storeKeyAt, dev1AcctAt, decl1At}, + }) + c.Check(toResolveSeq, HasLen, 0) + + b := asserts.NewBatch(nil) + err = b.Add(s.decl1) + c.Assert(err, IsNil) + err = b.Add(storeKey) + c.Assert(err, IsNil) + err = b.Add(s.dev1Acct) + c.Assert(err, IsNil) + + ok, err = pool.AddBatch(b, asserts.MakePoolGrouping(0)) + c.Assert(err, IsNil) + c.Assert(ok, Equals, true) + + toResolve, toResolveSeq, err = pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, HasLen, 0) + c.Check(toResolveSeq, HasLen, 0) + + c.Check(pool.Err("for_one"), IsNil) + + err = pool.CommitTo(s.db) + c.Check(err, IsNil) + c.Assert(pool.Err("for_one"), IsNil) + + a, err := at1111.Ref.Resolve(s.db.Find) + c.Assert(err, IsNil) + c.Check(a.(*asserts.TestOnlyRev).H(), Equals, "1111") +} + +func (s *poolSuite) TestPushSuggestionForPrerequisite(c *C) { + assertstest.AddMany(s.db, s.hub.StoreAccountKey("")) + + pool := asserts.NewPool(s.db, 64) + + at1111 := &asserts.AtRevision{ + Ref: asserts.Ref{Type: asserts.TestOnlyRevType, PrimaryKey: []string{"1111"}}, + Revision: asserts.RevisionNotKnown, + } + err := pool.AddUnresolved(at1111, "for_one") + c.Assert(err, IsNil) + + toResolve, toResolveSeq, err := pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, DeepEquals, map[asserts.Grouping][]*asserts.AtRevision{ + asserts.MakePoolGrouping(0): {at1111}, + }) + c.Check(toResolveSeq, HasLen, 0) + + ok, err := pool.Add(s.rev1_1111, asserts.MakePoolGrouping(0)) + c.Assert(err, IsNil) + c.Assert(ok, Equals, true) + + // push prerequisite suggestion + ok, err = pool.Add(s.decl1, asserts.MakePoolGrouping(0)) + c.Assert(err, IsNil) + c.Assert(ok, Equals, true) + + toResolve, toResolveSeq, err = pool.ToResolve() + c.Assert(err, IsNil) + sortToResolve(toResolve) + dev1AcctAt := s.dev1Acct.At() + dev1AcctAt.Revision = asserts.RevisionNotKnown + storeKey := s.hub.StoreAccountKey("") + c.Check(toResolve, DeepEquals, map[asserts.Grouping][]*asserts.AtRevision{ + asserts.MakePoolGrouping(0): {storeKey.At(), dev1AcctAt}, + }) + c.Check(toResolveSeq, HasLen, 0) + + c.Check(pool.Err("for_one"), IsNil) + + ok, err = pool.Add(s.dev1Acct, asserts.MakePoolGrouping(0)) + c.Assert(err, IsNil) + c.Assert(ok, Equals, true) + + toResolve, toResolveSeq, err = pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, HasLen, 0) + c.Check(toResolveSeq, HasLen, 0) + + c.Check(pool.Err("for_one"), IsNil) + + err = pool.CommitTo(s.db) + c.Check(err, IsNil) + c.Assert(pool.Err("for_one"), IsNil) + + a, err := at1111.Ref.Resolve(s.db.Find) + c.Assert(err, IsNil) + c.Check(a.(*asserts.TestOnlyRev).H(), Equals, "1111") +} + +func (s *poolSuite) TestPushSuggestionForNew(c *C) { + assertstest.AddMany(s.db, s.hub.StoreAccountKey("")) + + pool := asserts.NewPool(s.db, 64) + + atOne := &asserts.AtRevision{ + Ref: asserts.Ref{Type: asserts.TestOnlyDeclType, PrimaryKey: []string{"one"}}, + Revision: asserts.RevisionNotKnown, + } + err := pool.AddUnresolved(atOne, "for_one") + c.Assert(err, IsNil) + + toResolve, toResolveSeq, err := pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, DeepEquals, map[asserts.Grouping][]*asserts.AtRevision{ + asserts.MakePoolGrouping(0): {atOne}, + }) + c.Check(toResolveSeq, HasLen, 0) + + ok, err := pool.Add(s.decl1, asserts.MakePoolGrouping(0)) + c.Assert(err, IsNil) + c.Assert(ok, Equals, true) + + // new push suggestion + ok, err = pool.Add(s.rev1_1111, asserts.MakePoolGrouping(0)) + c.Assert(err, IsNil) + c.Assert(ok, Equals, true) + + toResolve, toResolveSeq, err = pool.ToResolve() + c.Assert(err, IsNil) + sortToResolve(toResolve) + dev1AcctAt := s.dev1Acct.At() + dev1AcctAt.Revision = asserts.RevisionNotKnown + storeKeyAt := s.hub.StoreAccountKey("").At() + c.Check(toResolve, DeepEquals, map[asserts.Grouping][]*asserts.AtRevision{ + asserts.MakePoolGrouping(0): {storeKeyAt, dev1AcctAt}, + }) + c.Check(toResolveSeq, HasLen, 0) + + c.Check(pool.Err("for_one"), IsNil) + + ok, err = pool.Add(s.dev1Acct, asserts.MakePoolGrouping(0)) + c.Assert(err, IsNil) + c.Assert(ok, Equals, true) + + toResolve, toResolveSeq, err = pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, HasLen, 0) + c.Check(toResolveSeq, HasLen, 0) + + c.Check(pool.Err("for_one"), IsNil) + + err = pool.CommitTo(s.db) + c.Check(err, IsNil) + c.Assert(pool.Err("for_one"), IsNil) + + a, err := s.rev1_1111.Ref().Resolve(s.db.Find) + c.Assert(err, IsNil) + c.Check(a.(*asserts.TestOnlyRev).H(), Equals, "1111") +} + +func (s *poolSuite) TestPushSuggestionForNewSeqForming(c *C) { + assertstest.AddMany(s.db, s.hub.StoreAccountKey("")) + + pool := asserts.NewPool(s.db, 64) + + atOne := &asserts.AtSequence{ + Type: asserts.TestOnlySeqType, + SequenceKey: []string{"1111"}, + Revision: asserts.RevisionNotKnown, + } + err := pool.AddUnresolvedSequence(atOne, "for_one") + c.Assert(err, IsNil) + + toResolve, toResolveSeq, err := pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, HasLen, 0) + c.Check(toResolveSeq, DeepEquals, map[asserts.Grouping][]*asserts.AtSequence{ + asserts.MakePoolGrouping(0): {atOne}, + }) + + ok, err := pool.Add(s.seq1_1111r5, asserts.MakePoolGrouping(0)) + c.Assert(err, IsNil) + c.Assert(ok, Equals, true) + + // new push suggestion + ok, err = pool.Add(s.seq2_1111r7, asserts.MakePoolGrouping(0)) + c.Assert(err, IsNil) + c.Assert(ok, Equals, true) + + toResolve, toResolveSeq, err = pool.ToResolve() + c.Assert(err, IsNil) + sortToResolve(toResolve) + dev1AcctAt := s.dev1Acct.At() + dev1AcctAt.Revision = asserts.RevisionNotKnown + + c.Check(toResolve, HasLen, 1) + c.Check(toResolveSeq, HasLen, 0) + + c.Check(pool.Err("for_one"), IsNil) + + err = pool.CommitTo(s.db) + c.Check(err, IsNil) + c.Assert(pool.Err("for_one"), IsNil) + + a, err := s.seq2_1111r7.Ref().Resolve(s.db.Find) + c.Assert(err, IsNil) + c.Check(a.(*asserts.TestOnlySeq).N(), Equals, "1111") + c.Check(a.Revision(), Equals, 7) +} + +func (s *poolSuite) TestPushSuggestionForNewViaBatch(c *C) { + assertstest.AddMany(s.db, s.hub.StoreAccountKey("")) + + pool := asserts.NewPool(s.db, 64) + + atOne := &asserts.AtRevision{ + Ref: asserts.Ref{Type: asserts.TestOnlyDeclType, PrimaryKey: []string{"one"}}, + Revision: asserts.RevisionNotKnown, + } + err := pool.AddUnresolved(atOne, "for_one") + c.Assert(err, IsNil) + + toResolve, toResolveSeq, err := pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, DeepEquals, map[asserts.Grouping][]*asserts.AtRevision{ + asserts.MakePoolGrouping(0): {atOne}, + }) + c.Check(toResolveSeq, HasLen, 0) + + b := asserts.NewBatch(nil) + err = b.Add(s.decl1) + c.Assert(err, IsNil) + + // new push suggestions + err = b.Add(s.rev1_1111) + c.Assert(err, IsNil) + err = b.Add(s.rev1_3333) + c.Assert(err, IsNil) + + ok, err := pool.AddBatch(b, asserts.MakePoolGrouping(0)) + c.Assert(err, IsNil) + c.Assert(ok, Equals, true) + + toResolve, toResolveSeq, err = pool.ToResolve() + c.Assert(err, IsNil) + sortToResolve(toResolve) + dev1AcctAt := s.dev1Acct.At() + dev1AcctAt.Revision = asserts.RevisionNotKnown + storeKeyAt := s.hub.StoreAccountKey("").At() + c.Check(toResolve, DeepEquals, map[asserts.Grouping][]*asserts.AtRevision{ + asserts.MakePoolGrouping(0): {storeKeyAt, dev1AcctAt}, + }) + c.Check(toResolveSeq, HasLen, 0) + + c.Check(pool.Err("for_one"), IsNil) + + ok, err = pool.Add(s.dev1Acct, asserts.MakePoolGrouping(0)) + c.Assert(err, IsNil) + c.Assert(ok, Equals, true) + + toResolve, toResolveSeq, err = pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, HasLen, 0) + c.Check(toResolveSeq, HasLen, 0) + + c.Check(pool.Err("for_one"), IsNil) + + err = pool.CommitTo(s.db) + c.Check(err, IsNil) + c.Assert(pool.Err("for_one"), IsNil) + + a, err := s.rev1_1111.Ref().Resolve(s.db.Find) + c.Assert(err, IsNil) + c.Check(a.(*asserts.TestOnlyRev).H(), Equals, "1111") + + a, err = s.rev1_3333.Ref().Resolve(s.db.Find) + c.Assert(err, IsNil) + c.Check(a.(*asserts.TestOnlyRev).H(), Equals, "3333") +} + +func (s *poolSuite) TestAddUnresolvedUnresolved(c *C) { + pool := asserts.NewPool(s.db, 64) + + at1 := &asserts.AtRevision{ + Ref: asserts.Ref{Type: asserts.TestOnlyRevType, PrimaryKey: []string{"1111"}}, + Revision: asserts.RevisionNotKnown, + } + err := pool.AddUnresolved(at1, "for_one") + c.Assert(err, IsNil) + + toResolve, toResolveSeq, err := pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, DeepEquals, map[asserts.Grouping][]*asserts.AtRevision{ + asserts.MakePoolGrouping(0): {at1}, + }) + c.Check(toResolveSeq, HasLen, 0) + + toResolve, toResolveSeq, err = pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, HasLen, 0) + c.Check(toResolveSeq, HasLen, 0) + + c.Check(pool.Err("for_one"), Equals, asserts.ErrUnresolved) +} + +func (s *poolSuite) TestAddFormatTooNew(c *C) { + pool := asserts.NewPool(s.db, 64) + + _, _, err := pool.ToResolve() + c.Assert(err, IsNil) + + var a asserts.Assertion + (func() { + restore := asserts.MockMaxSupportedFormat(asserts.TestOnlyDeclType, 2) + defer restore() + + a, err = s.hub.Sign(asserts.TestOnlyDeclType, map[string]interface{}{ + "id": "three", + "dev-id": "developer1", + "format": "2", + }, nil, "") + c.Assert(err, IsNil) + })() + + gSuggestion, err := pool.Singleton("suggestion") + c.Assert(err, IsNil) + + ok, err := pool.Add(a, gSuggestion) + c.Check(err, IsNil) + c.Check(ok, Equals, false) + c.Assert(pool.Err("suggestion"), ErrorMatches, `proposed "test-only-decl" assertion has format 2 but 0 is latest supported`) +} + +func (s *poolSuite) TestAddOlderIgnored(c *C) { + pool := asserts.NewPool(s.db, 64) + + _, _, err := pool.ToResolve() + c.Assert(err, IsNil) + + gSuggestion, err := pool.Singleton("suggestion") + c.Assert(err, IsNil) + + ok, err := pool.Add(s.decl1_1, gSuggestion) + c.Assert(err, IsNil) + c.Assert(ok, Equals, true) + + ok, err = pool.Add(s.decl1, gSuggestion) + c.Assert(err, IsNil) + c.Assert(ok, Equals, true) + + toResolve, toResolveSeq, err := pool.ToResolve() + c.Assert(err, IsNil) + sortToResolve(toResolve) + dev1AcctAt := s.dev1Acct.At() + dev1AcctAt.Revision = asserts.RevisionNotKnown + storeKeyAt := s.hub.StoreAccountKey("").At() + storeKeyAt.Revision = asserts.RevisionNotKnown + + c.Check(toResolve, DeepEquals, map[asserts.Grouping][]*asserts.AtRevision{ + gSuggestion: {storeKeyAt, dev1AcctAt}, + }) + c.Check(toResolveSeq, HasLen, 0) +} + +func (s *poolSuite) TestUnknownGroup(c *C) { + pool := asserts.NewPool(s.db, 64) + + _, err := pool.Singleton("suggestion") + c.Assert(err, IsNil) + // validity + c.Check(pool.Err("suggestion"), IsNil) + + c.Check(pool.Err("foo"), Equals, asserts.ErrUnknownPoolGroup) +} + +func (s *poolSuite) TestAddCurrentRevision(c *C) { + assertstest.AddMany(s.db, s.hub.StoreAccountKey(""), s.dev1Acct, s.decl1) + + pool := asserts.NewPool(s.db, 64) + + atDev1Acct := s.dev1Acct.At() + atDev1Acct.Revision = asserts.RevisionNotKnown + err := pool.AddUnresolved(atDev1Acct, "one") + c.Assert(err, IsNil) + + atDecl1 := s.decl1.At() + atDecl1.Revision = asserts.RevisionNotKnown + err = pool.AddUnresolved(atDecl1, "one") + c.Assert(err, IsNil) + + toResolve, toResolveSeq, err := pool.ToResolve() + c.Assert(err, IsNil) + sortToResolve(toResolve) + + c.Check(toResolve, DeepEquals, map[asserts.Grouping][]*asserts.AtRevision{ + asserts.MakePoolGrouping(0): {s.dev1Acct.At(), s.decl1.At()}, + }) + c.Check(toResolveSeq, HasLen, 0) + + // re-adding of current revisions, is not what we expect + // but needs to not produce unnecessary roundtrips + + ok, err := pool.Add(s.hub.StoreAccountKey(""), asserts.MakePoolGrouping(0)) + c.Assert(err, IsNil) + c.Assert(ok, Equals, true) + + // this will be kept marked as unresolved until the ToResolve + ok, err = pool.Add(s.dev1Acct, asserts.MakePoolGrouping(0)) + c.Assert(err, IsNil) + c.Assert(ok, Equals, true) + + ok, err = pool.Add(s.decl1_1, asserts.MakePoolGrouping(0)) + c.Assert(err, IsNil) + c.Assert(ok, Equals, true) + + toResolve, toResolveSeq, err = pool.ToResolve() + c.Assert(err, IsNil) + c.Assert(toResolve, HasLen, 0) + c.Assert(toResolveSeq, HasLen, 0) + + c.Check(pool.Err("one"), IsNil) +} + +func (s *poolSuite) TestAddCurrentRevisionSeqForming(c *C) { + assertstest.AddMany(s.db, s.hub.StoreAccountKey(""), s.dev1Acct, s.decl1) + + pool := asserts.NewPool(s.db, 64) + + atSeq := &asserts.AtSequence{ + Type: asserts.TestOnlySeqType, + SequenceKey: []string{"1111"}, + Revision: asserts.RevisionNotKnown, + } + err := pool.AddUnresolvedSequence(atSeq, "one") + c.Assert(err, IsNil) + + toResolve, toResolveSeq, err := pool.ToResolve() + c.Assert(err, IsNil) + + c.Check(toResolve, HasLen, 0) + c.Check(toResolveSeq, DeepEquals, map[asserts.Grouping][]*asserts.AtSequence{ + asserts.MakePoolGrouping(0): { + &asserts.AtSequence{ + Type: s.seq1_1111r5.Type(), + SequenceKey: []string{"1111"}, + Revision: asserts.RevisionNotKnown, + }}, + }) + + // re-adding of current revisions, is not what we expect + // but needs to not produce unnecessary roundtrips + + ok, err := pool.Add(s.hub.StoreAccountKey(""), asserts.MakePoolGrouping(0)) + c.Assert(err, IsNil) + c.Assert(ok, Equals, true) + + // this will be kept marked as unresolved until the ToResolve + ok, err = pool.Add(s.seq1_1111r5, asserts.MakePoolGrouping(0)) + c.Assert(err, IsNil) + c.Assert(ok, Equals, true) + + toResolve, toResolveSeq, err = pool.ToResolve() + c.Assert(err, IsNil) + c.Assert(toResolve, HasLen, 0) + c.Assert(toResolveSeq, HasLen, 0) + + c.Check(pool.Err("one"), IsNil) +} + +func (s *poolSuite) TestUpdate(c *C) { + assertstest.AddMany(s.db, s.hub.StoreAccountKey("")) + assertstest.AddMany(s.db, s.dev1Acct, s.decl1, s.rev1_1111) + assertstest.AddMany(s.db, s.dev2Acct, s.decl2, s.rev2_2222) + + pool := asserts.NewPool(s.db, 64) + + err := pool.AddToUpdate(s.decl1.Ref(), "for_one") // group num: 0 + c.Assert(err, IsNil) + err = pool.AddToUpdate(s.decl2.Ref(), "for_two") // group num: 1 + c.Assert(err, IsNil) + + storeKeyAt := s.hub.StoreAccountKey("").At() + + toResolve, toResolveSeq, err := pool.ToResolve() + c.Assert(err, IsNil) + sortToResolve(toResolve) + c.Check(toResolve, DeepEquals, map[asserts.Grouping][]*asserts.AtRevision{ + asserts.MakePoolGrouping(0, 1): {storeKeyAt}, + asserts.MakePoolGrouping(0): {s.dev1Acct.At(), s.decl1.At()}, + asserts.MakePoolGrouping(1): {s.dev2Acct.At(), s.decl2.At()}, + }) + c.Check(toResolveSeq, HasLen, 0) + + ok, err := pool.Add(s.decl1_1, asserts.MakePoolGrouping(0)) + c.Assert(err, IsNil) + c.Assert(ok, Equals, true) + + toResolve, toResolveSeq, err = pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, HasLen, 0) + c.Check(toResolveSeq, HasLen, 0) + + at2222 := &asserts.AtRevision{ + Ref: asserts.Ref{Type: asserts.TestOnlyRevType, PrimaryKey: []string{"2222"}}, + Revision: asserts.RevisionNotKnown, + } + err = pool.AddUnresolved(at2222, "for_two") + c.Assert(err, IsNil) + + toResolve, toResolveSeq, err = pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, DeepEquals, map[asserts.Grouping][]*asserts.AtRevision{ + asserts.MakePoolGrouping(1): {&asserts.AtRevision{ + Ref: asserts.Ref{Type: asserts.TestOnlyRevType, PrimaryKey: []string{"2222"}}, + Revision: 0, + }}, + }) + c.Check(toResolveSeq, HasLen, 0) + + c.Check(pool.Err("for_one"), IsNil) + c.Check(pool.Err("for_two"), IsNil) +} + +func (s *poolSuite) TestUpdateSeqFormingUnpinnedNewerSequence(c *C) { + assertstest.AddMany(s.db, s.hub.StoreAccountKey(""), s.seq1_1111r5) + + pool := asserts.NewPool(s.db, 64) + + atseq := &asserts.AtSequence{ + Type: s.seq1_1111r5.Type(), + SequenceKey: []string{"1111"}, + Sequence: 1, + Revision: 5, + } + err := pool.AddSequenceToUpdate(atseq, "for_one") // group num: 0 + c.Assert(err, IsNil) + + toResolve, toResolveSeq, err := pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, DeepEquals, map[asserts.Grouping][]*asserts.AtRevision{ + asserts.MakePoolGrouping(0): {s.hub.StoreAccountKey(s.dev1Acct.SignKeyID()).At()}, + }) + c.Check(toResolveSeq, DeepEquals, map[asserts.Grouping][]*asserts.AtSequence{ + asserts.MakePoolGrouping(0): { + &asserts.AtSequence{ + Type: s.seq1_1111r5.Type(), + SequenceKey: []string{"1111"}, + Sequence: 1, + Revision: 5, + }}, + }) + + c.Check(pool.Err("for_one"), IsNil) + + // resolve with sequence 3 + ok, err := pool.Add(s.seq3_1111r5, asserts.MakePoolGrouping(0)) + c.Assert(err, IsNil) + c.Assert(ok, Equals, true) + + toResolve, toResolveSeq, err = pool.ToResolve() + c.Assert(err, IsNil) + + c.Check(toResolve, HasLen, 0) + c.Check(toResolveSeq, HasLen, 0) + c.Check(pool.Err("for_one"), IsNil) + + err = pool.CommitTo(s.db) + c.Check(err, IsNil) + c.Assert(pool.Err("for_one"), IsNil) + + // sequence point 1, revision 5 is still in the db. + _, err = s.seq1_1111r5.Ref().Resolve(s.db.Find) + c.Assert(err, IsNil) + + // and sequence point 3 revision 5 is in the database. + _, err = s.seq3_1111r5.Ref().Resolve(s.db.Find) + c.Assert(err, IsNil) +} + +func (s *poolSuite) TestUpdateSeqFormingUnpinnedSameSequenceNewerRev(c *C) { + assertstest.AddMany(s.db, s.hub.StoreAccountKey(""), s.seq1_1111r5) + + pool := asserts.NewPool(s.db, 64) + + atseq := &asserts.AtSequence{ + Type: s.seq1_1111r5.Type(), + SequenceKey: []string{"1111"}, + Sequence: 1, + Revision: 5, + } + err := pool.AddSequenceToUpdate(atseq, "for_one") // group num: 0 + c.Assert(err, IsNil) + + toResolve, toResolveSeq, err := pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, DeepEquals, map[asserts.Grouping][]*asserts.AtRevision{ + asserts.MakePoolGrouping(0): {s.hub.StoreAccountKey(s.dev1Acct.SignKeyID()).At()}, + }) + c.Check(toResolveSeq, DeepEquals, map[asserts.Grouping][]*asserts.AtSequence{ + asserts.MakePoolGrouping(0): { + &asserts.AtSequence{ + Type: s.seq1_1111r5.Type(), + SequenceKey: []string{"1111"}, + Sequence: 1, + Revision: 5, + }}, + }) + + c.Check(pool.Err("for_one"), IsNil) + + // resolve + ok, err := pool.Add(s.seq1_1111r6, asserts.MakePoolGrouping(0)) + c.Assert(err, IsNil) + c.Assert(ok, Equals, true) + + toResolve, toResolveSeq, err = pool.ToResolve() + c.Assert(err, IsNil) + + c.Check(toResolve, HasLen, 0) + c.Check(toResolveSeq, HasLen, 0) + c.Check(pool.Err("for_one"), IsNil) + + err = pool.CommitTo(s.db) + c.Check(err, IsNil) + c.Assert(pool.Err("for_one"), IsNil) + + // sequence point 1, revision 5 is still in the database. + _, err = s.seq1_1111r5.Ref().Resolve(s.db.Find) + c.Assert(err, IsNil) + + // and sequence point 1 revision 6 is in the database. + _, err = s.seq1_1111r6.Ref().Resolve(s.db.Find) + c.Assert(err, IsNil) +} + +func (s *poolSuite) TestUpdateSeqFormingUnpinnedSameSequenceSameRevNoop(c *C) { + assertstest.AddMany(s.db, s.hub.StoreAccountKey(""), s.seq1_1111r5) + + pool := asserts.NewPool(s.db, 64) + + atseq := &asserts.AtSequence{ + Type: s.seq1_1111r5.Type(), + SequenceKey: []string{"1111"}, + Sequence: 1, + Revision: 5, + } + err := pool.AddSequenceToUpdate(atseq, "for_one") // group num: 0 + c.Assert(err, IsNil) + + toResolve, toResolveSeq, err := pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, DeepEquals, map[asserts.Grouping][]*asserts.AtRevision{ + asserts.MakePoolGrouping(0): {s.hub.StoreAccountKey(s.dev1Acct.SignKeyID()).At()}, + }) + c.Check(toResolveSeq, DeepEquals, map[asserts.Grouping][]*asserts.AtSequence{ + asserts.MakePoolGrouping(0): { + &asserts.AtSequence{ + Type: s.seq1_1111r5.Type(), + SequenceKey: []string{"1111"}, + Sequence: 1, + Revision: 5, + }}, + }) + + c.Check(pool.Err("for_one"), IsNil) + + // update with same assertion + ok, err := pool.Add(s.seq1_1111r5, asserts.MakePoolGrouping(0)) + c.Assert(err, IsNil) + c.Assert(ok, Equals, true) + + toResolve, toResolveSeq, err = pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, HasLen, 0) + c.Check(toResolveSeq, HasLen, 0) + c.Check(pool.Err("for_one"), IsNil) + + err = pool.CommitTo(s.db) + c.Check(err, IsNil) + c.Assert(pool.Err("for_one"), IsNil) + + // sequence point 1, revision 5 is still in the database. + _, err = s.seq1_1111r5.Ref().Resolve(s.db.Find) + c.Assert(err, IsNil) +} + +func (s *poolSuite) TestUpdateSeqFormingPinnedNewerSequenceSameRevisionNoop(c *C) { + assertstest.AddMany(s.db, s.hub.StoreAccountKey(""), s.seq1_1111r5) + + pool := asserts.NewPool(s.db, 64) + + atseq := &asserts.AtSequence{ + Type: s.seq1_1111r5.Type(), + SequenceKey: []string{"1111"}, + Sequence: 1, + Revision: 5, + Pinned: true, + } + err := pool.AddSequenceToUpdate(atseq, "for_one") // group num: 0 + c.Assert(err, IsNil) + + toResolve, toResolveSeq, err := pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, DeepEquals, map[asserts.Grouping][]*asserts.AtRevision{ + asserts.MakePoolGrouping(0): {s.hub.StoreAccountKey(s.dev1Acct.SignKeyID()).At()}, + }) + c.Check(toResolveSeq, DeepEquals, map[asserts.Grouping][]*asserts.AtSequence{ + asserts.MakePoolGrouping(0): { + &asserts.AtSequence{ + Type: s.seq1_1111r5.Type(), + SequenceKey: []string{"1111"}, + Sequence: 1, + Revision: 5, + Pinned: true, + }}, + }) + + c.Check(pool.Err("for_one"), IsNil) + + // resolve + ok, err := pool.Add(s.seq3_1111r5, asserts.MakePoolGrouping(0)) + c.Assert(err, IsNil) + c.Assert(ok, Equals, true) + + toResolve, toResolveSeq, err = pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, HasLen, 0) + c.Check(toResolveSeq, HasLen, 0) + c.Check(pool.Err("for_one"), IsNil) + + err = pool.CommitTo(s.db) + c.Check(err, IsNil) + c.Assert(pool.Err("for_one"), IsNil) + + // sequence point 1, revision 5 is still the latest. + _, err = s.seq1_1111r5.Ref().Resolve(s.db.Find) + c.Assert(err, IsNil) + + // and sequence point 3 revision 5 wasn't added to asserts database. + _, err = s.seq3_1111r5.Ref().Resolve(s.db.Find) + c.Assert(errors.Is(err, &asserts.NotFoundError{}), Equals, true) +} + +func (s *poolSuite) TestUpdateSeqFormingPinnedNewerSequenceNewerRevisionNoop(c *C) { + assertstest.AddMany(s.db, s.hub.StoreAccountKey(""), s.seq1_1111r5) + + pool := asserts.NewPool(s.db, 64) + + atseq := &asserts.AtSequence{ + Type: s.seq1_1111r5.Type(), + SequenceKey: []string{"1111"}, + Sequence: 1, + Revision: 5, + Pinned: true, + } + err := pool.AddSequenceToUpdate(atseq, "for_one") // group num: 0 + c.Assert(err, IsNil) + + toResolve, toResolveSeq, err := pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, DeepEquals, map[asserts.Grouping][]*asserts.AtRevision{ + asserts.MakePoolGrouping(0): {s.hub.StoreAccountKey(s.dev1Acct.SignKeyID()).At()}, + }) + c.Check(toResolveSeq, DeepEquals, map[asserts.Grouping][]*asserts.AtSequence{ + asserts.MakePoolGrouping(0): { + &asserts.AtSequence{ + Type: s.seq1_1111r5.Type(), + SequenceKey: []string{"1111"}, + Sequence: 1, + Revision: 5, + Pinned: true, + }}, + }) + + c.Check(pool.Err("for_one"), IsNil) + + // resolve + ok, err := pool.Add(s.seq2_1111r7, asserts.MakePoolGrouping(0)) + c.Assert(err, IsNil) + c.Assert(ok, Equals, true) + + err = pool.CommitTo(s.db) + c.Check(err, IsNil) + c.Assert(pool.Err("for_one"), IsNil) + + // sequence point 1, revision 5 is still the latest. + _, err = s.seq1_1111r5.Ref().Resolve(s.db.Find) + c.Assert(err, IsNil) + + // and sequence point 2 revision 7 wasn't added to asserts database. + _, err = s.seq2_1111r7.Ref().Resolve(s.db.Find) + c.Assert(errors.Is(err, &asserts.NotFoundError{}), Equals, true) +} + +func (s *poolSuite) TestUpdateSeqFormingPinnedSameSequenceNewerRevision(c *C) { + assertstest.AddMany(s.db, s.hub.StoreAccountKey(""), s.seq1_1111r5) + pool := asserts.NewPool(s.db, 64) + + atseq := &asserts.AtSequence{ + Type: s.seq1_1111r5.Type(), + SequenceKey: []string{"1111"}, + Sequence: 1, + Revision: 5, + Pinned: true, + } + err := pool.AddSequenceToUpdate(atseq, "for_one") // group num: 0 + c.Assert(err, IsNil) + + toResolve, toResolveSeq, err := pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, DeepEquals, map[asserts.Grouping][]*asserts.AtRevision{ + asserts.MakePoolGrouping(0): {s.hub.StoreAccountKey(s.dev1Acct.SignKeyID()).At()}, + }) + c.Check(toResolveSeq, DeepEquals, map[asserts.Grouping][]*asserts.AtSequence{ + asserts.MakePoolGrouping(0): { + &asserts.AtSequence{ + Type: s.seq1_1111r5.Type(), + SequenceKey: []string{"1111"}, + Sequence: 1, + Revision: 5, + Pinned: true, + }}, + }) + + c.Check(pool.Err("for_one"), IsNil) + + // resolve + ok, err := pool.Add(s.seq1_1111r6, asserts.MakePoolGrouping(0)) + c.Assert(err, IsNil) + c.Assert(ok, Equals, true) + + err = pool.CommitTo(s.db) + c.Check(err, IsNil) + c.Assert(pool.Err("for_one"), IsNil) + + // sequence point 1, revision 6 is in db. + _, err = s.seq1_1111r6.Ref().Resolve(s.db.Find) + c.Assert(err, IsNil) +} + +func (s *poolSuite) TestUpdateSeqFormingUseAssertRevision(c *C) { + assertstest.AddMany(s.db, s.hub.StoreAccountKey(""), s.seq1_1111r5) + + pool := asserts.NewPool(s.db, 64) + + atseq := &asserts.AtSequence{ + Type: s.seq1_1111r5.Type(), + SequenceKey: []string{"1111"}, + Sequence: 1, + Revision: 0, // intentionaly unset + } + err := pool.AddSequenceToUpdate(atseq, "for_one") // group num: 0 + c.Assert(err, IsNil) + + toResolve, toResolveSeq, err := pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, DeepEquals, map[asserts.Grouping][]*asserts.AtRevision{ + asserts.MakePoolGrouping(0): {s.hub.StoreAccountKey(s.dev1Acct.SignKeyID()).At()}, + }) + + // verify that revision number from the existing assertion to update was used. + c.Check(toResolveSeq, DeepEquals, map[asserts.Grouping][]*asserts.AtSequence{ + asserts.MakePoolGrouping(0): { + &asserts.AtSequence{ + Type: s.seq1_1111r5.Type(), + SequenceKey: []string{"1111"}, + Sequence: 1, + Revision: 5, + }}, + }) +} + +func (s *poolSuite) TestAddSequenceToUpdateMissingSequenceError(c *C) { + pool := asserts.NewPool(s.db, 64) + atseq := &asserts.AtSequence{ + Type: s.seq1_1111r5.Type(), + SequenceKey: []string{"1111"}, + Revision: asserts.RevisionNotKnown, + } + err := pool.AddSequenceToUpdate(atseq, "for_one") + c.Assert(err, ErrorMatches, `internal error: sequence to update must have a sequence number set`) +} + +func (s *poolSuite) TestAddUnresolvedSeqUnresolved(c *C) { + pool := asserts.NewPool(s.db, 64) + + atseq := &asserts.AtSequence{ + Type: s.seq1_1111r5.Type(), + SequenceKey: []string{"1111"}, + Revision: asserts.RevisionNotKnown, + Sequence: 1, + } + err := pool.AddUnresolvedSequence(atseq, "for_one") + c.Assert(err, IsNil) + + toResolve, toResolveSeq, err := pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, HasLen, 0) + c.Check(toResolveSeq, DeepEquals, map[asserts.Grouping][]*asserts.AtSequence{ + asserts.MakePoolGrouping(0): { + &asserts.AtSequence{ + Type: s.seq1_1111r5.Type(), + SequenceKey: []string{"1111"}, + Revision: asserts.RevisionNotKnown, + Sequence: 1, + }}, + }) + + toResolve, toResolveSeq, err = pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, HasLen, 0) + c.Check(toResolveSeq, HasLen, 0) + + c.Check(pool.Err("for_one"), Equals, asserts.ErrUnresolved) +} + +func (s *poolSuite) TestAddUnresolvedSeqOnce(c *C) { + pool := asserts.NewPool(s.db, 64) + + atseq := &asserts.AtSequence{ + Type: s.seq1_1111r5.Type(), + SequenceKey: []string{"1111"}, + Revision: asserts.RevisionNotKnown, + Sequence: 1, + } + err := pool.AddUnresolvedSequence(atseq, "for_one") + c.Assert(err, IsNil) + + atseq.Sequence = 2 + atseq.Revision = 3 + err = pool.AddUnresolvedSequence(atseq, "for_one") + c.Assert(err, ErrorMatches, `internal error: sequence \[1111\] is already being resolved`) +} + +func (s *poolSuite) TestAddSeqToUpdateOnce(c *C) { + assertstest.AddMany(s.db, s.hub.StoreAccountKey(""), s.seq1_1111r5) + pool := asserts.NewPool(s.db, 64) + + atseq := &asserts.AtSequence{ + Type: s.seq1_1111r5.Type(), + SequenceKey: []string{"1111"}, + Revision: 2, + Sequence: 1, + } + err := pool.AddSequenceToUpdate(atseq, "for_one") + c.Assert(err, IsNil) + + atseq.Sequence = 3 + atseq.Revision = 3 + err = pool.AddSequenceToUpdate(atseq, "for_one") + c.Assert(err, ErrorMatches, `internal error: sequence \[1111\] is already being resolved`) +} + +func (s *poolSuite) TestAddSeqToUpdateNotFound(c *C) { + pool := asserts.NewPool(s.db, 64) + + atseq := &asserts.AtSequence{ + Type: s.seq1_1111r5.Type(), + SequenceKey: []string{"1111"}, + Revision: 2, + Sequence: 1, + } + err := pool.AddSequenceToUpdate(atseq, "for_one") + c.Assert(errors.Is(err, &asserts.NotFoundError{}), Equals, true) +} + +var errBoom = errors.New("boom") + +func (s *poolSuite) TestAddErrorEarly(c *C) { + assertstest.AddMany(s.db, s.hub.StoreAccountKey("")) + + pool := asserts.NewPool(s.db, 64) + + storeKey := s.hub.StoreAccountKey("") + err := pool.AddToUpdate(storeKey.Ref(), "store_key") + c.Assert(err, IsNil) + + at1111 := &asserts.AtRevision{ + Ref: asserts.Ref{Type: asserts.TestOnlyRevType, PrimaryKey: []string{"1111"}}, + Revision: asserts.RevisionNotKnown, + } + err = pool.AddUnresolved(at1111, "for_one") + c.Assert(err, IsNil) + + seq1111 := &asserts.AtSequence{ + Type: asserts.TestOnlySeqType, + SequenceKey: []string{"1111"}, + Revision: asserts.RevisionNotKnown, + } + err = pool.AddUnresolvedSequence(seq1111, "for_two") + c.Assert(err, IsNil) + + toResolve, toResolveSeq, err := pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, DeepEquals, map[asserts.Grouping][]*asserts.AtRevision{ + asserts.MakePoolGrouping(0): {storeKey.At()}, + asserts.MakePoolGrouping(1): {at1111}, + }) + c.Check(toResolveSeq, DeepEquals, map[asserts.Grouping][]*asserts.AtSequence{ + asserts.MakePoolGrouping(2): {seq1111}, + }) + + err = pool.AddError(errBoom, storeKey.Ref()) + c.Assert(err, IsNil) + + err = pool.AddSequenceError(errBoom, seq1111) + c.Assert(err, IsNil) + + ok, err := pool.Add(s.rev1_1111, asserts.MakePoolGrouping(1)) + c.Assert(err, IsNil) + c.Assert(ok, Equals, true) + + toResolve, toResolveSeq, err = pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, HasLen, 0) + c.Check(toResolveSeq, HasLen, 0) + + c.Check(pool.Err("store_key"), Equals, errBoom) + c.Check(pool.Err("for_one"), Equals, errBoom) + c.Check(pool.Err("for_two"), Equals, errBoom) +} + +func (s *poolSuite) TestAddErrorLater(c *C) { + assertstest.AddMany(s.db, s.hub.StoreAccountKey("")) + + pool := asserts.NewPool(s.db, 64) + + storeKey := s.hub.StoreAccountKey("") + err := pool.AddToUpdate(storeKey.Ref(), "store_key") + c.Assert(err, IsNil) + + at1111 := &asserts.AtRevision{ + Ref: asserts.Ref{Type: asserts.TestOnlyRevType, PrimaryKey: []string{"1111"}}, + Revision: asserts.RevisionNotKnown, + } + err = pool.AddUnresolved(at1111, "for_one") + c.Assert(err, IsNil) + + toResolve, toResolveSeq, err := pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, DeepEquals, map[asserts.Grouping][]*asserts.AtRevision{ + asserts.MakePoolGrouping(0): {storeKey.At()}, + asserts.MakePoolGrouping(1): {at1111}, + }) + c.Check(toResolveSeq, HasLen, 0) + + ok, err := pool.Add(s.rev1_1111, asserts.MakePoolGrouping(1)) + c.Assert(err, IsNil) + c.Assert(ok, Equals, true) + + err = pool.AddError(errBoom, storeKey.Ref()) + c.Assert(err, IsNil) + + toResolve, toResolveSeq, err = pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, HasLen, 0) + c.Check(toResolveSeq, HasLen, 0) + + c.Check(pool.Err("store_key"), Equals, errBoom) + c.Check(pool.Err("for_one"), Equals, errBoom) +} + +func (s *poolSuite) TestNopUpdatePlusFetchOfPushed(c *C) { + storeKey := s.hub.StoreAccountKey("") + assertstest.AddMany(s.db, storeKey) + assertstest.AddMany(s.db, s.dev1Acct) + assertstest.AddMany(s.db, s.decl1) + assertstest.AddMany(s.db, s.rev1_1111) + + pool := asserts.NewPool(s.db, 64) + + atOne := s.decl1.At() + err := pool.AddToUpdate(&atOne.Ref, "for_one") + c.Assert(err, IsNil) + + toResolve, toResolveSeq, err := pool.ToResolve() + c.Assert(err, IsNil) + sortToResolve(toResolve) + c.Check(toResolve, DeepEquals, map[asserts.Grouping][]*asserts.AtRevision{ + asserts.MakePoolGrouping(0): {storeKey.At(), s.dev1Acct.At(), atOne}, + }) + c.Check(toResolveSeq, HasLen, 0) + + // no updates but + // new push suggestion + + gSuggestion, err := pool.Singleton("suggestion") + c.Assert(err, IsNil) + + ok, err := pool.Add(s.rev1_3333, gSuggestion) + c.Assert(err, IsNil) + c.Assert(ok, Equals, true) + + toResolve, toResolveSeq, err = pool.ToResolve() + c.Assert(err, IsNil) + c.Assert(toResolve, HasLen, 0) + c.Check(toResolveSeq, HasLen, 0) + + c.Check(pool.Err("for_one"), IsNil) + + pool.AddGroupingError(errBoom, gSuggestion) + + c.Assert(pool.Err("for_one"), IsNil) + c.Assert(pool.Err("suggestion"), Equals, errBoom) + + at3333 := s.rev1_3333.At() + at3333.Revision = asserts.RevisionNotKnown + err = pool.AddUnresolved(at3333, at3333.Unique()) + c.Assert(err, IsNil) + + toResolve, toResolveSeq, err = pool.ToResolve() + c.Assert(err, IsNil) + c.Assert(toResolve, HasLen, 0) + c.Check(toResolveSeq, HasLen, 0) + + err = pool.CommitTo(s.db) + c.Check(err, IsNil) + + c.Assert(pool.Err(at3333.Unique()), IsNil) + + a, err := s.rev1_3333.Ref().Resolve(s.db.Find) + c.Assert(err, IsNil) + c.Check(a.(*asserts.TestOnlyRev).H(), Equals, "3333") +} + +func (s *poolSuite) TestAddToUpdateThenUnresolved(c *C) { + assertstest.AddMany(s.db, s.hub.StoreAccountKey("")) + + pool := asserts.NewPool(s.db, 64) + + storeKey := s.hub.StoreAccountKey("") + storeKeyAt := storeKey.At() + storeKeyAt.Revision = asserts.RevisionNotKnown + + err := pool.AddToUpdate(storeKey.Ref(), "for_one") + c.Assert(err, IsNil) + err = pool.AddUnresolved(storeKeyAt, "for_one") + c.Assert(err, IsNil) + + toResolve, toResolveSeq, err := pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, DeepEquals, map[asserts.Grouping][]*asserts.AtRevision{ + asserts.MakePoolGrouping(0): {storeKey.At()}, + }) + c.Check(toResolveSeq, HasLen, 0) +} + +func (s *poolSuite) TestAddUnresolvedThenToUpdate(c *C) { + assertstest.AddMany(s.db, s.hub.StoreAccountKey("")) + + pool := asserts.NewPool(s.db, 64) + + storeKey := s.hub.StoreAccountKey("") + storeKeyAt := storeKey.At() + storeKeyAt.Revision = asserts.RevisionNotKnown + + err := pool.AddUnresolved(storeKeyAt, "for_one") + c.Assert(err, IsNil) + err = pool.AddToUpdate(storeKey.Ref(), "for_one") + c.Assert(err, IsNil) + + toResolve, toResolveSeq, err := pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, DeepEquals, map[asserts.Grouping][]*asserts.AtRevision{ + asserts.MakePoolGrouping(0): {storeKey.At()}, + }) + c.Check(toResolveSeq, HasLen, 0) +} + +func (s *poolSuite) TestNopUpdatePlusFetch(c *C) { + assertstest.AddMany(s.db, s.hub.StoreAccountKey("")) + + pool := asserts.NewPool(s.db, 64) + + storeKey := s.hub.StoreAccountKey("") + err := pool.AddToUpdate(storeKey.Ref(), "store_key") + c.Assert(err, IsNil) + + at1111 := &asserts.AtRevision{ + Ref: asserts.Ref{Type: asserts.TestOnlyRevType, PrimaryKey: []string{"1111"}}, + Revision: asserts.RevisionNotKnown, + } + err = pool.AddUnresolved(at1111, "for_one") + c.Assert(err, IsNil) + + toResolve, toResolveSeq, err := pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, DeepEquals, map[asserts.Grouping][]*asserts.AtRevision{ + asserts.MakePoolGrouping(0): {storeKey.At()}, + asserts.MakePoolGrouping(1): {at1111}, + }) + c.Check(toResolveSeq, HasLen, 0) + + ok, err := pool.Add(s.rev1_1111, asserts.MakePoolGrouping(1)) + c.Assert(err, IsNil) + c.Assert(ok, Equals, true) + + toResolve, toResolveSeq, err = pool.ToResolve() + c.Assert(err, IsNil) + sortToResolve(toResolve) + dev1AcctAt := s.dev1Acct.At() + dev1AcctAt.Revision = asserts.RevisionNotKnown + decl1At := s.decl1.At() + decl1At.Revision = asserts.RevisionNotKnown + c.Check(toResolve, DeepEquals, map[asserts.Grouping][]*asserts.AtRevision{ + asserts.MakePoolGrouping(1): {dev1AcctAt, decl1At}, + }) + c.Check(toResolveSeq, HasLen, 0) + + c.Check(pool.Err("store_key"), IsNil) + c.Check(pool.Err("for_one"), IsNil) +} + +func (s *poolSuite) TestParallelPartialResolutionFailure(c *C) { + pool := asserts.NewPool(s.db, 64) + + atOne := &asserts.AtRevision{ + Ref: asserts.Ref{Type: asserts.TestOnlyDeclType, PrimaryKey: []string{"one"}}, + Revision: asserts.RevisionNotKnown, + } + err := pool.AddUnresolved(atOne, "one") + c.Assert(err, IsNil) + + toResolve, toResolveSeq, err := pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, DeepEquals, map[asserts.Grouping][]*asserts.AtRevision{ + asserts.MakePoolGrouping(0): {atOne}, + }) + c.Check(toResolveSeq, HasLen, 0) + + ok, err := pool.Add(s.decl1, asserts.MakePoolGrouping(0)) + c.Assert(err, IsNil) + c.Assert(ok, Equals, true) + + toResolve, toResolveSeq, err = pool.ToResolve() + c.Assert(err, IsNil) + sortToResolve(toResolve) + dev1AcctAt := s.dev1Acct.At() + dev1AcctAt.Revision = asserts.RevisionNotKnown + decl1At := s.decl1.At() + decl1At.Revision = asserts.RevisionNotKnown + storeKeyAt := s.hub.StoreAccountKey("").At() + storeKeyAt.Revision = asserts.RevisionNotKnown + c.Check(toResolve, DeepEquals, map[asserts.Grouping][]*asserts.AtRevision{ + asserts.MakePoolGrouping(0): {storeKeyAt, dev1AcctAt}, + }) + c.Check(toResolveSeq, HasLen, 0) + + // failed to get prereqs + c.Check(pool.AddGroupingError(errBoom, asserts.MakePoolGrouping(0)), IsNil) + + err = pool.AddUnresolved(atOne, "other") + c.Assert(err, IsNil) + + toResolve, toResolveSeq, err = pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, HasLen, 0) + c.Check(toResolveSeq, HasLen, 0) + + c.Check(pool.Err("one"), Equals, errBoom) + c.Check(pool.Err("other"), IsNil) + + // we fail at commit though + err = pool.CommitTo(s.db) + c.Check(err, IsNil) + c.Check(pool.Err("one"), Equals, errBoom) + c.Check(pool.Err("other"), ErrorMatches, "cannot resolve prerequisite assertion.*") +} + +func (s *poolSuite) TestAddErrors(c *C) { + assertstest.AddMany(s.db, s.hub.StoreAccountKey("")) + + pool := asserts.NewPool(s.db, 64) + + storeKey := s.hub.StoreAccountKey("") + err := pool.AddToUpdate(storeKey.Ref(), "store_key") + c.Assert(err, IsNil) + + at1111 := &asserts.AtRevision{ + Ref: asserts.Ref{Type: asserts.TestOnlyRevType, PrimaryKey: []string{"1111"}}, + Revision: asserts.RevisionNotKnown, + } + err = pool.AddUnresolved(at1111, "for_one") + c.Assert(err, IsNil) + + seq1111 := &asserts.AtSequence{ + Type: asserts.TestOnlySeqType, + SequenceKey: []string{"1111"}, + Revision: asserts.RevisionNotKnown, + } + err = pool.AddUnresolvedSequence(seq1111, "for_two") + c.Assert(err, IsNil) + + toResolve, toResolveSeq, err := pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, HasLen, 2) + c.Check(toResolveSeq, HasLen, 1) + + err = pool.AddError(errBoom, storeKey.Ref()) + c.Assert(err, IsNil) + + toResolve, toResolveSeq, err = pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, HasLen, 0) + c.Check(toResolveSeq, HasLen, 0) + + c.Check(pool.Errors(), DeepEquals, map[string]error{ + "store_key": errBoom, + "for_one": asserts.ErrUnresolved, + "for_two": asserts.ErrUnresolved, + }) +} + +func (s *poolSuite) TestPoolReuseWithClearGroupsAndUnchanged(c *C) { + assertstest.AddMany(s.db, s.hub.StoreAccountKey("")) + assertstest.AddMany(s.db, s.dev1Acct, s.decl1) + assertstest.AddMany(s.db, s.dev2Acct, s.decl2) + + pool := asserts.NewPool(s.db, 64) + + err := pool.AddToUpdate(s.decl1.Ref(), "for_one") // group num: 0 + c.Assert(err, IsNil) + + storeKeyAt := s.hub.StoreAccountKey("").At() + + toResolve, toResolveSeq, err := pool.ToResolve() + c.Assert(err, IsNil) + sortToResolve(toResolve) + c.Check(toResolve, DeepEquals, map[asserts.Grouping][]*asserts.AtRevision{ + asserts.MakePoolGrouping(0): {storeKeyAt, s.dev1Acct.At(), s.decl1.At()}, + }) + c.Check(toResolveSeq, HasLen, 0) + + ok, err := pool.Add(s.decl1_1, asserts.MakePoolGrouping(0)) + c.Assert(err, IsNil) + c.Assert(ok, Equals, true) + + toResolve, toResolveSeq, err = pool.ToResolve() + c.Assert(err, IsNil) + c.Check(toResolve, HasLen, 0) + c.Check(toResolveSeq, HasLen, 0) + + // clear the groups as we would do for real reuse when we have + // exhausted allowed groups + err = pool.ClearGroups() + c.Assert(err, IsNil) + + err = pool.AddToUpdate(s.decl2.Ref(), "for_two") // group num: 0 again + c.Assert(err, IsNil) + + // no reference to store key because it is remebered as unchanged + // across the clearing + toResolve, toResolveSeq, err = pool.ToResolve() + c.Assert(err, IsNil) + sortToResolve(toResolve) + c.Check(toResolve, DeepEquals, map[asserts.Grouping][]*asserts.AtRevision{ + asserts.MakePoolGrouping(0): {s.dev2Acct.At(), s.decl2.At()}, + }) + c.Check(toResolveSeq, HasLen, 0) +} + +func (s *poolSuite) TestBackstore(c *C) { + assertstest.AddMany(s.db, s.hub.StoreAccountKey(""), s.dev1Acct) + pool := asserts.NewPool(s.db, 64) + + at1111 := &asserts.AtRevision{ + Ref: asserts.Ref{Type: asserts.TestOnlyRevType, PrimaryKey: []string{"1111"}}, + Revision: asserts.RevisionNotKnown, + } + c.Assert(pool.AddUnresolved(at1111, "for_one"), IsNil) + res, _, err := pool.ToResolve() + c.Assert(err, IsNil) + c.Assert(res, HasLen, 1) + + // resolve (but do not commit) + ok, err := pool.Add(s.rev1_1111, asserts.MakePoolGrouping(0)) + c.Assert(err, IsNil) + c.Assert(ok, Equals, true) + + // the assertion should be available via pool's backstore + bs := pool.Backstore() + c.Assert(bs, NotNil) + a, err := bs.Get(s.rev1_1111.Type(), s.rev1_1111.At().PrimaryKey, s.rev1_1111.Type().MaxSupportedFormat()) + c.Assert(err, IsNil) + c.Assert(a, NotNil) +} diff --git a/asserts/preseed.go b/asserts/preseed.go new file mode 100644 index 00000000..53a4a32b --- /dev/null +++ b/asserts/preseed.go @@ -0,0 +1,226 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2023 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts + +import ( + "crypto" + "fmt" + "regexp" + "time" + + "github.com/snapcore/snapd/snap/naming" +) + +// validSystemLabel is the regex describing a valid system label. Typically +// system labels are expected to be date based, eg. 20201116, but for +// completeness follow the same rule as model names (incl. one letter model +// names and thus system labels), with the exception that uppercase letters are +// not allowed, as the systems will often be stored in a FAT filesystem. +var validSystemLabel = regexp.MustCompile("^[a-z0-9](?:-?[a-z0-9])*$") + +// IsValidSystemLabel checks whether the string is a valid UC20 seed system +// label. +func IsValidSystemLabel(label string) error { + if !validSystemLabel.MatchString(label) { + return fmt.Errorf("invalid seed system label: %q", label) + } + return nil +} + +// PreseedSnap holds the details about a snap constrained by a preseed assertion. +type PreseedSnap struct { + Name string + SnapID string + Revision int +} + +// SnapName implements naming.SnapRef. +func (s *PreseedSnap) SnapName() string { + return s.Name +} + +// ID implements naming.SnapRef. +func (s *PreseedSnap) ID() string { + return s.SnapID +} + +// Preseed holds preseed assertion, which is a statement about system-label, +// model, set of snaps and preseed artifact used for preseeding of UC20 system. +type Preseed struct { + assertionBase + snaps []*PreseedSnap + timestamp time.Time +} + +// Series returns the series that this assertion is valid for. +func (p *Preseed) Series() string { + return p.HeaderString("series") +} + +// BrandID returns the brand identifier. +func (p *Preseed) BrandID() string { + return p.HeaderString("brand-id") +} + +// Model returns the model name identifier. +func (p *Preseed) Model() string { + return p.HeaderString("model") +} + +// SystemLabel returns the label of the seeded system. +func (p *Preseed) SystemLabel() string { + return p.HeaderString("system-label") +} + +// Timestamp returns the time when the preseed assertion was issued. +func (p *Preseed) Timestamp() time.Time { + return p.timestamp +} + +// ArtifactSHA3_384 returns the checksum of preseeding artifact. +func (p *Preseed) ArtifactSHA3_384() string { + return p.HeaderString("artifact-sha3-384") +} + +// Snaps returns the snaps for preseeding. +func (p *Preseed) Snaps() []*PreseedSnap { + return p.snaps +} + +func checkPreseedSnap(snap map[string]interface{}) (*PreseedSnap, error) { + name, err := checkNotEmptyStringWhat(snap, "name", "of snap") + if err != nil { + return nil, err + } + if err := naming.ValidateSnap(name); err != nil { + return nil, fmt.Errorf("invalid snap name %q", name) + } + + what := fmt.Sprintf("of snap %q", name) + + // snap id can be omitted if the model allows for unasserted snaps + var snapID string + if _, ok := snap["id"]; ok { + snapID, err = checkStringMatchesWhat(snap, "id", what, naming.ValidSnapID) + if err != nil { + return nil, err + } + } + + var snapRevision int + if _, ok := snap["revision"]; ok { + var err error + snapRevision, err = checkSnapRevisionWhat(snap, "revision", what) + if err != nil { + return nil, err + } + } + + if snapID != "" && snapRevision <= 0 { + return nil, fmt.Errorf("snap revision is required when snap id is set") + } + if snapID == "" && snapRevision > 0 { + return nil, fmt.Errorf("snap id is required when revision is set") + } + + return &PreseedSnap{ + Name: name, + SnapID: snapID, + Revision: snapRevision, + }, nil +} + +func checkPreseedSnaps(snapList interface{}) ([]*PreseedSnap, error) { + const wrongHeaderType = `"snaps" header must be a list of maps` + + entries, ok := snapList.([]interface{}) + if !ok { + return nil, fmt.Errorf(wrongHeaderType) + } + + seen := make(map[string]bool, len(entries)) + seenIDs := make(map[string]string, len(entries)) + snaps := make([]*PreseedSnap, 0, len(entries)) + for _, entry := range entries { + snap, ok := entry.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf(wrongHeaderType) + } + preseedSnap, err := checkPreseedSnap(snap) + if err != nil { + return nil, err + } + + if seen[preseedSnap.Name] { + return nil, fmt.Errorf("cannot list the same snap %q multiple times", preseedSnap.Name) + } + seen[preseedSnap.Name] = true + snapID := preseedSnap.SnapID + if snapID != "" { + if underName := seenIDs[snapID]; underName != "" { + return nil, fmt.Errorf("cannot specify the same snap id %q multiple times, specified for snaps %q and %q", snapID, underName, preseedSnap.Name) + } + seenIDs[snapID] = preseedSnap.Name + } + snaps = append(snaps, preseedSnap) + } + + return snaps, nil +} + +func assemblePreseed(assert assertionBase) (Assertion, error) { + // because the authority-id and model-id can differ (as per the model), + // authority-id should be validated against allowed IDs when the preseed + // blob is being checked + + _, err := checkModel(assert.headers) + if err != nil { + return nil, err + } + + _, err = checkStringMatches(assert.headers, "system-label", validSystemLabel) + if err != nil { + return nil, err + } + + snapList, ok := assert.headers["snaps"] + if !ok { + return nil, fmt.Errorf(`"snaps" header is mandatory`) + } + snaps, err := checkPreseedSnaps(snapList) + if err != nil { + return nil, err + } + + _, err = checkDigest(assert.headers, "artifact-sha3-384", crypto.SHA3_384) + if err != nil { + return nil, err + } + + timestamp, err := checkRFC3339Date(assert.headers, "timestamp") + if err != nil { + return nil, err + } + return &Preseed{ + assertionBase: assert, + snaps: snaps, + timestamp: timestamp, + }, nil +} diff --git a/asserts/preseed_test.go b/asserts/preseed_test.go new file mode 100644 index 00000000..363ef854 --- /dev/null +++ b/asserts/preseed_test.go @@ -0,0 +1,195 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2023 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts_test + +import ( + "fmt" + "strings" + "time" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/asserts" +) + +type preseedSuite struct { + ts time.Time + tsLine string +} + +var _ = Suite(&preseedSuite{}) + +func (ps *preseedSuite) SetUpSuite(c *C) { + ps.ts = time.Now().Truncate(time.Second).UTC() + ps.tsLine = "timestamp: " + ps.ts.Format(time.RFC3339) + "\n" +} + +const ( + preseedExample = `type: preseed +authority-id: brand-id1 +series: 16 +brand-id: brand-id1 +model: baz-3000 +system-label: 20220210 +artifact-sha3-384: KPIl7M4vQ9d4AUjkoU41TGAwtOMLc_bWUCeW8AvdRWD4_xcP60Oo4ABs1No7BtXj +snaps: + - + name: baz-linux + id: bazlinuxidididididididididididid + revision: 99 +OTHER` + "TSLINE" + + "body-length: 0\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==" +) + +func (ps *preseedSuite) TestValidateSeedSystemLabel(c *C) { + valid := []string{ + "a", + "ab", + "a-a", + "a-123", + "a-a-a", + "20191119", + "foobar", + "my-system", + "brand-system-date-1234", + } + for _, label := range valid { + c.Logf("trying valid label: %q", label) + err := asserts.IsValidSystemLabel(label) + c.Check(err, IsNil) + } + + invalid := []string{ + "", + "/bin", + "../../bin/bar", + ":invalid:", + "日本語", + "-invalid", + "invalid-", + "MYSYSTEM", + "mySystem", + } + for _, label := range invalid { + c.Logf("trying invalid label: %q", label) + err := asserts.IsValidSystemLabel(label) + c.Check(err, ErrorMatches, fmt.Sprintf("invalid seed system label: %q", label)) + } +} + +func (ps *preseedSuite) TestDecodeOK(c *C) { + encoded := strings.Replace(preseedExample, "TSLINE", ps.tsLine, 1) + encoded = strings.Replace(encoded, "OTHER", "", 1) + + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + c.Check(a.Type(), Equals, asserts.PreseedType) + preseed := a.(*asserts.Preseed) + c.Check(preseed.AuthorityID(), Equals, "brand-id1") + c.Check(preseed.Timestamp(), Equals, ps.ts) + c.Check(preseed.Series(), Equals, "16") + c.Check(preseed.BrandID(), Equals, "brand-id1") + c.Check(preseed.Model(), Equals, "baz-3000") + c.Check(preseed.SystemLabel(), Equals, "20220210") + c.Check(preseed.ArtifactSHA3_384(), Equals, "KPIl7M4vQ9d4AUjkoU41TGAwtOMLc_bWUCeW8AvdRWD4_xcP60Oo4ABs1No7BtXj") + snaps := preseed.Snaps() + c.Assert(snaps, DeepEquals, []*asserts.PreseedSnap{ + { + Name: "baz-linux", + SnapID: "bazlinuxidididididididididididid", + Revision: 99, + }, + }) + c.Check(snaps[0].SnapName(), Equals, "baz-linux") + c.Check(snaps[0].ID(), Equals, "bazlinuxidididididididididididid") +} + +func (ps *preseedSuite) TestDecodeInvalid(c *C) { + const errPrefix = "assertion preseed: " + + encoded := strings.Replace(preseedExample, "TSLINE", ps.tsLine, 1) + + snapsStanza := encoded[strings.Index(encoded, "snaps:"):strings.Index(encoded, "timestamp:")] + + invalidTests := []struct{ original, invalid, expectedErr string }{ + {"series: 16\n", "", `"series" header is mandatory`}, + {"series: 16\n", "series: \n", `"series" header should not be empty`}, + {"model: baz-3000\n", "model: \n", `"model" header should not be empty`}, + {"model: baz-3000\n", "model: -\n", `"model" header contains invalid characters: "-"`}, + {"brand-id: brand-id1\n", "", `"brand-id" header is mandatory`}, + {"brand-id: brand-id1\n", "brand-id: \n", `"brand-id" header should not be empty`}, + {"system-label: 20220210\n", "system-label: \n", `"system-label" header should not be empty`}, + {"system-label: 20220210\n", "system-label: -x\n", `"system-label" header contains invalid characters: "-x"`}, + {ps.tsLine, "timestamp: 12:30\n", `"timestamp" header is not a RFC3339 date: .*`}, + {"artifact-sha3-384: KPIl7M4vQ9d4AUjkoU41TGAwtOMLc_bWUCeW8AvdRWD4_xcP60Oo4ABs1No7BtXj\n", "artifact-sha3-384: 1\n", `"artifact-sha3-384" header cannot be decoded: illegal base64 data at input byte 0`}, + {"revision: 99\n", "revision: 0\n", `"revision" of snap "baz-linux" must be >=1: 0`}, + {snapsStanza, "", `"snaps" header is mandatory`}, + {snapsStanza, "snaps: snap\n", `"snaps" header must be a list of maps`}, + {snapsStanza, "snaps:\n - snap\n", `"snaps" header must be a list of maps`}, + {"name: baz-linux\n", "other: 1\n", `"name" of snap is mandatory`}, + {"name: baz-linux\n", "name: linux_2\n", `invalid snap name "linux_2"`}, + {"id: bazlinuxidididididididididididid\n", "id: 2\n", `"id" of snap "baz-linux" contains invalid characters: "2"`}, + {"OTHER", " -\n name: baz-linux\n id: bazlinuxidididididididididididid\n revision: 1\n", `cannot list the same snap "baz-linux" multiple times`}, + {"OTHER", " -\n name: baz-linux2\n id: bazlinuxidididididididididididid\n revision: 1\n", `cannot specify the same snap id "bazlinuxidididididididididididid" multiple times, specified for snaps "baz-linux" and "baz-linux2"`}, + } + + for _, test := range invalidTests { + invalid := strings.Replace(encoded, test.original, test.invalid, 1) + invalid = strings.Replace(invalid, "OTHER", "", 1) + _, err := asserts.Decode([]byte(invalid)) + c.Check(err, ErrorMatches, errPrefix+test.expectedErr) + } + +} + +func (ps *preseedSuite) TestSnapRevisionImpliesSnapId(c *C) { + encoded := strings.Replace(preseedExample, "TSLINE", ps.tsLine, 1) + encoded = strings.Replace(encoded, "OTHER", "", 1) + encoded = strings.Replace(encoded, " revision: 99\n", "", 1) + + _, err := asserts.Decode([]byte(encoded)) + c.Assert(err, ErrorMatches, `assertion preseed: snap revision is required when snap id is set`) +} + +func (ps *preseedSuite) TestSnapIdImpliesRevision(c *C) { + encoded := strings.Replace(preseedExample, "TSLINE", ps.tsLine, 1) + encoded = strings.Replace(encoded, "OTHER", "", 1) + encoded = strings.Replace(encoded, " id: bazlinuxidididididididididididid\n", "", 1) + + _, err := asserts.Decode([]byte(encoded)) + c.Assert(err, ErrorMatches, `assertion preseed: snap id is required when revision is set`) +} + +func (ps *preseedSuite) TestSnapIdOptional(c *C) { + encoded := strings.Replace(preseedExample, "TSLINE", ps.tsLine, 1) + encoded = strings.Replace(encoded, "OTHER", " -\n name: foo-linux\n", 1) + encoded = strings.Replace(encoded, " revision: 99\n", "", 1) + encoded = strings.Replace(encoded, " id: bazlinuxidididididididididididid\n", "", 1) + + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + snaps := a.(*asserts.Preseed).Snaps() + c.Assert(snaps, HasLen, 2) + c.Check(snaps[0].Name, Equals, "baz-linux") + c.Check(snaps[1].Name, Equals, "foo-linux") +} diff --git a/asserts/privkeys_for_test.go b/asserts/privkeys_for_test.go new file mode 100644 index 00000000..ec433f00 --- /dev/null +++ b/asserts/privkeys_for_test.go @@ -0,0 +1,54 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2015 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts_test + +import ( + "encoding/base64" + + "golang.org/x/crypto/openpgp/packet" + "golang.org/x/crypto/sha3" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/asserts/assertstest" +) + +// private keys to use in tests +var ( + // use a shorter key length here for test keys because otherwise + // they take too long to generate; + // the ones that care use pregenerated keys of the right length + // or use GenerateKey directly + testPrivKey0, _ = assertstest.GenerateKey(752) + testPrivKey1, testPrivKey1RSA = assertstest.GenerateKey(752) + testPrivKey2, _ = assertstest.GenerateKey(752) + + testPrivKey1SHA3_384 string +) + +func init() { + pkt := packet.NewRSAPrivateKey(asserts.V1FixedTimestamp, testPrivKey1RSA) + h := sha3.New384() + h.Write([]byte{0x1}) + err := pkt.PublicKey.Serialize(h) + if err != nil { + panic(err) + } + testPrivKey1SHA3_384 = base64.RawURLEncoding.EncodeToString(h.Sum(nil)) +} diff --git a/asserts/repair.go b/asserts/repair.go new file mode 100644 index 00000000..c1a93ef4 --- /dev/null +++ b/asserts/repair.go @@ -0,0 +1,218 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts + +import ( + "fmt" + "strings" + "time" + + "github.com/snapcore/snapd/snap/naming" + "github.com/snapcore/snapd/strutil" +) + +// Repair holds an repair assertion which allows running repair +// code to fixup broken systems. It can be limited by series and models, as well +// as by bases and modes. +type Repair struct { + assertionBase + + series []string + architectures []string + models []string + + modes []string + bases []string + + id int + + disabled bool + timestamp time.Time +} + +// BrandID returns the brand identifier that signed this assertion. +func (r *Repair) BrandID() string { + return r.HeaderString("brand-id") +} + +// RepairID returns the sequential id of the repair. There +// should be a public place to look up details about the repair +// by brand-id and repair-id. +// (e.g. the snapcraft forum). +func (r *Repair) RepairID() int { + return r.id +} + +// Sequence implements SequenceMember, it returns the same as RepairID. +func (r *Repair) Sequence() int { + return r.RepairID() +} + +// Summary returns the mandatory summary description of the repair. +func (r *Repair) Summary() string { + return r.HeaderString("summary") +} + +// Architectures returns the architectures that this assertions applies to. +func (r *Repair) Architectures() []string { + return r.architectures +} + +// Series returns the series that this assertion is valid for. +func (r *Repair) Series() []string { + return r.series +} + +// Modes returns the modes that this assertion is valid for. It is either a list +// of "run", "recover", or "install", or it is the empty list. The empty list +// is interpreted to mean only "run" mode. +func (r *Repair) Modes() []string { + return r.modes +} + +// Bases returns the bases that this assertion is valid for. It is either a list +// of valid base snaps that Ubuntu Core systems can have or it is the empty +// list. The empty list effectively means all Ubuntu Core systems while "core" +// means Ubuntu Core 16, "core18" means Ubuntu Core 18, etc. +func (r *Repair) Bases() []string { + return r.bases +} + +// Models returns the models that this assertion is valid for. +// It is a list of "brand-id/model-name" strings. +func (r *Repair) Models() []string { + return r.models +} + +// Disabled returns true if the repair has been disabled. +func (r *Repair) Disabled() bool { + return r.disabled +} + +// Timestamp returns the time when the repair was issued. +func (r *Repair) Timestamp() time.Time { + return r.timestamp +} + +// Implement further consistency checks. +func (r *Repair) checkConsistency(db RODatabase, acck *AccountKey) error { + // Do the cross-checks when this assertion is actually used, + // i.e. in the future repair code + + return nil +} + +// expected interface is implemented +var _ consistencyChecker = (*Repair)(nil) + +func assembleRepair(assert assertionBase) (Assertion, error) { + err := checkAuthorityMatchesBrand(&assert) + if err != nil { + return nil, err + } + + repairID, err := checkSequence(assert.headers, "repair-id") + if err != nil { + return nil, err + } + + summary, err := checkNotEmptyString(assert.headers, "summary") + if err != nil { + return nil, err + } + if strings.ContainsAny(summary, "\n\r") { + return nil, fmt.Errorf(`"summary" header cannot have newlines`) + } + + series, err := checkStringList(assert.headers, "series") + if err != nil { + return nil, err + } + models, err := checkStringList(assert.headers, "models") + if err != nil { + return nil, err + } + architectures, err := checkStringList(assert.headers, "architectures") + if err != nil { + return nil, err + } + modes, err := checkStringList(assert.headers, "modes") + if err != nil { + return nil, err + } + bases, err := checkStringList(assert.headers, "bases") + if err != nil { + return nil, err + } + + // validate that all base snap names are valid snap names + for _, b := range bases { + if err := naming.ValidateSnap(b); err != nil { + return nil, fmt.Errorf("invalid snap name %q in \"bases\"", b) + } + } + + // verify that modes is a list of only "run" and "recover" + if len(modes) != 0 { + for _, m := range modes { + // note that we could import boot here to use i.e. boot.ModeRun, but + // that is rather a heavy package considering that this package is + // used in many places, so instead just use the values directly, + // they're unlikely to change now + if !strutil.ListContains([]string{"run", "recover"}, m) { + return nil, fmt.Errorf("header \"modes\" contains an invalid element: %q (valid values are run and recover)", m) + } + } + + // if modes is non-empty, then bases must be core2X, i.e. core20+ + // however, we don't know what future bases could be UC20-like and named + // differently yet, so we just fail on bases that we know as of today + // are _not_ UC20: core and core18 + + for _, b := range bases { + // fail on uc16 and uc18 base snaps + if b == "core" || b == "core18" || b == "core16" { + return nil, fmt.Errorf("in the presence of a non-empty \"modes\" header, \"bases\" must only contain base snaps supporting recovery modes") + } + } + } + + disabled, err := checkOptionalBool(assert.headers, "disabled") + if err != nil { + return nil, err + } + + timestamp, err := checkRFC3339Date(assert.headers, "timestamp") + if err != nil { + return nil, err + } + + return &Repair{ + assertionBase: assert, + series: series, + architectures: architectures, + models: models, + modes: modes, + bases: bases, + id: repairID, + disabled: disabled, + timestamp: timestamp, + }, nil +} diff --git a/asserts/repair_test.go b/asserts/repair_test.go new file mode 100644 index 00000000..ee2e6b5b --- /dev/null +++ b/asserts/repair_test.go @@ -0,0 +1,368 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts_test + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/asserts" +) + +var ( + _ = Suite(&repairSuite{}) +) + +type repairSuite struct { + modelsLine string + ts time.Time + tsLine string + basesLine string + modesLine string + + repairStr string +} + +const script = `#!/bin/sh +set -e +echo "Unpack embedded payload" +match=$(grep --text --line-number '^PAYLOAD:$' $0 | cut -d ':' -f 1) +payload_start=$((match + 1)) +# Using "base64" as its part of coreutils which should be available +# everywhere +tail -n +$payload_start $0 | base64 --decode - | tar -xzf - +# run embedded content +./hello +exit 0 +# payload generated with, may contain binary data +# printf '#!/bin/sh\necho hello from the inside\n' > hello +# chmod +x hello +# tar czf - hello | base64 - +PAYLOAD: +H4sIAJJt+FgAA+3STQrCMBDF8ax7ihEP0CkxyXn8iCZQE2jr/W11Iwi6KiL8f5u3mLd4i0mx76tZ +l86Cc0t2welrPu2c6awGr95bG4x26rw1oivveriN034QMfFSy6fet/uf2m7aQy7tmJp4TFXS8g5y +HupVphQllzGfYvPrkQAAAAAAAAAAAAAAAACAN3dTp9TNACgAAA== +` + +var repairExample = fmt.Sprintf("type: repair\n"+ + "authority-id: acme\n"+ + "brand-id: acme\n"+ + "summary: example repair\n"+ + "architectures:\n"+ + " - amd64\n"+ + " - arm64\n"+ + "repair-id: 42\n"+ + "series:\n"+ + " - 16\n"+ + "MODELSLINE"+ + "TSLINE"+ + "BASESLINE"+ + "MODESLINE"+ + "body-length: %v\n"+ + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij"+ + "\n\n"+ + script+"\n\n"+ + "AXNpZw==", len(script)) + +func (s *repairSuite) SetUpTest(c *C) { + s.modelsLine = "models:\n - acme/frobinator\n" + s.ts = time.Now().Truncate(time.Second).UTC() + s.tsLine = "timestamp: " + s.ts.Format(time.RFC3339) + "\n" + s.basesLine = "bases:\n - core20\n" + s.modesLine = "modes:\n - run\n" + + s.repairStr = strings.Replace(repairExample, "MODELSLINE", s.modelsLine, 1) + s.repairStr = strings.Replace(s.repairStr, "TSLINE", s.tsLine, 1) + s.repairStr = strings.Replace(s.repairStr, "BASESLINE", s.basesLine, 1) + s.repairStr = strings.Replace(s.repairStr, "MODESLINE", s.modesLine, 1) +} + +func (s *repairSuite) TestDecodeOK(c *C) { + a, err := asserts.Decode([]byte(s.repairStr)) + c.Assert(err, IsNil) + c.Check(a.Type(), Equals, asserts.RepairType) + _, ok := a.(asserts.SequenceMember) + c.Assert(ok, Equals, true) + repair := a.(*asserts.Repair) + c.Check(repair.Timestamp(), Equals, s.ts) + c.Check(repair.BrandID(), Equals, "acme") + c.Check(repair.RepairID(), Equals, 42) + c.Check(repair.Sequence(), Equals, 42) + c.Check(repair.Summary(), Equals, "example repair") + c.Check(repair.Series(), DeepEquals, []string{"16"}) + c.Check(repair.Bases(), DeepEquals, []string{"core20"}) + c.Check(repair.Modes(), DeepEquals, []string{"run"}) + c.Check(repair.Architectures(), DeepEquals, []string{"amd64", "arm64"}) + c.Check(repair.Models(), DeepEquals, []string{"acme/frobinator"}) + c.Check(string(repair.Body()), Equals, script) +} + +const ( + repairErrPrefix = "assertion repair: " +) + +func (s *repairSuite) TestDisabled(c *C) { + disabledTests := []struct { + disabled, expectedErr string + dis bool + }{ + {"true", "", true}, + {"false", "", false}, + {"foo", `"disabled" header must be 'true' or 'false'`, false}, + } + + for _, test := range disabledTests { + repairStr := strings.Replace(repairExample, "MODELSLINE", fmt.Sprintf("disabled: %s\n", test.disabled), 1) + repairStr = strings.Replace(repairStr, "TSLINE", s.tsLine, 1) + repairStr = strings.Replace(repairStr, "BASESLINE", "", 1) + repairStr = strings.Replace(repairStr, "MODESLINE", "", 1) + + a, err := asserts.Decode([]byte(repairStr)) + if test.expectedErr != "" { + c.Check(err, ErrorMatches, repairErrPrefix+test.expectedErr) + } else { + c.Assert(err, IsNil) + repair := a.(*asserts.Repair) + c.Check(repair.Disabled(), Equals, test.dis) + } + } +} + +func (s *repairSuite) TestDecodeModesAndBases(c *C) { + tt := []struct { + comment string + bases []string + modes []string + expbases []string + expmodes []string + err string + }{ + // happy uc20+ cases + { + comment: "core20 base with run mode", + bases: []string{"core20"}, + modes: []string{"run"}, + expbases: []string{"core20"}, + expmodes: []string{"run"}, + }, + { + comment: "core20 base with recover mode", + bases: []string{"core20"}, + modes: []string{"recover"}, + expbases: []string{"core20"}, + expmodes: []string{"recover"}, + }, + { + comment: "core20 base with recover and run modes", + bases: []string{"core20"}, + modes: []string{"recover", "run"}, + expbases: []string{"core20"}, + expmodes: []string{"recover", "run"}, + }, + { + comment: "core22 base with run mode", + bases: []string{"core22"}, + modes: []string{"run"}, + expbases: []string{"core22"}, + expmodes: []string{"run"}, + }, + { + comment: "core20 and core22 bases with run mode", + bases: []string{"core20", "core22"}, + modes: []string{"run"}, + expbases: []string{"core20", "core22"}, + expmodes: []string{"run"}, + }, + { + comment: "core20 and core22 bases with run and recover modes", + bases: []string{"core20", "core22"}, + modes: []string{"run", "recover"}, + expbases: []string{"core20", "core22"}, + expmodes: []string{"run", "recover"}, + }, + { + comment: "all bases with run mode (effectively all uc20 bases)", + modes: []string{"run"}, + expmodes: []string{"run"}, + }, + { + comment: "all bases with recover mode (effectively all uc20 bases)", + modes: []string{"recover"}, + expmodes: []string{"recover"}, + }, + { + comment: "core20 base with empty modes", + bases: []string{"core20"}, + expbases: []string{"core20"}, + }, + { + comment: "core22 base with empty modes", + bases: []string{"core22"}, + expbases: []string{"core22"}, + }, + + // unhappy uc20 cases + { + comment: "core20 base with single invalid mode", + bases: []string{"core20"}, + modes: []string{"not-a-real-uc20-mode"}, + err: `assertion repair: header \"modes\" contains an invalid element: \"not-a-real-uc20-mode\" \(valid values are run and recover\)`, + }, + { + comment: "core20 base with invalid modes", + bases: []string{"core20"}, + modes: []string{"run", "not-a-real-uc20-mode"}, + err: `assertion repair: header \"modes\" contains an invalid element: \"not-a-real-uc20-mode\" \(valid values are run and recover\)`, + }, + { + comment: "core20 base with install mode", + bases: []string{"core20"}, + modes: []string{"install"}, + err: `assertion repair: header \"modes\" contains an invalid element: \"install\" \(valid values are run and recover\)`, + }, + + // happy uc18/uc16 cases + { + comment: "core18 base with empty modes", + bases: []string{"core18"}, + expbases: []string{"core18"}, + }, + { + comment: "core base with empty modes", + bases: []string{"core"}, + expbases: []string{"core"}, + }, + + // unhappy uc18/uc16 cases + { + comment: "core18 base with non-empty modes", + bases: []string{"core18"}, + modes: []string{"run"}, + err: "assertion repair: in the presence of a non-empty \"modes\" header, \"bases\" must only contain base snaps supporting recovery modes", + }, + { + comment: "core base with non-empty modes", + bases: []string{"core"}, + modes: []string{"run"}, + err: "assertion repair: in the presence of a non-empty \"modes\" header, \"bases\" must only contain base snaps supporting recovery modes", + }, + { + comment: "core16 base with non-empty modes", + bases: []string{"core16"}, + modes: []string{"run"}, + err: "assertion repair: in the presence of a non-empty \"modes\" header, \"bases\" must only contain base snaps supporting recovery modes", + }, + + // unhappy non-core specific cases + { + comment: "invalid snap name as base", + bases: []string{"foo....bar"}, + err: "assertion repair: invalid snap name \"foo....bar\" in \"bases\"", + }, + } + + for _, t := range tt { + comment := Commentf(t.comment) + repairStr := strings.Replace(repairExample, "MODELSLINE", s.modelsLine, 1) + repairStr = strings.Replace(repairStr, "TSLINE", s.tsLine, 1) + + var basesStr, modesStr string + if len(t.bases) != 0 { + basesStr = "bases:\n" + for _, b := range t.bases { + basesStr += " - " + b + "\n" + } + } + if len(t.modes) != 0 { + modesStr = "modes:\n" + for _, m := range t.modes { + modesStr += " - " + m + "\n" + } + } + + repairStr = strings.Replace(repairStr, "BASESLINE", basesStr, 1) + repairStr = strings.Replace(repairStr, "MODESLINE", modesStr, 1) + + assert, err := asserts.Decode([]byte(repairStr)) + if t.err != "" { + c.Assert(err, ErrorMatches, t.err, comment) + } else { + c.Assert(err, IsNil, comment) + repair, ok := assert.(*asserts.Repair) + c.Assert(ok, Equals, true, comment) + + c.Assert(repair.Bases(), DeepEquals, t.expbases, comment) + c.Assert(repair.Modes(), DeepEquals, t.expmodes, comment) + } + } +} + +func (s *repairSuite) TestDecodeInvalid(c *C) { + invalidTests := []struct{ original, invalid, expectedErr string }{ + {"series:\n - 16\n", "series: \n", `"series" header must be a list of strings`}, + {"series:\n - 16\n", "series: something\n", `"series" header must be a list of strings`}, + {"architectures:\n - amd64\n - arm64\n", "architectures: foo\n", `"architectures" header must be a list of strings`}, + {"models:\n - acme/frobinator\n", "models: \n", `"models" header must be a list of strings`}, + {"models:\n - acme/frobinator\n", "models: something\n", `"models" header must be a list of strings`}, + {"repair-id: 42\n", "repair-id: no-number\n", `"repair-id" header is not an integer: no-number`}, + {"repair-id: 42\n", "repair-id: 0\n", `"repair-id" must be >=1: 0`}, + {"repair-id: 42\n", "repair-id: 01\n", `"repair-id" header has invalid prefix zeros: 01`}, + {"repair-id: 42\n", "repair-id: 99999999999999999999\n", `"repair-id" header is out of range: 99999999999999999999`}, + {"brand-id: acme\n", "brand-id: brand-id-not-eq-authority-id\n", `authority-id and brand-id must match, repair assertions are expected to be signed by the brand: "acme" != "brand-id-not-eq-authority-id"`}, + {"summary: example repair\n", "", `"summary" header is mandatory`}, + {"summary: example repair\n", "summary: \n", `"summary" header should not be empty`}, + {"summary: example repair\n", "summary:\n multi\n line\n", `"summary" header cannot have newlines`}, + {s.tsLine, "", `"timestamp" header is mandatory`}, + {s.tsLine, "timestamp: \n", `"timestamp" header should not be empty`}, + {s.tsLine, "timestamp: 12:30\n", `"timestamp" header is not a RFC3339 date: .*`}, + } + + for _, test := range invalidTests { + invalid := strings.Replace(s.repairStr, test.original, test.invalid, 1) + _, err := asserts.Decode([]byte(invalid)) + c.Check(err, ErrorMatches, repairErrPrefix+test.expectedErr) + } +} + +// FIXME: move to a different layer later +func (s *repairSuite) TestRepairCanEmbedScripts(c *C) { + a, err := asserts.Decode([]byte(s.repairStr)) + c.Assert(err, IsNil) + c.Check(a.Type(), Equals, asserts.RepairType) + repair := a.(*asserts.Repair) + + tmpdir := c.MkDir() + repairScript := filepath.Join(tmpdir, "repair") + err = os.WriteFile(repairScript, []byte(repair.Body()), 0755) + c.Assert(err, IsNil) + cmd := exec.Command(repairScript) + cmd.Dir = tmpdir + output, err := cmd.CombinedOutput() + c.Check(err, IsNil) + c.Check(string(output), Equals, `Unpack embedded payload +hello from the inside +`) +} diff --git a/asserts/serial_asserts.go b/asserts/serial_asserts.go new file mode 100644 index 00000000..973de3b4 --- /dev/null +++ b/asserts/serial_asserts.go @@ -0,0 +1,251 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts + +import ( + "errors" + "fmt" + "time" + + "github.com/snapcore/snapd/release" + "github.com/snapcore/snapd/strutil" +) + +// Serial holds a serial assertion, which is a statement binding a +// device identity with the device public key. +type Serial struct { + assertionBase + timestamp time.Time + pubKey PublicKey +} + +// BrandID returns the brand identifier of the device. +func (ser *Serial) BrandID() string { + return ser.HeaderString("brand-id") +} + +// Model returns the model name identifier of the device. +func (ser *Serial) Model() string { + return ser.HeaderString("model") +} + +// Serial returns the serial identifier of the device, together with +// brand id and model they form the unique identifier of the device. +func (ser *Serial) Serial() string { + return ser.HeaderString("serial") +} + +// DeviceKey returns the public key of the device. +func (ser *Serial) DeviceKey() PublicKey { + return ser.pubKey +} + +// Timestamp returns the time when the serial assertion was issued. +func (ser *Serial) Timestamp() time.Time { + return ser.timestamp +} + +func (ser *Serial) checkConsistency(db RODatabase, acck *AccountKey) error { + if ser.AuthorityID() != ser.BrandID() { + // serial authority and brand do not match, check the model + a, err := db.Find(ModelType, map[string]string{ + "series": release.Series, + "brand-id": ser.BrandID(), + "model": ser.Model(), + }) + if err != nil && !errors.Is(err, &NotFoundError{}) { + return err + } + if errors.Is(err, &NotFoundError{}) || !strutil.ListContains(a.(*Model).SerialAuthority(), ser.AuthorityID()) { + return fmt.Errorf("serial with authority %q different from brand %q without model assertion with serial-authority set to to allow for them", ser.AuthorityID(), ser.BrandID()) + } + } + return nil +} + +func assembleSerial(assert assertionBase) (Assertion, error) { + // brand-id and authority-id can diverge if the model allows + // for it via serial-authority, check for brand-id well-formedness + _, err := checkStringMatches(assert.headers, "brand-id", validAccountID) + if err != nil { + return nil, err + } + + _, err = checkModel(assert.headers) + if err != nil { + return nil, err + } + + encodedKey, err := checkNotEmptyString(assert.headers, "device-key") + if err != nil { + return nil, err + } + pubKey, err := DecodePublicKey([]byte(encodedKey)) + if err != nil { + return nil, err + } + keyID, err := checkNotEmptyString(assert.headers, "device-key-sha3-384") + if err != nil { + return nil, err + } + if keyID != pubKey.ID() { + return nil, fmt.Errorf("device key does not match provided key id") + } + + timestamp, err := checkRFC3339Date(assert.headers, "timestamp") + if err != nil { + return nil, err + } + + // ignore extra headers and non-empty body for future compatibility + return &Serial{ + assertionBase: assert, + timestamp: timestamp, + pubKey: pubKey, + }, nil +} + +// SerialRequest holds a serial-request assertion, which is a self-signed request to obtain a full device identity bound to the device public key. +type SerialRequest struct { + assertionBase + pubKey PublicKey +} + +// BrandID returns the brand identifier of the device making the request. +func (sreq *SerialRequest) BrandID() string { + return sreq.HeaderString("brand-id") +} + +// Model returns the model name identifier of the device making the request. +func (sreq *SerialRequest) Model() string { + return sreq.HeaderString("model") +} + +// Serial returns the optional proposed serial identifier for the device, the service taking the request might use it or ignore it. +func (sreq *SerialRequest) Serial() string { + return sreq.HeaderString("serial") +} + +// RequestID returns the id for the request, obtained from and to be presented to the serial signing service. +func (sreq *SerialRequest) RequestID() string { + return sreq.HeaderString("request-id") +} + +// DeviceKey returns the public key of the device making the request. +func (sreq *SerialRequest) DeviceKey() PublicKey { + return sreq.pubKey +} + +func assembleSerialRequest(assert assertionBase) (Assertion, error) { + _, err := checkNotEmptyString(assert.headers, "brand-id") + if err != nil { + return nil, err + } + + _, err = checkModel(assert.headers) + if err != nil { + return nil, err + } + + _, err = checkNotEmptyString(assert.headers, "request-id") + if err != nil { + return nil, err + } + + _, err = checkOptionalString(assert.headers, "serial") + if err != nil { + return nil, err + } + + encodedKey, err := checkNotEmptyString(assert.headers, "device-key") + if err != nil { + return nil, err + } + pubKey, err := DecodePublicKey([]byte(encodedKey)) + if err != nil { + return nil, err + } + + if pubKey.ID() != assert.SignKeyID() { + return nil, fmt.Errorf("device key does not match included signing key id") + } + + // ignore extra headers and non-empty body for future compatibility + return &SerialRequest{ + assertionBase: assert, + pubKey: pubKey, + }, nil +} + +// DeviceSessionRequest holds a device-session-request assertion, which is a request wrapping a store-provided nonce to start a session by a device signed with its key. +type DeviceSessionRequest struct { + assertionBase + timestamp time.Time +} + +// BrandID returns the brand identifier of the device making the request. +func (req *DeviceSessionRequest) BrandID() string { + return req.HeaderString("brand-id") +} + +// Model returns the model name identifier of the device making the request. +func (req *DeviceSessionRequest) Model() string { + return req.HeaderString("model") +} + +// Serial returns the serial identifier of the device making the request, +// together with brand id and model it forms the unique identifier of +// the device. +func (req *DeviceSessionRequest) Serial() string { + return req.HeaderString("serial") +} + +// Nonce returns the nonce obtained from store and to be presented when requesting a device session. +func (req *DeviceSessionRequest) Nonce() string { + return req.HeaderString("nonce") +} + +// Timestamp returns the time when the device-session-request was created. +func (req *DeviceSessionRequest) Timestamp() time.Time { + return req.timestamp +} + +func assembleDeviceSessionRequest(assert assertionBase) (Assertion, error) { + _, err := checkModel(assert.headers) + if err != nil { + return nil, err + } + + _, err = checkNotEmptyString(assert.headers, "nonce") + if err != nil { + return nil, err + } + + timestamp, err := checkRFC3339Date(assert.headers, "timestamp") + if err != nil { + return nil, err + } + + // ignore extra headers and non-empty body for future compatibility + return &DeviceSessionRequest{ + assertionBase: assert, + timestamp: timestamp, + }, nil +} diff --git a/asserts/serial_asserts_test.go b/asserts/serial_asserts_test.go new file mode 100644 index 00000000..fbe3714a --- /dev/null +++ b/asserts/serial_asserts_test.go @@ -0,0 +1,365 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts_test + +import ( + "strings" + "time" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/asserts/assertstest" +) + +var ( + _ = Suite(&serialSuite{}) +) + +type serialSuite struct { + ts time.Time + tsLine string + deviceKey asserts.PrivateKey + encodedDevKey string +} + +func (ss *serialSuite) SetUpSuite(c *C) { + ss.ts = time.Now().Truncate(time.Second).UTC() + ss.tsLine = "timestamp: " + ss.ts.Format(time.RFC3339) + "\n" + + ss.deviceKey = testPrivKey2 + encodedPubKey, err := asserts.EncodePublicKey(ss.deviceKey.PublicKey()) + c.Assert(err, IsNil) + ss.encodedDevKey = string(encodedPubKey) +} + +const serialExample = "type: serial\n" + + "authority-id: brand-id1\n" + + "brand-id: brand-id1\n" + + "model: baz-3000\n" + + "serial: 2700\n" + + "device-key:\n DEVICEKEY\n" + + "device-key-sha3-384: KEYID\n" + + "TSLINE" + + "body-length: 2\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij\n\n" + + "HW" + + "\n\n" + + "AXNpZw==" + +func (ss *serialSuite) TestDecodeOK(c *C) { + encoded := strings.Replace(serialExample, "TSLINE", ss.tsLine, 1) + encoded = strings.Replace(encoded, "DEVICEKEY", strings.Replace(ss.encodedDevKey, "\n", "\n ", -1), 1) + encoded = strings.Replace(encoded, "KEYID", ss.deviceKey.PublicKey().ID(), 1) + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + c.Check(a.Type(), Equals, asserts.SerialType) + serial := a.(*asserts.Serial) + c.Check(serial.AuthorityID(), Equals, "brand-id1") + c.Check(serial.Timestamp(), Equals, ss.ts) + c.Check(serial.BrandID(), Equals, "brand-id1") + c.Check(serial.Model(), Equals, "baz-3000") + c.Check(serial.Serial(), Equals, "2700") + c.Check(serial.DeviceKey().ID(), Equals, ss.deviceKey.PublicKey().ID()) +} + +const ( + deviceSessReqErrPrefix = "assertion device-session-request: " + serialErrPrefix = "assertion serial: " + serialReqErrPrefix = "assertion serial-request: " +) + +func (ss *serialSuite) TestDecodeInvalid(c *C) { + encoded := strings.Replace(serialExample, "TSLINE", ss.tsLine, 1) + + invalidTests := []struct{ original, invalid, expectedErr string }{ + {"brand-id: brand-id1\n", "", `"brand-id" header is mandatory`}, + {"brand-id: brand-id1\n", "brand-id: \n", `"brand-id" header should not be empty`}, + {"brand-id: brand-id1\n", "brand-id: ,1\n", `"brand-id" header contains invalid characters: ",1\"`}, + {"model: baz-3000\n", "", `"model" header is mandatory`}, + {"model: baz-3000\n", "model: \n", `"model" header should not be empty`}, + {"model: baz-3000\n", "model: _what\n", `"model" header contains invalid characters: "_what"`}, + {"serial: 2700\n", "", `"serial" header is mandatory`}, + {"serial: 2700\n", "serial: \n", `"serial" header should not be empty`}, + {ss.tsLine, "", `"timestamp" header is mandatory`}, + {ss.tsLine, "timestamp: \n", `"timestamp" header should not be empty`}, + {ss.tsLine, "timestamp: 12:30\n", `"timestamp" header is not a RFC3339 date: .*`}, + {"device-key:\n DEVICEKEY\n", "", `"device-key" header is mandatory`}, + {"device-key:\n DEVICEKEY\n", "device-key: \n", `"device-key" header should not be empty`}, + {"device-key:\n DEVICEKEY\n", "device-key: $$$\n", `cannot decode public key: .*`}, + {"device-key-sha3-384: KEYID\n", "", `"device-key-sha3-384" header is mandatory`}, + } + + for _, test := range invalidTests { + invalid := strings.Replace(encoded, test.original, test.invalid, 1) + invalid = strings.Replace(invalid, "DEVICEKEY", strings.Replace(ss.encodedDevKey, "\n", "\n ", -1), 1) + invalid = strings.Replace(invalid, "KEYID", ss.deviceKey.PublicKey().ID(), 1) + _, err := asserts.Decode([]byte(invalid)) + c.Check(err, ErrorMatches, serialErrPrefix+test.expectedErr) + } +} + +func (ss *serialSuite) TestDecodeKeyIDMismatch(c *C) { + invalid := strings.Replace(serialExample, "TSLINE", ss.tsLine, 1) + invalid = strings.Replace(invalid, "DEVICEKEY", strings.Replace(ss.encodedDevKey, "\n", "\n ", -1), 1) + invalid = strings.Replace(invalid, "KEYID", "Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij", 1) + + _, err := asserts.Decode([]byte(invalid)) + c.Check(err, ErrorMatches, serialErrPrefix+"device key does not match provided key id") +} + +func (ss *serialSuite) TestSerialCheck(c *C) { + encoded := strings.Replace(serialExample, "TSLINE", ss.tsLine, 1) + encoded = strings.Replace(encoded, "DEVICEKEY", strings.Replace(ss.encodedDevKey, "\n", "\n ", -1), 1) + encoded = strings.Replace(encoded, "KEYID", ss.deviceKey.PublicKey().ID(), 1) + ex, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + + storeDB, db := makeStoreAndCheckDB(c) + brandDB := setup3rdPartySigning(c, "brand1", storeDB, db) + + const serialMismatchErr = `serial with authority "generic" different from brand "brand1" without model assertion with serial-authority set to to allow for them` + brandID := brandDB.AuthorityID + brandKeyID := brandDB.KeyID + genericKeyID := storeDB.GenericKey.PublicKeyID() + modelNA := []interface{}(nil) + brandOnly := []interface{}{} + tests := []struct { + // serial-authority setting in model + // nil == model not available at check (modelNA) + // empty == just brand (brandOnly) + serialAuth []interface{} + signDB assertstest.SignerDB + authID string + keyID string + expectedErr string + }{ + {modelNA, brandDB, "", brandKeyID, ""}, + {brandOnly, brandDB, "", brandKeyID, ""}, + {[]interface{}{"generic"}, brandDB, "", brandKeyID, ""}, + {[]interface{}{"generic", brandID}, brandDB, "", brandKeyID, ""}, + {[]interface{}{"generic"}, storeDB, "generic", genericKeyID, ""}, + {brandOnly, storeDB, "generic", genericKeyID, serialMismatchErr}, + {modelNA, storeDB, "generic", genericKeyID, serialMismatchErr}, + {[]interface{}{"other"}, storeDB, "generic", genericKeyID, serialMismatchErr}, + } + + for _, test := range tests { + checkDB := db.WithStackedBackstore(asserts.NewMemoryBackstore()) + + if test.serialAuth != nil { + modHeaders := map[string]interface{}{ + "series": "16", + "brand-id": brandID, + "architecture": "amd64", + "model": "baz-3000", + "gadget": "gadget", + "kernel": "kernel", + "timestamp": time.Now().Format(time.RFC3339), + } + if len(test.serialAuth) != 0 { + modHeaders["serial-authority"] = test.serialAuth + } + model, err := brandDB.Sign(asserts.ModelType, modHeaders, nil, "") + c.Assert(err, IsNil) + err = checkDB.Add(model) + c.Assert(err, IsNil) + } + + headers := ex.Headers() + headers["brand-id"] = brandID + if test.authID != "" { + headers["authority-id"] = test.authID + } else { + headers["authority-id"] = brandID + } + headers["timestamp"] = time.Now().Format(time.RFC3339) + serial, err := test.signDB.Sign(asserts.SerialType, headers, nil, test.keyID) + c.Check(err, IsNil) + if err != nil { + continue + } + + err = checkDB.Check(serial) + if test.expectedErr == "" { + c.Check(err, IsNil) + } else { + c.Check(err, ErrorMatches, test.expectedErr) + } + } +} + +func (ss *serialSuite) TestSerialRequestHappy(c *C) { + sreq, err := asserts.SignWithoutAuthority(asserts.SerialRequestType, + map[string]interface{}{ + "brand-id": "brand-id1", + "model": "baz-3000", + "device-key": ss.encodedDevKey, + "request-id": "REQID", + }, []byte("HW-DETAILS"), ss.deviceKey) + c.Assert(err, IsNil) + + // roundtrip + a, err := asserts.Decode(asserts.Encode(sreq)) + c.Assert(err, IsNil) + + sreq2, ok := a.(*asserts.SerialRequest) + c.Assert(ok, Equals, true) + + // standalone signature check + err = asserts.SignatureCheck(sreq2, sreq2.DeviceKey()) + c.Check(err, IsNil) + + c.Check(sreq2.BrandID(), Equals, "brand-id1") + c.Check(sreq2.Model(), Equals, "baz-3000") + c.Check(sreq2.RequestID(), Equals, "REQID") + + c.Check(sreq2.Serial(), Equals, "") +} + +func (ss *serialSuite) TestSerialRequestHappyOptionalSerial(c *C) { + sreq, err := asserts.SignWithoutAuthority(asserts.SerialRequestType, + map[string]interface{}{ + "brand-id": "brand-id1", + "model": "baz-3000", + "serial": "pserial", + "device-key": ss.encodedDevKey, + "request-id": "REQID", + }, []byte("HW-DETAILS"), ss.deviceKey) + c.Assert(err, IsNil) + + // roundtrip + a, err := asserts.Decode(asserts.Encode(sreq)) + c.Assert(err, IsNil) + + sreq2, ok := a.(*asserts.SerialRequest) + c.Assert(ok, Equals, true) + + c.Check(sreq2.Model(), Equals, "baz-3000") + c.Check(sreq2.Serial(), Equals, "pserial") +} + +func (ss *serialSuite) TestSerialRequestDecodeInvalid(c *C) { + encoded := "type: serial-request\n" + + "brand-id: brand-id1\n" + + "model: baz-3000\n" + + "device-key:\n DEVICEKEY\n" + + "request-id: REQID\n" + + "serial: S\n" + + "body-length: 2\n" + + "sign-key-sha3-384: " + ss.deviceKey.PublicKey().ID() + "\n\n" + + "HW" + + "\n\n" + + "AXNpZw==" + + invalidTests := []struct{ original, invalid, expectedErr string }{ + {"brand-id: brand-id1\n", "", `"brand-id" header is mandatory`}, + {"brand-id: brand-id1\n", "brand-id: \n", `"brand-id" header should not be empty`}, + {"model: baz-3000\n", "", `"model" header is mandatory`}, + {"model: baz-3000\n", "model: \n", `"model" header should not be empty`}, + {"request-id: REQID\n", "", `"request-id" header is mandatory`}, + {"request-id: REQID\n", "request-id: \n", `"request-id" header should not be empty`}, + {"device-key:\n DEVICEKEY\n", "", `"device-key" header is mandatory`}, + {"device-key:\n DEVICEKEY\n", "device-key: \n", `"device-key" header should not be empty`}, + {"device-key:\n DEVICEKEY\n", "device-key: $$$\n", `cannot decode public key: .*`}, + {"serial: S\n", "serial:\n - xyz\n", `"serial" header must be a string`}, + } + + for _, test := range invalidTests { + invalid := strings.Replace(encoded, test.original, test.invalid, 1) + invalid = strings.Replace(invalid, "DEVICEKEY", strings.Replace(ss.encodedDevKey, "\n", "\n ", -1), 1) + + _, err := asserts.Decode([]byte(invalid)) + c.Check(err, ErrorMatches, serialReqErrPrefix+test.expectedErr) + } +} + +func (ss *serialSuite) TestSerialRequestDecodeKeyIDMismatch(c *C) { + invalid := "type: serial-request\n" + + "brand-id: brand-id1\n" + + "model: baz-3000\n" + + "device-key:\n " + strings.Replace(ss.encodedDevKey, "\n", "\n ", -1) + "\n" + + "request-id: REQID\n" + + "body-length: 2\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij\n\n" + + "HW" + + "\n\n" + + "AXNpZw==" + + _, err := asserts.Decode([]byte(invalid)) + c.Check(err, ErrorMatches, "assertion serial-request: device key does not match included signing key id") +} + +func (ss *serialSuite) TestDeviceSessionRequest(c *C) { + ts := time.Now().UTC().Round(time.Second) + sessReq, err := asserts.SignWithoutAuthority(asserts.DeviceSessionRequestType, + map[string]interface{}{ + "brand-id": "brand-id1", + "model": "baz-3000", + "serial": "99990", + "nonce": "NONCE", + "timestamp": ts.Format(time.RFC3339), + }, nil, ss.deviceKey) + c.Assert(err, IsNil) + + // roundtrip + a, err := asserts.Decode(asserts.Encode(sessReq)) + c.Assert(err, IsNil) + + sessReq2, ok := a.(*asserts.DeviceSessionRequest) + c.Assert(ok, Equals, true) + + // standalone signature check + err = asserts.SignatureCheck(sessReq2, ss.deviceKey.PublicKey()) + c.Check(err, IsNil) + + c.Check(sessReq2.BrandID(), Equals, "brand-id1") + c.Check(sessReq2.Model(), Equals, "baz-3000") + c.Check(sessReq2.Serial(), Equals, "99990") + c.Check(sessReq2.Nonce(), Equals, "NONCE") + c.Check(sessReq2.Timestamp().Equal(ts), Equals, true) +} + +func (ss *serialSuite) TestDeviceSessionRequestDecodeInvalid(c *C) { + tsLine := "timestamp: " + time.Now().Format(time.RFC3339) + "\n" + encoded := "type: device-session-request\n" + + "brand-id: brand-id1\n" + + "model: baz-3000\n" + + "serial: 99990\n" + + "nonce: NONCE\n" + + tsLine + + "body-length: 0\n" + + "sign-key-sha3-384: " + ss.deviceKey.PublicKey().ID() + "\n\n" + + "AXNpZw==" + + invalidTests := []struct{ original, invalid, expectedErr string }{ + {"brand-id: brand-id1\n", "brand-id: \n", `"brand-id" header should not be empty`}, + {"model: baz-3000\n", "model: \n", `"model" header should not be empty`}, + {"serial: 99990\n", "", `"serial" header is mandatory`}, + {"nonce: NONCE\n", "nonce: \n", `"nonce" header should not be empty`}, + {tsLine, "timestamp: 12:30\n", `"timestamp" header is not a RFC3339 date: .*`}, + } + + for _, test := range invalidTests { + invalid := strings.Replace(encoded, test.original, test.invalid, 1) + _, err := asserts.Decode([]byte(invalid)) + c.Check(err, ErrorMatches, deviceSessReqErrPrefix+test.expectedErr) + } +} diff --git a/asserts/signtool/keymgr.go b/asserts/signtool/keymgr.go new file mode 100644 index 00000000..f9b211b7 --- /dev/null +++ b/asserts/signtool/keymgr.go @@ -0,0 +1,101 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2021 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package signtool + +import ( + "errors" + "fmt" + "os" + + "golang.org/x/crypto/ssh/terminal" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/i18n" +) + +// KeypairManager is an interface for common methods of ExternalKeypairManager +// and GPGPKeypairManager. +type KeypairManager interface { + asserts.KeypairManager + + GetByName(keyNname string) (asserts.PrivateKey, error) + Export(keyName string) ([]byte, error) + List() ([]asserts.ExternalKeyInfo, error) + DeleteByName(keyName string) error +} + +// GetKeypairManager returns a KeypairManager - either the standrd gpg-based +// or external one if set via SNAPD_EXT_KEYMGR environment variable. +func GetKeypairManager() (KeypairManager, error) { + keymgrPath := os.Getenv("SNAPD_EXT_KEYMGR") + if keymgrPath != "" { + keypairMgr, err := asserts.NewExternalKeypairManager(keymgrPath) + if err != nil { + return nil, fmt.Errorf(i18n.G("cannot setup external keypair manager: %v"), err) + } + return keypairMgr, nil + } + keypairMgr := asserts.NewGPGKeypairManager() + return keypairMgr, nil +} + +type takingPassKeyGen interface { + Generate(passphrase string, keyName string) error +} + +type ownSecuringKeyGen interface { + Generate(keyName string) error +} + +// GenerateKey generates a private RSA key using the provided keypairMgr. +func GenerateKey(keypairMgr KeypairManager, keyName string) error { + switch keyGen := keypairMgr.(type) { + case takingPassKeyGen: + return takePassGenKey(keyGen, keyName) + case ownSecuringKeyGen: + err := keyGen.Generate(keyName) + if _, ok := err.(*asserts.ExternalUnsupportedOpError); ok { + return fmt.Errorf(i18n.G("cannot generate external keypair manager key via snap command, use the appropriate external procedure to create a 4096-bit RSA key under the name/label %q"), keyName) + } + return err + default: + return fmt.Errorf("internal error: unsupported keypair manager %T", keypairMgr) + } +} + +func takePassGenKey(keyGen takingPassKeyGen, keyName string) error { + fmt.Fprint(Stdout, i18n.G("Passphrase: ")) + passphrase, err := terminal.ReadPassword(0) + fmt.Fprint(Stdout, "\n") + if err != nil { + return err + } + fmt.Fprint(Stdout, i18n.G("Confirm passphrase: ")) + confirmPassphrase, err := terminal.ReadPassword(0) + fmt.Fprint(Stdout, "\n") + if err != nil { + return err + } + if string(passphrase) != string(confirmPassphrase) { + return errors.New(i18n.G("passphrases do not match")) + } + + return keyGen.Generate(string(passphrase), keyName) +} diff --git a/asserts/signtool/keymgr_test.go b/asserts/signtool/keymgr_test.go new file mode 100644 index 00000000..43812545 --- /dev/null +++ b/asserts/signtool/keymgr_test.go @@ -0,0 +1,91 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2021 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package signtool_test + +import ( + "os" + + "gopkg.in/check.v1" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/asserts/signtool" + "github.com/snapcore/snapd/testutil" +) + +type keymgrSuite struct{} + +var _ = check.Suite(&keymgrSuite{}) + +func (keymgrSuite) TestGPGKeypairManager(c *check.C) { + keypairMgr, err := signtool.GetKeypairManager() + c.Check(err, check.IsNil) + c.Check(keypairMgr, check.FitsTypeOf, &asserts.GPGKeypairManager{}) +} + +func mockNopExtKeyMgr(c *check.C) (pgm *testutil.MockCmd, restore func()) { + os.Setenv("SNAPD_EXT_KEYMGR", "keymgr") + pgm = testutil.MockCommand(c, "keymgr", ` +if [ "$1" == "features" ]; then + echo '{"signing":["RSA-PKCS"] , "public-keys":["DER"]}' + exit 0 +fi +exit 1 +`) + r := func() { + pgm.Restore() + os.Unsetenv("SNAPD_EXT_KEYMGR") + } + + return pgm, r +} + +func (keymgrSuite) TestExternalKeypairManager(c *check.C) { + pgm, restore := mockNopExtKeyMgr(c) + defer restore() + + keypairMgr, err := signtool.GetKeypairManager() + c.Check(err, check.IsNil) + c.Check(keypairMgr, check.FitsTypeOf, &asserts.ExternalKeypairManager{}) + c.Check(pgm.Calls(), check.HasLen, 1) +} + +func (keymgrSuite) TestExternalKeypairManagerError(c *check.C) { + os.Setenv("SNAPD_EXT_KEYMGR", "keymgr") + defer os.Unsetenv("SNAPD_EXT_KEYMGR") + + pgm := testutil.MockCommand(c, "keymgr", ` +exit 1 +`) + defer pgm.Restore() + + _, err := signtool.GetKeypairManager() + c.Check(err, check.ErrorMatches, `cannot setup external keypair manager: external keypair manager "keymgr" \[features\] failed: exit status 1.*`) +} + +func (keymgrSuite) TestExternalKeypairManagerGenerateKey(c *check.C) { + _, restore := mockNopExtKeyMgr(c) + defer restore() + + keypairMgr, err := signtool.GetKeypairManager() + c.Check(err, check.IsNil) + + err = signtool.GenerateKey(keypairMgr, "key") + c.Check(err, check.ErrorMatches, `cannot generate external keypair manager key via snap command, use the appropriate external procedure to create a 4096-bit RSA key under the name/label "key"`) +} diff --git a/asserts/signtool/sign.go b/asserts/signtool/sign.go new file mode 100644 index 00000000..0eada2bc --- /dev/null +++ b/asserts/signtool/sign.go @@ -0,0 +1,131 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016-2023 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +// Package signtool offers tooling to sign assertions. +package signtool + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/snapcore/snapd/asserts" +) + +var ( + Stdout = os.Stdout +) + +// Options specifies the complete input for signing an assertion. +type Options struct { + // KeyID specifies the key id of the key to use + KeyID string + + // AccountKey optionally holds the account-key for the key to use, + // used for cross-checking + AccountKey *asserts.AccountKey + + // Statement is used as input to construct the assertion + // it's a mapping encoded as JSON + // of the header fields of the assertion + // plus an optional pseudo-header "body" to specify + // the body of the assertion + Statement []byte + + // Complement specifies complementary headers to what is in + // Statement, for use by tools that fill-in/compute some of + // the headers. Headers appearing both in Statement and + // Complement are an error, except for "type" that needs + // instead to match if present. Pseudo-header "body" can also + // be specified here. + Complement map[string]interface{} +} + +// Sign produces the text of a signed assertion as specified by opts. +func Sign(opts *Options, keypairMgr asserts.KeypairManager) ([]byte, error) { + var headers map[string]interface{} + err := json.Unmarshal(opts.Statement, &headers) + if err != nil { + return nil, fmt.Errorf("cannot parse the assertion input as JSON: %v", err) + } + + for name, value := range opts.Complement { + if v, ok := headers[name]; ok { + if name == "type" { + if v != value { + return nil, fmt.Errorf("repeated assertion type does not match") + } + } else { + return nil, fmt.Errorf("complementary header %q clashes with assertion input", name) + } + } + headers[name] = value + } + + typCand, ok := headers["type"] + if !ok { + return nil, fmt.Errorf("missing assertion type header") + } + typStr, ok := typCand.(string) + if !ok { + return nil, fmt.Errorf("assertion type must be a string, not: %v", typCand) + } + typ := asserts.Type(typStr) + if typ == nil { + return nil, fmt.Errorf("invalid assertion type: %v", headers["type"]) + } + + var body []byte + if bodyCand, ok := headers["body"]; ok { + bodyStr, ok := bodyCand.(string) + if !ok { + return nil, fmt.Errorf("body if specified must be a string") + } + body = []byte(bodyStr) + delete(headers, "body") + } + + adb, err := asserts.OpenDatabase(&asserts.DatabaseConfig{ + KeypairManager: keypairMgr, + }) + if err != nil { + return nil, err + } + + if opts.AccountKey != nil { + // cross-check with the actual account-key if provided + accKey := opts.AccountKey + if accKey.PublicKeyID() != opts.KeyID { + return nil, fmt.Errorf("internal error: key id does not match the signing account-key") + } + if accKey.AccountID() != headers["authority-id"] { + return nil, fmt.Errorf("authority-id does not match the account-id of the signing account-key") + } + if accKey.ConstraintsPrecheck(typ, headers) != nil { + return nil, fmt.Errorf("the assertion headers do not match the constraints of the signing account-key") + } + } + + a, err := adb.Sign(typ, headers, body, opts.KeyID) + if err != nil { + return nil, err + } + + return asserts.Encode(a), nil +} diff --git a/asserts/signtool/sign_test.go b/asserts/signtool/sign_test.go new file mode 100644 index 00000000..aee362b0 --- /dev/null +++ b/asserts/signtool/sign_test.go @@ -0,0 +1,321 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package signtool_test + +import ( + "encoding/json" + "strconv" + "testing" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/asserts/assertstest" + "github.com/snapcore/snapd/asserts/signtool" +) + +func TestSigntool(t *testing.T) { TestingT(t) } + +type signSuite struct { + keypairMgr asserts.KeypairManager + testKeyID string + testAccKey *asserts.AccountKey +} + +var _ = Suite(&signSuite{}) + +func (s *signSuite) SetUpSuite(c *C) { + testKey, _ := assertstest.GenerateKey(752) + + s.keypairMgr = asserts.NewMemoryKeypairManager() + s.keypairMgr.Put(testKey) + s.testKeyID = testKey.PublicKey().ID() + + encPubKey, err := asserts.EncodePublicKey(testKey.PublicKey()) + c.Assert(err, IsNil) + s.testAccKey = assertstest.FakeAssertionWithBody(encPubKey, + map[string]interface{}{ + "type": "account-key", + "authority-id": "canonical", + "public-key-sha3-384": s.testKeyID, + "account-id": "user-id1", + "since": "2015-11-01T20:00:00Z", + "body-length": strconv.Itoa(len(encPubKey)), + "constraints": []interface{}{ + map[string]interface{}{ + "headers": map[string]interface{}{ + "type": "model", + "model": `baz-.*`, + }, + }, + }, + }, + ).(*asserts.AccountKey) +} + +func expectedModelHeaders(a asserts.Assertion) map[string]interface{} { + m := map[string]interface{}{ + "type": "model", + "authority-id": "user-id1", + "series": "16", + "brand-id": "user-id1", + "model": "baz-3000", + "architecture": "amd64", + "gadget": "brand-gadget", + "kernel": "baz-linux", + "store": "brand-store", + "required-snaps": []interface{}{"foo", "bar"}, + "timestamp": "2015-11-25T20:00:00Z", + } + if a != nil { + m["sign-key-sha3-384"] = a.SignKeyID() + } + return m +} + +func exampleJSON(overrides map[string]interface{}) []byte { + m := expectedModelHeaders(nil) + for k, v := range overrides { + if v == nil { + delete(m, k) + } else { + m[k] = v + } + } + b, err := json.Marshal(m) + if err != nil { + panic(err) + } + return b +} + +func (s *signSuite) TestSignJSON(c *C) { + opts := signtool.Options{ + KeyID: s.testKeyID, + + Statement: exampleJSON(nil), + } + + assertText, err := signtool.Sign(&opts, s.keypairMgr) + c.Assert(err, IsNil) + + a, err := asserts.Decode(assertText) + c.Assert(err, IsNil) + + c.Check(a.Type(), Equals, asserts.ModelType) + c.Check(a.Revision(), Equals, 0) + expectedHeaders := expectedModelHeaders(a) + c.Check(a.Headers(), DeepEquals, expectedHeaders) + + for n, v := range a.Headers() { + c.Check(v, DeepEquals, expectedHeaders[n], Commentf(n)) + } + + c.Check(a.Body(), IsNil) +} + +func (s *signSuite) TestSignJSONWithAccountKeyCrossCheck(c *C) { + opts := signtool.Options{ + KeyID: s.testKeyID, + AccountKey: s.testAccKey, + + Statement: exampleJSON(nil), + } + + assertText, err := signtool.Sign(&opts, s.keypairMgr) + c.Assert(err, IsNil) + + a, err := asserts.Decode(assertText) + c.Assert(err, IsNil) + + c.Check(a.Type(), Equals, asserts.ModelType) + c.Check(a.Revision(), Equals, 0) + expectedHeaders := expectedModelHeaders(a) + c.Check(a.Headers(), DeepEquals, expectedHeaders) + + for n, v := range a.Headers() { + c.Check(v, DeepEquals, expectedHeaders[n], Commentf(n)) + } + + c.Check(a.Body(), IsNil) +} + +func (s *signSuite) TestSignJSONWithBodyAndRevision(c *C) { + statement := exampleJSON(map[string]interface{}{ + "body": "BODY", + "revision": "11", + }) + opts := signtool.Options{ + KeyID: s.testKeyID, + + Statement: statement, + } + + assertText, err := signtool.Sign(&opts, s.keypairMgr) + c.Assert(err, IsNil) + + a, err := asserts.Decode(assertText) + c.Assert(err, IsNil) + + c.Check(a.Type(), Equals, asserts.ModelType) + c.Check(a.Revision(), Equals, 11) + + expectedHeaders := expectedModelHeaders(a) + expectedHeaders["revision"] = "11" + expectedHeaders["body-length"] = "4" + + c.Check(a.Headers(), DeepEquals, expectedHeaders) + + c.Check(a.Body(), DeepEquals, []byte("BODY")) +} + +func (s *signSuite) TestSignJSONWithBodyAndComplementRevision(c *C) { + statement := exampleJSON(map[string]interface{}{ + "body": "BODY", + }) + opts := signtool.Options{ + KeyID: s.testKeyID, + + Statement: statement, + Complement: map[string]interface{}{ + "revision": "11", + }, + } + + assertText, err := signtool.Sign(&opts, s.keypairMgr) + c.Assert(err, IsNil) + + a, err := asserts.Decode(assertText) + c.Assert(err, IsNil) + + c.Check(a.Type(), Equals, asserts.ModelType) + c.Check(a.Revision(), Equals, 11) + + expectedHeaders := expectedModelHeaders(a) + expectedHeaders["revision"] = "11" + expectedHeaders["body-length"] = "4" + + c.Check(a.Headers(), DeepEquals, expectedHeaders) + + c.Check(a.Body(), DeepEquals, []byte("BODY")) +} + +func (s *signSuite) TestSignJSONWithRevisionAndComplementBodyAndRepeatedType(c *C) { + statement := exampleJSON(map[string]interface{}{ + "revision": "11", + }) + opts := signtool.Options{ + KeyID: s.testKeyID, + + Statement: statement, + Complement: map[string]interface{}{ + "type": "model", + "body": "BODY", + }, + } + + assertText, err := signtool.Sign(&opts, s.keypairMgr) + c.Assert(err, IsNil) + + a, err := asserts.Decode(assertText) + c.Assert(err, IsNil) + + c.Check(a.Type(), Equals, asserts.ModelType) + c.Check(a.Revision(), Equals, 11) + + expectedHeaders := expectedModelHeaders(a) + expectedHeaders["revision"] = "11" + expectedHeaders["body-length"] = "4" + + c.Check(a.Headers(), DeepEquals, expectedHeaders) + + c.Check(a.Body(), DeepEquals, []byte("BODY")) +} + +func (s *signSuite) TestSignErrors(c *C) { + opts := signtool.Options{ + KeyID: s.testKeyID, + } + + emptyList := []interface{}{} + + tests := []struct { + expError string + brokenStatement []byte + complement map[string]interface{} + accKey *asserts.AccountKey + }{ + {`cannot parse the assertion input as JSON:.*`, + []byte("\x00"), + nil, nil, + }, + {`invalid assertion type: what`, + exampleJSON(map[string]interface{}{"type": "what"}), + nil, nil, + }, + {`assertion type must be a string, not: \[\]`, + exampleJSON(map[string]interface{}{"type": emptyList}), + nil, nil, + }, + {`missing assertion type header`, + exampleJSON(map[string]interface{}{"type": nil}), + nil, nil, + }, + {"revision should be positive: -10", + exampleJSON(map[string]interface{}{"revision": "-10"}), + nil, nil, + }, + {`"authority-id" header is mandatory`, + exampleJSON(map[string]interface{}{"authority-id": nil}), + nil, nil, + }, + {`body if specified must be a string`, + exampleJSON(map[string]interface{}{"body": emptyList}), + nil, nil, + }, + {`repeated assertion type does not match`, + exampleJSON(nil), + map[string]interface{}{"type": "foo"}, nil, + }, + {`complementary header "kernel" clashes with assertion input`, + exampleJSON(nil), + map[string]interface{}{"kernel": "foo"}, nil, + }, + {`authority-id does not match the account-id of the signing account-key`, + exampleJSON(map[string]interface{}{"authority-id": "user-id2", "brand-id": "user-id2"}), + nil, s.testAccKey, + }, + {`the assertion headers do not match the constraints of the signing account-key`, + exampleJSON(map[string]interface{}{"model": "bar"}), + nil, s.testAccKey, + }, + } + + for _, t := range tests { + fresh := opts + + fresh.Statement = t.brokenStatement + fresh.Complement = t.complement + fresh.AccountKey = t.accKey + + _, err := signtool.Sign(&fresh, s.keypairMgr) + c.Check(err, ErrorMatches, t.expError) + } +} diff --git a/asserts/snap_asserts.go b/asserts/snap_asserts.go new file mode 100644 index 00000000..f0bcaccc --- /dev/null +++ b/asserts/snap_asserts.go @@ -0,0 +1,1158 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2015-2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts + +import ( + "bytes" + "crypto" + "errors" + "fmt" + "time" + + // expected for digests + _ "golang.org/x/crypto/sha3" + + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/release" + "github.com/snapcore/snapd/snap/naming" + "github.com/snapcore/snapd/strutil" +) + +// SnapDeclaration holds a snap-declaration assertion, declaring a +// snap binding its identifying snap-id to a name, asserting its +// publisher and its other properties. +type SnapDeclaration struct { + assertionBase + refreshControl []string + plugRules map[string]*PlugRule + slotRules map[string]*SlotRule + autoAliases []string + aliases map[string]string + revisionAuthorities []*RevisionAuthority + timestamp time.Time +} + +// Series returns the series for which the snap is being declared. +func (snapdcl *SnapDeclaration) Series() string { + return snapdcl.HeaderString("series") +} + +// SnapID returns the snap id of the declared snap. +func (snapdcl *SnapDeclaration) SnapID() string { + return snapdcl.HeaderString("snap-id") +} + +// SnapName returns the declared snap name. +func (snapdcl *SnapDeclaration) SnapName() string { + return snapdcl.HeaderString("snap-name") +} + +// PublisherID returns the identifier of the publisher of the declared snap. +func (snapdcl *SnapDeclaration) PublisherID() string { + return snapdcl.HeaderString("publisher-id") +} + +// Timestamp returns the time when the snap-declaration was issued. +func (snapdcl *SnapDeclaration) Timestamp() time.Time { + return snapdcl.timestamp +} + +// RefreshControl returns the ids of snaps whose updates are controlled by this declaration. +func (snapdcl *SnapDeclaration) RefreshControl() []string { + return snapdcl.refreshControl +} + +// PlugRule returns the plug-side rule about the given interface if one was included in the plugs stanza of the declaration, otherwise it returns nil. +func (snapdcl *SnapDeclaration) PlugRule(interfaceName string) *PlugRule { + return snapdcl.plugRules[interfaceName] +} + +// SlotRule returns the slot-side rule about the given interface if one was included in the slots stanza of the declaration, otherwise it returns nil. +func (snapdcl *SnapDeclaration) SlotRule(interfaceName string) *SlotRule { + return snapdcl.slotRules[interfaceName] +} + +// AutoAliases returns the optional auto-aliases granted to this snap. +// XXX: deprecated, will go away +func (snapdcl *SnapDeclaration) AutoAliases() []string { + return snapdcl.autoAliases +} + +// Aliases returns the optional explicit aliases granted to this snap. +func (snapdcl *SnapDeclaration) Aliases() map[string]string { + return snapdcl.aliases +} + +// RevisionAuthority return any revision authority entries matching the given +// provenance. +func (snapdcl *SnapDeclaration) RevisionAuthority(provenance string) []*RevisionAuthority { + res := make([]*RevisionAuthority, 0, 1) + for _, ra := range snapdcl.revisionAuthorities { + if strutil.ListContains(ra.Provenance, provenance) { + res = append(res, ra) + } + } + if len(res) == 0 { + return nil + } + return res +} + +// Implement further consistency checks. +func (snapdcl *SnapDeclaration) checkConsistency(db RODatabase, acck *AccountKey) error { + if !db.IsTrustedAccount(snapdcl.AuthorityID()) { + return fmt.Errorf("snap-declaration assertion for %q (id %q) is not signed by a directly trusted authority: %s", snapdcl.SnapName(), snapdcl.SnapID(), snapdcl.AuthorityID()) + } + _, err := db.Find(AccountType, map[string]string{ + "account-id": snapdcl.PublisherID(), + }) + if errors.Is(err, &NotFoundError{}) { + return fmt.Errorf("snap-declaration assertion for %q (id %q) does not have a matching account assertion for the publisher %q", snapdcl.SnapName(), snapdcl.SnapID(), snapdcl.PublisherID()) + } + if err != nil { + return err + } + + return nil +} + +// expected interface is implemented +var _ consistencyChecker = (*SnapDeclaration)(nil) + +// Prerequisites returns references to this snap-declaration's prerequisite assertions. +func (snapdcl *SnapDeclaration) Prerequisites() []*Ref { + return []*Ref{ + {Type: AccountType, PrimaryKey: []string{snapdcl.PublisherID()}}, + } +} + +func compilePlugRules(plugs map[string]interface{}, compiled func(iface string, plugRule *PlugRule)) error { + for iface, rule := range plugs { + plugRule, err := compilePlugRule(iface, rule) + if err != nil { + return err + } + compiled(iface, plugRule) + } + return nil +} + +func compileSlotRules(slots map[string]interface{}, compiled func(iface string, slotRule *SlotRule)) error { + for iface, rule := range slots { + slotRule, err := compileSlotRule(iface, rule) + if err != nil { + return err + } + compiled(iface, slotRule) + } + return nil +} + +func snapDeclarationFormatAnalyze(headers map[string]interface{}, body []byte) (formatnum int, err error) { + _, plugsOk := headers["plugs"] + _, slotsOk := headers["slots"] + if !(plugsOk || slotsOk) { + return 0, nil + } + + formatnum = 1 + setFormatNum := func(num int) { + if num > formatnum { + formatnum = num + } + } + + plugs, err := checkMap(headers, "plugs") + if err != nil { + return 0, err + } + err = compilePlugRules(plugs, func(_ string, rule *PlugRule) { + if rule.feature(dollarAttrConstraintsFeature) { + setFormatNum(2) + } + if rule.feature(deviceScopeConstraintsFeature) { + setFormatNum(3) + } + if rule.feature(nameConstraintsFeature) { + setFormatNum(4) + } + if rule.feature(altAttrMatcherFeature) { + setFormatNum(5) + } + }) + if err != nil { + return 0, err + } + + slots, err := checkMap(headers, "slots") + if err != nil { + return 0, err + } + err = compileSlotRules(slots, func(_ string, rule *SlotRule) { + if rule.feature(dollarAttrConstraintsFeature) { + setFormatNum(2) + } + if rule.feature(deviceScopeConstraintsFeature) { + setFormatNum(3) + } + if rule.feature(nameConstraintsFeature) { + setFormatNum(4) + } + if rule.feature(altAttrMatcherFeature) { + setFormatNum(5) + } + }) + if err != nil { + return 0, err + } + + return formatnum, nil +} + +func checkAliases(headers map[string]interface{}) (map[string]string, error) { + value, ok := headers["aliases"] + if !ok { + return nil, nil + } + aliasList, ok := value.([]interface{}) + if !ok { + return nil, fmt.Errorf(`"aliases" header must be a list of alias maps`) + } + if len(aliasList) == 0 { + return nil, nil + } + + aliasMap := make(map[string]string, len(aliasList)) + for i, item := range aliasList { + aliasItem, ok := item.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf(`"aliases" header must be a list of alias maps`) + } + + what := fmt.Sprintf(`in "aliases" item %d`, i+1) + name, err := checkStringMatchesWhat(aliasItem, "name", what, naming.ValidAlias) + if err != nil { + return nil, err + } + + what = fmt.Sprintf(`for alias %q`, name) + target, err := checkStringMatchesWhat(aliasItem, "target", what, naming.ValidApp) + if err != nil { + return nil, err + } + + if _, ok := aliasMap[name]; ok { + return nil, fmt.Errorf(`duplicated definition in "aliases" for alias %q`, name) + } + + aliasMap[name] = target + } + + return aliasMap, nil +} + +func assembleSnapDeclaration(assert assertionBase) (Assertion, error) { + _, err := checkExistsString(assert.headers, "snap-name") + if err != nil { + return nil, err + } + + _, err = checkNotEmptyString(assert.headers, "publisher-id") + if err != nil { + return nil, err + } + + timestamp, err := checkRFC3339Date(assert.headers, "timestamp") + if err != nil { + return nil, err + } + + var refControl []string + var plugRules map[string]*PlugRule + var slotRules map[string]*SlotRule + + refControl, err = checkStringList(assert.headers, "refresh-control") + if err != nil { + return nil, err + } + + plugs, err := checkMap(assert.headers, "plugs") + if err != nil { + return nil, err + } + if plugs != nil { + plugRules = make(map[string]*PlugRule, len(plugs)) + err := compilePlugRules(plugs, func(iface string, rule *PlugRule) { + plugRules[iface] = rule + }) + if err != nil { + return nil, err + } + } + + slots, err := checkMap(assert.headers, "slots") + if err != nil { + return nil, err + } + if slots != nil { + slotRules = make(map[string]*SlotRule, len(slots)) + err := compileSlotRules(slots, func(iface string, rule *SlotRule) { + slotRules[iface] = rule + }) + if err != nil { + return nil, err + } + } + + // XXX: depracated, will go away later + autoAliases, err := checkStringListMatches(assert.headers, "auto-aliases", naming.ValidAlias) + if err != nil { + return nil, err + } + + aliases, err := checkAliases(assert.headers) + if err != nil { + return nil, err + } + + var ras []*RevisionAuthority + + ra, ok := assert.headers["revision-authority"] + if ok { + ramaps, ok := ra.([]interface{}) + if !ok { + return nil, fmt.Errorf("revision-authority stanza must be a list of maps") + } + if len(ramaps) == 0 { + // there is no syntax producing this scenario but be robust + return nil, fmt.Errorf("revision-authority stanza cannot be empty") + } + ras = make([]*RevisionAuthority, 0, len(ramaps)) + for _, ramap := range ramaps { + m, ok := ramap.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("revision-authority stanza must be a list of maps") + } + accountID, err := checkStringMatchesWhat(m, "account-id", "in revision authority", validAccountID) + if err != nil { + return nil, err + } + prov, err := checkStringListInMap(m, "provenance", "provenance in revision authority", naming.ValidProvenance) + if err != nil { + return nil, err + } + if len(prov) == 0 { + return nil, fmt.Errorf("provenance in revision authority cannot be empty") + } + minRevision := 1 + maxRevision := 0 + if _, ok := m["min-revision"]; ok { + var err error + minRevision, err = checkSnapRevisionWhat(m, "min-revision", "in revision authority") + if err != nil { + return nil, err + } + } + if _, ok := m["max-revision"]; ok { + var err error + maxRevision, err = checkSnapRevisionWhat(m, "max-revision", "in revision authority") + if err != nil { + return nil, err + } + } + if maxRevision != 0 && maxRevision < minRevision { + return nil, fmt.Errorf("optional max-revision cannot be less than min-revision in revision-authority") + } + devscope, err := compileDeviceScopeConstraint(m, "revision-authority") + if err != nil { + return nil, err + } + ras = append(ras, &RevisionAuthority{ + AccountID: accountID, + Provenance: prov, + MinRevision: minRevision, + MaxRevision: maxRevision, + DeviceScope: devscope, + }) + } + + } + + return &SnapDeclaration{ + assertionBase: assert, + refreshControl: refControl, + plugRules: plugRules, + slotRules: slotRules, + autoAliases: autoAliases, + aliases: aliases, + revisionAuthorities: ras, + timestamp: timestamp, + }, nil +} + +// RevisionAuthority holds information about an account that can sign revisions +// for a given snap. +type RevisionAuthority struct { + AccountID string + Provenance []string + + MinRevision int + MaxRevision int + + DeviceScope *DeviceScopeConstraint +} + +func (ra *RevisionAuthority) checkProvenanceAndRevision(a interface { + Assertion + Provenance() string +}, what string, revno int, model *Model, store *Store) error { + if !strutil.ListContains(ra.Provenance, a.Provenance()) { + return fmt.Errorf("provenance mismatch") + } + if a.AuthorityID() != ra.AccountID { + return fmt.Errorf("authority-id mismatch") + } + if revno < ra.MinRevision { + return fmt.Errorf("%s revision %d is less than min-revision %d", what, revno, ra.MinRevision) + } + if ra.MaxRevision != 0 && revno > ra.MaxRevision { + return fmt.Errorf("%s revision %d is greater than max-revision %d", what, revno, ra.MaxRevision) + } + if ra.DeviceScope != nil && model != nil { + opts := DeviceScopeConstraintCheckOptions{UseFriendlyStores: true} + if err := ra.DeviceScope.Check(model, store, &opts); err != nil { + return err + } + } + return nil +} + +// Check tests whether rev matches the revision authority constraints. +// Optional model and store must be provided to cross-check device-specific +// constraints. +func (ra *RevisionAuthority) Check(rev *SnapRevision, model *Model, store *Store) error { + return ra.checkProvenanceAndRevision(rev, "snap", rev.SnapRevision(), model, store) +} + +// CheckResourceRevision tests whether resrev matches the revision authority +// constraints. Optional model and store must be provided to cross-check +// device-specific constraints. +func (ra *RevisionAuthority) CheckResourceRevision(resrev *SnapResourceRevision, model *Model, store *Store) error { + return ra.checkProvenanceAndRevision(resrev, "resource", resrev.ResourceRevision(), model, store) +} + +// SnapIntegrity holds information about integrity data included in a revision +// for a given snap. +type SnapIntegrity struct { + SHA3_384 string + Size uint64 +} + +// SnapFileSHA3_384 computes the SHA3-384 digest of the given snap file. +// It also returns its size. +func SnapFileSHA3_384(snapPath string) (digest string, size uint64, err error) { + sha3_384Dgst, size, err := osutil.FileDigest(snapPath, crypto.SHA3_384) + if err != nil { + return "", 0, fmt.Errorf("cannot compute snap %q digest: %v", snapPath, err) + } + + sha3_384, err := EncodeDigest(crypto.SHA3_384, sha3_384Dgst) + if err != nil { + return "", 0, fmt.Errorf("cannot encode snap %q digest: %v", snapPath, err) + } + return sha3_384, size, nil +} + +// SnapBuild holds a snap-build assertion, asserting the properties of a snap +// at the time it was built by the developer. +type SnapBuild struct { + assertionBase + size uint64 + timestamp time.Time +} + +// SnapSHA3_384 returns the SHA3-384 digest of the snap. +func (snapbld *SnapBuild) SnapSHA3_384() string { + return snapbld.HeaderString("snap-sha3-384") +} + +// SnapID returns the snap id of the snap. +func (snapbld *SnapBuild) SnapID() string { + return snapbld.HeaderString("snap-id") +} + +// SnapSize returns the size of the snap. +func (snapbld *SnapBuild) SnapSize() uint64 { + return snapbld.size +} + +// Grade returns the grade of the snap: devel|stable +func (snapbld *SnapBuild) Grade() string { + return snapbld.HeaderString("grade") +} + +// Timestamp returns the time when the snap-build assertion was created. +func (snapbld *SnapBuild) Timestamp() time.Time { + return snapbld.timestamp +} + +func assembleSnapBuild(assert assertionBase) (Assertion, error) { + _, err := checkDigest(assert.headers, "snap-sha3-384", crypto.SHA3_384) + if err != nil { + return nil, err + } + + _, err = checkNotEmptyString(assert.headers, "snap-id") + if err != nil { + return nil, err + } + + _, err = checkNotEmptyString(assert.headers, "grade") + if err != nil { + return nil, err + } + + size, err := checkUint(assert.headers, "snap-size", 64) + if err != nil { + return nil, err + } + + timestamp, err := checkRFC3339Date(assert.headers, "timestamp") + if err != nil { + return nil, err + } + // ignore extra headers and non-empty body for future compatibility + return &SnapBuild{ + assertionBase: assert, + size: size, + timestamp: timestamp, + }, nil +} + +// SnapRevision holds a snap-revision assertion, which is a statement by the +// store acknowledging the receipt of a build of a snap and labeling it with a +// snap revision. +type SnapRevision struct { + assertionBase + snapSize uint64 + snapRevision int + timestamp time.Time + + snapIntegrity *SnapIntegrity +} + +// SnapSHA3_384 returns the SHA3-384 digest of the snap. +func (snaprev *SnapRevision) SnapSHA3_384() string { + return snaprev.HeaderString("snap-sha3-384") +} + +// Provenance returns the optional provenance of the snap (defaults to +// global-upload (naming.DefaultProvenance)). +func (snaprev *SnapRevision) Provenance() string { + return snaprev.HeaderString("provenance") +} + +// SnapID returns the snap id of the snap. +func (snaprev *SnapRevision) SnapID() string { + return snaprev.HeaderString("snap-id") +} + +// SnapSize returns the size in bytes of the snap submitted to the store. +func (snaprev *SnapRevision) SnapSize() uint64 { + return snaprev.snapSize +} + +// SnapRevision returns the revision assigned to this build of the snap. +func (snaprev *SnapRevision) SnapRevision() int { + return snaprev.snapRevision +} + +// DeveloperID returns the id of the developer that submitted this build of the +// snap. +func (snaprev *SnapRevision) DeveloperID() string { + return snaprev.HeaderString("developer-id") +} + +// Timestamp returns the time when the snap-revision was issued. +func (snaprev *SnapRevision) Timestamp() time.Time { + return snaprev.timestamp +} + +// SnapIntegrity returns the snap integrity data associated with the snap revision if any. +func (snaprev *SnapRevision) SnapIntegrity() *SnapIntegrity { + return snaprev.snapIntegrity +} + +// Implement further consistency checks. +func (snaprev *SnapRevision) checkConsistency(db RODatabase, acck *AccountKey) error { + otherProvenance := snaprev.Provenance() != naming.DefaultProvenance + if !otherProvenance && !db.IsTrustedAccount(snaprev.AuthorityID()) { + // delegating global-upload revisions is not allowed + return fmt.Errorf("snap-revision assertion for snap id %q is not signed by a store: %s", snaprev.SnapID(), snaprev.AuthorityID()) + } + _, err := db.Find(AccountType, map[string]string{ + "account-id": snaprev.DeveloperID(), + }) + if errors.Is(err, &NotFoundError{}) { + return fmt.Errorf("snap-revision assertion for snap id %q does not have a matching account assertion for the developer %q", snaprev.SnapID(), snaprev.DeveloperID()) + } + if err != nil { + return err + } + a, err := db.Find(SnapDeclarationType, map[string]string{ + // XXX: mediate getting current series through some context object? this gets the job done for now + "series": release.Series, + "snap-id": snaprev.SnapID(), + }) + if errors.Is(err, &NotFoundError{}) { + return fmt.Errorf("snap-revision assertion for snap id %q does not have a matching snap-declaration assertion", snaprev.SnapID()) + } + if err != nil { + return err + } + if otherProvenance { + decl := a.(*SnapDeclaration) + ras := decl.RevisionAuthority(snaprev.Provenance()) + matchingRevAuthority := false + for _, ra := range ras { + // model==store==nil, we do not perform device-specific + // checks at this level, those are performed at + // higher-level guarding installing actual snaps + if err := ra.Check(snaprev, nil, nil); err == nil { + matchingRevAuthority = true + break + } + } + if !matchingRevAuthority { + return fmt.Errorf("snap-revision assertion with provenance %q for snap id %q is not signed by an authorized authority: %s", snaprev.Provenance(), snaprev.SnapID(), snaprev.AuthorityID()) + } + } + return nil +} + +// expected interface is implemented +var _ consistencyChecker = (*SnapRevision)(nil) + +// Prerequisites returns references to this snap-revision's prerequisite assertions. +func (snaprev *SnapRevision) Prerequisites() []*Ref { + return []*Ref{ + // XXX: mediate getting current series through some context object? this gets the job done for now + {Type: SnapDeclarationType, PrimaryKey: []string{release.Series, snaprev.SnapID()}}, + {Type: AccountType, PrimaryKey: []string{snaprev.DeveloperID()}}, + } +} + +func checkSnapRevisionWhat(headers map[string]interface{}, name, what string) (snapRevision int, err error) { + snapRevision, err = checkIntWhat(headers, name, what) + if err != nil { + return 0, err + } + if snapRevision < 1 { + return 0, fmt.Errorf(`%q %s must be >=1: %d`, name, what, snapRevision) + } + return snapRevision, nil +} + +func assembleSnapRevision(assert assertionBase) (Assertion, error) { + _, err := checkDigest(assert.headers, "snap-sha3-384", crypto.SHA3_384) + if err != nil { + return nil, err + } + + _, err = checkStringMatches(assert.headers, "provenance", naming.ValidProvenance) + if err != nil { + return nil, err + } + + _, err = checkNotEmptyString(assert.headers, "snap-id") + if err != nil { + return nil, err + } + + snapSize, err := checkUint(assert.headers, "snap-size", 64) + if err != nil { + return nil, err + } + + snapRevision, err := checkSnapRevisionWhat(assert.headers, "snap-revision", "header") + if err != nil { + return nil, err + } + + _, err = checkNotEmptyString(assert.headers, "developer-id") + if err != nil { + return nil, err + } + + timestamp, err := checkRFC3339Date(assert.headers, "timestamp") + if err != nil { + return nil, err + } + + integrityMap, err := checkMap(assert.headers, "integrity") + if err != nil { + return nil, err + } + + var snapIntegrity *SnapIntegrity + + if integrityMap != nil { + // TODO: this will change again to support format agility + _, err := checkDigestWhat(integrityMap, "sha3-384", crypto.SHA3_384, "of integrity header") + if err != nil { + return nil, err + } + + size, err := checkUintWhat(integrityMap, "size", 64, "of integrity header") + if err != nil { + return nil, err + } + + snapIntegrity = &SnapIntegrity{ + SHA3_384: integrityMap["sha3-384"].(string), + Size: size, + } + } + + return &SnapRevision{ + assertionBase: assert, + snapSize: snapSize, + snapRevision: snapRevision, + timestamp: timestamp, + snapIntegrity: snapIntegrity, + }, nil +} + +// Validation holds a validation assertion, describing that a combination of +// (snap-id, approved-snap-id, approved-revision) has been validated for +// the series, meaning updating to that revision of approved-snap-id +// has been approved by the owner of the gating snap with snap-id. +type Validation struct { + assertionBase + revoked bool + timestamp time.Time + approvedSnapRevision int +} + +// Series returns the series for which the validation holds. +func (validation *Validation) Series() string { + return validation.HeaderString("series") +} + +// SnapID returns the ID of the gating snap. +func (validation *Validation) SnapID() string { + return validation.HeaderString("snap-id") +} + +// ApprovedSnapID returns the ID of the gated snap. +func (validation *Validation) ApprovedSnapID() string { + return validation.HeaderString("approved-snap-id") +} + +// ApprovedSnapRevision returns the approved revision of the gated snap. +func (validation *Validation) ApprovedSnapRevision() int { + return validation.approvedSnapRevision +} + +// Revoked returns true if the validation has been revoked. +func (validation *Validation) Revoked() bool { + return validation.revoked +} + +// Timestamp returns the time when the validation was issued. +func (validation *Validation) Timestamp() time.Time { + return validation.timestamp +} + +// Implement further consistency checks. +func (validation *Validation) checkConsistency(db RODatabase, acck *AccountKey) error { + _, err := db.Find(SnapDeclarationType, map[string]string{ + "series": validation.Series(), + "snap-id": validation.ApprovedSnapID(), + }) + if errors.Is(err, &NotFoundError{}) { + return fmt.Errorf("validation assertion by snap-id %q does not have a matching snap-declaration assertion for approved-snap-id %q", validation.SnapID(), validation.ApprovedSnapID()) + } + if err != nil { + return err + } + a, err := db.Find(SnapDeclarationType, map[string]string{ + "series": validation.Series(), + "snap-id": validation.SnapID(), + }) + if errors.Is(err, &NotFoundError{}) { + return fmt.Errorf("validation assertion by snap-id %q does not have a matching snap-declaration assertion", validation.SnapID()) + } + if err != nil { + return err + } + + gatingDecl := a.(*SnapDeclaration) + if gatingDecl.PublisherID() != validation.AuthorityID() { + return fmt.Errorf("validation assertion by snap %q (id %q) not signed by its publisher", gatingDecl.SnapName(), validation.SnapID()) + } + + return nil +} + +// expected interface is implemented +var _ consistencyChecker = (*Validation)(nil) + +// Prerequisites returns references to this validation's prerequisite assertions. +func (validation *Validation) Prerequisites() []*Ref { + return []*Ref{ + {Type: SnapDeclarationType, PrimaryKey: []string{validation.Series(), validation.SnapID()}}, + {Type: SnapDeclarationType, PrimaryKey: []string{validation.Series(), validation.ApprovedSnapID()}}, + } +} + +func assembleValidation(assert assertionBase) (Assertion, error) { + approvedSnapRevision, err := checkSnapRevisionWhat(assert.headers, "approved-snap-revision", "header") + if err != nil { + return nil, err + } + + revoked, err := checkOptionalBool(assert.headers, "revoked") + if err != nil { + return nil, err + } + + timestamp, err := checkRFC3339Date(assert.headers, "timestamp") + if err != nil { + return nil, err + } + + return &Validation{ + assertionBase: assert, + revoked: revoked, + timestamp: timestamp, + approvedSnapRevision: approvedSnapRevision, + }, nil +} + +// BaseDeclaration holds a base-declaration assertion, declaring the +// policies (to start with interface ones) applying to all snaps of +// a series. +type BaseDeclaration struct { + assertionBase + plugRules map[string]*PlugRule + slotRules map[string]*SlotRule + timestamp time.Time +} + +// Series returns the series whose snaps are governed by the declaration. +func (basedcl *BaseDeclaration) Series() string { + return basedcl.HeaderString("series") +} + +// Timestamp returns the time when the base-declaration was issued. +func (basedcl *BaseDeclaration) Timestamp() time.Time { + return basedcl.timestamp +} + +// PlugRule returns the plug-side rule about the given interface if one was included in the plugs stanza of the declaration, otherwise it returns nil. +func (basedcl *BaseDeclaration) PlugRule(interfaceName string) *PlugRule { + return basedcl.plugRules[interfaceName] +} + +// SlotRule returns the slot-side rule about the given interface if one was included in the slots stanza of the declaration, otherwise it returns nil. +func (basedcl *BaseDeclaration) SlotRule(interfaceName string) *SlotRule { + return basedcl.slotRules[interfaceName] +} + +// Implement further consistency checks. +func (basedcl *BaseDeclaration) checkConsistency(db RODatabase, acck *AccountKey) error { + // XXX: not signed or stored yet in a db, but being ready for that + if !db.IsTrustedAccount(basedcl.AuthorityID()) { + return fmt.Errorf("base-declaration assertion for series %s is not signed by a directly trusted authority: %s", basedcl.Series(), basedcl.AuthorityID()) + } + return nil +} + +// expected interface is implemented +var _ consistencyChecker = (*BaseDeclaration)(nil) + +func assembleBaseDeclaration(assert assertionBase) (Assertion, error) { + var plugRules map[string]*PlugRule + plugs, err := checkMap(assert.headers, "plugs") + if err != nil { + return nil, err + } + if plugs != nil { + plugRules = make(map[string]*PlugRule, len(plugs)) + err := compilePlugRules(plugs, func(iface string, rule *PlugRule) { + plugRules[iface] = rule + }) + if err != nil { + return nil, err + } + } + + var slotRules map[string]*SlotRule + slots, err := checkMap(assert.headers, "slots") + if err != nil { + return nil, err + } + if slots != nil { + slotRules = make(map[string]*SlotRule, len(slots)) + err := compileSlotRules(slots, func(iface string, rule *SlotRule) { + slotRules[iface] = rule + }) + if err != nil { + return nil, err + } + } + + timestamp, err := checkRFC3339Date(assert.headers, "timestamp") + if err != nil { + return nil, err + } + + return &BaseDeclaration{ + assertionBase: assert, + plugRules: plugRules, + slotRules: slotRules, + timestamp: timestamp, + }, nil +} + +var builtinBaseDeclaration *BaseDeclaration + +// BuiltinBaseDeclaration exposes the initialized builtin base-declaration assertion. This is used by overlord/assertstate, other code should use assertstate.BaseDeclaration. +func BuiltinBaseDeclaration() *BaseDeclaration { + return builtinBaseDeclaration +} + +var ( + builtinBaseDeclarationCheckOrder = []string{"type", "authority-id", "series"} + builtinBaseDeclarationExpectedHeaders = map[string]interface{}{ + "type": "base-declaration", + "authority-id": "canonical", + "series": release.Series, + } +) + +// InitBuiltinBaseDeclaration initializes the builtin base-declaration based on headers (or resets it if headers is nil). +func InitBuiltinBaseDeclaration(headers []byte) error { + if headers == nil { + builtinBaseDeclaration = nil + return nil + } + trimmed := bytes.TrimSpace(headers) + h, err := parseHeaders(trimmed) + if err != nil { + return err + } + for _, name := range builtinBaseDeclarationCheckOrder { + expected := builtinBaseDeclarationExpectedHeaders[name] + if h[name] != expected { + return fmt.Errorf("the builtin base-declaration %q header is not set to expected value %q", name, expected) + } + } + revision, err := checkRevision(h) + if err != nil { + return fmt.Errorf("cannot assemble the builtin-base declaration: %v", err) + } + h["timestamp"] = time.Now().UTC().Format(time.RFC3339) + a, err := assembleBaseDeclaration(assertionBase{ + headers: h, + body: nil, + revision: revision, + content: trimmed, + signature: []byte("$builtin"), + }) + if err != nil { + return fmt.Errorf("cannot assemble the builtin base-declaration: %v", err) + } + builtinBaseDeclaration = a.(*BaseDeclaration) + return nil +} + +type dateRange struct { + Since time.Time + Until time.Time +} + +// SnapDeveloper holds a snap-developer assertion, defining the developers who +// can collaborate on a snap while it's owned by a specific publisher. +// +// The primary key (snap-id, publisher-id) allows a snap to have many +// snap-developer assertions, e.g. to allow a future publisher's collaborations +// to be defined before the snap is transferred. However only the +// snap-developer for the current publisher (the snap-declaration publisher-id) +// is relevant to a device. +type SnapDeveloper struct { + assertionBase + developerRanges map[string][]*dateRange +} + +// SnapID returns the snap id of the snap. +func (snapdev *SnapDeveloper) SnapID() string { + return snapdev.HeaderString("snap-id") +} + +// PublisherID returns the publisher's account id. +func (snapdev *SnapDeveloper) PublisherID() string { + return snapdev.HeaderString("publisher-id") +} + +func (snapdev *SnapDeveloper) checkConsistency(db RODatabase, acck *AccountKey) error { + // Check authority is the publisher or trusted. + authorityID := snapdev.AuthorityID() + publisherID := snapdev.PublisherID() + if !db.IsTrustedAccount(authorityID) && (publisherID != authorityID) { + return fmt.Errorf("snap-developer must be signed by the publisher or a trusted authority but got authority %q and publisher %q", authorityID, publisherID) + } + + // Check snap-declaration for the snap-id exists for the series. + // Note: the current publisher is irrelevant here because this assertion + // may be for a future publisher. + _, err := db.Find(SnapDeclarationType, map[string]string{ + // XXX: mediate getting current series through some context object? this gets the job done for now + "series": release.Series, + "snap-id": snapdev.SnapID(), + }) + if err != nil { + if errors.Is(err, &NotFoundError{}) { + return fmt.Errorf("snap-developer assertion for snap id %q does not have a matching snap-declaration assertion", snapdev.SnapID()) + } + return err + } + + // check there's an account for the publisher-id + _, err = db.Find(AccountType, map[string]string{"account-id": publisherID}) + if err != nil { + if errors.Is(err, &NotFoundError{}) { + return fmt.Errorf("snap-developer assertion for snap-id %q does not have a matching account assertion for the publisher %q", snapdev.SnapID(), publisherID) + } + return err + } + + // check there's an account for each developer + for developerID := range snapdev.developerRanges { + if developerID == publisherID { + continue + } + _, err = db.Find(AccountType, map[string]string{"account-id": developerID}) + if err != nil { + if errors.Is(err, &NotFoundError{}) { + return fmt.Errorf("snap-developer assertion for snap-id %q does not have a matching account assertion for the developer %q", snapdev.SnapID(), developerID) + } + return err + } + } + + return nil +} + +// expected interface is implemented +var _ consistencyChecker = (*SnapDeveloper)(nil) + +// Prerequisites returns references to this snap-developer's prerequisite assertions. +func (snapdev *SnapDeveloper) Prerequisites() []*Ref { + // Capacity for the snap-declaration, the publisher and all developers. + refs := make([]*Ref, 0, 2+len(snapdev.developerRanges)) + + // snap-declaration + // XXX: mediate getting current series through some context object? this gets the job done for now + refs = append(refs, &Ref{SnapDeclarationType, []string{release.Series, snapdev.SnapID()}}) + + // the publisher and developers + publisherID := snapdev.PublisherID() + refs = append(refs, &Ref{AccountType, []string{publisherID}}) + for developerID := range snapdev.developerRanges { + if developerID != publisherID { + refs = append(refs, &Ref{AccountType, []string{developerID}}) + } + } + + return refs +} + +func assembleSnapDeveloper(assert assertionBase) (Assertion, error) { + developerRanges, err := checkDevelopers(assert.headers) + if err != nil { + return nil, err + } + + return &SnapDeveloper{ + assertionBase: assert, + developerRanges: developerRanges, + }, nil +} + +func checkDevelopers(headers map[string]interface{}) (map[string][]*dateRange, error) { + value, ok := headers["developers"] + if !ok { + return nil, nil + } + developers, ok := value.([]interface{}) + if !ok { + return nil, fmt.Errorf(`"developers" must be a list of developer maps`) + } + if len(developers) == 0 { + return nil, nil + } + + // Used to check for a developer with revoking and non-revoking items. + // No entry means developer not yet seen, false means seen but not revoked, + // true means seen and revoked. + revocationStatus := map[string]bool{} + + developerRanges := make(map[string][]*dateRange) + for i, item := range developers { + developer, ok := item.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf(`"developers" must be a list of developer maps`) + } + + what := fmt.Sprintf(`in "developers" item %d`, i+1) + accountID, err := checkStringMatchesWhat(developer, "developer-id", what, validAccountID) + if err != nil { + return nil, err + } + + what = fmt.Sprintf(`in "developers" item %d for developer %q`, i+1, accountID) + since, err := checkRFC3339DateWhat(developer, "since", what) + if err != nil { + return nil, err + } + until, err := checkRFC3339DateWithDefaultWhat(developer, "until", what, time.Time{}) + if err != nil { + return nil, err + } + if !until.IsZero() && since.After(until) { + return nil, fmt.Errorf(`"since" %s must be less than or equal to "until"`, what) + } + + // Track/check for revocation conflicts. + revoked := since.Equal(until) + previouslyRevoked, ok := revocationStatus[accountID] + if !ok { + revocationStatus[accountID] = revoked + } else if previouslyRevoked || revoked { + return nil, fmt.Errorf(`revocation for developer %q must be standalone but found other "developers" items`, accountID) + } + + developerRanges[accountID] = append(developerRanges[accountID], &dateRange{since, until}) + } + + return developerRanges, nil +} diff --git a/asserts/snap_asserts_test.go b/asserts/snap_asserts_test.go new file mode 100644 index 00000000..6c83621b --- /dev/null +++ b/asserts/snap_asserts_test.go @@ -0,0 +1,2458 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2015-2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts_test + +import ( + "encoding/base64" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "golang.org/x/crypto/sha3" + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/asserts/assertstest" +) + +var ( + _ = Suite(&snapDeclSuite{}) + _ = Suite(&snapFileDigestSuite{}) + _ = Suite(&snapBuildSuite{}) + _ = Suite(&snapRevSuite{}) + _ = Suite(&validationSuite{}) + _ = Suite(&baseDeclSuite{}) + _ = Suite(&snapDevSuite{}) +) + +type snapDeclSuite struct { + ts time.Time + tsLine string +} + +type emptyAttrerObject struct{} + +func (o emptyAttrerObject) Lookup(path string) (interface{}, bool) { + return nil, false +} + +func (sds *snapDeclSuite) SetUpSuite(c *C) { + sds.ts = time.Now().Truncate(time.Second).UTC() + sds.tsLine = "timestamp: " + sds.ts.Format(time.RFC3339) + "\n" +} + +func (sds *snapDeclSuite) TestDecodeOK(c *C) { + encoded := "type: snap-declaration\n" + + "authority-id: canonical\n" + + "series: 16\n" + + "snap-id: snap-id-1\n" + + "snap-name: first\n" + + "publisher-id: dev-id1\n" + + "refresh-control:\n - foo\n - bar\n" + + "auto-aliases:\n - cmd1\n - cmd_2\n - Cmd-3\n - CMD.4\n" + + sds.tsLine + + `aliases: + - + name: cmd1 + target: cmd-1 + - + name: cmd_2 + target: cmd-2 + - + name: Cmd-3 + target: cmd-3 + - + name: CMD.4 + target: cmd-4 +` + + "body-length: 0\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==" + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + c.Check(a.Type(), Equals, asserts.SnapDeclarationType) + snapDecl := a.(*asserts.SnapDeclaration) + c.Check(snapDecl.AuthorityID(), Equals, "canonical") + c.Check(snapDecl.Timestamp(), Equals, sds.ts) + c.Check(snapDecl.Series(), Equals, "16") + c.Check(snapDecl.SnapID(), Equals, "snap-id-1") + c.Check(snapDecl.SnapName(), Equals, "first") + c.Check(snapDecl.PublisherID(), Equals, "dev-id1") + c.Check(snapDecl.RefreshControl(), DeepEquals, []string{"foo", "bar"}) + c.Check(snapDecl.AutoAliases(), DeepEquals, []string{"cmd1", "cmd_2", "Cmd-3", "CMD.4"}) + c.Check(snapDecl.Aliases(), DeepEquals, map[string]string{ + "cmd1": "cmd-1", + "cmd_2": "cmd-2", + "Cmd-3": "cmd-3", + "CMD.4": "cmd-4", + }) + c.Check(snapDecl.RevisionAuthority(""), IsNil) +} + +func (sds *snapDeclSuite) TestDecodeOKWithRevisionAuthority(c *C) { + encoded := "type: snap-declaration\n" + + "authority-id: canonical\n" + + "series: 16\n" + + "snap-id: snap-id-1\n" + + "snap-name: first\n" + + "publisher-id: dev-id1\n" + + "refresh-control:\n - foo\n - bar\n" + + sds.tsLine + + `revision-authority: + - + account-id: delegated-acc-id + provenance: + - prov1 + - prov2 + min-revision: 100 + max-revision: 1000000 + on-store: + - store1 +` + + "body-length: 0\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==" + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + c.Check(a.Type(), Equals, asserts.SnapDeclarationType) + snapDecl := a.(*asserts.SnapDeclaration) + c.Check(snapDecl.AuthorityID(), Equals, "canonical") + c.Check(snapDecl.Timestamp(), Equals, sds.ts) + c.Check(snapDecl.Series(), Equals, "16") + c.Check(snapDecl.SnapID(), Equals, "snap-id-1") + c.Check(snapDecl.SnapName(), Equals, "first") + c.Check(snapDecl.PublisherID(), Equals, "dev-id1") + c.Check(snapDecl.RefreshControl(), DeepEquals, []string{"foo", "bar"}) + ras := snapDecl.RevisionAuthority("prov1") + c.Check(ras, DeepEquals, []*asserts.RevisionAuthority{ + { + AccountID: "delegated-acc-id", + Provenance: []string{"prov1", "prov2"}, + MinRevision: 100, + MaxRevision: 1000000, + DeviceScope: &asserts.DeviceScopeConstraint{ + Store: []string{"store1"}, + }, + }, + }) +} + +func (sds *snapDeclSuite) TestDecodeOKWithRevisionAuthorityDefaults(c *C) { + initial := "type: snap-declaration\n" + + "authority-id: canonical\n" + + "series: 16\n" + + "snap-id: snap-id-1\n" + + "snap-name: first\n" + + "publisher-id: dev-id1\n" + + "refresh-control:\n - foo\n - bar\n" + + sds.tsLine + + `revision-authority: + - + account-id: delegated-acc-id + provenance: + - prov1 + - prov2 + min-revision: 100 +` + + "body-length: 0\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==" + tests := []struct { + original, replaced string + revAuth asserts.RevisionAuthority + }{ + {"min", "min", asserts.RevisionAuthority{ + AccountID: "delegated-acc-id", + Provenance: []string{"prov1", "prov2"}, + MinRevision: 100, + }}, + {"min", "max", asserts.RevisionAuthority{ + AccountID: "delegated-acc-id", + Provenance: []string{"prov1", "prov2"}, + MinRevision: 1, + MaxRevision: 100, + }}, + {" min-revision: 100\n", "", asserts.RevisionAuthority{ + AccountID: "delegated-acc-id", + Provenance: []string{"prov1", "prov2"}, + MinRevision: 1, + }}, + } + + for _, t := range tests { + encoded := strings.Replace(initial, t.original, t.replaced, 1) + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + snapDecl := a.(*asserts.SnapDeclaration) + ras := snapDecl.RevisionAuthority("prov2") + c.Check(ras, HasLen, 1) + c.Check(*ras[0], DeepEquals, t.revAuth) + } +} + +func (sds *snapDeclSuite) TestEmptySnapName(c *C) { + encoded := "type: snap-declaration\n" + + "authority-id: canonical\n" + + "series: 16\n" + + "snap-id: snap-id-1\n" + + "snap-name: \n" + + "publisher-id: dev-id1\n" + + sds.tsLine + + "body-length: 0\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==" + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + snapDecl := a.(*asserts.SnapDeclaration) + c.Check(snapDecl.SnapName(), Equals, "") +} + +func (sds *snapDeclSuite) TestMissingRefreshControlAutoAliases(c *C) { + encoded := "type: snap-declaration\n" + + "authority-id: canonical\n" + + "series: 16\n" + + "snap-id: snap-id-1\n" + + "snap-name: \n" + + "publisher-id: dev-id1\n" + + sds.tsLine + + "body-length: 0\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==" + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + snapDecl := a.(*asserts.SnapDeclaration) + c.Check(snapDecl.RefreshControl(), HasLen, 0) + c.Check(snapDecl.AutoAliases(), HasLen, 0) +} + +const ( + snapDeclErrPrefix = "assertion snap-declaration: " +) + +func (sds *snapDeclSuite) TestDecodeInvalid(c *C) { + aliases := `aliases: + - + name: cmd_1 + target: cmd-1 +` + encoded := "type: snap-declaration\n" + + "authority-id: canonical\n" + + "series: 16\n" + + "snap-id: snap-id-1\n" + + "snap-name: first\n" + + "publisher-id: dev-id1\n" + + "refresh-control:\n - foo\n - bar\n" + + "auto-aliases:\n - cmd1\n - cmd2\n" + + aliases + + "plugs:\n interface1: true\n" + + "slots:\n interface2: true\n" + + sds.tsLine + + "body-length: 0\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==" + + invalidTests := []struct{ original, invalid, expectedErr string }{ + {"series: 16\n", "", `"series" header is mandatory`}, + {"series: 16\n", "series: \n", `"series" header should not be empty`}, + {"snap-id: snap-id-1\n", "", `"snap-id" header is mandatory`}, + {"snap-id: snap-id-1\n", "snap-id: \n", `"snap-id" header should not be empty`}, + {"snap-name: first\n", "", `"snap-name" header is mandatory`}, + {"publisher-id: dev-id1\n", "", `"publisher-id" header is mandatory`}, + {"publisher-id: dev-id1\n", "publisher-id: \n", `"publisher-id" header should not be empty`}, + {"refresh-control:\n - foo\n - bar\n", "refresh-control: foo\n", `"refresh-control" header must be a list of strings`}, + {"refresh-control:\n - foo\n - bar\n", "refresh-control:\n -\n - nested\n", `"refresh-control" header must be a list of strings`}, + {"plugs:\n interface1: true\n", "plugs: \n", `"plugs" header must be a map`}, + {"plugs:\n interface1: true\n", "plugs:\n intf1:\n foo: bar\n", `plug rule for interface "intf1" must specify at least one of.*`}, + {"slots:\n interface2: true\n", "slots: \n", `"slots" header must be a map`}, + {"slots:\n interface2: true\n", "slots:\n intf1:\n foo: bar\n", `slot rule for interface "intf1" must specify at least one of.*`}, + {"auto-aliases:\n - cmd1\n - cmd2\n", "auto-aliases: cmd0\n", `"auto-aliases" header must be a list of strings`}, + {"auto-aliases:\n - cmd1\n - cmd2\n", "auto-aliases:\n -\n - nested\n", `"auto-aliases" header must be a list of strings`}, + {"auto-aliases:\n - cmd1\n - cmd2\n", "auto-aliases:\n - _cmd-1\n - cmd2\n", `"auto-aliases" header contains an invalid element: "_cmd-1"`}, + {aliases, "aliases: cmd0\n", `"aliases" header must be a list of alias maps`}, + {aliases, "aliases:\n - cmd1\n", `"aliases" header must be a list of alias maps`}, + {"name: cmd_1\n", "name: .cmd1\n", `"name" in "aliases" item 1 contains invalid characters: ".cmd1"`}, + {"target: cmd-1\n", "target: -cmd-1\n", `"target" for alias "cmd_1" contains invalid characters: "-cmd-1"`}, + {aliases, aliases + " -\n name: cmd_1\n target: foo\n", `duplicated definition in "aliases" for alias "cmd_1"`}, + {sds.tsLine, "", `"timestamp" header is mandatory`}, + {sds.tsLine, "timestamp: \n", `"timestamp" header should not be empty`}, + {sds.tsLine, "timestamp: 12:30\n", `"timestamp" header is not a RFC3339 date: .*`}, + } + + for _, test := range invalidTests { + invalid := strings.Replace(encoded, test.original, test.invalid, 1) + _, err := asserts.Decode([]byte(invalid)) + c.Check(err, ErrorMatches, snapDeclErrPrefix+test.expectedErr) + } + +} + +func (sds *snapDeclSuite) TestDecodeInvalidWithRevisionAuthority(c *C) { + const revAuth = `revision-authority: + - + account-id: delegated-acc-id + provenance: + - prov1 + - prov2 + min-revision: 100 + max-revision: 1000000 + on-store: + - store1 +` + encoded := "type: snap-declaration\n" + + "authority-id: canonical\n" + + "series: 16\n" + + "snap-id: snap-id-1\n" + + "snap-name: first\n" + + "publisher-id: dev-id1\n" + + "refresh-control:\n - foo\n - bar\n" + + sds.tsLine + + revAuth + + "body-length: 0\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==" + + invalidTests := []struct{ original, invalid, expectedErr string }{ + {revAuth, "revision-authority: x\n", `revision-authority stanza must be a list of maps`}, + {revAuth, "revision-authority:\n - x\n", `revision-authority stanza must be a list of maps`}, + {" account-id: delegated-acc-id\n", "", `"account-id" in revision authority is mandatory`}, + {"account-id: delegated-acc-id\n", "account-id: *\n", `"account-id" in revision authority contains invalid characters: "\*"`}, + {" provenance:\n - prov1\n - prov2\n", " provenance: \n", `provenance in revision authority must be a list of strings`}, + {"prov2\n", "*\n", `provenance in revision authority contains an invalid element: "\*"`}, + {" min-revision: 100\n", " min-revision: 0\n", `"min-revision" in revision authority must be >=1: 0`}, + {" max-revision: 1000000\n", " max-revision: 0\n", `"max-revision" in revision authority must be >=1: 0`}, + {" max-revision: 1000000\n", " max-revision: 10\n", `optional max-revision cannot be less than min-revision in revision-authority`}, + {" on-store:\n - store1\n", " on-store: foo", `on-store in revision-authority must be a list of strings`}, + } + + for _, test := range invalidTests { + invalid := strings.Replace(encoded, test.original, test.invalid, 1) + _, err := asserts.Decode([]byte(invalid)) + c.Check(err, ErrorMatches, snapDeclErrPrefix+test.expectedErr) + } +} + +func (sds *snapDeclSuite) TestDecodePlugsAndSlots(c *C) { + encoded := `type: snap-declaration +format: 1 +authority-id: canonical +series: 16 +snap-id: snap-id-1 +snap-name: first +publisher-id: dev-id1 +plugs: + interface1: + deny-installation: false + allow-auto-connection: + slot-snap-type: + - app + slot-publisher-id: + - acme + slot-attributes: + a1: /foo/.* + plug-attributes: + b1: B1 + deny-auto-connection: + slot-attributes: + a1: !A1 + plug-attributes: + b1: !B1 + interface2: + allow-installation: true + allow-connection: + plug-attributes: + a2: A2 + slot-attributes: + b2: B2 + deny-connection: + slot-snap-id: + - snapidsnapidsnapidsnapidsnapid01 + - snapidsnapidsnapidsnapidsnapid02 + plug-attributes: + a2: !A2 + slot-attributes: + b2: !B2 +slots: + interface3: + deny-installation: false + allow-auto-connection: + plug-snap-type: + - app + plug-publisher-id: + - acme + slot-attributes: + c1: /foo/.* + plug-attributes: + d1: C1 + deny-auto-connection: + slot-attributes: + c1: !C1 + plug-attributes: + d1: !D1 + interface4: + allow-connection: + plug-attributes: + c2: C2 + slot-attributes: + d2: D2 + deny-connection: + plug-snap-id: + - snapidsnapidsnapidsnapidsnapid01 + - snapidsnapidsnapidsnapidsnapid02 + plug-attributes: + c2: !D2 + slot-attributes: + d2: !D2 + allow-installation: + slot-snap-type: + - app + slot-attributes: + e1: E1 +TSLINE +body-length: 0 +sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij + +AXNpZw==` + encoded = strings.Replace(encoded, "TSLINE\n", sds.tsLine, 1) + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + c.Check(a.SupportedFormat(), Equals, true) + snapDecl := a.(*asserts.SnapDeclaration) + c.Check(snapDecl.Series(), Equals, "16") + c.Check(snapDecl.SnapID(), Equals, "snap-id-1") + + c.Check(snapDecl.PlugRule("interfaceX"), IsNil) + c.Check(snapDecl.SlotRule("interfaceX"), IsNil) + + plugRule1 := snapDecl.PlugRule("interface1") + c.Assert(plugRule1, NotNil) + c.Assert(plugRule1.DenyInstallation, HasLen, 1) + c.Check(plugRule1.DenyInstallation[0].PlugAttributes, Equals, asserts.NeverMatchAttributes) + c.Assert(plugRule1.AllowAutoConnection, HasLen, 1) + + plug := emptyAttrerObject{} + slot := emptyAttrerObject{} + + c.Check(plugRule1.AllowAutoConnection[0].SlotAttributes.Check(slot, nil), ErrorMatches, `attribute "a1".*`) + c.Check(plugRule1.AllowAutoConnection[0].PlugAttributes.Check(plug, nil), ErrorMatches, `attribute "b1".*`) + c.Check(plugRule1.AllowAutoConnection[0].SlotSnapTypes, DeepEquals, []string{"app"}) + c.Check(plugRule1.AllowAutoConnection[0].SlotPublisherIDs, DeepEquals, []string{"acme"}) + c.Assert(plugRule1.DenyAutoConnection, HasLen, 1) + c.Check(plugRule1.DenyAutoConnection[0].SlotAttributes.Check(slot, nil), ErrorMatches, `attribute "a1".*`) + c.Check(plugRule1.DenyAutoConnection[0].PlugAttributes.Check(plug, nil), ErrorMatches, `attribute "b1".*`) + plugRule2 := snapDecl.PlugRule("interface2") + c.Assert(plugRule2, NotNil) + c.Assert(plugRule2.AllowInstallation, HasLen, 1) + c.Check(plugRule2.AllowInstallation[0].PlugAttributes, Equals, asserts.AlwaysMatchAttributes) + c.Assert(plugRule2.AllowConnection, HasLen, 1) + c.Check(plugRule2.AllowConnection[0].PlugAttributes.Check(plug, nil), ErrorMatches, `attribute "a2".*`) + c.Check(plugRule2.AllowConnection[0].SlotAttributes.Check(slot, nil), ErrorMatches, `attribute "b2".*`) + c.Assert(plugRule2.DenyConnection, HasLen, 1) + c.Check(plugRule2.DenyConnection[0].PlugAttributes.Check(plug, nil), ErrorMatches, `attribute "a2".*`) + c.Check(plugRule2.DenyConnection[0].SlotAttributes.Check(slot, nil), ErrorMatches, `attribute "b2".*`) + c.Check(plugRule2.DenyConnection[0].SlotSnapIDs, DeepEquals, []string{"snapidsnapidsnapidsnapidsnapid01", "snapidsnapidsnapidsnapidsnapid02"}) + + slotRule3 := snapDecl.SlotRule("interface3") + c.Assert(slotRule3, NotNil) + c.Assert(slotRule3.DenyInstallation, HasLen, 1) + c.Check(slotRule3.DenyInstallation[0].SlotAttributes, Equals, asserts.NeverMatchAttributes) + c.Assert(slotRule3.AllowAutoConnection, HasLen, 1) + c.Check(slotRule3.AllowAutoConnection[0].SlotAttributes.Check(slot, nil), ErrorMatches, `attribute "c1".*`) + c.Check(slotRule3.AllowAutoConnection[0].PlugAttributes.Check(plug, nil), ErrorMatches, `attribute "d1".*`) + c.Check(slotRule3.AllowAutoConnection[0].PlugSnapTypes, DeepEquals, []string{"app"}) + c.Check(slotRule3.AllowAutoConnection[0].PlugPublisherIDs, DeepEquals, []string{"acme"}) + c.Assert(slotRule3.DenyAutoConnection, HasLen, 1) + c.Check(slotRule3.DenyAutoConnection[0].SlotAttributes.Check(slot, nil), ErrorMatches, `attribute "c1".*`) + c.Check(slotRule3.DenyAutoConnection[0].PlugAttributes.Check(plug, nil), ErrorMatches, `attribute "d1".*`) + slotRule4 := snapDecl.SlotRule("interface4") + c.Assert(slotRule4, NotNil) + c.Assert(slotRule4.AllowAutoConnection, HasLen, 1) + c.Check(slotRule4.AllowConnection[0].PlugAttributes.Check(plug, nil), ErrorMatches, `attribute "c2".*`) + c.Check(slotRule4.AllowConnection[0].SlotAttributes.Check(slot, nil), ErrorMatches, `attribute "d2".*`) + c.Assert(slotRule4.DenyAutoConnection, HasLen, 1) + c.Check(slotRule4.DenyConnection[0].PlugAttributes.Check(plug, nil), ErrorMatches, `attribute "c2".*`) + c.Check(slotRule4.DenyConnection[0].SlotAttributes.Check(slot, nil), ErrorMatches, `attribute "d2".*`) + c.Check(slotRule4.DenyConnection[0].PlugSnapIDs, DeepEquals, []string{"snapidsnapidsnapidsnapidsnapid01", "snapidsnapidsnapidsnapidsnapid02"}) + c.Assert(slotRule4.AllowInstallation, HasLen, 1) + c.Check(slotRule4.AllowInstallation[0].SlotAttributes.Check(slot, nil), ErrorMatches, `attribute "e1".*`) + c.Check(slotRule4.AllowInstallation[0].SlotSnapTypes, DeepEquals, []string{"app"}) +} + +func (sds *snapDeclSuite) TestSuggestedFormat(c *C) { + fmtnum, err := asserts.SuggestFormat(asserts.SnapDeclarationType, nil, nil) + c.Assert(err, IsNil) + c.Check(fmtnum, Equals, 0) + + headers := map[string]interface{}{ + "plugs": map[string]interface{}{ + "interface1": "true", + }, + } + fmtnum, err = asserts.SuggestFormat(asserts.SnapDeclarationType, headers, nil) + c.Assert(err, IsNil) + c.Check(fmtnum, Equals, 1) + + headers = map[string]interface{}{ + "slots": map[string]interface{}{ + "interface2": "true", + }, + } + fmtnum, err = asserts.SuggestFormat(asserts.SnapDeclarationType, headers, nil) + c.Assert(err, IsNil) + c.Check(fmtnum, Equals, 1) + + headers = map[string]interface{}{ + "plugs": map[string]interface{}{ + "interface3": map[string]interface{}{ + "allow-auto-connection": map[string]interface{}{ + "plug-attributes": map[string]interface{}{ + "x": "$SLOT(x)", + }, + }, + }, + }, + } + fmtnum, err = asserts.SuggestFormat(asserts.SnapDeclarationType, headers, nil) + c.Assert(err, IsNil) + c.Check(fmtnum, Equals, 2) + + headers = map[string]interface{}{ + "slots": map[string]interface{}{ + "interface3": map[string]interface{}{ + "allow-auto-connection": map[string]interface{}{ + "plug-attributes": map[string]interface{}{ + "x": "$SLOT(x)", + }, + }, + }, + }, + } + fmtnum, err = asserts.SuggestFormat(asserts.SnapDeclarationType, headers, nil) + c.Assert(err, IsNil) + c.Check(fmtnum, Equals, 2) + + // combinations with on-store/on-brand/on-model => format 3 + for _, side := range []string{"plugs", "slots"} { + for k, vals := range deviceScopeConstrs { + + headers := map[string]interface{}{ + side: map[string]interface{}{ + "interface3": map[string]interface{}{ + "allow-installation": map[string]interface{}{ + k: vals, + }, + }, + }, + } + fmtnum, err = asserts.SuggestFormat(asserts.SnapDeclarationType, headers, nil) + c.Assert(err, IsNil) + c.Check(fmtnum, Equals, 3) + + for _, conn := range []string{"connection", "auto-connection"} { + + headers = map[string]interface{}{ + side: map[string]interface{}{ + "interface3": map[string]interface{}{ + "allow-" + conn: map[string]interface{}{ + k: vals, + }, + }, + }, + } + fmtnum, err = asserts.SuggestFormat(asserts.SnapDeclarationType, headers, nil) + c.Assert(err, IsNil) + c.Check(fmtnum, Equals, 3) + } + } + } + + // higher format features win + + headers = map[string]interface{}{ + "plugs": map[string]interface{}{ + "interface3": map[string]interface{}{ + "allow-auto-connection": map[string]interface{}{ + "on-store": []interface{}{"store"}, + }, + }, + }, + "slots": map[string]interface{}{ + "interface4": map[string]interface{}{ + "allow-auto-connection": map[string]interface{}{ + "plug-attributes": map[string]interface{}{ + "x": "$SLOT(x)", + }, + }, + }, + }, + } + fmtnum, err = asserts.SuggestFormat(asserts.SnapDeclarationType, headers, nil) + c.Assert(err, IsNil) + c.Check(fmtnum, Equals, 3) + + headers = map[string]interface{}{ + "plugs": map[string]interface{}{ + "interface4": map[string]interface{}{ + "allow-auto-connection": map[string]interface{}{ + "slot-attributes": map[string]interface{}{ + "x": "$SLOT(x)", + }, + }, + }, + }, + "slots": map[string]interface{}{ + "interface3": map[string]interface{}{ + "allow-auto-connection": map[string]interface{}{ + "on-store": []interface{}{"store"}, + }, + }, + }, + } + fmtnum, err = asserts.SuggestFormat(asserts.SnapDeclarationType, headers, nil) + c.Assert(err, IsNil) + c.Check(fmtnum, Equals, 3) + + // errors + headers = map[string]interface{}{ + "plugs": "what", + } + _, err = asserts.SuggestFormat(asserts.SnapDeclarationType, headers, nil) + c.Assert(err, ErrorMatches, `assertion snap-declaration: "plugs" header must be a map`) + + headers = map[string]interface{}{ + "slots": "what", + } + _, err = asserts.SuggestFormat(asserts.SnapDeclarationType, headers, nil) + c.Assert(err, ErrorMatches, `assertion snap-declaration: "slots" header must be a map`) + + // plug-names/slot-names => format 4 + for _, sidePrefix := range []string{"plug", "slot"} { + side := sidePrefix + "s" + headers := map[string]interface{}{ + side: map[string]interface{}{ + "interface3": map[string]interface{}{ + "allow-installation": map[string]interface{}{ + sidePrefix + "-names": []interface{}{"foo"}, + }, + }, + }, + } + fmtnum, err = asserts.SuggestFormat(asserts.SnapDeclarationType, headers, nil) + c.Assert(err, IsNil) + c.Check(fmtnum, Equals, 4) + + for _, conn := range []string{"connection", "auto-connection"} { + + headers = map[string]interface{}{ + side: map[string]interface{}{ + "interface3": map[string]interface{}{ + "allow-" + conn: map[string]interface{}{ + sidePrefix + "-names": []interface{}{"foo"}, + }, + }, + }, + } + fmtnum, err = asserts.SuggestFormat(asserts.SnapDeclarationType, headers, nil) + c.Assert(err, IsNil) + c.Check(fmtnum, Equals, 4) + + headers = map[string]interface{}{ + side: map[string]interface{}{ + "interface3": map[string]interface{}{ + "allow-" + conn: map[string]interface{}{ + "plug-names": []interface{}{"Pfoo"}, + "slot-names": []interface{}{"Sfoo"}, + }, + }, + }, + } + fmtnum, err = asserts.SuggestFormat(asserts.SnapDeclarationType, headers, nil) + c.Assert(err, IsNil) + c.Check(fmtnum, Equals, 4) + } + } + + // alt matcher (so far unused) => format 5 + for _, sidePrefix := range []string{"plug", "slot"} { + headers = map[string]interface{}{ + sidePrefix + "s": map[string]interface{}{ + "interface5": map[string]interface{}{ + "allow-auto-connection": map[string]interface{}{ + sidePrefix + "-attributes": map[string]interface{}{ + "x": []interface{}{"alt1", "alt2"}, // alt matcher + }, + }, + }, + }, + } + fmtnum, err = asserts.SuggestFormat(asserts.SnapDeclarationType, headers, nil) + c.Assert(err, IsNil) + c.Check(fmtnum, Equals, 5) + } +} + +func prereqDevAccount(c *C, storeDB assertstest.SignerDB, db *asserts.Database) { + dev1Acct := assertstest.NewAccount(storeDB, "developer1", map[string]interface{}{ + "account-id": "dev-id1", + }, "") + err := db.Add(dev1Acct) + c.Assert(err, IsNil) +} + +func (sds *snapDeclSuite) TestSnapDeclarationCheck(c *C) { + storeDB, db := makeStoreAndCheckDB(c) + + prereqDevAccount(c, storeDB, db) + + headers := map[string]interface{}{ + "series": "16", + "snap-id": "snap-id-1", + "snap-name": "foo", + "publisher-id": "dev-id1", + "timestamp": time.Now().Format(time.RFC3339), + } + snapDecl, err := storeDB.Sign(asserts.SnapDeclarationType, headers, nil, "") + c.Assert(err, IsNil) + + err = db.Check(snapDecl) + c.Assert(err, IsNil) +} + +func (sds *snapDeclSuite) TestSnapDeclarationCheckUntrustedAuthority(c *C) { + storeDB, db := makeStoreAndCheckDB(c) + + otherDB := setup3rdPartySigning(c, "other", storeDB, db) + + headers := map[string]interface{}{ + "series": "16", + "snap-id": "snap-id-1", + "snap-name": "foo", + "publisher-id": "dev-id1", + "timestamp": time.Now().Format(time.RFC3339), + } + snapDecl, err := otherDB.Sign(asserts.SnapDeclarationType, headers, nil, "") + c.Assert(err, IsNil) + + err = db.Check(snapDecl) + c.Assert(err, ErrorMatches, `snap-declaration assertion for "foo" \(id "snap-id-1"\) is not signed by a directly trusted authority:.*`) +} + +func (sds *snapDeclSuite) TestSnapDeclarationCheckMissingPublisherAccount(c *C) { + storeDB, db := makeStoreAndCheckDB(c) + + headers := map[string]interface{}{ + "series": "16", + "snap-id": "snap-id-1", + "snap-name": "foo", + "publisher-id": "dev-id1", + "timestamp": time.Now().Format(time.RFC3339), + } + snapDecl, err := storeDB.Sign(asserts.SnapDeclarationType, headers, nil, "") + c.Assert(err, IsNil) + + err = db.Check(snapDecl) + c.Assert(err, ErrorMatches, `snap-declaration assertion for "foo" \(id "snap-id-1"\) does not have a matching account assertion for the publisher "dev-id1"`) +} + +type snapFileDigestSuite struct{} + +func (s *snapFileDigestSuite) TestSnapFileSHA3_384(c *C) { + exData := []byte("hashmeplease") + + tempdir := c.MkDir() + snapFn := filepath.Join(tempdir, "ex.snap") + err := os.WriteFile(snapFn, exData, 0644) + c.Assert(err, IsNil) + + encDgst, size, err := asserts.SnapFileSHA3_384(snapFn) + c.Assert(err, IsNil) + c.Check(size, Equals, uint64(len(exData))) + + h3_384 := sha3.Sum384(exData) + expected := base64.RawURLEncoding.EncodeToString(h3_384[:]) + c.Check(encDgst, DeepEquals, expected) +} + +type snapBuildSuite struct { + ts time.Time + tsLine string +} + +func (sds *snapDeclSuite) TestPrerequisites(c *C) { + encoded := "type: snap-declaration\n" + + "authority-id: canonical\n" + + "series: 16\n" + + "snap-id: snap-id-1\n" + + "snap-name: first\n" + + "publisher-id: dev-id1\n" + + sds.tsLine + + "body-length: 0\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==" + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + + prereqs := a.Prerequisites() + c.Assert(prereqs, HasLen, 1) + c.Check(prereqs[0], DeepEquals, &asserts.Ref{ + Type: asserts.AccountType, + PrimaryKey: []string{"dev-id1"}, + }) +} + +func (sbs *snapBuildSuite) SetUpSuite(c *C) { + sbs.ts = time.Now().Truncate(time.Second).UTC() + sbs.tsLine = "timestamp: " + sbs.ts.Format(time.RFC3339) + "\n" +} + +const ( + blobSHA3_384 = "QlqR0uAWEAWF5Nwnzj5kqmmwFslYPu1IL16MKtLKhwhv0kpBv5wKZ_axf_nf_2cL" +) + +func (sbs *snapBuildSuite) TestDecodeOK(c *C) { + encoded := "type: snap-build\n" + + "authority-id: dev-id1\n" + + "snap-sha3-384: " + blobSHA3_384 + "\n" + + "grade: stable\n" + + "snap-id: snap-id-1\n" + + "snap-size: 10000\n" + + sbs.tsLine + + "body-length: 0\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==" + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + c.Check(a.Type(), Equals, asserts.SnapBuildType) + snapBuild := a.(*asserts.SnapBuild) + c.Check(snapBuild.AuthorityID(), Equals, "dev-id1") + c.Check(snapBuild.Timestamp(), Equals, sbs.ts) + c.Check(snapBuild.SnapID(), Equals, "snap-id-1") + c.Check(snapBuild.SnapSHA3_384(), Equals, blobSHA3_384) + c.Check(snapBuild.SnapSize(), Equals, uint64(10000)) + c.Check(snapBuild.Grade(), Equals, "stable") +} + +const ( + snapBuildErrPrefix = "assertion snap-build: " +) + +func (sbs *snapBuildSuite) TestDecodeInvalid(c *C) { + digestHdr := "snap-sha3-384: " + blobSHA3_384 + "\n" + + encoded := "type: snap-build\n" + + "authority-id: dev-id1\n" + + digestHdr + + "grade: stable\n" + + "snap-id: snap-id-1\n" + + "snap-size: 10000\n" + + sbs.tsLine + + "body-length: 0\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==" + + invalidTests := []struct{ original, invalid, expectedErr string }{ + {"snap-id: snap-id-1\n", "", `"snap-id" header is mandatory`}, + {"snap-id: snap-id-1\n", "snap-id: \n", `"snap-id" header should not be empty`}, + {digestHdr, "", `"snap-sha3-384" header is mandatory`}, + {digestHdr, "snap-sha3-384: \n", `"snap-sha3-384" header should not be empty`}, + {digestHdr, "snap-sha3-384: #\n", `"snap-sha3-384" header cannot be decoded:.*`}, + {"snap-size: 10000\n", "", `"snap-size" header is mandatory`}, + {"snap-size: 10000\n", "snap-size: -1\n", `"snap-size" header is not an unsigned integer: -1`}, + {"snap-size: 10000\n", "snap-size: zzz\n", `"snap-size" header is not an unsigned integer: zzz`}, + {"snap-size: 10000\n", "snap-size: 010\n", `"snap-size" header has invalid prefix zeros: 010`}, + {"snap-size: 10000\n", "snap-size: 99999999999999999999\n", `"snap-size" header is out of range: 99999999999999999999`}, + {"grade: stable\n", "", `"grade" header is mandatory`}, + {"grade: stable\n", "grade: \n", `"grade" header should not be empty`}, + {sbs.tsLine, "", `"timestamp" header is mandatory`}, + {sbs.tsLine, "timestamp: \n", `"timestamp" header should not be empty`}, + {sbs.tsLine, "timestamp: 12:30\n", `"timestamp" header is not a RFC3339 date: .*`}, + } + + for _, test := range invalidTests { + invalid := strings.Replace(encoded, test.original, test.invalid, 1) + _, err := asserts.Decode([]byte(invalid)) + c.Check(err, ErrorMatches, snapBuildErrPrefix+test.expectedErr) + } +} + +func makeStoreAndCheckDB(c *C) (store *assertstest.StoreStack, checkDB *asserts.Database) { + store = assertstest.NewStoreStack("canonical", nil) + cfg := &asserts.DatabaseConfig{ + Backstore: asserts.NewMemoryBackstore(), + Trusted: store.Trusted, + OtherPredefined: store.Generic, + } + checkDB, err := asserts.OpenDatabase(cfg) + c.Assert(err, IsNil) + + // add store key + err = checkDB.Add(store.StoreAccountKey("")) + c.Assert(err, IsNil) + // add generic key + err = checkDB.Add(store.GenericKey) + c.Assert(err, IsNil) + + return store, checkDB +} + +func setup3rdPartySigning(c *C, username string, storeDB assertstest.SignerDB, checkDB *asserts.Database) (signingDB *assertstest.SigningDB) { + privKey := testPrivKey2 + + acct := assertstest.NewAccount(storeDB, username, map[string]interface{}{ + "account-id": username, + }, "") + accKey := assertstest.NewAccountKey(storeDB, acct, nil, privKey.PublicKey(), "") + + err := checkDB.Add(acct) + c.Assert(err, IsNil) + err = checkDB.Add(accKey) + c.Assert(err, IsNil) + + return assertstest.NewSigningDB(acct.AccountID(), privKey) +} + +func (sbs *snapBuildSuite) TestSnapBuildCheck(c *C) { + storeDB, db := makeStoreAndCheckDB(c) + devDB := setup3rdPartySigning(c, "devel1", storeDB, db) + + headers := map[string]interface{}{ + "authority-id": "devel1", + "snap-sha3-384": blobSHA3_384, + "snap-id": "snap-id-1", + "grade": "devel", + "snap-size": "1025", + "timestamp": time.Now().Format(time.RFC3339), + } + snapBuild, err := devDB.Sign(asserts.SnapBuildType, headers, nil, "") + c.Assert(err, IsNil) + + err = db.Check(snapBuild) + c.Assert(err, IsNil) +} + +func (sbs *snapBuildSuite) TestSnapBuildCheckInconsistentTimestamp(c *C) { + storeDB, db := makeStoreAndCheckDB(c) + devDB := setup3rdPartySigning(c, "devel1", storeDB, db) + + headers := map[string]interface{}{ + "snap-sha3-384": blobSHA3_384, + "snap-id": "snap-id-1", + "grade": "devel", + "snap-size": "1025", + "timestamp": "2013-01-01T14:00:00Z", + } + snapBuild, err := devDB.Sign(asserts.SnapBuildType, headers, nil, "") + c.Assert(err, IsNil) + + err = db.Check(snapBuild) + c.Assert(err, ErrorMatches, `snap-build assertion timestamp "2013-01-01 14:00:00 \+0000 UTC" outside of signing key validity \(key valid since.*\)`) +} + +type snapRevSuite struct { + ts time.Time + tsLine string +} + +func (srs *snapRevSuite) SetUpSuite(c *C) { + srs.ts = time.Now().Truncate(time.Second).UTC() + srs.tsLine = "timestamp: " + srs.ts.Format(time.RFC3339) + "\n" +} + +func (srs *snapRevSuite) makeValidEncoded() string { + return "type: snap-revision\n" + + "authority-id: store-id1\n" + + "snap-sha3-384: " + blobSHA3_384 + "\n" + + "snap-id: snap-id-1\n" + + "snap-size: 123\n" + + "snap-revision: 1\n" + + "developer-id: dev-id1\n" + + "revision: 1\n" + + srs.tsLine + + "body-length: 0\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==" +} + +func (srs *snapRevSuite) makeValidEncodedWithIntegrity() string { + return "type: snap-revision\n" + + "authority-id: store-id1\n" + + "snap-sha3-384: " + blobSHA3_384 + "\n" + + "snap-id: snap-id-1\n" + + "snap-size: 123\n" + + "snap-revision: 1\n" + + "integrity:\n" + + " sha3-384: " + blobSHA3_384 + "\n" + + " size: 128\n" + + "developer-id: dev-id1\n" + + "revision: 1\n" + + srs.tsLine + + "body-length: 0\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==" +} + +func makeSnapRevisionHeaders(overrides map[string]interface{}) map[string]interface{} { + headers := map[string]interface{}{ + "authority-id": "canonical", + "snap-sha3-384": blobSHA3_384, + "snap-id": "snap-id-1", + "snap-size": "123", + "snap-revision": "1", + "developer-id": "dev-id1", + "revision": "1", + "timestamp": time.Now().Format(time.RFC3339), + } + for k, v := range overrides { + headers[k] = v + } + return headers +} + +func (srs *snapRevSuite) makeHeaders(overrides map[string]interface{}) map[string]interface{} { + return makeSnapRevisionHeaders(overrides) +} + +func (srs *snapRevSuite) TestDecodeOK(c *C) { + encoded := srs.makeValidEncoded() + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + c.Check(a.Type(), Equals, asserts.SnapRevisionType) + snapRev := a.(*asserts.SnapRevision) + c.Check(snapRev.AuthorityID(), Equals, "store-id1") + c.Check(snapRev.Timestamp(), Equals, srs.ts) + c.Check(snapRev.SnapID(), Equals, "snap-id-1") + c.Check(snapRev.SnapSHA3_384(), Equals, blobSHA3_384) + c.Check(snapRev.SnapSize(), Equals, uint64(123)) + c.Check(snapRev.SnapRevision(), Equals, 1) + c.Check(snapRev.DeveloperID(), Equals, "dev-id1") + c.Check(snapRev.Revision(), Equals, 1) + c.Check(snapRev.Provenance(), Equals, "global-upload") +} + +func (srs *snapRevSuite) TestDecodeOKWithProvenance(c *C) { + encoded := srs.makeValidEncoded() + encoded = strings.Replace(encoded, "snap-id: snap-id-1", "provenance: foo\nsnap-id: snap-id-1", 1) + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + c.Check(a.Type(), Equals, asserts.SnapRevisionType) + snapRev := a.(*asserts.SnapRevision) + c.Check(snapRev.AuthorityID(), Equals, "store-id1") + c.Check(snapRev.Timestamp(), Equals, srs.ts) + c.Check(snapRev.SnapID(), Equals, "snap-id-1") + c.Check(snapRev.SnapSHA3_384(), Equals, blobSHA3_384) + c.Check(snapRev.SnapSize(), Equals, uint64(123)) + c.Check(snapRev.SnapRevision(), Equals, 1) + c.Check(snapRev.DeveloperID(), Equals, "dev-id1") + c.Check(snapRev.Revision(), Equals, 1) + c.Check(snapRev.Provenance(), Equals, "foo") +} + +func (srs *snapRevSuite) TestDecodeOKWithIntegrity(c *C) { + encoded := srs.makeValidEncodedWithIntegrity() + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + c.Check(a.Type(), Equals, asserts.SnapRevisionType) + snapRev := a.(*asserts.SnapRevision) + c.Check(snapRev.AuthorityID(), Equals, "store-id1") + c.Check(snapRev.Timestamp(), Equals, srs.ts) + c.Check(snapRev.SnapID(), Equals, "snap-id-1") + c.Check(snapRev.SnapSHA3_384(), Equals, blobSHA3_384) + c.Check(snapRev.SnapSize(), Equals, uint64(123)) + c.Check(snapRev.SnapRevision(), Equals, 1) + c.Check(snapRev.DeveloperID(), Equals, "dev-id1") + c.Check(snapRev.Revision(), Equals, 1) + c.Check(snapRev.Provenance(), Equals, "global-upload") + c.Check(snapRev.SnapIntegrity().SHA3_384, Equals, blobSHA3_384) + c.Check(snapRev.SnapIntegrity().Size, Equals, uint64(128)) +} + +const ( + snapRevErrPrefix = "assertion snap-revision: " +) + +func (srs *snapRevSuite) TestDecodeInvalid(c *C) { + encoded := srs.makeValidEncoded() + + digestHdr := "snap-sha3-384: " + blobSHA3_384 + "\n" + invalidTests := []struct{ original, invalid, expectedErr string }{ + {"snap-id: snap-id-1\n", "", `"snap-id" header is mandatory`}, + {"snap-id: snap-id-1\n", "snap-id: \n", `"snap-id" header should not be empty`}, + {digestHdr, "", `"snap-sha3-384" header is mandatory`}, + {digestHdr, "snap-sha3-384: \n", `"snap-sha3-384" header should not be empty`}, + {digestHdr, "snap-sha3-384: #\n", `"snap-sha3-384" header cannot be decoded:.*`}, + {digestHdr, "snap-sha3-384: eHl6\n", `"snap-sha3-384" header does not have the expected bit length: 24`}, + {"snap-id: snap-id-1\n", "provenance: \nsnap-id: snap-id-1\n", `"provenance" header should not be empty`}, + {"snap-id: snap-id-1\n", "provenance: *\nsnap-id: snap-id-1\n", `"provenance" header contains invalid characters: "\*"`}, + {"snap-size: 123\n", "", `"snap-size" header is mandatory`}, + {"snap-size: 123\n", "snap-size: \n", `"snap-size" header should not be empty`}, + {"snap-size: 123\n", "snap-size: -1\n", `"snap-size" header is not an unsigned integer: -1`}, + {"snap-size: 123\n", "snap-size: zzz\n", `"snap-size" header is not an unsigned integer: zzz`}, + {"snap-revision: 1\n", "", `"snap-revision" header is mandatory`}, + {"snap-revision: 1\n", "snap-revision: \n", `"snap-revision" header should not be empty`}, + {"snap-revision: 1\n", "snap-revision: -1\n", `"snap-revision" header must be >=1: -1`}, + {"snap-revision: 1\n", "snap-revision: 0\n", `"snap-revision" header must be >=1: 0`}, + {"snap-revision: 1\n", "snap-revision: zzz\n", `"snap-revision" header is not an integer: zzz`}, + {"developer-id: dev-id1\n", "", `"developer-id" header is mandatory`}, + {"developer-id: dev-id1\n", "developer-id: \n", `"developer-id" header should not be empty`}, + {srs.tsLine, "", `"timestamp" header is mandatory`}, + {srs.tsLine, "timestamp: \n", `"timestamp" header should not be empty`}, + {srs.tsLine, "timestamp: 12:30\n", `"timestamp" header is not a RFC3339 date: .*`}, + } + + for _, test := range invalidTests { + invalid := strings.Replace(encoded, test.original, test.invalid, 1) + _, err := asserts.Decode([]byte(invalid)) + c.Check(err, ErrorMatches, snapRevErrPrefix+test.expectedErr) + } +} + +func (srs *snapRevSuite) TestDecodeInvalidWithIntegrity(c *C) { + encoded := srs.makeValidEncodedWithIntegrity() + + integrityHdr := "integrity:\n" + + " sha3-384: " + blobSHA3_384 + "\n" + + " size: 128\n" + + integrityShaHdr := " sha3-384: " + blobSHA3_384 + "\n" + + integritySizeHdr := " size: 128\n" + + invalidTests := []struct{ original, invalid, expectedErr string }{ + {integrityHdr, "integrity: \n", `"integrity" header must be a map`}, + {integrityShaHdr, " sha3-384: \n", `"sha3-384" of integrity header should not be empty`}, + {integrityShaHdr, " sha3-384: #\n", `"sha3-384" of integrity header cannot be decoded:.*`}, + {integrityShaHdr, " sha3-384: eHl6\n", `"sha3-384" of integrity header does not have the expected bit length: 24`}, + {integritySizeHdr, "", `"size" of integrity header is mandatory`}, + {integritySizeHdr, " size: \n", `"size" of integrity header should not be empty`}, + {integritySizeHdr, " size: -1\n", `"size" of integrity header is not an unsigned integer: -1`}, + {integritySizeHdr, " size: zzz\n", `"size" of integrity header is not an unsigned integer: zzz`}, + } + + for _, test := range invalidTests { + invalid := strings.Replace(encoded, test.original, test.invalid, 1) + _, err := asserts.Decode([]byte(invalid)) + c.Check(err, ErrorMatches, snapRevErrPrefix+test.expectedErr) + } +} + +func prereqSnapDecl(c *C, storeDB assertstest.SignerDB, db *asserts.Database) { + snapDecl, err := storeDB.Sign(asserts.SnapDeclarationType, map[string]interface{}{ + "series": "16", + "snap-id": "snap-id-1", + "snap-name": "foo", + "publisher-id": "dev-id1", + "timestamp": time.Now().Format(time.RFC3339), + }, nil, "") + c.Assert(err, IsNil) + err = db.Add(snapDecl) + c.Assert(err, IsNil) +} + +func (srs *snapRevSuite) TestSnapRevisionCheck(c *C) { + storeDB, db := makeStoreAndCheckDB(c) + + prereqDevAccount(c, storeDB, db) + prereqSnapDecl(c, storeDB, db) + + headers := srs.makeHeaders(nil) + snapRev, err := storeDB.Sign(asserts.SnapRevisionType, headers, nil, "") + c.Assert(err, IsNil) + + err = db.Check(snapRev) + c.Assert(err, IsNil) +} + +func (srs *snapRevSuite) TestSnapRevisionCheckInconsistentTimestamp(c *C) { + storeDB, db := makeStoreAndCheckDB(c) + + headers := srs.makeHeaders(map[string]interface{}{ + "timestamp": "2013-01-01T14:00:00Z", + }) + snapRev, err := storeDB.Sign(asserts.SnapRevisionType, headers, nil, "") + c.Assert(err, IsNil) + + err = db.Check(snapRev) + c.Assert(err, ErrorMatches, `snap-revision assertion timestamp "2013-01-01 14:00:00 \+0000 UTC" outside of signing key validity \(key valid since.*\)`) +} + +func (srs *snapRevSuite) TestSnapRevisionCheckUntrustedAuthority(c *C) { + storeDB, db := makeStoreAndCheckDB(c) + + otherDB := setup3rdPartySigning(c, "other", storeDB, db) + + headers := srs.makeHeaders(map[string]interface{}{ + "authority-id": "other", + }) + snapRev, err := otherDB.Sign(asserts.SnapRevisionType, headers, nil, "") + c.Assert(err, IsNil) + + err = db.Check(snapRev) + c.Assert(err, ErrorMatches, `snap-revision assertion for snap id "snap-id-1" is not signed by a store:.*`) +} + +func (srs *snapRevSuite) TestSnapRevisionCheckMissingDeveloperAccount(c *C) { + storeDB, db := makeStoreAndCheckDB(c) + + headers := srs.makeHeaders(nil) + snapRev, err := storeDB.Sign(asserts.SnapRevisionType, headers, nil, "") + c.Assert(err, IsNil) + + err = db.Check(snapRev) + c.Assert(err, ErrorMatches, `snap-revision assertion for snap id "snap-id-1" does not have a matching account assertion for the developer "dev-id1"`) +} + +func (srs *snapRevSuite) TestSnapRevisionCheckMissingDeclaration(c *C) { + storeDB, db := makeStoreAndCheckDB(c) + + prereqDevAccount(c, storeDB, db) + + headers := srs.makeHeaders(nil) + snapRev, err := storeDB.Sign(asserts.SnapRevisionType, headers, nil, "") + c.Assert(err, IsNil) + + err = db.Check(snapRev) + c.Assert(err, ErrorMatches, `snap-revision assertion for snap id "snap-id-1" does not have a matching snap-declaration assertion`) +} + +func (srs *snapRevSuite) TestRevisionAuthorityCheck(c *C) { + storeDB, db := makeStoreAndCheckDB(c) + + delegatedDB := setup3rdPartySigning(c, "delegated-id", storeDB, db) + headers := srs.makeHeaders(map[string]interface{}{ + "authority-id": "delegated-id", + "developer-id": "delegated-id", + "snap-revision": "200", + "provenance": "prov1", + }) + a, err := delegatedDB.Sign(asserts.SnapRevisionType, headers, nil, "") + c.Assert(err, IsNil) + snapRev := a.(*asserts.SnapRevision) + + tests := []struct { + revAuth asserts.RevisionAuthority + err string + }{ + {asserts.RevisionAuthority{ + AccountID: "delegated-id", + Provenance: []string{"prov1", "prov2"}, + MinRevision: 1, + }, ""}, + {asserts.RevisionAuthority{ + AccountID: "delegated-id", + Provenance: []string{"prov1", "prov2"}, + MinRevision: 1, + MaxRevision: 1000, + }, ""}, + {asserts.RevisionAuthority{ + AccountID: "delegated-id", + Provenance: []string{"prov2"}, + MinRevision: 1, + MaxRevision: 1000, + }, "provenance mismatch"}, + {asserts.RevisionAuthority{ + AccountID: "delegated-id-2", + Provenance: []string{"prov1", "prov2"}, + MinRevision: 1, + MaxRevision: 1000, + }, "authority-id mismatch"}, + {asserts.RevisionAuthority{ + AccountID: "delegated-id", + Provenance: []string{"prov1", "prov2"}, + MinRevision: 1000, + }, "snap revision 200 is less than min-revision 1000"}, + {asserts.RevisionAuthority{ + AccountID: "delegated-id", + Provenance: []string{"prov1", "prov2"}, + MinRevision: 10, + MaxRevision: 110, + }, "snap revision 200 is greater than max-revision 110"}, + } + + for _, t := range tests { + err := t.revAuth.Check(snapRev, nil, nil) + if t.err == "" { + c.Check(err, IsNil) + } else { + c.Check(err, ErrorMatches, t.err) + } + } +} + +func (srs *snapRevSuite) TestRevisionAuthorityCheckDeviceScope(c *C) { + a, err := asserts.Decode([]byte(`type: model +authority-id: my-brand +series: 16 +brand-id: my-brand +model: my-model +store: substore +architecture: armhf +kernel: krnl +gadget: gadget +timestamp: 2018-09-12T12:00:00Z +sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij + +AXNpZw==`)) + c.Assert(err, IsNil) + myModel := a.(*asserts.Model) + + a, err = asserts.Decode([]byte(`type: store +store: substore +authority-id: canonical +operator-id: canonical +friendly-stores: + - a-store + - store1 + - store2 +timestamp: 2018-09-12T12:00:00Z +sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij + +AXNpZw==`)) + c.Assert(err, IsNil) + substore := a.(*asserts.Store) + + storeDB, db := makeStoreAndCheckDB(c) + + delegatedDB := setup3rdPartySigning(c, "my-brand", storeDB, db) + headers := srs.makeHeaders(map[string]interface{}{ + "authority-id": "my-brand", + "developer-id": "my-brand", + "snap-revision": "200", + "provenance": "prov1", + }) + a, err = delegatedDB.Sign(asserts.SnapRevisionType, headers, nil, "") + c.Assert(err, IsNil) + snapRev := a.(*asserts.SnapRevision) + + tests := []struct { + revAuth asserts.RevisionAuthority + substore *asserts.Store + err string + }{ + {asserts.RevisionAuthority{ + AccountID: "my-brand", + Provenance: []string{"prov1", "prov2"}, + MinRevision: 1, + }, nil, ""}, + {asserts.RevisionAuthority{ + AccountID: "my-brand", + Provenance: []string{"prov1", "prov2"}, + MinRevision: 1, + DeviceScope: &asserts.DeviceScopeConstraint{ + Store: []string{"other-store"}, + }, + }, nil, "on-store mismatch"}, + {asserts.RevisionAuthority{ + AccountID: "my-brand", + Provenance: []string{"prov1", "prov2"}, + MinRevision: 1, + DeviceScope: &asserts.DeviceScopeConstraint{ + Store: []string{"substore"}, + }, + }, nil, ""}, + {asserts.RevisionAuthority{ + AccountID: "my-brand", + Provenance: []string{"prov1", "prov2"}, + MinRevision: 1, + DeviceScope: &asserts.DeviceScopeConstraint{ + Store: []string{"substore"}, + }, + }, substore, ""}, + {asserts.RevisionAuthority{ + AccountID: "my-brand", + Provenance: []string{"prov1", "prov2"}, + MinRevision: 1, + DeviceScope: &asserts.DeviceScopeConstraint{ + Store: []string{"a-store"}, + }, + }, substore, ""}, + {asserts.RevisionAuthority{ + AccountID: "my-brand", + Provenance: []string{"prov1", "prov2"}, + MinRevision: 1, + DeviceScope: &asserts.DeviceScopeConstraint{ + Store: []string{"store1"}, + }, + }, nil, "on-store mismatch"}, + {asserts.RevisionAuthority{ + AccountID: "my-brand", + Provenance: []string{"prov1", "prov2"}, + MinRevision: 1, + DeviceScope: &asserts.DeviceScopeConstraint{ + Store: []string{"store1", "other-store"}, + }, + }, substore, ""}, + } + + for _, t := range tests { + err := t.revAuth.Check(snapRev, myModel, t.substore) + if t.err == "" { + c.Check(err, IsNil) + } else { + c.Check(err, ErrorMatches, t.err) + } + } +} + +func (srs *snapRevSuite) TestSnapRevisionDelegation(c *C) { + storeDB, db := makeStoreAndCheckDB(c) + + delegatedDB := setup3rdPartySigning(c, "delegated-id", storeDB, db) + + snapDecl, err := storeDB.Sign(asserts.SnapDeclarationType, map[string]interface{}{ + "series": "16", + "snap-id": "snap-id-1", + "snap-name": "foo", + "publisher-id": "delegated-id", + "timestamp": time.Now().Format(time.RFC3339), + }, nil, "") + c.Assert(err, IsNil) + err = db.Add(snapDecl) + c.Assert(err, IsNil) + + headers := srs.makeHeaders(map[string]interface{}{ + "authority-id": "delegated-id", + "developer-id": "delegated-id", + "provenance": "prov1", + }) + snapRev, err := delegatedDB.Sign(asserts.SnapRevisionType, headers, nil, "") + c.Assert(err, IsNil) + + err = db.Check(snapRev) + c.Check(err, ErrorMatches, `snap-revision assertion with provenance "prov1" for snap id "snap-id-1" is not signed by an authorized authority: delegated-id`) + + // establish delegation + snapDecl, err = storeDB.Sign(asserts.SnapDeclarationType, map[string]interface{}{ + "series": "16", + "snap-id": "snap-id-1", + "snap-name": "foo", + "publisher-id": "delegated-id", + "revision": "1", + "revision-authority": []interface{}{ + map[string]interface{}{ + "account-id": "delegated-id", + "provenance": []interface{}{ + "prov1", + }, + // present but not checked at this level + "on-store": []interface{}{ + "store1", + }, + }, + }, + "timestamp": time.Now().Format(time.RFC3339), + }, nil, "") + c.Assert(err, IsNil) + err = db.Add(snapDecl) + c.Assert(err, IsNil) + + // now revision should be accepted + err = db.Check(snapRev) + c.Check(err, IsNil) +} + +func (srs *snapRevSuite) TestSnapRevisionDelegationRevisionOutOfRange(c *C) { + storeDB, db := makeStoreAndCheckDB(c) + + delegatedDB := setup3rdPartySigning(c, "delegated-id", storeDB, db) + + // establish delegation + snapDecl, err := storeDB.Sign(asserts.SnapDeclarationType, map[string]interface{}{ + "series": "16", + "snap-id": "snap-id-1", + "snap-name": "foo", + "publisher-id": "delegated-id", + "revision-authority": []interface{}{ + map[string]interface{}{ + "account-id": "delegated-id", + "provenance": []interface{}{ + "prov1", + }, + // present but not checked at this level + "on-store": []interface{}{ + "store1", + }, + "max-revision": "200", + }, + }, + "timestamp": time.Now().Format(time.RFC3339), + }, nil, "") + c.Assert(err, IsNil) + err = db.Add(snapDecl) + c.Assert(err, IsNil) + + headers := srs.makeHeaders(map[string]interface{}{ + "authority-id": "delegated-id", + "developer-id": "delegated-id", + "provenance": "prov1", + "snap-revision": "1000", + }) + snapRev, err := delegatedDB.Sign(asserts.SnapRevisionType, headers, nil, "") + c.Assert(err, IsNil) + + err = db.Check(snapRev) + c.Check(err, ErrorMatches, `snap-revision assertion with provenance "prov1" for snap id "snap-id-1" is not signed by an authorized authority: delegated-id`) +} + +func (srs *snapRevSuite) TestPrimaryKey(c *C) { + storeDB, db := makeStoreAndCheckDB(c) + + prereqDevAccount(c, storeDB, db) + prereqSnapDecl(c, storeDB, db) + + headers := srs.makeHeaders(nil) + snapRev, err := storeDB.Sign(asserts.SnapRevisionType, headers, nil, "") + c.Assert(err, IsNil) + err = db.Add(snapRev) + c.Assert(err, IsNil) + + _, err = db.Find(asserts.SnapRevisionType, map[string]string{ + "snap-sha3-384": headers["snap-sha3-384"].(string), + }) + c.Assert(err, IsNil) +} + +func (srs *snapRevSuite) TestPrerequisites(c *C) { + encoded := srs.makeValidEncoded() + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + + prereqs := a.Prerequisites() + c.Assert(prereqs, HasLen, 2) + c.Check(prereqs[0], DeepEquals, &asserts.Ref{ + Type: asserts.SnapDeclarationType, + PrimaryKey: []string{"16", "snap-id-1"}, + }) + c.Check(prereqs[1], DeepEquals, &asserts.Ref{ + Type: asserts.AccountType, + PrimaryKey: []string{"dev-id1"}, + }) +} + +type validationSuite struct { + ts time.Time + tsLine string +} + +func (vs *validationSuite) SetUpSuite(c *C) { + vs.ts = time.Now().Truncate(time.Second).UTC() + vs.tsLine = "timestamp: " + vs.ts.Format(time.RFC3339) + "\n" +} + +func (vs *validationSuite) makeValidEncoded() string { + return "type: validation\n" + + "authority-id: dev-id1\n" + + "series: 16\n" + + "snap-id: snap-id-1\n" + + "approved-snap-id: snap-id-2\n" + + "approved-snap-revision: 42\n" + + "revision: 1\n" + + vs.tsLine + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==" +} + +func (vs *validationSuite) makeHeaders(overrides map[string]interface{}) map[string]interface{} { + headers := map[string]interface{}{ + "authority-id": "dev-id1", + "series": "16", + "snap-id": "snap-id-1", + "approved-snap-id": "snap-id-2", + "approved-snap-revision": "42", + "revision": "1", + "timestamp": time.Now().Format(time.RFC3339), + } + for k, v := range overrides { + headers[k] = v + } + return headers +} + +func (vs *validationSuite) TestDecodeOK(c *C) { + encoded := vs.makeValidEncoded() + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + c.Check(a.Type(), Equals, asserts.ValidationType) + validation := a.(*asserts.Validation) + c.Check(validation.AuthorityID(), Equals, "dev-id1") + c.Check(validation.Timestamp(), Equals, vs.ts) + c.Check(validation.Series(), Equals, "16") + c.Check(validation.SnapID(), Equals, "snap-id-1") + c.Check(validation.ApprovedSnapID(), Equals, "snap-id-2") + c.Check(validation.ApprovedSnapRevision(), Equals, 42) + c.Check(validation.Revoked(), Equals, false) + c.Check(validation.Revision(), Equals, 1) +} + +const ( + validationErrPrefix = "assertion validation: " +) + +func (vs *validationSuite) TestDecodeInvalid(c *C) { + encoded := vs.makeValidEncoded() + + invalidTests := []struct{ original, invalid, expectedErr string }{ + {"series: 16\n", "", `"series" header is mandatory`}, + {"series: 16\n", "series: \n", `"series" header should not be empty`}, + {"snap-id: snap-id-1\n", "", `"snap-id" header is mandatory`}, + {"snap-id: snap-id-1\n", "snap-id: \n", `"snap-id" header should not be empty`}, + {"approved-snap-id: snap-id-2\n", "", `"approved-snap-id" header is mandatory`}, + {"approved-snap-id: snap-id-2\n", "approved-snap-id: \n", `"approved-snap-id" header should not be empty`}, + {"approved-snap-revision: 42\n", "", `"approved-snap-revision" header is mandatory`}, + {"approved-snap-revision: 42\n", "approved-snap-revision: z\n", `"approved-snap-revision" header is not an integer: z`}, + {"approved-snap-revision: 42\n", "approved-snap-revision: 0\n", `"approved-snap-revision" header must be >=1: 0`}, + {"approved-snap-revision: 42\n", "approved-snap-revision: -1\n", `"approved-snap-revision" header must be >=1: -1`}, + {vs.tsLine, "", `"timestamp" header is mandatory`}, + {vs.tsLine, "timestamp: \n", `"timestamp" header should not be empty`}, + {vs.tsLine, "timestamp: 12:30\n", `"timestamp" header is not a RFC3339 date: .*`}, + } + + for _, test := range invalidTests { + invalid := strings.Replace(encoded, test.original, test.invalid, 1) + _, err := asserts.Decode([]byte(invalid)) + c.Check(err, ErrorMatches, validationErrPrefix+test.expectedErr) + } +} + +func prereqSnapDecl2(c *C, storeDB assertstest.SignerDB, db *asserts.Database) { + snapDecl, err := storeDB.Sign(asserts.SnapDeclarationType, map[string]interface{}{ + "series": "16", + "snap-id": "snap-id-2", + "snap-name": "bar", + "publisher-id": "dev-id1", + "timestamp": time.Now().Format(time.RFC3339), + }, nil, "") + c.Assert(err, IsNil) + err = db.Add(snapDecl) + c.Assert(err, IsNil) +} + +func (vs *validationSuite) TestValidationCheck(c *C) { + storeDB, db := makeStoreAndCheckDB(c) + devDB := setup3rdPartySigning(c, "dev-id1", storeDB, db) + + prereqSnapDecl(c, storeDB, db) + prereqSnapDecl2(c, storeDB, db) + + headers := vs.makeHeaders(nil) + validation, err := devDB.Sign(asserts.ValidationType, headers, nil, "") + c.Assert(err, IsNil) + + err = db.Check(validation) + c.Assert(err, IsNil) +} + +func (vs *validationSuite) TestValidationCheckWrongAuthority(c *C) { + storeDB, db := makeStoreAndCheckDB(c) + + prereqDevAccount(c, storeDB, db) + prereqSnapDecl(c, storeDB, db) + prereqSnapDecl2(c, storeDB, db) + + headers := vs.makeHeaders(map[string]interface{}{ + "authority-id": "canonical", // not the publisher + }) + validation, err := storeDB.Sign(asserts.ValidationType, headers, nil, "") + c.Assert(err, IsNil) + + err = db.Check(validation) + c.Assert(err, ErrorMatches, `validation assertion by snap "foo" \(id "snap-id-1"\) not signed by its publisher`) +} + +func (vs *validationSuite) TestRevocation(c *C) { + encoded := "type: validation\n" + + "authority-id: dev-id1\n" + + "series: 16\n" + + "snap-id: snap-id-1\n" + + "approved-snap-id: snap-id-2\n" + + "approved-snap-revision: 42\n" + + "revoked: true\n" + + "revision: 1\n" + + vs.tsLine + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==" + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + validation := a.(*asserts.Validation) + c.Check(validation.Revoked(), Equals, true) +} + +func (vs *validationSuite) TestRevokedFalse(c *C) { + encoded := "type: validation\n" + + "authority-id: dev-id1\n" + + "series: 16\n" + + "snap-id: snap-id-1\n" + + "approved-snap-id: snap-id-2\n" + + "approved-snap-revision: 42\n" + + "revoked: false\n" + + "revision: 1\n" + + vs.tsLine + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==" + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + validation := a.(*asserts.Validation) + c.Check(validation.Revoked(), Equals, false) +} + +func (vs *validationSuite) TestRevokedInvalid(c *C) { + encoded := "type: validation\n" + + "authority-id: dev-id1\n" + + "series: 16\n" + + "snap-id: snap-id-1\n" + + "approved-snap-id: snap-id-2\n" + + "approved-snap-revision: 42\n" + + "revoked: foo\n" + + "revision: 1\n" + + vs.tsLine + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==" + _, err := asserts.Decode([]byte(encoded)) + c.Check(err, ErrorMatches, `.*: "revoked" header must be 'true' or 'false'`) +} + +func (vs *validationSuite) TestMissingGatedSnapDeclaration(c *C) { + storeDB, db := makeStoreAndCheckDB(c) + devDB := setup3rdPartySigning(c, "dev-id1", storeDB, db) + + headers := vs.makeHeaders(nil) + a, err := devDB.Sign(asserts.ValidationType, headers, nil, "") + c.Assert(err, IsNil) + + err = db.Check(a) + c.Assert(err, ErrorMatches, `validation assertion by snap-id "snap-id-1" does not have a matching snap-declaration assertion for approved-snap-id "snap-id-2"`) +} + +func (vs *validationSuite) TestMissingGatingSnapDeclaration(c *C) { + storeDB, db := makeStoreAndCheckDB(c) + devDB := setup3rdPartySigning(c, "dev-id1", storeDB, db) + + prereqSnapDecl2(c, storeDB, db) + + headers := vs.makeHeaders(nil) + a, err := devDB.Sign(asserts.ValidationType, headers, nil, "") + c.Assert(err, IsNil) + + err = db.Check(a) + c.Assert(err, ErrorMatches, `validation assertion by snap-id "snap-id-1" does not have a matching snap-declaration assertion`) +} + +func (vs *validationSuite) TestPrerequisites(c *C) { + encoded := vs.makeValidEncoded() + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + + prereqs := a.Prerequisites() + c.Assert(prereqs, HasLen, 2) + c.Check(prereqs[0], DeepEquals, &asserts.Ref{ + Type: asserts.SnapDeclarationType, + PrimaryKey: []string{"16", "snap-id-1"}, + }) + c.Check(prereqs[1], DeepEquals, &asserts.Ref{ + Type: asserts.SnapDeclarationType, + PrimaryKey: []string{"16", "snap-id-2"}, + }) +} + +type baseDeclSuite struct{} + +func (s *baseDeclSuite) TestDecodeOK(c *C) { + encoded := `type: base-declaration +authority-id: canonical +series: 16 +plugs: + interface1: + deny-installation: false + allow-auto-connection: + slot-snap-type: + - app + slot-publisher-id: + - acme + slot-attributes: + a1: /foo/.* + plug-attributes: + b1: B1 + deny-auto-connection: + slot-attributes: + a1: !A1 + plug-attributes: + b1: !B1 + interface2: + allow-installation: true + allow-connection: + plug-attributes: + a2: A2 + slot-attributes: + b2: B2 + deny-connection: + slot-snap-id: + - snapidsnapidsnapidsnapidsnapid01 + - snapidsnapidsnapidsnapidsnapid02 + plug-attributes: + a2: !A2 + slot-attributes: + b2: !B2 +slots: + interface3: + deny-installation: false + allow-auto-connection: + plug-snap-type: + - app + plug-publisher-id: + - acme + slot-attributes: + c1: /foo/.* + plug-attributes: + d1: C1 + deny-auto-connection: + slot-attributes: + c1: !C1 + plug-attributes: + d1: !D1 + interface4: + allow-connection: + plug-attributes: + c2: C2 + slot-attributes: + d2: D2 + deny-connection: + plug-snap-id: + - snapidsnapidsnapidsnapidsnapid01 + - snapidsnapidsnapidsnapidsnapid02 + plug-attributes: + c2: !D2 + slot-attributes: + d2: !D2 + allow-installation: + slot-snap-type: + - app + slot-attributes: + e1: E1 +timestamp: 2016-09-29T19:50:49Z +sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij + +AXNpZw==` + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + baseDecl := a.(*asserts.BaseDeclaration) + c.Check(baseDecl.Series(), Equals, "16") + ts, err := time.Parse(time.RFC3339, "2016-09-29T19:50:49Z") + c.Assert(err, IsNil) + c.Check(baseDecl.Timestamp().Equal(ts), Equals, true) + + c.Check(baseDecl.PlugRule("interfaceX"), IsNil) + c.Check(baseDecl.SlotRule("interfaceX"), IsNil) + + plug := emptyAttrerObject{} + slot := emptyAttrerObject{} + + plugRule1 := baseDecl.PlugRule("interface1") + c.Assert(plugRule1, NotNil) + c.Assert(plugRule1.DenyInstallation, HasLen, 1) + c.Check(plugRule1.DenyInstallation[0].PlugAttributes, Equals, asserts.NeverMatchAttributes) + c.Assert(plugRule1.AllowAutoConnection, HasLen, 1) + c.Check(plugRule1.AllowAutoConnection[0].SlotAttributes.Check(slot, nil), ErrorMatches, `attribute "a1".*`) + c.Check(plugRule1.AllowAutoConnection[0].PlugAttributes.Check(plug, nil), ErrorMatches, `attribute "b1".*`) + c.Check(plugRule1.AllowAutoConnection[0].SlotSnapTypes, DeepEquals, []string{"app"}) + c.Check(plugRule1.AllowAutoConnection[0].SlotPublisherIDs, DeepEquals, []string{"acme"}) + c.Assert(plugRule1.DenyAutoConnection, HasLen, 1) + c.Check(plugRule1.DenyAutoConnection[0].SlotAttributes.Check(slot, nil), ErrorMatches, `attribute "a1".*`) + c.Check(plugRule1.DenyAutoConnection[0].PlugAttributes.Check(plug, nil), ErrorMatches, `attribute "b1".*`) + plugRule2 := baseDecl.PlugRule("interface2") + c.Assert(plugRule2, NotNil) + c.Assert(plugRule2.AllowInstallation, HasLen, 1) + c.Check(plugRule2.AllowInstallation[0].PlugAttributes, Equals, asserts.AlwaysMatchAttributes) + c.Assert(plugRule2.AllowConnection, HasLen, 1) + c.Check(plugRule2.AllowConnection[0].PlugAttributes.Check(plug, nil), ErrorMatches, `attribute "a2".*`) + c.Check(plugRule2.AllowConnection[0].SlotAttributes.Check(slot, nil), ErrorMatches, `attribute "b2".*`) + c.Assert(plugRule2.DenyConnection, HasLen, 1) + c.Check(plugRule2.DenyConnection[0].PlugAttributes.Check(plug, nil), ErrorMatches, `attribute "a2".*`) + c.Check(plugRule2.DenyConnection[0].SlotAttributes.Check(slot, nil), ErrorMatches, `attribute "b2".*`) + c.Check(plugRule2.DenyConnection[0].SlotSnapIDs, DeepEquals, []string{"snapidsnapidsnapidsnapidsnapid01", "snapidsnapidsnapidsnapidsnapid02"}) + + slotRule3 := baseDecl.SlotRule("interface3") + c.Assert(slotRule3, NotNil) + c.Assert(slotRule3.DenyInstallation, HasLen, 1) + c.Check(slotRule3.DenyInstallation[0].SlotAttributes, Equals, asserts.NeverMatchAttributes) + c.Assert(slotRule3.AllowAutoConnection, HasLen, 1) + c.Check(slotRule3.AllowAutoConnection[0].SlotAttributes.Check(slot, nil), ErrorMatches, `attribute "c1".*`) + c.Check(slotRule3.AllowAutoConnection[0].PlugAttributes.Check(plug, nil), ErrorMatches, `attribute "d1".*`) + c.Check(slotRule3.AllowAutoConnection[0].PlugSnapTypes, DeepEquals, []string{"app"}) + c.Check(slotRule3.AllowAutoConnection[0].PlugPublisherIDs, DeepEquals, []string{"acme"}) + c.Assert(slotRule3.DenyAutoConnection, HasLen, 1) + c.Check(slotRule3.DenyAutoConnection[0].SlotAttributes.Check(slot, nil), ErrorMatches, `attribute "c1".*`) + c.Check(slotRule3.DenyAutoConnection[0].PlugAttributes.Check(plug, nil), ErrorMatches, `attribute "d1".*`) + slotRule4 := baseDecl.SlotRule("interface4") + c.Assert(slotRule4, NotNil) + c.Assert(slotRule4.AllowConnection, HasLen, 1) + c.Check(slotRule4.AllowConnection[0].PlugAttributes.Check(plug, nil), ErrorMatches, `attribute "c2".*`) + c.Check(slotRule4.AllowConnection[0].SlotAttributes.Check(slot, nil), ErrorMatches, `attribute "d2".*`) + c.Assert(slotRule4.DenyConnection, HasLen, 1) + c.Check(slotRule4.DenyConnection[0].PlugAttributes.Check(plug, nil), ErrorMatches, `attribute "c2".*`) + c.Check(slotRule4.DenyConnection[0].SlotAttributes.Check(slot, nil), ErrorMatches, `attribute "d2".*`) + c.Check(slotRule4.DenyConnection[0].PlugSnapIDs, DeepEquals, []string{"snapidsnapidsnapidsnapidsnapid01", "snapidsnapidsnapidsnapidsnapid02"}) + c.Assert(slotRule4.AllowInstallation, HasLen, 1) + c.Check(slotRule4.AllowInstallation[0].SlotAttributes.Check(slot, nil), ErrorMatches, `attribute "e1".*`) + c.Check(slotRule4.AllowInstallation[0].SlotSnapTypes, DeepEquals, []string{"app"}) + +} + +func (s *baseDeclSuite) TestBaseDeclarationCheckUntrustedAuthority(c *C) { + storeDB, db := makeStoreAndCheckDB(c) + + otherDB := setup3rdPartySigning(c, "other", storeDB, db) + + headers := map[string]interface{}{ + "series": "16", + "timestamp": time.Now().Format(time.RFC3339), + } + baseDecl, err := otherDB.Sign(asserts.BaseDeclarationType, headers, nil, "") + c.Assert(err, IsNil) + + err = db.Check(baseDecl) + c.Assert(err, ErrorMatches, `base-declaration assertion for series 16 is not signed by a directly trusted authority: other`) +} + +const ( + baseDeclErrPrefix = "assertion base-declaration: " +) + +func (s *baseDeclSuite) TestDecodeInvalid(c *C) { + tsLine := "timestamp: 2016-09-29T19:50:49Z\n" + + encoded := "type: base-declaration\n" + + "authority-id: canonical\n" + + "series: 16\n" + + "plugs:\n interface1: true\n" + + "slots:\n interface2: true\n" + + tsLine + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==" + + invalidTests := []struct{ original, invalid, expectedErr string }{ + {"series: 16\n", "", `"series" header is mandatory`}, + {"series: 16\n", "series: \n", `"series" header should not be empty`}, + {"plugs:\n interface1: true\n", "plugs: \n", `"plugs" header must be a map`}, + {"plugs:\n interface1: true\n", "plugs:\n intf1:\n foo: bar\n", `plug rule for interface "intf1" must specify at least one of.*`}, + {"slots:\n interface2: true\n", "slots: \n", `"slots" header must be a map`}, + {"slots:\n interface2: true\n", "slots:\n intf1:\n foo: bar\n", `slot rule for interface "intf1" must specify at least one of.*`}, + {tsLine, "", `"timestamp" header is mandatory`}, + {tsLine, "timestamp: 12:30\n", `"timestamp" header is not a RFC3339 date: .*`}, + } + + for _, test := range invalidTests { + invalid := strings.Replace(encoded, test.original, test.invalid, 1) + _, err := asserts.Decode([]byte(invalid)) + c.Check(err, ErrorMatches, baseDeclErrPrefix+test.expectedErr) + } + +} + +func (s *baseDeclSuite) TestBuiltin(c *C) { + baseDecl := asserts.BuiltinBaseDeclaration() + c.Check(baseDecl, IsNil) + + defer asserts.InitBuiltinBaseDeclaration(nil) + + const headers = ` +type: base-declaration +authority-id: canonical +series: 16 +revision: 0 +plugs: + network: true +slots: + network: + allow-installation: + slot-snap-type: + - core +` + + err := asserts.InitBuiltinBaseDeclaration([]byte(headers)) + c.Assert(err, IsNil) + + baseDecl = asserts.BuiltinBaseDeclaration() + c.Assert(baseDecl, NotNil) + + cont, _ := baseDecl.Signature() + c.Check(string(cont), Equals, strings.TrimSpace(headers)) + + c.Check(baseDecl.AuthorityID(), Equals, "canonical") + c.Check(baseDecl.Series(), Equals, "16") + c.Check(baseDecl.PlugRule("network").AllowAutoConnection[0].SlotAttributes, Equals, asserts.AlwaysMatchAttributes) + c.Check(baseDecl.SlotRule("network").AllowInstallation[0].SlotSnapTypes, DeepEquals, []string{"core"}) + + enc := asserts.Encode(baseDecl) + // it's expected that it cannot be decoded + _, err = asserts.Decode(enc) + c.Check(err, NotNil) +} + +func (s *baseDeclSuite) TestBuiltinInitErrors(c *C) { + defer asserts.InitBuiltinBaseDeclaration(nil) + + tests := []struct { + headers string + err string + }{ + {"", `header entry missing ':' separator: ""`}, + {"type: foo\n", `the builtin base-declaration "type" header is not set to expected value "base-declaration"`}, + {"type: base-declaration", `the builtin base-declaration "authority-id" header is not set to expected value "canonical"`}, + {"type: base-declaration\nauthority-id: canonical", `the builtin base-declaration "series" header is not set to expected value "16"`}, + {"type: base-declaration\nauthority-id: canonical\nseries: 16\nrevision: zzz", `cannot assemble the builtin-base declaration: "revision" header is not an integer: zzz`}, + {"type: base-declaration\nauthority-id: canonical\nseries: 16\nplugs: foo", `cannot assemble the builtin base-declaration: "plugs" header must be a map`}, + } + + for _, t := range tests { + err := asserts.InitBuiltinBaseDeclaration([]byte(t.headers)) + c.Check(err, ErrorMatches, t.err, Commentf(t.headers)) + } +} + +type snapDevSuite struct { + developersLines string + validEncoded string +} + +func (sds *snapDevSuite) SetUpSuite(c *C) { + sds.developersLines = "developers:\n -\n developer-id: dev-id2\n since: 2017-01-01T00:00:00.0Z\n until: 2017-02-01T00:00:00.0Z\n" + sds.validEncoded = "type: snap-developer\n" + + "authority-id: dev-id1\n" + + "snap-id: snap-id-1\n" + + "publisher-id: dev-id1\n" + + sds.developersLines + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==" +} + +func (sds *snapDevSuite) TestDecodeOK(c *C) { + encoded := sds.validEncoded + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + c.Check(a.Type(), Equals, asserts.SnapDeveloperType) + snapDev := a.(*asserts.SnapDeveloper) + c.Check(snapDev.AuthorityID(), Equals, "dev-id1") + c.Check(snapDev.PublisherID(), Equals, "dev-id1") + c.Check(snapDev.SnapID(), Equals, "snap-id-1") +} + +func (sds *snapDevSuite) TestDevelopersOptional(c *C) { + encoded := strings.Replace(sds.validEncoded, sds.developersLines, "", 1) + _, err := asserts.Decode([]byte(encoded)) + c.Check(err, IsNil) +} + +func (sds *snapDevSuite) TestDevelopersUntilOptional(c *C) { + encoded := strings.Replace( + sds.validEncoded, sds.developersLines, + "developers:\n -\n developer-id: dev-id2\n since: 2017-01-01T00:00:00.0Z\n", 1) + _, err := asserts.Decode([]byte(encoded)) + c.Check(err, IsNil) +} + +func (sds *snapDevSuite) TestDevelopersRevoked(c *C) { + encoded := sds.validEncoded + encoded = strings.Replace( + encoded, sds.developersLines, + "developers:\n -\n developer-id: dev-id2\n since: 2017-01-01T00:00:00.0Z\n until: 2017-01-01T00:00:00.0Z\n", 1) + _, err := asserts.Decode([]byte(encoded)) + c.Check(err, IsNil) + // TODO(matt): check actually revoked rather than just parsed +} + +const ( + snapDevErrPrefix = "assertion snap-developer: " +) + +func (sds *snapDevSuite) TestDecodeInvalid(c *C) { + encoded := sds.validEncoded + + invalidTests := []struct{ original, invalid, expectedErr string }{ + {"publisher-id: dev-id1\n", "", `"publisher-id" header is mandatory`}, + {"publisher-id: dev-id1\n", "publisher-id: \n", `"publisher-id" header should not be empty`}, + {"snap-id: snap-id-1\n", "", `"snap-id" header is mandatory`}, + {"snap-id: snap-id-1\n", "snap-id: \n", `"snap-id" header should not be empty`}, + {sds.developersLines, "developers: \n", `"developers" must be a list of developer maps`}, + {sds.developersLines, "developers: foo\n", `"developers" must be a list of developer maps`}, + {sds.developersLines, "developers:\n foo: bar\n", `"developers" must be a list of developer maps`}, + {sds.developersLines, "developers:\n - foo\n", `"developers" must be a list of developer maps`}, + {sds.developersLines, "developers:\n -\n foo: bar\n", `"developer-id" in "developers" item 1 is mandatory`}, + {sds.developersLines, "developers:\n -\n developer-id: a\n", + `"developer-id" in "developers" item 1 contains invalid characters: "a"`}, + {sds.developersLines, "developers:\n -\n developer-id: dev-id2\n", + `"since" in "developers" item 1 for developer "dev-id2" is mandatory`}, + {sds.developersLines, "developers:\n -\n developer-id: dev-id2\n since: \n", + `"since" in "developers" item 1 for developer "dev-id2" should not be empty`}, + {sds.developersLines, "developers:\n -\n developer-id: dev-id2\n since: foo\n", + `"since" in "developers" item 1 for developer "dev-id2" is not a RFC3339 date.*`}, + {sds.developersLines, "developers:\n -\n developer-id: dev-id2\n since: 2017-01-01T00:00:00.0Z\n until: \n", + `"until" in "developers" item 1 for developer "dev-id2" is not a RFC3339 date.*`}, + {sds.developersLines, "developers:\n -\n developer-id: dev-id2\n since: 2017-01-01T00:00:00.0Z\n until: foo\n", + `"until" in "developers" item 1 for developer "dev-id2" is not a RFC3339 date.*`}, + {sds.developersLines, "developers:\n -\n developer-id: dev-id2\n since: 2017-01-01T00:00:00.0Z\n -\n foo: bar\n", + `"developer-id" in "developers" item 2 is mandatory`}, + {sds.developersLines, "developers:\n -\n developer-id: dev-id2\n since: 2017-01-02T00:00:00.0Z\n until: 2017-01-01T00:00:00.0Z\n", + `"since" in "developers" item 1 for developer "dev-id2" must be less than or equal to "until"`}, + } + + for _, test := range invalidTests { + invalid := strings.Replace(encoded, test.original, test.invalid, 1) + _, err := asserts.Decode([]byte(invalid)) + c.Check(err, ErrorMatches, snapDevErrPrefix+test.expectedErr) + } +} + +func (sds *snapDevSuite) TestRevokedValidation(c *C) { + // Multiple non-revoking items are fine. + encoded := strings.Replace(sds.validEncoded, sds.developersLines, + "developers:\n"+ + " -\n developer-id: dev-id2\n since: 2017-01-01T00:00:00.0Z\n until: 2017-02-01T00:00:00.0Z\n"+ + " -\n developer-id: dev-id2\n since: 2017-03-01T00:00:00.0Z\n", + 1) + _, err := asserts.Decode([]byte(encoded)) + c.Check(err, IsNil) + + // Multiple revocations for different developers are fine. + encoded = strings.Replace(sds.validEncoded, sds.developersLines, + "developers:\n"+ + " -\n developer-id: dev-id2\n since: 2017-01-01T00:00:00.0Z\n until: 2017-01-01T00:00:00.0Z\n"+ + " -\n developer-id: dev-id3\n since: 2017-02-01T00:00:00.0Z\n until: 2017-02-01T00:00:00.0Z\n", + 1) + _, err = asserts.Decode([]byte(encoded)) + c.Check(err, IsNil) + + invalidTests := []string{ + // Multiple revocations. + "developers:\n" + + " -\n developer-id: dev-id2\n since: 2017-01-01T00:00:00.0Z\n until: 2017-01-01T00:00:00.0Z\n" + + " -\n developer-id: dev-id2\n since: 2017-02-01T00:00:00.0Z\n until: 2017-02-01T00:00:00.0Z\n", + // Revocation after non-revoking. + "developers:\n" + + " -\n developer-id: dev-id2\n since: 2017-01-01T00:00:00.0Z\n" + + " -\n developer-id: dev-id2\n since: 2017-03-01T00:00:00.0Z\n until: 2017-03-01T00:00:00.0Z\n", + // Non-revoking after revocation. + "developers:\n" + + " -\n developer-id: dev-id2\n since: 2017-01-01T00:00:00.0Z\n until: 2017-01-01T00:00:00.0Z\n" + + " -\n developer-id: dev-id2\n since: 2017-02-01T00:00:00.0Z\n", + } + for _, test := range invalidTests { + encoded := strings.Replace(sds.validEncoded, sds.developersLines, test, 1) + _, err := asserts.Decode([]byte(encoded)) + c.Check(err, ErrorMatches, snapDevErrPrefix+`revocation for developer "dev-id2" must be standalone but found other "developers" items`) + } +} + +func (sds *snapDevSuite) TestAuthorityIsPublisher(c *C) { + storeDB, db := makeStoreAndCheckDB(c) + devDB := setup3rdPartySigning(c, "dev-id1", storeDB, db) + + snapDecl, err := storeDB.Sign(asserts.SnapDeclarationType, map[string]interface{}{ + "series": "16", + "snap-id": "snap-id-1", + "snap-name": "snap-name-1", + "publisher-id": "dev-id1", + "timestamp": time.Now().UTC().Format(time.RFC3339), + }, nil, "") + c.Assert(err, IsNil) + err = db.Add(snapDecl) + c.Assert(err, IsNil) + + snapDev, err := devDB.Sign(asserts.SnapDeveloperType, map[string]interface{}{ + "snap-id": "snap-id-1", + "publisher-id": "dev-id1", + }, nil, "") + c.Assert(err, IsNil) + // Just to be super sure ... + c.Assert(snapDev.HeaderString("authority-id"), Equals, "dev-id1") + c.Assert(snapDev.HeaderString("publisher-id"), Equals, "dev-id1") + + err = db.Check(snapDev) + c.Assert(err, IsNil) +} + +func (sds *snapDevSuite) TestAuthorityIsNotPublisher(c *C) { + storeDB, db := makeStoreAndCheckDB(c) + devDB := setup3rdPartySigning(c, "dev-id1", storeDB, db) + + snapDecl, err := storeDB.Sign(asserts.SnapDeclarationType, map[string]interface{}{ + "series": "16", + "snap-id": "snap-id-1", + "snap-name": "snap-name-1", + "publisher-id": "dev-id1", + "timestamp": time.Now().UTC().Format(time.RFC3339), + }, nil, "") + c.Assert(err, IsNil) + err = db.Add(snapDecl) + c.Assert(err, IsNil) + + snapDev, err := devDB.Sign(asserts.SnapDeveloperType, map[string]interface{}{ + "authority-id": "dev-id1", + "snap-id": "snap-id-1", + "publisher-id": "dev-id2", + }, nil, "") + c.Assert(err, IsNil) + // Just to be super sure ... + c.Assert(snapDev.HeaderString("authority-id"), Equals, "dev-id1") + c.Assert(snapDev.HeaderString("publisher-id"), Equals, "dev-id2") + + err = db.Check(snapDev) + c.Assert(err, ErrorMatches, `snap-developer must be signed by the publisher or a trusted authority but got authority "dev-id1" and publisher "dev-id2"`) +} + +func (sds *snapDevSuite) TestAuthorityIsNotPublisherButIsTrusted(c *C) { + storeDB, db := makeStoreAndCheckDB(c) + + account, err := storeDB.Sign(asserts.AccountType, map[string]interface{}{ + "account-id": "dev-id1", + "display-name": "dev-id1", + "validation": "unknown", + "timestamp": time.Now().UTC().Format(time.RFC3339), + }, nil, "") + c.Assert(err, IsNil) + err = db.Add(account) + c.Assert(err, IsNil) + + snapDecl, err := storeDB.Sign(asserts.SnapDeclarationType, map[string]interface{}{ + "series": "16", + "snap-id": "snap-id-1", + "snap-name": "snap-name-1", + "publisher-id": "dev-id1", + "timestamp": time.Now().UTC().Format(time.RFC3339), + }, nil, "") + c.Assert(err, IsNil) + err = db.Add(snapDecl) + c.Assert(err, IsNil) + + snapDev, err := storeDB.Sign(asserts.SnapDeveloperType, map[string]interface{}{ + "snap-id": "snap-id-1", + "publisher-id": "dev-id1", + }, nil, "") + c.Assert(err, IsNil) + // Just to be super sure ... + c.Assert(snapDev.HeaderString("authority-id"), Equals, "canonical") + c.Assert(snapDev.HeaderString("publisher-id"), Equals, "dev-id1") + + err = db.Check(snapDev) + c.Assert(err, IsNil) +} + +func (sds *snapDevSuite) TestCheckNewPublisherAccountExists(c *C) { + storeDB, db := makeStoreAndCheckDB(c) + + account, err := storeDB.Sign(asserts.AccountType, map[string]interface{}{ + "account-id": "dev-id1", + "display-name": "dev-id1", + "validation": "unknown", + "timestamp": time.Now().UTC().Format(time.RFC3339), + }, nil, "") + c.Assert(err, IsNil) + err = db.Add(account) + c.Assert(err, IsNil) + + snapDecl, err := storeDB.Sign(asserts.SnapDeclarationType, map[string]interface{}{ + "series": "16", + "snap-id": "snap-id-1", + "snap-name": "snap-name-1", + "publisher-id": "dev-id1", + "timestamp": time.Now().UTC().Format(time.RFC3339), + }, nil, "") + c.Assert(err, IsNil) + err = db.Add(snapDecl) + c.Assert(err, IsNil) + + snapDev, err := storeDB.Sign(asserts.SnapDeveloperType, map[string]interface{}{ + "snap-id": "snap-id-1", + "publisher-id": "dev-id2", + }, nil, "") + c.Assert(err, IsNil) + // Just to be super sure ... + c.Assert(snapDev.HeaderString("authority-id"), Equals, "canonical") + c.Assert(snapDev.HeaderString("publisher-id"), Equals, "dev-id2") + + // There's no account for dev-id2 yet so it should fail. + err = db.Check(snapDev) + c.Assert(err, ErrorMatches, `snap-developer assertion for snap-id "snap-id-1" does not have a matching account assertion for the publisher "dev-id2"`) + + // But once the dev-id2 account is added the snap-developer is ok. + account, err = storeDB.Sign(asserts.AccountType, map[string]interface{}{ + "account-id": "dev-id2", + "display-name": "dev-id2", + "validation": "unknown", + "timestamp": time.Now().UTC().Format(time.RFC3339), + }, nil, "") + c.Assert(err, IsNil) + err = db.Add(account) + c.Assert(err, IsNil) + + err = db.Check(snapDev) + c.Assert(err, IsNil) +} + +func (sds *snapDevSuite) TestCheckDeveloperAccountExists(c *C) { + storeDB, db := makeStoreAndCheckDB(c) + devDB := setup3rdPartySigning(c, "dev-id1", storeDB, db) + + snapDecl, err := storeDB.Sign(asserts.SnapDeclarationType, map[string]interface{}{ + "series": "16", + "snap-id": "snap-id-1", + "snap-name": "snap-name-1", + "publisher-id": "dev-id1", + "timestamp": time.Now().UTC().Format(time.RFC3339), + }, nil, "") + c.Assert(err, IsNil) + err = db.Add(snapDecl) + c.Assert(err, IsNil) + + snapDev, err := devDB.Sign(asserts.SnapDeveloperType, map[string]interface{}{ + "snap-id": "snap-id-1", + "publisher-id": "dev-id1", + "developers": []interface{}{ + map[string]interface{}{ + "developer-id": "dev-id2", + "since": "2017-01-01T00:00:00.0Z", + }, + }, + }, nil, "") + c.Assert(err, IsNil) + err = db.Check(snapDev) + c.Assert(err, ErrorMatches, `snap-developer assertion for snap-id "snap-id-1" does not have a matching account assertion for the developer "dev-id2"`) +} + +func (sds *snapDevSuite) TestCheckMissingDeclaration(c *C) { + storeDB, db := makeStoreAndCheckDB(c) + devDB := setup3rdPartySigning(c, "dev-id1", storeDB, db) + + headers := map[string]interface{}{ + "authority-id": "dev-id1", + "snap-id": "snap-id-1", + "publisher-id": "dev-id1", + } + snapDev, err := devDB.Sign(asserts.SnapDeveloperType, headers, nil, "") + c.Assert(err, IsNil) + + err = db.Check(snapDev) + c.Assert(err, ErrorMatches, `snap-developer assertion for snap id "snap-id-1" does not have a matching snap-declaration assertion`) +} + +func (sds *snapDevSuite) TestPrerequisitesNoDevelopers(c *C) { + encoded := strings.Replace(sds.validEncoded, sds.developersLines, "", 1) + assert, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + prereqs := assert.Prerequisites() + sort.Sort(RefSlice(prereqs)) + c.Assert(prereqs, DeepEquals, []*asserts.Ref{ + {Type: asserts.AccountType, PrimaryKey: []string{"dev-id1"}}, + {Type: asserts.SnapDeclarationType, PrimaryKey: []string{"16", "snap-id-1"}}, + }) +} + +func (sds *snapDevSuite) TestPrerequisitesWithDevelopers(c *C) { + encoded := strings.Replace( + sds.validEncoded, sds.developersLines, + "developers:\n"+ + " -\n developer-id: dev-id2\n since: 2017-01-01T00:00:00.0Z\n"+ + " -\n developer-id: dev-id3\n since: 2017-01-01T00:00:00.0Z\n", + 1) + assert, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + prereqs := assert.Prerequisites() + sort.Sort(RefSlice(prereqs)) + c.Assert(prereqs, DeepEquals, []*asserts.Ref{ + {Type: asserts.AccountType, PrimaryKey: []string{"dev-id1"}}, + {Type: asserts.AccountType, PrimaryKey: []string{"dev-id2"}}, + {Type: asserts.AccountType, PrimaryKey: []string{"dev-id3"}}, + {Type: asserts.SnapDeclarationType, PrimaryKey: []string{"16", "snap-id-1"}}, + }) +} + +func (sds *snapDevSuite) TestPrerequisitesWithDeveloperRepeated(c *C) { + encoded := strings.Replace( + sds.validEncoded, sds.developersLines, + "developers:\n"+ + " -\n developer-id: dev-id2\n since: 2015-01-01T00:00:00.0Z\n until: 2016-01-01T00:00:00.0Z\n"+ + " -\n developer-id: dev-id2\n since: 2017-01-01T00:00:00.0Z\n", + 1) + assert, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + prereqs := assert.Prerequisites() + sort.Sort(RefSlice(prereqs)) + c.Assert(prereqs, DeepEquals, []*asserts.Ref{ + {Type: asserts.AccountType, PrimaryKey: []string{"dev-id1"}}, + {Type: asserts.AccountType, PrimaryKey: []string{"dev-id2"}}, + {Type: asserts.SnapDeclarationType, PrimaryKey: []string{"16", "snap-id-1"}}, + }) +} + +func (sds *snapDevSuite) TestPrerequisitesWithPublisherAsDeveloper(c *C) { + encoded := strings.Replace( + sds.validEncoded, sds.developersLines, + "developers:\n -\n developer-id: dev-id1\n since: 2017-01-01T00:00:00.0Z\n", + 1) + assert, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + prereqs := assert.Prerequisites() + sort.Sort(RefSlice(prereqs)) + c.Assert(prereqs, DeepEquals, []*asserts.Ref{ + {Type: asserts.AccountType, PrimaryKey: []string{"dev-id1"}}, + {Type: asserts.SnapDeclarationType, PrimaryKey: []string{"16", "snap-id-1"}}, + }) +} + +type RefSlice []*asserts.Ref + +func (s RefSlice) Len() int { + return len(s) +} + +func (s RefSlice) Less(i, j int) bool { + iref, jref := s[i], s[j] + if v := strings.Compare(iref.Type.Name, jref.Type.Name); v != 0 { + return v == -1 + } + for n, ipk := range iref.PrimaryKey { + jpk := jref.PrimaryKey[n] + if v := strings.Compare(ipk, jpk); v != 0 { + return v == -1 + } + } + return false +} + +func (s RefSlice) Swap(i, j int) { + s[i], s[j] = s[j], s[i] +} diff --git a/asserts/snap_resource_asserts.go b/asserts/snap_resource_asserts.go new file mode 100644 index 00000000..5a51df3b --- /dev/null +++ b/asserts/snap_resource_asserts.go @@ -0,0 +1,339 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2023 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts + +import ( + "crypto" + "errors" + "fmt" + "time" + + "github.com/snapcore/snapd/release" + "github.com/snapcore/snapd/snap/naming" +) + +// SnapResourceRevision holds a snap-resource-revision assertion, which is a +// statement by the store acknowledging the receipt of data for a resource of a +// snap and labeling it with a resource revision. +type SnapResourceRevision struct { + assertionBase + resourceSize uint64 + resourceRevision int + timestamp time.Time + + // TODO: integrity when the format is stabilized again +} + +// ResourceSHA3_384 returns the SHA3-384 digest of the snap resource. +func (resrev *SnapResourceRevision) ResourceSHA3_384() string { + return resrev.HeaderString("resource-sha3-384") +} + +// Provenance returns the optional provenance of the snap (defaults to +// global-upload (naming.DefaultProvenance)). +func (resrev *SnapResourceRevision) Provenance() string { + return resrev.HeaderString("provenance") +} + +// SnapID returns the snap id of the snap for the resource. +func (resrev *SnapResourceRevision) SnapID() string { + return resrev.HeaderString("snap-id") +} + +// ResourceName returns the name of the snap resource. +func (resrev *SnapResourceRevision) ResourceName() string { + return resrev.HeaderString("resource-name") +} + +// ResourceSize returns the size in bytes of the snap resource submitted to the store. +func (resrev *SnapResourceRevision) ResourceSize() uint64 { + return resrev.resourceSize +} + +// ResourceRevision returns the revision assigned to this upload of the snap resource. +func (resrev *SnapResourceRevision) ResourceRevision() int { + return resrev.resourceRevision +} + +// DeveloperID returns the id of the developer that submitted the snap resource. +func (resrev *SnapResourceRevision) DeveloperID() string { + return resrev.HeaderString("developer-id") +} + +// Timestamp returns the time when the snap-resource-revision was issued. +func (resrev *SnapResourceRevision) Timestamp() time.Time { + return resrev.timestamp +} + +// Implement further consistency checks. +func (resrev *SnapResourceRevision) checkConsistency(db RODatabase, acck *AccountKey) error { + otherProvenance := resrev.Provenance() != naming.DefaultProvenance + if !otherProvenance && !db.IsTrustedAccount(resrev.AuthorityID()) { + // delegating global-upload revisions is not allowed + return fmt.Errorf("snap-resource-revision assertion for snap id %q is not signed by a store: %s", resrev.SnapID(), resrev.AuthorityID()) + } + _, err := db.Find(AccountType, map[string]string{ + "account-id": resrev.DeveloperID(), + }) + if errors.Is(err, &NotFoundError{}) { + return fmt.Errorf("snap-resource-revision assertion for snap id %q does not have a matching account assertion for the developer %q", resrev.SnapID(), resrev.DeveloperID()) + } + if err != nil { + return err + } + a, err := db.Find(SnapDeclarationType, map[string]string{ + "series": release.Series, + "snap-id": resrev.SnapID(), + }) + if errors.Is(err, &NotFoundError{}) { + return fmt.Errorf("snap-resource-revision assertion for snap id %q does not have a matching snap-declaration assertion", resrev.SnapID()) + } + if err != nil { + return err + } + if otherProvenance { + decl := a.(*SnapDeclaration) + ras := decl.RevisionAuthority(resrev.Provenance()) + matchingRevAuthority := false + for _, ra := range ras { + // model==store==nil, we do not perform device-specific + // checks at this level, those are performed at + // higher-level guarding installing actual components + if err := ra.CheckResourceRevision(resrev, nil, nil); err == nil { + matchingRevAuthority = true + break + } + } + if !matchingRevAuthority { + return fmt.Errorf("snap-resource-revision assertion with provenance %q for snap id %q is not signed by an authorized authority: %s", resrev.Provenance(), resrev.SnapID(), resrev.AuthorityID()) + } + } + return nil +} + +// expected interface is implemented +var _ consistencyChecker = (*SnapResourceRevision)(nil) + +// Prerequisites returns references to this snap-resource-revision's prerequisite assertions. +func (resrev *SnapResourceRevision) Prerequisites() []*Ref { + return []*Ref{ + {Type: SnapDeclarationType, PrimaryKey: []string{release.Series, resrev.SnapID()}}, + {Type: AccountType, PrimaryKey: []string{resrev.DeveloperID()}}, + } +} + +func checkResourceName(headers map[string]interface{}) error { + resName, err := checkNotEmptyString(headers, "resource-name") + if err != nil { + return err + } + // same format as snap names + if err := naming.ValidateSnap(resName); err != nil { + return fmt.Errorf("invalid resource name %q", resName) + } + return nil +} + +func assembleSnapResourceRevision(assert assertionBase) (Assertion, error) { + if err := checkResourceName(assert.headers); err != nil { + return nil, err + } + + _, err := checkDigest(assert.headers, "resource-sha3-384", crypto.SHA3_384) + if err != nil { + return nil, err + } + + _, err = checkStringMatches(assert.headers, "provenance", naming.ValidProvenance) + if err != nil { + return nil, err + } + + resourceSize, err := checkUint(assert.headers, "resource-size", 64) + if err != nil { + return nil, err + } + + resourceRevision, err := checkSnapRevisionWhat(assert.headers, "resource-revision", "header") + if err != nil { + return nil, err + } + + _, err = checkNotEmptyString(assert.headers, "developer-id") + if err != nil { + return nil, err + } + + timestamp, err := checkRFC3339Date(assert.headers, "timestamp") + if err != nil { + return nil, err + } + + // TODO: implement integrity stanza when format is stabilized again + + return &SnapResourceRevision{ + assertionBase: assert, + resourceSize: resourceSize, + resourceRevision: resourceRevision, + timestamp: timestamp, + }, nil +} + +// SnapResourcePair holds a snap-resource-pair assertion, which is a +// statement by the store acknowledging that it received indication +// that the given snap resource revision can work with the given +// snap revision. +type SnapResourcePair struct { + assertionBase + resourceRevision int + snapRevision int + timestamp time.Time +} + +// SnapID returns the snap id of the snap for the resource. +func (respair *SnapResourcePair) SnapID() string { + return respair.HeaderString("snap-id") +} + +// ResourceName returns the name of the snap resource. +func (respair *SnapResourcePair) ResourceName() string { + return respair.HeaderString("resource-name") +} + +// ResourceRevision returns the snap resource revision being paired. +func (respair *SnapResourcePair) ResourceRevision() int { + return respair.resourceRevision +} + +// SnapRevision returns the snap revision being paired with. +func (respair *SnapResourcePair) SnapRevision() int { + return respair.snapRevision +} + +// Provenance returns the optional provenance of the snap (defaults to +// global-upload (naming.DefaultProvenance)). +func (respair *SnapResourcePair) Provenance() string { + return respair.HeaderString("provenance") +} + +// DeveloperID returns the id of the developer that submitted the snap resource for the snap revision. +func (respair *SnapResourcePair) DeveloperID() string { + return respair.HeaderString("developer-id") +} + +// Timestamp returns the time when the snap-resource-pair was issued. +func (respair *SnapResourcePair) Timestamp() time.Time { + return respair.timestamp +} + +// Implement further consistency checks. +func (respair *SnapResourcePair) checkConsistency(db RODatabase, acck *AccountKey) error { + otherProvenance := respair.Provenance() != naming.DefaultProvenance + if !otherProvenance && !db.IsTrustedAccount(respair.AuthorityID()) { + // delegating global-upload revisions is not allowed + return fmt.Errorf("snap-resource-pair assertion for snap id %q is not signed by a store: %s", respair.SnapID(), respair.AuthorityID()) + } + _, err := db.Find(AccountType, map[string]string{ + "account-id": respair.DeveloperID(), + }) + if errors.Is(err, &NotFoundError{}) { + return fmt.Errorf("snap-resource-pair assertion for snap id %q does not have a matching account assertion for the developer %q", respair.SnapID(), respair.DeveloperID()) + } + if err != nil { + return err + } + a, err := db.Find(SnapDeclarationType, map[string]string{ + "series": release.Series, + "snap-id": respair.SnapID(), + }) + if errors.Is(err, &NotFoundError{}) { + return fmt.Errorf("snap-resource-pair assertion for snap id %q does not have a matching snap-declaration assertion", respair.SnapID()) + } + if err != nil { + return err + } + if otherProvenance { + decl := a.(*SnapDeclaration) + ras := decl.RevisionAuthority(respair.Provenance()) + // check that there's matching delegation using the snap-revision + matchingRevAuthority := false + for _, ra := range ras { + // model==store==nil, we do not perform device-specific + // checks at this level, those are performed at + // higher-level guarding installing actual components + if err := ra.checkProvenanceAndRevision(respair, "snap", respair.SnapRevision(), nil, nil); err == nil { + matchingRevAuthority = true + break + } + } + if !matchingRevAuthority { + return fmt.Errorf("snap-resource-pair assertion with provenance %q for snap id %q is not signed by an authorized authority: %s", respair.Provenance(), respair.SnapID(), respair.AuthorityID()) + } + } + return nil +} + +// expected interface is implemented +var _ consistencyChecker = (*SnapResourcePair)(nil) + +// Prerequisites returns references to this snap-resource-pair's prerequisite assertions. +func (respair *SnapResourcePair) Prerequisites() []*Ref { + return []*Ref{ + {Type: SnapDeclarationType, PrimaryKey: []string{release.Series, respair.SnapID()}}, + } +} + +func assembleSnapResourcePair(assert assertionBase) (Assertion, error) { + if err := checkResourceName(assert.headers); err != nil { + return nil, err + } + + _, err := checkStringMatches(assert.headers, "provenance", naming.ValidProvenance) + if err != nil { + return nil, err + } + + resourceRevision, err := checkSnapRevisionWhat(assert.headers, "resource-revision", "header") + if err != nil { + return nil, err + } + + snapRevision, err := checkSnapRevisionWhat(assert.headers, "snap-revision", "header") + if err != nil { + return nil, err + } + + _, err = checkNotEmptyString(assert.headers, "developer-id") + if err != nil { + return nil, err + } + + timestamp, err := checkRFC3339Date(assert.headers, "timestamp") + if err != nil { + return nil, err + } + + return &SnapResourcePair{ + assertionBase: assert, + resourceRevision: resourceRevision, + snapRevision: snapRevision, + timestamp: timestamp, + }, nil +} diff --git a/asserts/snap_resource_asserts_test.go b/asserts/snap_resource_asserts_test.go new file mode 100644 index 00000000..ce30fbc8 --- /dev/null +++ b/asserts/snap_resource_asserts_test.go @@ -0,0 +1,693 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2023 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts_test + +import ( + "strings" + "time" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/asserts" +) + +var ( + _ = Suite(&snapResourceRevSuite{}) + _ = Suite(&snapResourcePairSuite{}) +) + +type snapResourceRevSuite struct { + ts time.Time + tsLine string +} + +func (s *snapResourceRevSuite) SetUpSuite(c *C) { + s.ts = time.Now().Truncate(time.Second).UTC() + s.tsLine = "timestamp: " + s.ts.Format(time.RFC3339) + "\n" +} + +func (s *snapResourceRevSuite) makeValidEncoded() string { + return "type: snap-resource-revision\n" + + "authority-id: store-id1\n" + + "snap-id: snap-id-1\n" + + "resource-name: comp-name\n" + + "resource-sha3-384: " + blobSHA3_384 + "\n" + + "resource-revision: 4\n" + + "resource-size: 127\n" + + "developer-id: dev-id1\n" + + "revision: 1\n" + + s.tsLine + + "body-length: 0\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==" +} + +func makeSnapResourceRevisionHeaders(overrides map[string]interface{}) map[string]interface{} { + headers := map[string]interface{}{ + "authority-id": "canonical", + "snap-id": "snap-id-1", + "resource-name": "comp-name", + "resource-sha3-384": blobSHA3_384, + "resource-size": "127", + "resource-revision": "4", + "developer-id": "dev-id1", + "revision": "1", + "timestamp": time.Now().Format(time.RFC3339), + } + for k, v := range overrides { + headers[k] = v + } + return headers +} + +func (s *snapResourceRevSuite) makeHeaders(overrides map[string]interface{}) map[string]interface{} { + return makeSnapResourceRevisionHeaders(overrides) +} + +func (s *snapResourceRevSuite) TestDecodeOK(c *C) { + encoded := s.makeValidEncoded() + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + c.Check(a.Type(), Equals, asserts.SnapResourceRevisionType) + snapResourceRev := a.(*asserts.SnapResourceRevision) + c.Check(snapResourceRev.AuthorityID(), Equals, "store-id1") + c.Check(snapResourceRev.Timestamp(), Equals, s.ts) + c.Check(snapResourceRev.SnapID(), Equals, "snap-id-1") + c.Check(snapResourceRev.ResourceName(), Equals, "comp-name") + c.Check(snapResourceRev.ResourceSHA3_384(), Equals, blobSHA3_384) + c.Check(snapResourceRev.ResourceSize(), Equals, uint64(127)) + c.Check(snapResourceRev.ResourceRevision(), Equals, 4) + c.Check(snapResourceRev.DeveloperID(), Equals, "dev-id1") + c.Check(snapResourceRev.Revision(), Equals, 1) + c.Check(snapResourceRev.Provenance(), Equals, "global-upload") +} + +func (s *snapResourceRevSuite) TestDecodeOKWithProvenance(c *C) { + encoded := s.makeValidEncoded() + encoded = strings.Replace(encoded, "snap-id: snap-id-1", "provenance: foo\nsnap-id: snap-id-1", 1) + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + c.Check(a.Type(), Equals, asserts.SnapResourceRevisionType) + snapResourceRev := a.(*asserts.SnapResourceRevision) + c.Check(snapResourceRev.AuthorityID(), Equals, "store-id1") + c.Check(snapResourceRev.Timestamp(), Equals, s.ts) + c.Check(snapResourceRev.SnapID(), Equals, "snap-id-1") + c.Check(snapResourceRev.ResourceName(), Equals, "comp-name") + c.Check(snapResourceRev.ResourceSHA3_384(), Equals, blobSHA3_384) + c.Check(snapResourceRev.ResourceSize(), Equals, uint64(127)) + c.Check(snapResourceRev.ResourceRevision(), Equals, 4) + c.Check(snapResourceRev.DeveloperID(), Equals, "dev-id1") + c.Check(snapResourceRev.Revision(), Equals, 1) + c.Check(snapResourceRev.Provenance(), Equals, "foo") +} + +const ( + snapResourceRevErrPrefix = "assertion snap-resource-revision: " +) + +func (s *snapResourceRevSuite) TestDecodeInvalid(c *C) { + encoded := s.makeValidEncoded() + + digestHdr := "resource-sha3-384: " + blobSHA3_384 + "\n" + invalidTests := []struct{ original, invalid, expectedErr string }{ + {"snap-id: snap-id-1\n", "", `"snap-id" header is mandatory`}, + {"snap-id: snap-id-1\n", "snap-id: \n", `"snap-id" header should not be empty`}, + {"resource-name: comp-name\n", "", `"resource-name" header is mandatory`}, + {"resource-name: comp-name\n", "resource-name: \n", `"resource-name" header should not be empty`}, + {"resource-name: comp-name\n", "resource-name: --comp-name\n", `invalid resource name "--comp-name"`}, + {digestHdr, "", `"resource-sha3-384" header is mandatory`}, + {digestHdr, "resource-sha3-384: \n", `"resource-sha3-384" header should not be empty`}, + {digestHdr, "resource-sha3-384: #\n", `"resource-sha3-384" header cannot be decoded:.*`}, + {digestHdr, "resource-sha3-384: eHl6\n", `"resource-sha3-384" header does not have the expected bit length: 24`}, + {"snap-id: snap-id-1\n", "provenance: \nsnap-id: snap-id-1\n", `"provenance" header should not be empty`}, + {"snap-id: snap-id-1\n", "provenance: *\nsnap-id: snap-id-1\n", `"provenance" header contains invalid characters: "\*"`}, + {"resource-size: 127\n", "", `"resource-size" header is mandatory`}, + {"resource-size: 127\n", "resource-size: \n", `"resource-size" header should not be empty`}, + {"resource-size: 127\n", "resource-size: -1\n", `"resource-size" header is not an unsigned integer: -1`}, + {"resource-size: 127\n", "resource-size: zzz\n", `"resource-size" header is not an unsigned integer: zzz`}, + {"resource-revision: 4\n", "", `"resource-revision" header is mandatory`}, + {"resource-revision: 4\n", "resource-revision: \n", `"resource-revision" header should not be empty`}, + {"resource-revision: 4\n", "resource-revision: -1\n", `"resource-revision" header must be >=1: -1`}, + {"resource-revision: 4\n", "resource-revision: 0\n", `"resource-revision" header must be >=1: 0`}, + {"resource-revision: 4\n", "resource-revision: zzz\n", `"resource-revision" header is not an integer: zzz`}, + {"developer-id: dev-id1\n", "", `"developer-id" header is mandatory`}, + {"developer-id: dev-id1\n", "developer-id: \n", `"developer-id" header should not be empty`}, + {s.tsLine, "", `"timestamp" header is mandatory`}, + {s.tsLine, "timestamp: \n", `"timestamp" header should not be empty`}, + {s.tsLine, "timestamp: 12:30\n", `"timestamp" header is not a RFC3339 date: .*`}, + } + + for _, test := range invalidTests { + invalid := strings.Replace(encoded, test.original, test.invalid, 1) + _, err := asserts.Decode([]byte(invalid)) + c.Check(err, ErrorMatches, snapResourceRevErrPrefix+test.expectedErr) + } +} + +func (s *snapResourceRevSuite) TestPrerequisites(c *C) { + encoded := s.makeValidEncoded() + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + + prereqs := a.Prerequisites() + c.Assert(prereqs, HasLen, 2) + c.Check(prereqs[0], DeepEquals, &asserts.Ref{ + Type: asserts.SnapDeclarationType, + PrimaryKey: []string{"16", "snap-id-1"}, + }) + c.Check(prereqs[1], DeepEquals, &asserts.Ref{ + Type: asserts.AccountType, + PrimaryKey: []string{"dev-id1"}, + }) +} + +func (s *snapResourceRevSuite) TestPrimaryKey(c *C) { + storeDB, db := makeStoreAndCheckDB(c) + + prereqDevAccount(c, storeDB, db) + prereqSnapDecl(c, storeDB, db) + + headers := s.makeHeaders(nil) + snapResRev, err := storeDB.Sign(asserts.SnapResourceRevisionType, headers, nil, "") + c.Assert(err, IsNil) + err = db.Add(snapResRev) + c.Assert(err, IsNil) + + _, err = db.Find(asserts.SnapResourceRevisionType, map[string]string{ + "snap-id": "snap-id-1", + "resource-name": "comp-name", + "resource-sha3-384": blobSHA3_384, + }) + c.Assert(err, IsNil) +} + +func (s *snapResourceRevSuite) TestCheckMissingDeveloperAccount(c *C) { + storeDB, db := makeStoreAndCheckDB(c) + + headers := s.makeHeaders(nil) + snapResRev, err := storeDB.Sign(asserts.SnapResourceRevisionType, headers, nil, "") + c.Assert(err, IsNil) + + err = db.Check(snapResRev) + c.Assert(err, ErrorMatches, `snap-resource-revision assertion for snap id "snap-id-1" does not have a matching account assertion for the developer "dev-id1"`) +} + +func (s *snapResourceRevSuite) TestCheckMissingDeclaration(c *C) { + storeDB, db := makeStoreAndCheckDB(c) + + prereqDevAccount(c, storeDB, db) + + headers := s.makeHeaders(nil) + snapResRev, err := storeDB.Sign(asserts.SnapResourceRevisionType, headers, nil, "") + c.Assert(err, IsNil) + + err = db.Check(snapResRev) + c.Assert(err, ErrorMatches, `snap-resource-revision assertion for snap id "snap-id-1" does not have a matching snap-declaration assertion`) +} + +func (s *snapResourceRevSuite) TestCheckUntrustedAuthority(c *C) { + storeDB, db := makeStoreAndCheckDB(c) + + otherDB := setup3rdPartySigning(c, "other", storeDB, db) + + headers := s.makeHeaders(map[string]interface{}{ + "authority-id": "other", + }) + snapResRev, err := otherDB.Sign(asserts.SnapResourceRevisionType, headers, nil, "") + c.Assert(err, IsNil) + + err = db.Check(snapResRev) + c.Assert(err, ErrorMatches, `snap-resource-revision assertion for snap id "snap-id-1" is not signed by a store:.*`) +} + +func (s *snapResourceRevSuite) TestRevisionAuthorityCheck(c *C) { + storeDB, db := makeStoreAndCheckDB(c) + + delegatedDB := setup3rdPartySigning(c, "delegated-id", storeDB, db) + headers := s.makeHeaders(map[string]interface{}{ + "authority-id": "delegated-id", + "developer-id": "delegated-id", + "resource-revision": "200", + "provenance": "prov1", + }) + a, err := delegatedDB.Sign(asserts.SnapResourceRevisionType, headers, nil, "") + c.Assert(err, IsNil) + snapResRev := a.(*asserts.SnapResourceRevision) + + tests := []struct { + revAuth asserts.RevisionAuthority + err string + }{ + {asserts.RevisionAuthority{ + AccountID: "delegated-id", + Provenance: []string{"prov1", "prov2"}, + MinRevision: 1, + }, ""}, + {asserts.RevisionAuthority{ + AccountID: "delegated-id", + Provenance: []string{"prov1", "prov2"}, + MinRevision: 1, + MaxRevision: 1000, + }, ""}, + {asserts.RevisionAuthority{ + AccountID: "delegated-id", + Provenance: []string{"prov2"}, + MinRevision: 1, + MaxRevision: 1000, + }, "provenance mismatch"}, + {asserts.RevisionAuthority{ + AccountID: "delegated-id-2", + Provenance: []string{"prov1", "prov2"}, + MinRevision: 1, + MaxRevision: 1000, + }, "authority-id mismatch"}, + {asserts.RevisionAuthority{ + AccountID: "delegated-id", + Provenance: []string{"prov1", "prov2"}, + MinRevision: 1000, + }, "resource revision 200 is less than min-revision 1000"}, + {asserts.RevisionAuthority{ + AccountID: "delegated-id", + Provenance: []string{"prov1", "prov2"}, + MinRevision: 10, + MaxRevision: 110, + }, "resource revision 200 is greater than max-revision 110"}, + } + + for _, t := range tests { + err := t.revAuth.CheckResourceRevision(snapResRev, nil, nil) + if t.err == "" { + c.Check(err, IsNil) + } else { + c.Check(err, ErrorMatches, t.err) + } + } +} + +func (s *snapResourceRevSuite) TestSnapResourceRevisionDelegation(c *C) { + storeDB, db := makeStoreAndCheckDB(c) + + delegatedDB := setup3rdPartySigning(c, "delegated-id", storeDB, db) + + snapDecl, err := storeDB.Sign(asserts.SnapDeclarationType, map[string]interface{}{ + "series": "16", + "snap-id": "snap-id-1", + "snap-name": "foo", + "publisher-id": "delegated-id", + "timestamp": time.Now().Format(time.RFC3339), + }, nil, "") + c.Assert(err, IsNil) + err = db.Add(snapDecl) + c.Assert(err, IsNil) + + headers := s.makeHeaders(map[string]interface{}{ + "authority-id": "delegated-id", + "developer-id": "delegated-id", + "provenance": "prov1", + }) + snapResRev, err := delegatedDB.Sign(asserts.SnapResourceRevisionType, headers, nil, "") + c.Assert(err, IsNil) + + err = db.Check(snapResRev) + c.Check(err, ErrorMatches, `snap-resource-revision assertion with provenance "prov1" for snap id "snap-id-1" is not signed by an authorized authority: delegated-id`) + + // establish delegation + snapDecl, err = storeDB.Sign(asserts.SnapDeclarationType, map[string]interface{}{ + "series": "16", + "snap-id": "snap-id-1", + "snap-name": "foo", + "publisher-id": "delegated-id", + "revision": "1", + "revision-authority": []interface{}{ + map[string]interface{}{ + "account-id": "delegated-id", + "provenance": []interface{}{ + "prov1", + }, + // present but not checked at this level + "on-store": []interface{}{ + "store1", + }, + }, + }, + "timestamp": time.Now().Format(time.RFC3339), + }, nil, "") + c.Assert(err, IsNil) + err = db.Add(snapDecl) + c.Assert(err, IsNil) + + // now revision should be accepted + err = db.Check(snapResRev) + c.Check(err, IsNil) +} + +func (s *snapResourceRevSuite) TestSnapResourceRevisionDelegationRevisionOutOfRange(c *C) { + storeDB, db := makeStoreAndCheckDB(c) + + delegatedDB := setup3rdPartySigning(c, "delegated-id", storeDB, db) + + // establish delegation + snapDecl, err := storeDB.Sign(asserts.SnapDeclarationType, map[string]interface{}{ + "series": "16", + "snap-id": "snap-id-1", + "snap-name": "foo", + "publisher-id": "delegated-id", + "revision-authority": []interface{}{ + map[string]interface{}{ + "account-id": "delegated-id", + "provenance": []interface{}{ + "prov1", + }, + // present but not checked at this level + "on-store": []interface{}{ + "store1", + }, + "max-revision": "200", + }, + }, + "timestamp": time.Now().Format(time.RFC3339), + }, nil, "") + c.Assert(err, IsNil) + err = db.Add(snapDecl) + c.Assert(err, IsNil) + + headers := s.makeHeaders(map[string]interface{}{ + "authority-id": "delegated-id", + "developer-id": "delegated-id", + "provenance": "prov1", + "resource-revision": "1000", + }) + snapResRev, err := delegatedDB.Sign(asserts.SnapResourceRevisionType, headers, nil, "") + c.Assert(err, IsNil) + + err = db.Check(snapResRev) + c.Check(err, ErrorMatches, `snap-resource-revision assertion with provenance "prov1" for snap id "snap-id-1" is not signed by an authorized authority: delegated-id`) +} + +type snapResourcePairSuite struct { + ts time.Time + tsLine string +} + +func (s *snapResourcePairSuite) SetUpSuite(c *C) { + s.ts = time.Now().Truncate(time.Second).UTC() + s.tsLine = "timestamp: " + s.ts.Format(time.RFC3339) + "\n" +} + +func (s *snapResourcePairSuite) makeValidEncoded() string { + return "type: snap-resource-pair\n" + + "authority-id: store-id1\n" + + "snap-id: snap-id-1\n" + + "resource-name: comp-name\n" + + "resource-revision: 4\n" + + "snap-revision: 20\n" + + "developer-id: dev-id1\n" + + "revision: 1\n" + + s.tsLine + + "body-length: 0\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==" +} + +func (s *snapResourcePairSuite) makeHeaders(overrides map[string]interface{}) map[string]interface{} { + headers := map[string]interface{}{ + "authority-id": "canonical", + "snap-id": "snap-id-1", + "resource-name": "comp-name", + "resource-revision": "4", + "snap-revision": "20", + "developer-id": "dev-id1", + "revision": "1", + "timestamp": time.Now().Format(time.RFC3339), + } + for k, v := range overrides { + headers[k] = v + } + return headers +} + +func (s *snapResourcePairSuite) TestDecodeOK(c *C) { + encoded := s.makeValidEncoded() + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + c.Check(a.Type(), Equals, asserts.SnapResourcePairType) + snapResourcePair := a.(*asserts.SnapResourcePair) + c.Check(snapResourcePair.AuthorityID(), Equals, "store-id1") + c.Check(snapResourcePair.Timestamp(), Equals, s.ts) + c.Check(snapResourcePair.SnapID(), Equals, "snap-id-1") + c.Check(snapResourcePair.ResourceName(), Equals, "comp-name") + c.Check(snapResourcePair.ResourceRevision(), Equals, 4) + c.Check(snapResourcePair.SnapRevision(), Equals, 20) + c.Check(snapResourcePair.DeveloperID(), Equals, "dev-id1") + c.Check(snapResourcePair.Revision(), Equals, 1) + c.Check(snapResourcePair.Provenance(), Equals, "global-upload") +} + +func (s *snapResourcePairSuite) TestDecodeOKWithProvenance(c *C) { + encoded := s.makeValidEncoded() + encoded = strings.Replace(encoded, "snap-id: snap-id-1", "provenance: foo\nsnap-id: snap-id-1", 1) + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + c.Check(a.Type(), Equals, asserts.SnapResourcePairType) + snapResourcePair := a.(*asserts.SnapResourcePair) + c.Check(snapResourcePair.AuthorityID(), Equals, "store-id1") + c.Check(snapResourcePair.Timestamp(), Equals, s.ts) + c.Check(snapResourcePair.SnapID(), Equals, "snap-id-1") + c.Check(snapResourcePair.ResourceName(), Equals, "comp-name") + c.Check(snapResourcePair.ResourceRevision(), Equals, 4) + c.Check(snapResourcePair.SnapRevision(), Equals, 20) + c.Check(snapResourcePair.DeveloperID(), Equals, "dev-id1") + c.Check(snapResourcePair.Revision(), Equals, 1) + c.Check(snapResourcePair.Provenance(), Equals, "foo") +} + +const ( + snapResourcePairErrPrefix = "assertion snap-resource-pair: " +) + +func (s *snapResourcePairSuite) TestDecodeInvalid(c *C) { + encoded := s.makeValidEncoded() + + invalidTests := []struct{ original, invalid, expectedErr string }{ + {"snap-id: snap-id-1\n", "", `"snap-id" header is mandatory`}, + {"snap-id: snap-id-1\n", "snap-id: \n", `"snap-id" header should not be empty`}, + {"resource-name: comp-name\n", "", `"resource-name" header is mandatory`}, + {"resource-name: comp-name\n", "resource-name: \n", `"resource-name" header should not be empty`}, + {"resource-name: comp-name\n", "resource-name: --comp-name\n", `invalid resource name "--comp-name"`}, + {"snap-id: snap-id-1\n", "provenance: \nsnap-id: snap-id-1\n", `"provenance" header should not be empty`}, + {"snap-id: snap-id-1\n", "provenance: *\nsnap-id: snap-id-1\n", `"provenance" header contains invalid characters: "\*"`}, + {"resource-revision: 4\n", "", `"resource-revision" header is mandatory`}, + {"resource-revision: 4\n", "resource-revision: \n", `"resource-revision" header should not be empty`}, + {"resource-revision: 4\n", "resource-revision: -1\n", `"resource-revision" header must be >=1: -1`}, + {"resource-revision: 4\n", "resource-revision: 0\n", `"resource-revision" header must be >=1: 0`}, + {"resource-revision: 4\n", "resource-revision: zzz\n", `"resource-revision" header is not an integer: zzz`}, + {"snap-revision: 20\n", "", `"snap-revision" header is mandatory`}, + {"snap-revision: 20\n", "snap-revision: \n", `"snap-revision" header should not be empty`}, + {"snap-revision: 20\n", "snap-revision: -1\n", `"snap-revision" header must be >=1: -1`}, + {"snap-revision: 20\n", "snap-revision: 0\n", `"snap-revision" header must be >=1: 0`}, + {"snap-revision: 20\n", "snap-revision: zzz\n", `"snap-revision" header is not an integer: zzz`}, + {"developer-id: dev-id1\n", "", `"developer-id" header is mandatory`}, + {"developer-id: dev-id1\n", "developer-id: \n", `"developer-id" header should not be empty`}, + {s.tsLine, "", `"timestamp" header is mandatory`}, + {s.tsLine, "timestamp: \n", `"timestamp" header should not be empty`}, + {s.tsLine, "timestamp: 12:30\n", `"timestamp" header is not a RFC3339 date: .*`}, + } + + for _, test := range invalidTests { + invalid := strings.Replace(encoded, test.original, test.invalid, 1) + _, err := asserts.Decode([]byte(invalid)) + c.Check(err, ErrorMatches, snapResourcePairErrPrefix+test.expectedErr) + } +} + +func (s *snapResourcePairSuite) TestPrerequisites(c *C) { + encoded := s.makeValidEncoded() + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + + prereqs := a.Prerequisites() + c.Assert(prereqs, HasLen, 1) + c.Check(prereqs[0], DeepEquals, &asserts.Ref{ + Type: asserts.SnapDeclarationType, + PrimaryKey: []string{"16", "snap-id-1"}, + }) +} + +func (s *snapResourcePairSuite) TestPrimaryKey(c *C) { + storeDB, db := makeStoreAndCheckDB(c) + + prereqDevAccount(c, storeDB, db) + prereqSnapDecl(c, storeDB, db) + + headers := s.makeHeaders(nil) + snapResPair, err := storeDB.Sign(asserts.SnapResourcePairType, headers, nil, "") + c.Assert(err, IsNil) + err = db.Add(snapResPair) + c.Assert(err, IsNil) + + _, err = db.Find(asserts.SnapResourcePairType, map[string]string{ + "snap-id": "snap-id-1", + "resource-name": "comp-name", + "resource-revision": "4", + "snap-revision": "20", + }) + c.Assert(err, IsNil) +} + +func (s *snapResourcePairSuite) TestCheckMissingDeveloperAccount(c *C) { + storeDB, db := makeStoreAndCheckDB(c) + + headers := s.makeHeaders(nil) + snapResPair, err := storeDB.Sign(asserts.SnapResourcePairType, headers, nil, "") + c.Assert(err, IsNil) + + err = db.Check(snapResPair) + c.Assert(err, ErrorMatches, `snap-resource-pair assertion for snap id "snap-id-1" does not have a matching account assertion for the developer "dev-id1"`) +} + +func (s *snapResourcePairSuite) TestCheckMissingDeclaration(c *C) { + storeDB, db := makeStoreAndCheckDB(c) + + prereqDevAccount(c, storeDB, db) + + headers := s.makeHeaders(nil) + snapResPair, err := storeDB.Sign(asserts.SnapResourcePairType, headers, nil, "") + c.Assert(err, IsNil) + + err = db.Check(snapResPair) + c.Assert(err, ErrorMatches, `snap-resource-pair assertion for snap id "snap-id-1" does not have a matching snap-declaration assertion`) +} + +func (s *snapResourcePairSuite) TestCheckUntrustedAuthority(c *C) { + storeDB, db := makeStoreAndCheckDB(c) + + otherDB := setup3rdPartySigning(c, "other", storeDB, db) + + headers := s.makeHeaders(map[string]interface{}{ + "authority-id": "other", + }) + snapResPair, err := otherDB.Sign(asserts.SnapResourcePairType, headers, nil, "") + c.Assert(err, IsNil) + + err = db.Check(snapResPair) + c.Assert(err, ErrorMatches, `snap-resource-pair assertion for snap id "snap-id-1" is not signed by a store:.*`) +} + +func (s *snapResourcePairSuite) TestDelegation(c *C) { + storeDB, db := makeStoreAndCheckDB(c) + + delegatedDB := setup3rdPartySigning(c, "delegated-id", storeDB, db) + + snapDecl, err := storeDB.Sign(asserts.SnapDeclarationType, map[string]interface{}{ + "series": "16", + "snap-id": "snap-id-1", + "snap-name": "foo", + "publisher-id": "delegated-id", + "timestamp": time.Now().Format(time.RFC3339), + }, nil, "") + c.Assert(err, IsNil) + err = db.Add(snapDecl) + c.Assert(err, IsNil) + + headers := s.makeHeaders(map[string]interface{}{ + "authority-id": "delegated-id", + "developer-id": "delegated-id", + "provenance": "prov1", + }) + snapResPair, err := delegatedDB.Sign(asserts.SnapResourcePairType, headers, nil, "") + c.Assert(err, IsNil) + + err = db.Check(snapResPair) + c.Check(err, ErrorMatches, `snap-resource-pair assertion with provenance "prov1" for snap id "snap-id-1" is not signed by an authorized authority: delegated-id`) + + // establish delegation + snapDecl, err = storeDB.Sign(asserts.SnapDeclarationType, map[string]interface{}{ + "series": "16", + "snap-id": "snap-id-1", + "snap-name": "foo", + "publisher-id": "delegated-id", + "revision": "1", + "revision-authority": []interface{}{ + map[string]interface{}{ + "account-id": "delegated-id", + "provenance": []interface{}{ + "prov1", + }, + // present but not checked at this level + "on-store": []interface{}{ + "store1", + }, + }, + }, + "timestamp": time.Now().Format(time.RFC3339), + }, nil, "") + c.Assert(err, IsNil) + err = db.Add(snapDecl) + c.Assert(err, IsNil) + + // now revision should be accepted + err = db.Check(snapResPair) + c.Check(err, IsNil) +} + +func (s *snapResourcePairSuite) TestDelegationRevisionOutOfRange(c *C) { + storeDB, db := makeStoreAndCheckDB(c) + + delegatedDB := setup3rdPartySigning(c, "delegated-id", storeDB, db) + + // establish delegation + snapDecl, err := storeDB.Sign(asserts.SnapDeclarationType, map[string]interface{}{ + "series": "16", + "snap-id": "snap-id-1", + "snap-name": "foo", + "publisher-id": "delegated-id", + "revision-authority": []interface{}{ + map[string]interface{}{ + "account-id": "delegated-id", + "provenance": []interface{}{ + "prov1", + }, + // present but not checked at this level + "on-store": []interface{}{ + "store1", + }, + "max-revision": "200", + }, + }, + "timestamp": time.Now().Format(time.RFC3339), + }, nil, "") + c.Assert(err, IsNil) + err = db.Add(snapDecl) + c.Assert(err, IsNil) + + headers := s.makeHeaders(map[string]interface{}{ + "authority-id": "delegated-id", + "developer-id": "delegated-id", + "provenance": "prov1", + "snap-revision": "1000", + }) + snapResPair, err := delegatedDB.Sign(asserts.SnapResourcePairType, headers, nil, "") + c.Assert(err, IsNil) + + err = db.Check(snapResPair) + c.Check(err, ErrorMatches, `snap-resource-pair assertion with provenance "prov1" for snap id "snap-id-1" is not signed by an authorized authority: delegated-id`) +} diff --git a/asserts/snapasserts/export_test.go b/asserts/snapasserts/export_test.go new file mode 100644 index 00000000..d2836d89 --- /dev/null +++ b/asserts/snapasserts/export_test.go @@ -0,0 +1,21 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2021 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package snapasserts + +type ByRevision = byRevision diff --git a/asserts/snapasserts/snapasserts.go b/asserts/snapasserts/snapasserts.go new file mode 100644 index 00000000..70b7eea7 --- /dev/null +++ b/asserts/snapasserts/snapasserts.go @@ -0,0 +1,282 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +// Package snapasserts offers helpers to handle snap related assertions and their checking for installation. +package snapasserts + +import ( + "errors" + "fmt" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/release" + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/snap/snapfile" +) + +type Finder interface { + // Find an assertion based on arbitrary headers. Provided + // headers must contain the primary key for the assertion + // type. It returns a asserts.NotFoundError if the assertion + // cannot be found. + Find(assertionType *asserts.AssertionType, headers map[string]string) (asserts.Assertion, error) + // FindMany finds assertions based on arbitrary headers. + // It returns a NotFoundError if no assertion can be found. + FindMany(assertionType *asserts.AssertionType, headers map[string]string) ([]asserts.Assertion, error) +} + +func findSnapDeclaration(snapID, name string, db Finder) (*asserts.SnapDeclaration, error) { + a, err := db.Find(asserts.SnapDeclarationType, map[string]string{ + "series": release.Series, + "snap-id": snapID, + }) + if err != nil { + return nil, fmt.Errorf("internal error: cannot find snap declaration for %q: %s", name, snapID) + } + snapDecl := a.(*asserts.SnapDeclaration) + + if snapDecl.SnapName() == "" { + return nil, fmt.Errorf("cannot install snap %q with a revoked snap declaration", name) + } + + return snapDecl, nil +} + +// CrossCheck tries to cross check the instance name, hash digest, provenance +// and size of a snap plus its metadata in a SideInfo with the relevant +// snap assertions in a database that should have been populated with +// them. +// The optional model assertion must be passed to have full cross +// checks in the case of delegated authority snap-revisions before +// installing a snap. +// It returns the corresponding cross-checked snap-revision. +// Ultimately the provided provenance (if not default) must be checked +// with the provenance in the snap metadata by the caller as well, if +// the provided provenance was not read safely from there already. +func CrossCheck(instanceName, snapSHA3_384, provenance string, snapSize uint64, si *snap.SideInfo, model *asserts.Model, db Finder) (snapRev *asserts.SnapRevision, err error) { + // get relevant assertions and do cross checks + headers := map[string]string{ + "snap-sha3-384": snapSHA3_384, + } + if provenance != "" { + headers["provenance"] = provenance + } + a, err := db.Find(asserts.SnapRevisionType, headers) + if err != nil { + provInf := "" + if provenance != "" { + provInf = fmt.Sprintf(" provenance: %s", provenance) + } + return nil, fmt.Errorf("internal error: cannot find pre-populated snap-revision assertion for %q: %s%s", instanceName, snapSHA3_384, provInf) + } + snapRev = a.(*asserts.SnapRevision) + + if snapRev.SnapSize() != snapSize { + return nil, fmt.Errorf("snap %q file does not have expected size according to signatures (download is broken or tampered): %d != %d", instanceName, snapSize, snapRev.SnapSize()) + } + + snapID := si.SnapID + + if snapRev.SnapID() != snapID || snapRev.SnapRevision() != si.Revision.N { + return nil, fmt.Errorf("snap %q does not have expected ID or revision according to assertions (metadata is broken or tampered): %s / %s != %d / %s", instanceName, si.Revision, snapID, snapRev.SnapRevision(), snapRev.SnapID()) + } + + snapDecl, err := findSnapDeclaration(snapID, instanceName, db) + if err != nil { + return nil, err + } + + if snapDecl.SnapName() != snap.InstanceSnap(instanceName) { + return nil, fmt.Errorf("cannot install %q, snap %q is undergoing a rename to %q", instanceName, snap.InstanceSnap(instanceName), snapDecl.SnapName()) + } + + if _, err := CrossCheckProvenance(instanceName, snapRev, snapDecl, model, db); err != nil { + return nil, err + } + + return snapRev, nil +} + +// CrossCheckProvenance tries to cross check the given snap-revision +// if it has a non default provenance with the revision-authority +// constraints of the given snap-declaration including any device +// scope constraints using model (and implied store). +// It also returns the provenance if it is different from the default. +// Ultimately if not default the provenance must also be checked +// with the provenance in the snap metadata by the caller. +func CrossCheckProvenance(instanceName string, snapRev *asserts.SnapRevision, snapDecl *asserts.SnapDeclaration, model *asserts.Model, db Finder) (signedProvenance string, err error) { + if snapRev.Provenance() == "global-upload" { + // nothing to check + return "", nil + } + var store *asserts.Store + if model != nil && model.Store() != "" { + a, err := db.Find(asserts.StoreType, map[string]string{ + "store": model.Store(), + }) + if err != nil && !errors.Is(err, &asserts.NotFoundError{}) { + return "", err + } + if a != nil { + store = a.(*asserts.Store) + } + } + ras := snapDecl.RevisionAuthority(snapRev.Provenance()) + matchingRevAuthority := false + for _, ra := range ras { + if err := ra.Check(snapRev, model, store); err == nil { + matchingRevAuthority = true + break + } + } + if !matchingRevAuthority { + return "", fmt.Errorf("snap %q revision assertion with provenance %q is not signed by an authority authorized on this device: %s", instanceName, snapRev.Provenance(), snapRev.AuthorityID()) + } + return snapRev.Provenance(), nil +} + +// CheckProvenanceWithVerifiedRevision checks that the given snap has +// the same provenance as of the provided snap-revision. +// It is intended to be called safely on snaps for which a matching +// and authorized snap-revision has been already found and cross-checked. +// Its purpose is to check that a blob has not been re-signed under an +// inappropriate provenance. +func CheckProvenanceWithVerifiedRevision(snapPath string, verifiedRev *asserts.SnapRevision) error { + snapf, err := snapfile.Open(snapPath) + if err != nil { + return err + } + info, err := snap.ReadInfoFromSnapFile(snapf, nil) + if err != nil { + return err + } + if verifiedRev.Provenance() != info.Provenance() { + return fmt.Errorf("snap %q has been signed under provenance %q different from the metadata one: %q", snapPath, verifiedRev.Provenance(), info.Provenance()) + } + return nil +} + +// DeriveSideInfo tries to construct a SideInfo for the given snap +// using its digest to find the relevant snap assertions with the +// information in the given database. It will fail with an +// asserts.NotFoundError if it cannot find them. +// model is used to cross check that the found snap-revision is applicable +// on the device. +func DeriveSideInfo(snapPath string, model *asserts.Model, db Finder) (*snap.SideInfo, error) { + snapSHA3_384, snapSize, err := asserts.SnapFileSHA3_384(snapPath) + if err != nil { + return nil, err + } + + return DeriveSideInfoFromDigestAndSize(snapPath, snapSHA3_384, snapSize, model, db) +} + +// DeriveSideInfoFromDigestAndSize tries to construct a SideInfo +// using digest and size as provided for the snap to find the relevant +// snap assertions with the information in the given database. It will +// fail with an asserts.NotFoundError if it cannot find them. +// model is used to cross check that the found snap-revision is applicable +// on the device. +func DeriveSideInfoFromDigestAndSize(snapPath string, snapSHA3_384 string, snapSize uint64, model *asserts.Model, db Finder) (*snap.SideInfo, error) { + // get relevant assertions and reconstruct metadata + headers := map[string]string{ + "snap-sha3-384": snapSHA3_384, + } + a, err := db.Find(asserts.SnapRevisionType, headers) + if err != nil && !errors.Is(err, &asserts.NotFoundError{}) { + return nil, err + } + if a == nil { + // non-default provenance? + cands, err := db.FindMany(asserts.SnapRevisionType, headers) + if err != nil { + return nil, err + } + if len(cands) != 1 { + return nil, fmt.Errorf("safely handling snaps with different provenance but same hash not yet supported") + } + a = cands[0] + } + + snapRev := a.(*asserts.SnapRevision) + + if snapRev.SnapSize() != snapSize { + return nil, fmt.Errorf("snap %q does not have expected size according to signatures (broken or tampered): %d != %d", snapPath, snapSize, snapRev.SnapSize()) + } + + snapID := snapRev.SnapID() + + snapDecl, err := findSnapDeclaration(snapID, snapPath, db) + if err != nil { + return nil, err + } + + if _, err = CrossCheckProvenance(snapDecl.SnapName(), snapRev, snapDecl, model, db); err != nil { + return nil, err + } + + if err := CheckProvenanceWithVerifiedRevision(snapPath, snapRev); err != nil { + return nil, err + } + + return SideInfoFromSnapAssertions(snapDecl, snapRev), nil +} + +// SideInfoFromSnapAssertions returns a *snap.SideInfo reflecting the given snap assertions. +func SideInfoFromSnapAssertions(snapDecl *asserts.SnapDeclaration, snapRev *asserts.SnapRevision) *snap.SideInfo { + return &snap.SideInfo{ + RealName: snapDecl.SnapName(), + SnapID: snapDecl.SnapID(), + Revision: snap.R(snapRev.SnapRevision()), + } +} + +// FetchSnapAssertions fetches the assertions matching the snap file digest and optional provenance using the given fetcher. +func FetchSnapAssertions(f asserts.Fetcher, snapSHA3_384, provenance string) error { + // for now starting from the snap-revision will get us all other relevant assertions + ref := &asserts.Ref{ + Type: asserts.SnapRevisionType, + PrimaryKey: []string{snapSHA3_384}, + } + if provenance != "" { + ref.PrimaryKey = append(ref.PrimaryKey, provenance) + } + + return f.Fetch(ref) +} + +// FetchSnapDeclaration fetches the snap declaration and its prerequisites for the given snap id using the given fetcher. +func FetchSnapDeclaration(f asserts.Fetcher, snapID string) error { + ref := &asserts.Ref{ + Type: asserts.SnapDeclarationType, + PrimaryKey: []string{release.Series, snapID}, + } + + return f.Fetch(ref) +} + +// FetchStore fetches the store assertion and its prerequisites for the given store id using the given fetcher. +func FetchStore(f asserts.Fetcher, storeID string) error { + ref := &asserts.Ref{ + Type: asserts.StoreType, + PrimaryKey: []string{storeID}, + } + + return f.Fetch(ref) +} diff --git a/asserts/snapasserts/snapasserts_test.go b/asserts/snapasserts/snapasserts_test.go new file mode 100644 index 00000000..11f243b0 --- /dev/null +++ b/asserts/snapasserts/snapasserts_test.go @@ -0,0 +1,791 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016-2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package snapasserts_test + +import ( + "crypto" + "errors" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "golang.org/x/crypto/sha3" + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/asserts/assertstest" + "github.com/snapcore/snapd/asserts/snapasserts" + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/snap/snaptest" + "github.com/snapcore/snapd/testutil" +) + +func TestSnapasserts(t *testing.T) { TestingT(t) } + +type snapassertsSuite struct { + testutil.BaseTest + + storeSigning *assertstest.StoreStack + dev1Acct *asserts.Account + dev1Signing *assertstest.SigningDB + + localDB *asserts.Database +} + +var _ = Suite(&snapassertsSuite{}) + +func (s *snapassertsSuite) SetUpTest(c *C) { + s.storeSigning = assertstest.NewStoreStack("can0nical", nil) + + s.dev1Acct = assertstest.NewAccount(s.storeSigning, "developer1", nil, "") + + localDB, err := asserts.OpenDatabase(&asserts.DatabaseConfig{ + Backstore: asserts.NewMemoryBackstore(), + Trusted: s.storeSigning.Trusted, + }) + c.Assert(err, IsNil) + + s.localDB = localDB + + // add in prereqs assertions + err = s.localDB.Add(s.storeSigning.StoreAccountKey("")) + c.Assert(err, IsNil) + err = s.localDB.Add(s.dev1Acct) + c.Assert(err, IsNil) + + privKey, _ := assertstest.GenerateKey(752) + accKey := assertstest.NewAccountKey(s.storeSigning, s.dev1Acct, nil, privKey.PublicKey(), "") + err = s.localDB.Add(accKey) + c.Assert(err, IsNil) + + s.dev1Signing = assertstest.NewSigningDB(s.dev1Acct.AccountID(), privKey) + + headers := map[string]interface{}{ + "series": "16", + "snap-id": "snap-id-1", + "snap-name": "foo", + "publisher-id": s.dev1Acct.AccountID(), + "timestamp": time.Now().Format(time.RFC3339), + } + snapDecl, err := s.storeSigning.Sign(asserts.SnapDeclarationType, headers, nil, "") + c.Assert(err, IsNil) + err = s.localDB.Add(snapDecl) + c.Assert(err, IsNil) + + s.AddCleanup(snap.MockSanitizePlugsSlots(func(snapInfo *snap.Info) {})) +} + +func fakeSnap(rev int) []byte { + fake := fmt.Sprintf("hsqs________________%d", rev) + return []byte(fake) +} + +func fakeHash(rev int) []byte { + h := sha3.Sum384(fakeSnap(rev)) + return h[:] +} + +func makeDigest(rev int) string { + d, err := asserts.EncodeDigest(crypto.SHA3_384, fakeHash(rev)) + if err != nil { + panic(err) + } + return string(d) +} + +func (s *snapassertsSuite) TestCrossCheckHappy(c *C) { + digest := makeDigest(12) + size := uint64(len(fakeSnap(12))) + headers := map[string]interface{}{ + "snap-id": "snap-id-1", + "snap-sha3-384": digest, + "snap-size": fmt.Sprintf("%d", size), + "snap-revision": "12", + "developer-id": s.dev1Acct.AccountID(), + "timestamp": time.Now().Format(time.RFC3339), + } + snapRev, err := s.storeSigning.Sign(asserts.SnapRevisionType, headers, nil, "") + c.Assert(err, IsNil) + err = s.localDB.Add(snapRev) + c.Assert(err, IsNil) + + si := &snap.SideInfo{ + SnapID: "snap-id-1", + Revision: snap.R(12), + } + + // everything cross checks, with the regular snap name + checkedRev, err := snapasserts.CrossCheck("foo", digest, "", size, si, nil, s.localDB) + c.Assert(err, IsNil) + c.Check(checkedRev, DeepEquals, snapRev) + + // and a snap instance name + _, err = snapasserts.CrossCheck("foo_instance", digest, "", size, si, nil, s.localDB) + c.Check(err, IsNil) +} + +func (s *snapassertsSuite) TestCrossCheckErrors(c *C) { + digest := makeDigest(12) + size := uint64(len(fakeSnap(12))) + headers := map[string]interface{}{ + "snap-id": "snap-id-1", + "snap-sha3-384": digest, + "snap-size": fmt.Sprintf("%d", size), + "snap-revision": "12", + "developer-id": s.dev1Acct.AccountID(), + "timestamp": time.Now().Format(time.RFC3339), + } + snapRev, err := s.storeSigning.Sign(asserts.SnapRevisionType, headers, nil, "") + c.Assert(err, IsNil) + err = s.localDB.Add(snapRev) + c.Assert(err, IsNil) + + si := &snap.SideInfo{ + SnapID: "snap-id-1", + Revision: snap.R(12), + } + + // different size + _, err = snapasserts.CrossCheck("foo", digest, "", size+1, si, nil, s.localDB) + c.Check(err, ErrorMatches, fmt.Sprintf(`snap "foo" file does not have expected size according to signatures \(download is broken or tampered\): %d != %d`, size+1, size)) + _, err = snapasserts.CrossCheck("foo_instance", digest, "", size+1, si, nil, s.localDB) + c.Check(err, ErrorMatches, fmt.Sprintf(`snap "foo_instance" file does not have expected size according to signatures \(download is broken or tampered\): %d != %d`, size+1, size)) + + // mismatched revision vs what we got from store original info + _, err = snapasserts.CrossCheck("foo", digest, "", size, &snap.SideInfo{ + SnapID: "snap-id-1", + Revision: snap.R(21), + }, nil, s.localDB) + c.Check(err, ErrorMatches, `snap "foo" does not have expected ID or revision according to assertions \(metadata is broken or tampered\): 21 / snap-id-1 != 12 / snap-id-1`) + _, err = snapasserts.CrossCheck("foo_instance", digest, "", size, &snap.SideInfo{ + SnapID: "snap-id-1", + Revision: snap.R(21), + }, nil, s.localDB) + c.Check(err, ErrorMatches, `snap "foo_instance" does not have expected ID or revision according to assertions \(metadata is broken or tampered\): 21 / snap-id-1 != 12 / snap-id-1`) + + // mismatched snap id vs what we got from store original info + _, err = snapasserts.CrossCheck("foo", digest, "", size, &snap.SideInfo{ + SnapID: "snap-id-other", + Revision: snap.R(12), + }, nil, s.localDB) + c.Check(err, ErrorMatches, `snap "foo" does not have expected ID or revision according to assertions \(metadata is broken or tampered\): 12 / snap-id-other != 12 / snap-id-1`) + _, err = snapasserts.CrossCheck("foo_instance", digest, "", size, &snap.SideInfo{ + SnapID: "snap-id-other", + Revision: snap.R(12), + }, nil, s.localDB) + c.Check(err, ErrorMatches, `snap "foo_instance" does not have expected ID or revision according to assertions \(metadata is broken or tampered\): 12 / snap-id-other != 12 / snap-id-1`) + + // changed name + _, err = snapasserts.CrossCheck("baz", digest, "", size, si, nil, s.localDB) + c.Check(err, ErrorMatches, `cannot install "baz", snap "baz" is undergoing a rename to "foo"`) + _, err = snapasserts.CrossCheck("baz_instance", digest, "", size, si, nil, s.localDB) + c.Check(err, ErrorMatches, `cannot install "baz_instance", snap "baz" is undergoing a rename to "foo"`) + +} + +func (s *snapassertsSuite) TestCrossCheckRevokedSnapDecl(c *C) { + // revoked snap declaration (snap-name=="") ! + headers := map[string]interface{}{ + "series": "16", + "snap-id": "snap-id-1", + "snap-name": "", + "publisher-id": s.dev1Acct.AccountID(), + "revision": "1", + "timestamp": time.Now().Format(time.RFC3339), + } + snapDecl, err := s.storeSigning.Sign(asserts.SnapDeclarationType, headers, nil, "") + c.Assert(err, IsNil) + err = s.localDB.Add(snapDecl) + c.Assert(err, IsNil) + + digest := makeDigest(12) + size := uint64(len(fakeSnap(12))) + headers = map[string]interface{}{ + "snap-id": "snap-id-1", + "snap-sha3-384": digest, + "snap-size": fmt.Sprintf("%d", size), + "snap-revision": "12", + "developer-id": s.dev1Acct.AccountID(), + "timestamp": time.Now().Format(time.RFC3339), + } + snapRev, err := s.storeSigning.Sign(asserts.SnapRevisionType, headers, nil, "") + c.Assert(err, IsNil) + err = s.localDB.Add(snapRev) + c.Assert(err, IsNil) + + si := &snap.SideInfo{ + SnapID: "snap-id-1", + Revision: snap.R(12), + } + + _, err = snapasserts.CrossCheck("foo", digest, "", size, si, nil, s.localDB) + c.Check(err, ErrorMatches, `cannot install snap "foo" with a revoked snap declaration`) + _, err = snapasserts.CrossCheck("foo_instance", digest, "", size, si, nil, s.localDB) + c.Check(err, ErrorMatches, `cannot install snap "foo_instance" with a revoked snap declaration`) +} + +func (s *snapassertsSuite) TestDeriveSideInfoHappy(c *C) { + fooSnap := snaptest.MakeTestSnapWithFiles(c, `name: foo +version: 1`, nil) + digest, size, err := asserts.SnapFileSHA3_384(fooSnap) + c.Assert(err, IsNil) + + headers := map[string]interface{}{ + "snap-id": "snap-id-1", + "snap-sha3-384": digest, + "snap-size": fmt.Sprintf("%d", size), + "snap-revision": "42", + "developer-id": s.dev1Acct.AccountID(), + "timestamp": time.Now().Format(time.RFC3339), + } + snapRev, err := s.storeSigning.Sign(asserts.SnapRevisionType, headers, nil, "") + c.Assert(err, IsNil) + err = s.localDB.Add(snapRev) + c.Assert(err, IsNil) + + si, err := snapasserts.DeriveSideInfo(fooSnap, nil, s.localDB) + c.Assert(err, IsNil) + c.Check(si, DeepEquals, &snap.SideInfo{ + RealName: "foo", + SnapID: "snap-id-1", + Revision: snap.R(42), + Channel: "", + }) +} + +func (s *snapassertsSuite) TestDeriveSideInfoNoSignatures(c *C) { + tempdir := c.MkDir() + snapPath := filepath.Join(tempdir, "anon.snap") + err := os.WriteFile(snapPath, fakeSnap(42), 0644) + c.Assert(err, IsNil) + + _, err = snapasserts.DeriveSideInfo(snapPath, nil, s.localDB) + // cannot find signatures with metadata for snap + c.Assert(errors.Is(err, &asserts.NotFoundError{}), Equals, true) +} + +func (s *snapassertsSuite) TestDeriveSideInfoSizeMismatch(c *C) { + digest := makeDigest(42) + size := uint64(len(fakeSnap(42))) + headers := map[string]interface{}{ + "snap-id": "snap-id-1", + "snap-sha3-384": digest, + "snap-size": fmt.Sprintf("%d", size+5), // broken + "snap-revision": "42", + "developer-id": s.dev1Acct.AccountID(), + "timestamp": time.Now().Format(time.RFC3339), + } + snapRev, err := s.storeSigning.Sign(asserts.SnapRevisionType, headers, nil, "") + c.Assert(err, IsNil) + err = s.localDB.Add(snapRev) + c.Assert(err, IsNil) + + tempdir := c.MkDir() + snapPath := filepath.Join(tempdir, "anon.snap") + err = os.WriteFile(snapPath, fakeSnap(42), 0644) + c.Assert(err, IsNil) + + _, err = snapasserts.DeriveSideInfo(snapPath, nil, s.localDB) + c.Check(err, ErrorMatches, fmt.Sprintf(`snap %q does not have expected size according to signatures \(broken or tampered\): %d != %d`, snapPath, size, size+5)) +} + +func (s *snapassertsSuite) TestDeriveSideInfoRevokedSnapDecl(c *C) { + // revoked snap declaration (snap-name=="") ! + headers := map[string]interface{}{ + "series": "16", + "snap-id": "snap-id-1", + "snap-name": "", + "publisher-id": s.dev1Acct.AccountID(), + "revision": "1", + "timestamp": time.Now().Format(time.RFC3339), + } + snapDecl, err := s.storeSigning.Sign(asserts.SnapDeclarationType, headers, nil, "") + c.Assert(err, IsNil) + err = s.localDB.Add(snapDecl) + c.Assert(err, IsNil) + + digest := makeDigest(42) + size := uint64(len(fakeSnap(42))) + headers = map[string]interface{}{ + "snap-id": "snap-id-1", + "snap-sha3-384": digest, + "snap-size": fmt.Sprintf("%d", size), + "snap-revision": "42", + "developer-id": s.dev1Acct.AccountID(), + "timestamp": time.Now().Format(time.RFC3339), + } + snapRev, err := s.storeSigning.Sign(asserts.SnapRevisionType, headers, nil, "") + c.Assert(err, IsNil) + err = s.localDB.Add(snapRev) + c.Assert(err, IsNil) + + tempdir := c.MkDir() + snapPath := filepath.Join(tempdir, "anon.snap") + err = os.WriteFile(snapPath, fakeSnap(42), 0644) + c.Assert(err, IsNil) + + _, err = snapasserts.DeriveSideInfo(snapPath, nil, s.localDB) + c.Check(err, ErrorMatches, fmt.Sprintf(`cannot install snap %q with a revoked snap declaration`, snapPath)) +} + +func (s *snapassertsSuite) TestCrossCheckDelegatedSnapHappy(c *C) { + snapDecl, err := s.storeSigning.Sign(asserts.SnapDeclarationType, map[string]interface{}{ + "series": "16", + "snap-id": "snap-id-1", + "snap-name": "foo", + "publisher-id": s.dev1Acct.AccountID(), + "revision": "1", + "revision-authority": []interface{}{ + map[string]interface{}{ + "account-id": s.dev1Acct.AccountID(), + "provenance": []interface{}{ + "prov1", + }, + }, + }, + "timestamp": time.Now().Format(time.RFC3339), + }, nil, "") + c.Assert(err, IsNil) + err = s.localDB.Add(snapDecl) + c.Assert(err, IsNil) + + digest := makeDigest(42) + size := uint64(len(fakeSnap(42))) + headers := map[string]interface{}{ + "authority-id": s.dev1Acct.AccountID(), + "snap-id": "snap-id-1", + "snap-sha3-384": digest, + "snap-size": fmt.Sprintf("%d", size), + "provenance": "prov1", + "snap-revision": "42", + "developer-id": s.dev1Acct.AccountID(), + "timestamp": time.Now().Format(time.RFC3339), + } + snapRev, err := s.dev1Signing.Sign(asserts.SnapRevisionType, headers, nil, "") + c.Assert(err, IsNil) + + err = s.localDB.Add(snapRev) + c.Check(err, IsNil) + + si := &snap.SideInfo{ + SnapID: "snap-id-1", + Revision: snap.R(42), + } + + // everything cross checks, with the regular snap name + checkedRev, err := snapasserts.CrossCheck("foo", digest, "prov1", size, si, nil, s.localDB) + c.Assert(err, IsNil) + c.Check(checkedRev, DeepEquals, snapRev) + // and a snap instance name + _, err = snapasserts.CrossCheck("foo_instance", digest, "prov1", size, si, nil, s.localDB) + c.Check(err, IsNil) +} + +func (s *snapassertsSuite) TestCrossCheckWithDeviceDelegatedSnapHappy(c *C) { + a, err := s.dev1Signing.Sign(asserts.ModelType, map[string]interface{}{ + "brand-id": s.dev1Acct.AccountID(), + "series": "16", + "model": "dev-model", + "store": "substore", + "architecture": "amd64", + "base": "core18", + "kernel": "krnl", + "gadget": "gadget", + "timestamp": time.Now().Format(time.RFC3339), + }, nil, "") + c.Assert(err, IsNil) + model := a.(*asserts.Model) + + substore, err := s.storeSigning.Sign(asserts.StoreType, map[string]interface{}{ + "store": "substore", + "operator-id": "can0nical", + "friendly-stores": []interface{}{"store1"}, + "timestamp": time.Now().Format(time.RFC3339), + }, nil, "") + c.Assert(err, IsNil) + err = s.localDB.Add(substore) + c.Assert(err, IsNil) + + snapDecl, err := s.storeSigning.Sign(asserts.SnapDeclarationType, map[string]interface{}{ + "series": "16", + "snap-id": "snap-id-1", + "snap-name": "foo", + "publisher-id": s.dev1Acct.AccountID(), + "revision": "1", + "revision-authority": []interface{}{ + map[string]interface{}{ + "account-id": s.dev1Acct.AccountID(), + "provenance": []interface{}{ + "prov1", + }, + "on-store": []interface{}{ + "store1", + }, + }, + }, + "timestamp": time.Now().Format(time.RFC3339), + }, nil, "") + c.Assert(err, IsNil) + err = s.localDB.Add(snapDecl) + c.Assert(err, IsNil) + + digest := makeDigest(42) + size := uint64(len(fakeSnap(42))) + headers := map[string]interface{}{ + "authority-id": s.dev1Acct.AccountID(), + "snap-id": "snap-id-1", + "snap-sha3-384": digest, + "snap-size": fmt.Sprintf("%d", size), + "provenance": "prov1", + "snap-revision": "42", + "developer-id": s.dev1Acct.AccountID(), + "timestamp": time.Now().Format(time.RFC3339), + } + snapRev, err := s.dev1Signing.Sign(asserts.SnapRevisionType, headers, nil, "") + c.Assert(err, IsNil) + + err = s.localDB.Add(snapRev) + c.Check(err, IsNil) + + si := &snap.SideInfo{ + SnapID: "snap-id-1", + Revision: snap.R(42), + } + + // everything cross checks, with the regular snap name + checkedRev, err := snapasserts.CrossCheck("foo", digest, "prov1", size, si, model, s.localDB) + c.Assert(err, IsNil) + c.Check(checkedRev, Equals, snapRev) + // and a snap instance name + _, err = snapasserts.CrossCheck("foo_instance", digest, "prov1", size, si, model, s.localDB) + c.Check(err, IsNil) +} + +func (s *snapassertsSuite) TestCrossCheckWithDeviceDelegatedSnapUnhappy(c *C) { + a, err := s.dev1Signing.Sign(asserts.ModelType, map[string]interface{}{ + "brand-id": s.dev1Acct.AccountID(), + "series": "16", + "model": "dev-model", + "store": "substore", + "architecture": "amd64", + "base": "core18", + "kernel": "krnl", + "gadget": "gadget", + "timestamp": time.Now().Format(time.RFC3339), + }, nil, "") + c.Assert(err, IsNil) + model := a.(*asserts.Model) + + substore, err := s.storeSigning.Sign(asserts.StoreType, map[string]interface{}{ + "store": "substore", + "operator-id": "can0nical", + "friendly-stores": []interface{}{"store1"}, + "timestamp": time.Now().Format(time.RFC3339), + }, nil, "") + c.Assert(err, IsNil) + err = s.localDB.Add(substore) + c.Assert(err, IsNil) + + snapDecl, err := s.storeSigning.Sign(asserts.SnapDeclarationType, map[string]interface{}{ + "series": "16", + "snap-id": "snap-id-1", + "snap-name": "foo", + "publisher-id": s.dev1Acct.AccountID(), + "revision": "1", + "revision-authority": []interface{}{ + map[string]interface{}{ + "account-id": s.dev1Acct.AccountID(), + "provenance": []interface{}{ + "prov1", + }, + "on-store": []interface{}{ + "store2", + }, + }, + }, + "timestamp": time.Now().Format(time.RFC3339), + }, nil, "") + c.Assert(err, IsNil) + err = s.localDB.Add(snapDecl) + c.Assert(err, IsNil) + + digest := makeDigest(42) + size := uint64(len(fakeSnap(42))) + headers := map[string]interface{}{ + "authority-id": s.dev1Acct.AccountID(), + "snap-id": "snap-id-1", + "snap-sha3-384": digest, + "snap-size": fmt.Sprintf("%d", size), + "provenance": "prov1", + "snap-revision": "42", + "developer-id": s.dev1Acct.AccountID(), + "timestamp": time.Now().Format(time.RFC3339), + } + snapRev, err := s.dev1Signing.Sign(asserts.SnapRevisionType, headers, nil, "") + c.Assert(err, IsNil) + + err = s.localDB.Add(snapRev) + c.Check(err, IsNil) + + si := &snap.SideInfo{ + SnapID: "snap-id-1", + Revision: snap.R(42), + } + + _, err = snapasserts.CrossCheck("foo", digest, "prov1", size, si, model, s.localDB) + c.Check(err, ErrorMatches, `snap "foo" revision assertion with provenance "prov1" is not signed by an authority authorized on this device: .*`) +} + +func (s *snapassertsSuite) TestCrossCheckSpuriousProvenanceUnhappy(c *C) { + digest := makeDigest(12) + size := uint64(len(fakeSnap(12))) + headers := map[string]interface{}{ + "snap-id": "snap-id-1", + "snap-sha3-384": digest, + "snap-size": fmt.Sprintf("%d", size), + "snap-revision": "12", + "developer-id": s.dev1Acct.AccountID(), + "timestamp": time.Now().Format(time.RFC3339), + } + snapRev, err := s.storeSigning.Sign(asserts.SnapRevisionType, headers, nil, "") + c.Assert(err, IsNil) + err = s.localDB.Add(snapRev) + c.Assert(err, IsNil) + + si := &snap.SideInfo{ + SnapID: "snap-id-1", + Revision: snap.R(12), + } + + _, err = snapasserts.CrossCheck("foo", digest, "prov", size, si, nil, s.localDB) + c.Check(err, ErrorMatches, `.*cannot find pre-populated snap-revision assertion for "foo": .*provenance: prov`) +} + +func (s *snapassertsSuite) TestCheckProvenanceWithVerifiedRevision(c *C) { + digest := makeDigest(12) + size := uint64(len(fakeSnap(12))) + snapRevGlobalUpload := assertstest.FakeAssertion(map[string]interface{}{ + "type": "snap-revision", + "authority-id": "can0nical", + "snap-id": "snap-id-1", + "snap-sha3-384": digest, + "snap-size": fmt.Sprintf("%d", size), + "snap-revision": "12", + "developer-id": s.dev1Acct.AccountID(), + "timestamp": time.Now().Format(time.RFC3339), + }).(*asserts.SnapRevision) + snapRevProv := assertstest.FakeAssertion(map[string]interface{}{ + "type": "snap-revision", + "authority-id": s.dev1Acct.AccountID(), + "snap-id": "snap-id-1", + "snap-sha3-384": digest, + "provenance": "prov", + "snap-size": fmt.Sprintf("%d", size), + "snap-revision": "12", + "developer-id": s.dev1Acct.AccountID(), + "timestamp": time.Now().Format(time.RFC3339), + }).(*asserts.SnapRevision) + snapRevProv2 := assertstest.FakeAssertion(map[string]interface{}{ + "type": "snap-revision", + "authority-id": s.dev1Acct.AccountID(), + "snap-id": "snap-id-1", + "snap-sha3-384": digest, + "provenance": "prov2", + "snap-size": fmt.Sprintf("%d", size), + "snap-revision": "12", + "developer-id": s.dev1Acct.AccountID(), + "timestamp": time.Now().Format(time.RFC3339), + }).(*asserts.SnapRevision) + withProv := snaptest.MakeTestSnapWithFiles(c, `name: with-prov +version: 1 +provenance: prov`, nil) + defaultProv := snaptest.MakeTestSnapWithFiles(c, `name: defl +version: 1 +`, nil) + + // matching + c.Check(snapasserts.CheckProvenanceWithVerifiedRevision(withProv, snapRevProv), IsNil) + c.Check(snapasserts.CheckProvenanceWithVerifiedRevision(defaultProv, snapRevGlobalUpload), IsNil) + + // mismatches + mismatches := []struct { + path string + snapRev *asserts.SnapRevision + metadataProv string + }{ + {withProv, snapRevProv2, "prov"}, + {withProv, snapRevGlobalUpload, "prov"}, + {defaultProv, snapRevProv, "global-upload"}, + } + for _, mism := range mismatches { + c.Check(snapasserts.CheckProvenanceWithVerifiedRevision(mism.path, mism.snapRev), ErrorMatches, fmt.Sprintf("snap %q has been signed under provenance %q different from the metadata one: %q", mism.path, mism.snapRev.Provenance(), mism.metadataProv)) + } + +} + +func (s *snapassertsSuite) TestDeriveSideInfoFromDigestAndSizeDelegatedSnap(c *C) { + withProv := snaptest.MakeTestSnapWithFiles(c, `name: with-prov +version: 1 +provenance: prov`, nil) + digest, size, err := asserts.SnapFileSHA3_384(withProv) + c.Assert(err, IsNil) + + a, err := s.dev1Signing.Sign(asserts.ModelType, map[string]interface{}{ + "brand-id": s.dev1Acct.AccountID(), + "series": "16", + "model": "dev-model", + "store": "substore", + "architecture": "amd64", + "base": "core18", + "kernel": "krnl", + "gadget": "gadget", + "timestamp": time.Now().Format(time.RFC3339), + }, nil, "") + c.Assert(err, IsNil) + model := a.(*asserts.Model) + + substore, err := s.storeSigning.Sign(asserts.StoreType, map[string]interface{}{ + "store": "substore", + "operator-id": "can0nical", + "friendly-stores": []interface{}{"store1"}, + "timestamp": time.Now().Format(time.RFC3339), + }, nil, "") + c.Assert(err, IsNil) + err = s.localDB.Add(substore) + c.Assert(err, IsNil) + + snapDecl, err := s.storeSigning.Sign(asserts.SnapDeclarationType, map[string]interface{}{ + "series": "16", + "snap-id": "snap-id-1", + "snap-name": "foo", + "publisher-id": s.dev1Acct.AccountID(), + "revision": "1", + "revision-authority": []interface{}{ + map[string]interface{}{ + "account-id": s.dev1Acct.AccountID(), + "provenance": []interface{}{ + "prov", + }, + "on-store": []interface{}{ + "store1", + }, + }, + }, + "timestamp": time.Now().Format(time.RFC3339), + }, nil, "") + c.Assert(err, IsNil) + err = s.localDB.Add(snapDecl) + c.Assert(err, IsNil) + + headers := map[string]interface{}{ + "authority-id": s.dev1Acct.AccountID(), + "snap-id": "snap-id-1", + "snap-sha3-384": digest, + "snap-size": fmt.Sprintf("%d", size), + "provenance": "prov", + "snap-revision": "41", + "developer-id": s.dev1Acct.AccountID(), + "timestamp": time.Now().Format(time.RFC3339), + } + snapRev, err := s.dev1Signing.Sign(asserts.SnapRevisionType, headers, nil, "") + c.Assert(err, IsNil) + + err = s.localDB.Add(snapRev) + c.Check(err, IsNil) + + si, err := snapasserts.DeriveSideInfoFromDigestAndSize(withProv, digest, size, model, s.localDB) + c.Check(err, IsNil) + c.Check(si, DeepEquals, &snap.SideInfo{ + RealName: "foo", + SnapID: "snap-id-1", + Revision: snap.R(41), + Channel: "", + }) +} + +func (s *snapassertsSuite) TestDeriveSideInfoFromDigestAndSizeDelegatedSnapAmbiguous(c *C) { + // this is not a fully realistic test as this unlikely + // scenario would happen possibly across different delegated + // accounts, the goal is simply to trigger the error + // even if not in a realistic way + withProv := snaptest.MakeTestSnapWithFiles(c, `name: with-prov +version: 1 +provenance: prov`, nil) + digest, size, err := asserts.SnapFileSHA3_384(withProv) + c.Assert(err, IsNil) + + snapDecl, err := s.storeSigning.Sign(asserts.SnapDeclarationType, map[string]interface{}{ + "series": "16", + "snap-id": "snap-id-1", + "snap-name": "foo", + "publisher-id": s.dev1Acct.AccountID(), + "revision": "1", + "revision-authority": []interface{}{ + map[string]interface{}{ + "account-id": s.dev1Acct.AccountID(), + "provenance": []interface{}{ + "prov", + "prov2", + }, + }, + }, + "timestamp": time.Now().Format(time.RFC3339), + }, nil, "") + c.Assert(err, IsNil) + err = s.localDB.Add(snapDecl) + c.Assert(err, IsNil) + + headers := map[string]interface{}{ + "authority-id": s.dev1Acct.AccountID(), + "snap-id": "snap-id-1", + "snap-sha3-384": digest, + "snap-size": fmt.Sprintf("%d", size), + "provenance": "prov", + "snap-revision": "41", + "developer-id": s.dev1Acct.AccountID(), + "timestamp": time.Now().Format(time.RFC3339), + } + snapRev, err := s.dev1Signing.Sign(asserts.SnapRevisionType, headers, nil, "") + c.Assert(err, IsNil) + + err = s.localDB.Add(snapRev) + c.Check(err, IsNil) + + headers = map[string]interface{}{ + "authority-id": s.dev1Acct.AccountID(), + "snap-id": "snap-id-1", + "snap-sha3-384": digest, + "snap-size": fmt.Sprintf("%d", size), + "provenance": "prov2", + "snap-revision": "82", + "developer-id": s.dev1Acct.AccountID(), + "timestamp": time.Now().Format(time.RFC3339), + } + snapRev2, err := s.dev1Signing.Sign(asserts.SnapRevisionType, headers, nil, "") + c.Assert(err, IsNil) + + err = s.localDB.Add(snapRev2) + c.Check(err, IsNil) + + _, err = snapasserts.DeriveSideInfoFromDigestAndSize(withProv, digest, size, nil, s.localDB) + c.Check(err, ErrorMatches, `safely handling snaps with different provenance but same hash not yet supported`) +} diff --git a/asserts/snapasserts/validation_sets.go b/asserts/snapasserts/validation_sets.go new file mode 100644 index 00000000..08cb68d9 --- /dev/null +++ b/asserts/snapasserts/validation_sets.go @@ -0,0 +1,730 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package snapasserts + +import ( + "bytes" + "fmt" + "sort" + "strconv" + "strings" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/snap/naming" +) + +// InstalledSnap holds the minimal details about an installed snap required to +// check it against validation sets. +type InstalledSnap struct { + naming.SnapRef + Revision snap.Revision +} + +// NewInstalledSnap creates InstalledSnap. +func NewInstalledSnap(name, snapID string, revision snap.Revision) *InstalledSnap { + return &InstalledSnap{ + SnapRef: naming.NewSnapRef(name, snapID), + Revision: revision, + } +} + +// ValidationSetsConflictError describes an error where multiple +// validation sets are in conflict about snaps. +type ValidationSetsConflictError struct { + Sets map[string]*asserts.ValidationSet + Snaps map[string]error +} + +func (e *ValidationSetsConflictError) Error() string { + buf := bytes.NewBufferString("validation sets are in conflict:") + for _, err := range e.Snaps { + fmt.Fprintf(buf, "\n- %v", err) + } + return buf.String() +} + +func (e *ValidationSetsConflictError) Is(err error) bool { + _, ok := err.(*ValidationSetsConflictError) + return ok +} + +// ValidationSetsValidationError describes an error arising +// from validation of snaps against ValidationSets. +type ValidationSetsValidationError struct { + // MissingSnaps maps missing snap names to the expected revisions and respective validation sets requiring them. + // Revisions may be unset if no specific revision is required + MissingSnaps map[string]map[snap.Revision][]string + // InvalidSnaps maps snap names to the validation sets declaring them invalid. + InvalidSnaps map[string][]string + // WronRevisionSnaps maps snap names to the expected revisions and respective + // validation sets that require them. + WrongRevisionSnaps map[string]map[snap.Revision][]string + // Sets maps validation set keys to all validation sets assertions considered + // in the failed check. + Sets map[string]*asserts.ValidationSet +} + +// ValidationSetKey is a string-backed primary key for a validation set assertion. +type ValidationSetKey string + +// NewValidationSetKey returns a validation set key for a validation set. +func NewValidationSetKey(vs *asserts.ValidationSet) ValidationSetKey { + return ValidationSetKey(strings.Join(vs.Ref().PrimaryKey, "/")) +} + +func (k ValidationSetKey) String() string { + return string(k) +} + +// Components returns the components of the validation set's primary key (see +// assertion types in asserts/asserts.go). +func (k ValidationSetKey) Components() []string { + return strings.Split(k.String(), "/") +} + +// ValidationSetKeySlice can be used to sort slices of ValidationSetKey. +type ValidationSetKeySlice []ValidationSetKey + +func (s ValidationSetKeySlice) Len() int { return len(s) } +func (s ValidationSetKeySlice) Less(i, j int) bool { return s[i] < s[j] } +func (s ValidationSetKeySlice) Swap(i, j int) { s[i], s[j] = s[j], s[i] } + +// CommaSeparated returns the validation set keys separated by commas. +func (s ValidationSetKeySlice) CommaSeparated() string { + var sb strings.Builder + + for i, vsKey := range s { + sb.WriteString(vsKey.String()) + if i < len(s)-1 { + sb.WriteRune(',') + } + } + + return sb.String() +} + +type byRevision []snap.Revision + +func (b byRevision) Len() int { return len(b) } +func (b byRevision) Swap(i, j int) { b[i], b[j] = b[j], b[i] } +func (b byRevision) Less(i, j int) bool { return b[i].N < b[j].N } + +func (e *ValidationSetsValidationError) Error() string { + buf := bytes.NewBufferString("validation sets assertions are not met:") + printDetails := func(header string, details map[string][]string, + printSnap func(snapName string, keys []string) string) { + if len(details) == 0 { + return + } + fmt.Fprintf(buf, "\n- %s:", header) + for snapName, validationSetKeys := range details { + fmt.Fprintf(buf, "\n - %s", printSnap(snapName, validationSetKeys)) + } + } + + if len(e.MissingSnaps) > 0 { + fmt.Fprintf(buf, "\n- missing required snaps:") + for snapName, revisions := range e.MissingSnaps { + revisionsSorted := make([]snap.Revision, 0, len(revisions)) + for rev := range revisions { + revisionsSorted = append(revisionsSorted, rev) + } + sort.Sort(byRevision(revisionsSorted)) + t := make([]string, 0, len(revisionsSorted)) + for _, rev := range revisionsSorted { + keys := revisions[rev] + if rev.Unset() { + t = append(t, fmt.Sprintf("at any revision by sets %s", strings.Join(keys, ","))) + } else { + t = append(t, fmt.Sprintf("at revision %s by sets %s", rev, strings.Join(keys, ","))) + } + } + fmt.Fprintf(buf, "\n - %s (required %s)", snapName, strings.Join(t, ", ")) + } + } + + printDetails("invalid snaps", e.InvalidSnaps, func(snapName string, validationSetKeys []string) string { + return fmt.Sprintf("%s (invalid for sets %s)", snapName, strings.Join(validationSetKeys, ",")) + }) + + if len(e.WrongRevisionSnaps) > 0 { + fmt.Fprint(buf, "\n- snaps at wrong revisions:") + for snapName, revisions := range e.WrongRevisionSnaps { + revisionsSorted := make([]snap.Revision, 0, len(revisions)) + for rev := range revisions { + revisionsSorted = append(revisionsSorted, rev) + } + sort.Sort(byRevision(revisionsSorted)) + t := make([]string, 0, len(revisionsSorted)) + for _, rev := range revisionsSorted { + keys := revisions[rev] + t = append(t, fmt.Sprintf("at revision %s by sets %s", rev, strings.Join(keys, ","))) + } + fmt.Fprintf(buf, "\n - %s (required %s)", snapName, strings.Join(t, ", ")) + } + } + + return buf.String() +} + +// ValidationSets can hold a combination of validation-set assertions +// and can check for conflicts or help applying them. +type ValidationSets struct { + // sets maps sequence keys to validation-set in the combination + sets map[string]*asserts.ValidationSet + // snaps maps snap-ids to snap constraints + snaps map[string]*snapContraints +} + +const presConflict asserts.Presence = "conflict" + +var unspecifiedRevision = snap.R(0) +var invalidPresRevision = snap.R(-1) + +type snapContraints struct { + name string + presence asserts.Presence + // revisions maps revisions to pairing of ValidationSetSnap + // and the originating validation-set key + // * unspecifiedRevision is used for constraints without a + // revision + // * invalidPresRevision is used for constraints that mark + // presence as invalid + revisions map[snap.Revision][]*revConstraint +} + +type revConstraint struct { + validationSetKey string + asserts.ValidationSetSnap +} + +func (c *snapContraints) conflict() *snapConflictsError { + if c.presence != presConflict { + return nil + } + + const dontCare asserts.Presence = "" + whichSets := func(rcs []*revConstraint, presence asserts.Presence) []string { + which := make([]string, 0, len(rcs)) + for _, rc := range rcs { + if presence != dontCare && rc.Presence != presence { + continue + } + which = append(which, rc.validationSetKey) + } + if len(which) == 0 { + return nil + } + sort.Strings(which) + return which + } + + byRev := make(map[snap.Revision][]string, len(c.revisions)) + for r := range c.revisions { + pres := dontCare + switch r { + case invalidPresRevision: + pres = asserts.PresenceInvalid + case unspecifiedRevision: + pres = asserts.PresenceRequired + } + which := whichSets(c.revisions[r], pres) + if len(which) != 0 { + byRev[r] = which + } + } + + return &snapConflictsError{ + name: c.name, + revisions: byRev, + } +} + +type snapConflictsError struct { + name string + // revisions maps revisions to validation-set keys of the sets + // that are in conflict over the revision. + // * unspecifiedRevision is used for validation-sets conflicting + // on the snap by requiring it but without a revision + // * invalidPresRevision is used for validation-sets that mark + // presence as invalid + // see snapContraints.revisions as well + revisions map[snap.Revision][]string +} + +func (e *snapConflictsError) Error() string { + whichSets := func(which []string) string { + return fmt.Sprintf("(%s)", strings.Join(which, ",")) + } + + msg := fmt.Sprintf("cannot constrain snap %q", e.name) + invalid := false + if invalidOnes, ok := e.revisions[invalidPresRevision]; ok { + msg += fmt.Sprintf(" as both invalid %s and required", whichSets(invalidOnes)) + invalid = true + } + + var revnos []snap.Revision + for r := range e.revisions { + if r.N >= 1 { + revnos = append(revnos, r) + } + } + if len(revnos) == 1 { + msg += fmt.Sprintf(" at revision %s %s", revnos[0], whichSets(e.revisions[revnos[0]])) + } else if len(revnos) > 1 { + sort.Sort(byRevision(revnos)) + l := make([]string, 0, len(revnos)) + for _, rev := range revnos { + l = append(l, fmt.Sprintf("%s %s", rev, whichSets(e.revisions[rev]))) + } + msg += fmt.Sprintf(" at different revisions %s", strings.Join(l, ", ")) + } + + if unspecifiedOnes, ok := e.revisions[unspecifiedRevision]; ok { + which := whichSets(unspecifiedOnes) + if which != "" { + if len(revnos) != 0 { + msg += " or" + } + if invalid { + msg += fmt.Sprintf(" at any revision %s", which) + } else { + msg += fmt.Sprintf(" required at any revision %s", which) + } + } + } + return msg +} + +// NewValidationSets returns a new ValidationSets. +func NewValidationSets() *ValidationSets { + return &ValidationSets{ + sets: map[string]*asserts.ValidationSet{}, + snaps: map[string]*snapContraints{}, + } +} + +func valSetKey(valset *asserts.ValidationSet) string { + return fmt.Sprintf("%s/%s", valset.AccountID(), valset.Name()) +} + +// Revisions returns the set of snap revisions that is enforced by the +// validation sets that ValidationSets manages. +func (v *ValidationSets) Revisions() (map[string]snap.Revision, error) { + if err := v.Conflict(); err != nil { + return nil, fmt.Errorf("cannot get revisions when validation sets are in conflict: %w", err) + } + + snapNameToRevision := make(map[string]snap.Revision, len(v.snaps)) + for _, sn := range v.snaps { + for revision := range sn.revisions { + switch revision { + case invalidPresRevision, unspecifiedRevision: + continue + default: + snapNameToRevision[sn.name] = revision + } + } + } + return snapNameToRevision, nil +} + +// Keys returns a slice of ValidationSetKey structs that represent each +// validation set that this ValidationSets knows about. +func (v *ValidationSets) Keys() []ValidationSetKey { + keys := make([]ValidationSetKey, 0, len(v.sets)) + for _, vs := range v.sets { + keys = append(keys, NewValidationSetKey(vs)) + } + return keys +} + +// Sets returns a slice of all of the validation sets that this ValidationSets +// knows about. +func (v *ValidationSets) Sets() []*asserts.ValidationSet { + sets := make([]*asserts.ValidationSet, 0, len(v.sets)) + for _, vs := range v.sets { + sets = append(sets, vs) + } + return sets +} + +// Add adds the given asserts.ValidationSet to the combination. +// It errors if a validation-set with the same sequence key has been +// added already. +func (v *ValidationSets) Add(valset *asserts.ValidationSet) error { + k := valSetKey(valset) + if _, ok := v.sets[k]; ok { + return fmt.Errorf("cannot add a second validation-set under %q", k) + } + v.sets[k] = valset + for _, sn := range valset.Snaps() { + v.addSnap(sn, k) + } + return nil +} + +func (v *ValidationSets) addSnap(sn *asserts.ValidationSetSnap, validationSetKey string) { + rev := snap.R(sn.Revision) + if sn.Presence == asserts.PresenceInvalid { + rev = invalidPresRevision + } + + rc := &revConstraint{ + validationSetKey: validationSetKey, + ValidationSetSnap: *sn, + } + + cs := v.snaps[sn.SnapID] + if cs == nil { + v.snaps[sn.SnapID] = &snapContraints{ + name: sn.Name, + presence: sn.Presence, + revisions: map[snap.Revision][]*revConstraint{ + rev: {rc}, + }, + } + return + } + + cs.revisions[rev] = append(cs.revisions[rev], rc) + if cs.presence == presConflict { + // nothing to check anymore + return + } + // this counts really different revisions or invalid + ndiff := len(cs.revisions) + if _, ok := cs.revisions[unspecifiedRevision]; ok { + ndiff-- + } + switch { + case cs.presence == asserts.PresenceOptional: + cs.presence = sn.Presence + fallthrough + case cs.presence == sn.Presence || sn.Presence == asserts.PresenceOptional: + if ndiff > 1 { + if cs.presence == asserts.PresenceRequired { + // different revisions required/invalid + cs.presence = presConflict + return + } + // multiple optional at different revisions => invalid + cs.presence = asserts.PresenceInvalid + } + return + } + // we are left with a combo of required and invalid => conflict + cs.presence = presConflict +} + +// Conflict returns a non-nil error if the combination is in conflict, +// nil otherwise. +func (v *ValidationSets) Conflict() error { + sets := make(map[string]*asserts.ValidationSet) + snaps := make(map[string]error) + + for snapID, snConstrs := range v.snaps { + snConflictsErr := snConstrs.conflict() + if snConflictsErr != nil { + snaps[snapID] = snConflictsErr + for _, valsetKeys := range snConflictsErr.revisions { + for _, valsetKey := range valsetKeys { + sets[valsetKey] = v.sets[valsetKey] + } + } + } + } + + if len(snaps) != 0 { + return &ValidationSetsConflictError{ + Sets: sets, + Snaps: snaps, + } + } + return nil +} + +// CheckInstalledSnaps checks installed snaps against the validation sets. +func (v *ValidationSets) CheckInstalledSnaps(snaps []*InstalledSnap, ignoreValidation map[string]bool) error { + installed := naming.NewSnapSet(nil) + for _, sn := range snaps { + installed.Add(sn) + } + + // snapName -> validationSet key -> validation set + invalid := make(map[string]map[string]bool) + missing := make(map[string]map[snap.Revision]map[string]bool) + wrongrev := make(map[string]map[snap.Revision]map[string]bool) + + for _, cstrs := range v.snaps { + for rev, revCstr := range cstrs.revisions { + for _, rc := range revCstr { + sn := installed.Lookup(rc) + isInstalled := sn != nil + + if isInstalled && ignoreValidation[rc.Name] { + continue + } + + switch { + case !isInstalled && (cstrs.presence == asserts.PresenceOptional || cstrs.presence == asserts.PresenceInvalid): + // not installed, but optional or not required + case isInstalled && cstrs.presence == asserts.PresenceInvalid: + // installed but not expected to be present + if invalid[rc.Name] == nil { + invalid[rc.Name] = make(map[string]bool) + } + invalid[rc.Name][rc.validationSetKey] = true + case isInstalled: + // presence is either optional or required + if rev != unspecifiedRevision && rev != sn.(*InstalledSnap).Revision { + // expected a different revision + if wrongrev[rc.Name] == nil { + wrongrev[rc.Name] = make(map[snap.Revision]map[string]bool) + } + if wrongrev[rc.Name][rev] == nil { + wrongrev[rc.Name][rev] = make(map[string]bool) + } + wrongrev[rc.Name][rev][rc.validationSetKey] = true + } + default: + // not installed but required. + // note, not checking ignoreValidation here because it's not a viable scenario (it's not + // possible to have enforced validation set while not having the required snap at all - it + // is only possible to have it with a wrong revision, or installed while invalid, in both + // cases through --ignore-validation flag). + if missing[rc.Name] == nil { + missing[rc.Name] = make(map[snap.Revision]map[string]bool) + } + if missing[rc.Name][rev] == nil { + missing[rc.Name][rev] = make(map[string]bool) + } + missing[rc.Name][rev][rc.validationSetKey] = true + } + } + } + } + + setsToLists := func(in map[string]map[string]bool) map[string][]string { + if len(in) == 0 { + return nil + } + out := make(map[string][]string) + for snap, sets := range in { + out[snap] = make([]string, 0, len(sets)) + for validationSetKey := range sets { + out[snap] = append(out[snap], validationSetKey) + } + sort.Strings(out[snap]) + } + return out + } + + if len(invalid) > 0 || len(missing) > 0 || len(wrongrev) > 0 { + verr := &ValidationSetsValidationError{ + InvalidSnaps: setsToLists(invalid), + Sets: v.sets, + } + if len(missing) > 0 { + verr.MissingSnaps = make(map[string]map[snap.Revision][]string) + for snapName, revs := range missing { + verr.MissingSnaps[snapName] = make(map[snap.Revision][]string) + for rev, keys := range revs { + for key := range keys { + verr.MissingSnaps[snapName][rev] = append(verr.MissingSnaps[snapName][rev], key) + } + sort.Strings(verr.MissingSnaps[snapName][rev]) + } + } + } + if len(wrongrev) > 0 { + verr.WrongRevisionSnaps = make(map[string]map[snap.Revision][]string) + for snapName, revs := range wrongrev { + verr.WrongRevisionSnaps[snapName] = make(map[snap.Revision][]string) + for rev, keys := range revs { + for key := range keys { + verr.WrongRevisionSnaps[snapName][rev] = append(verr.WrongRevisionSnaps[snapName][rev], key) + } + sort.Strings(verr.WrongRevisionSnaps[snapName][rev]) + } + } + } + return verr + } + return nil +} + +// PresenceConstraintError describes an error where presence of the given snap +// has unexpected value, e.g. it's "invalid" while checking for "required". +type PresenceConstraintError struct { + SnapName string + Presence asserts.Presence +} + +func (e *PresenceConstraintError) Error() string { + return fmt.Sprintf("unexpected presence %q for snap %q", e.Presence, e.SnapName) +} + +func (v *ValidationSets) constraintsForSnap(snapRef naming.SnapRef) *snapContraints { + if snapRef.ID() != "" { + return v.snaps[snapRef.ID()] + } + // snapID not available, find by snap name + for _, cstrs := range v.snaps { + if cstrs.name == snapRef.SnapName() { + return cstrs + } + } + return nil +} + +// CheckPresenceRequired returns the list of all validation sets that declare +// presence of the given snap as required and the required revision (or +// snap.R(0) if no specific revision is required). PresenceConstraintError is +// returned if presence of the snap is "invalid". +// The method assumes that validation sets are not in conflict. +func (v *ValidationSets) CheckPresenceRequired(snapRef naming.SnapRef) ([]ValidationSetKey, snap.Revision, error) { + cstrs := v.constraintsForSnap(snapRef) + if cstrs == nil { + return nil, unspecifiedRevision, nil + } + if cstrs.presence == asserts.PresenceInvalid { + return nil, unspecifiedRevision, &PresenceConstraintError{snapRef.SnapName(), cstrs.presence} + } + if cstrs.presence != asserts.PresenceRequired { + return nil, unspecifiedRevision, nil + } + + snapRev := unspecifiedRevision + var keys []ValidationSetKey + for rev, revCstr := range cstrs.revisions { + for _, rc := range revCstr { + vs := v.sets[rc.validationSetKey] + if vs == nil { + return nil, unspecifiedRevision, fmt.Errorf("internal error: no validation set for %q", rc.validationSetKey) + } + keys = append(keys, NewValidationSetKey(vs)) + // there may be constraints without revision; only set snapRev if + // it wasn't already determined. Note that if revisions are set, + // then they are the same, otherwise validation sets would be in + // conflict. + // This is an equivalent of 'if rev != unspecifiedRevision`. + if snapRev == unspecifiedRevision { + snapRev = rev + } + } + } + + sort.Sort(ValidationSetKeySlice(keys)) + return keys, snapRev, nil +} + +// CanBePresent returns true if a snap can be present in a situation in which +// these validation sets are being applied. +func (v *ValidationSets) CanBePresent(snapRef naming.SnapRef) bool { + cstrs := v.constraintsForSnap(snapRef) + if cstrs == nil { + return true + } + return cstrs.presence != asserts.PresenceInvalid +} + +// RequiredSnaps returns a list of the names of all of the snaps that are +// required by any validation set known to this ValidationSets. +func (v *ValidationSets) RequiredSnaps() []string { + var names []string + for _, sn := range v.snaps { + if sn.presence == asserts.PresenceRequired { + names = append(names, sn.name) + } + } + return names +} + +// SnapConstrained returns true if the given snap is constrained by any of the +// validation sets known to this ValidationSets. +func (v *ValidationSets) SnapConstrained(snapRef naming.SnapRef) bool { + return v.constraintsForSnap(snapRef) != nil +} + +// CheckPresenceInvalid returns the list of all validation sets that declare +// presence of the given snap as invalid. PresenceConstraintError is returned if +// presence of the snap is "optional" or "required". +// The method assumes that validation sets are not in conflict. +func (v *ValidationSets) CheckPresenceInvalid(snapRef naming.SnapRef) ([]ValidationSetKey, error) { + cstrs := v.constraintsForSnap(snapRef) + if cstrs == nil { + return nil, nil + } + if cstrs.presence != asserts.PresenceInvalid { + return nil, &PresenceConstraintError{snapRef.SnapName(), cstrs.presence} + } + var keys []ValidationSetKey + for _, revCstr := range cstrs.revisions { + for _, rc := range revCstr { + if rc.Presence == asserts.PresenceInvalid { + vs := v.sets[rc.validationSetKey] + if vs == nil { + return nil, fmt.Errorf("internal error: no validation set for %q", rc.validationSetKey) + } + keys = append(keys, NewValidationSetKey(vs)) + } + } + } + + sort.Sort(ValidationSetKeySlice(keys)) + return keys, nil +} + +// ParseValidationSet parses a validation set string (account/name or account/name=sequence) +// and returns its individual components, or an error. +func ParseValidationSet(arg string) (account, name string, seq int, err error) { + errPrefix := func() string { + return fmt.Sprintf("cannot parse validation set %q", arg) + } + parts := strings.Split(arg, "=") + if len(parts) > 2 { + return "", "", 0, fmt.Errorf("%s: expected account/name=seq", errPrefix()) + } + if len(parts) == 2 { + seq, err = strconv.Atoi(parts[1]) + if err != nil { + return "", "", 0, fmt.Errorf("%s: invalid sequence: %v", errPrefix(), err) + } + } + + parts = strings.Split(parts[0], "/") + if len(parts) != 2 { + return "", "", 0, fmt.Errorf("%s: expected a single account/name", errPrefix()) + } + + account = parts[0] + name = parts[1] + if !asserts.IsValidAccountID(account) { + return "", "", 0, fmt.Errorf("%s: invalid account ID %q", errPrefix(), account) + } + if !asserts.IsValidValidationSetName(name) { + return "", "", 0, fmt.Errorf("%s: invalid validation set name %q", errPrefix(), name) + } + + return account, name, seq, nil +} diff --git a/asserts/snapasserts/validation_sets_test.go b/asserts/snapasserts/validation_sets_test.go new file mode 100644 index 00000000..b79a796a --- /dev/null +++ b/asserts/snapasserts/validation_sets_test.go @@ -0,0 +1,1512 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package snapasserts_test + +import ( + "errors" + "fmt" + "math/rand" + "sort" + "strconv" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/asserts/assertstest" + "github.com/snapcore/snapd/asserts/snapasserts" + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/snap/naming" + "github.com/snapcore/snapd/snap/snaptest" + "github.com/snapcore/snapd/testutil" +) + +type validationSetsSuite struct{} + +var _ = Suite(&validationSetsSuite{}) + +func (s *validationSetsSuite) TestAddFromSameSequence(c *C) { + mySnapAt7Valset := assertstest.FakeAssertion(map[string]interface{}{ + "type": "validation-set", + "authority-id": "account-id", + "series": "16", + "account-id": "account-id", + "name": "my-snap-ctl", + "sequence": "1", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "my-snap", + "id": "mysnapididididididididididididid", + "presence": "required", + "revision": "7", + }, + }, + }).(*asserts.ValidationSet) + + mySnapAt8Valset := assertstest.FakeAssertion(map[string]interface{}{ + "type": "validation-set", + "authority-id": "account-id", + "series": "16", + "account-id": "account-id", + "name": "my-snap-ctl", + "sequence": "2", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "my-snap", + "id": "mysnapididididididididididididid", + "presence": "required", + "revision": "8", + }, + }, + }).(*asserts.ValidationSet) + + valsets := snapasserts.NewValidationSets() + err := valsets.Add(mySnapAt7Valset) + c.Assert(err, IsNil) + err = valsets.Add(mySnapAt8Valset) + c.Check(err, ErrorMatches, `cannot add a second validation-set under "account-id/my-snap-ctl"`) +} + +func (s *validationSetsSuite) TestIntersections(c *C) { + mySnapAt7Valset := assertstest.FakeAssertion(map[string]interface{}{ + "type": "validation-set", + "authority-id": "account-id", + "series": "16", + "account-id": "account-id", + "name": "my-snap-ctl", + "sequence": "1", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "my-snap", + "id": "mysnapididididididididididididid", + "presence": "required", + "revision": "7", + }, + }, + }).(*asserts.ValidationSet) + + mySnapAt7Valset2 := assertstest.FakeAssertion(map[string]interface{}{ + "type": "validation-set", + "authority-id": "account-id", + "series": "16", + "account-id": "account-id", + "name": "my-snap-ctl2", + "sequence": "2", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "my-snap", + "id": "mysnapididididididididididididid", + "presence": "required", + "revision": "7", + }, + }, + }).(*asserts.ValidationSet) + + mySnapAt8Valset := assertstest.FakeAssertion(map[string]interface{}{ + "type": "validation-set", + "authority-id": "account-id", + "series": "16", + "account-id": "account-id", + "name": "my-snap-ctl-other", + "sequence": "1", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "my-snap", + "id": "mysnapididididididididididididid", + "presence": "required", + "revision": "8", + }, + }, + }).(*asserts.ValidationSet) + + mySnapAt8OptValset := assertstest.FakeAssertion(map[string]interface{}{ + "type": "validation-set", + "authority-id": "account-id", + "series": "16", + "account-id": "account-id", + "name": "my-snap-ctl-opt", + "sequence": "1", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "my-snap", + "id": "mysnapididididididididididididid", + "presence": "optional", + "revision": "8", + }, + }, + }).(*asserts.ValidationSet) + + mySnapInvalidValset := assertstest.FakeAssertion(map[string]interface{}{ + "type": "validation-set", + "authority-id": "account-id", + "series": "16", + "account-id": "account-id", + "name": "my-snap-ctl-inv", + "sequence": "1", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "my-snap", + "id": "mysnapididididididididididididid", + "presence": "invalid", + }, + }, + }).(*asserts.ValidationSet) + + mySnapAt7OptValset := assertstest.FakeAssertion(map[string]interface{}{ + "type": "validation-set", + "authority-id": "account-id", + "series": "16", + "account-id": "account-id", + "name": "my-snap-ctl-opt2", + "sequence": "1", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "my-snap", + "id": "mysnapididididididididididididid", + "presence": "optional", + "revision": "7", + }, + }, + }).(*asserts.ValidationSet) + + mySnapReqValset := assertstest.FakeAssertion(map[string]interface{}{ + "type": "validation-set", + "authority-id": "account-id", + "series": "16", + "account-id": "account-id", + "name": "my-snap-ctl-req-only", + "sequence": "1", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "my-snap", + "id": "mysnapididididididididididididid", + "presence": "required", + }, + }, + }).(*asserts.ValidationSet) + + mySnapOptValset := assertstest.FakeAssertion(map[string]interface{}{ + "type": "validation-set", + "authority-id": "account-id", + "series": "16", + "account-id": "account-id", + "name": "my-snap-ctl-opt-only", + "sequence": "1", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "my-snap", + "id": "mysnapididididididididididididid", + "presence": "optional", + }, + }, + }).(*asserts.ValidationSet) + + tests := []struct { + sets []*asserts.ValidationSet + conflictErr string + }{ + {[]*asserts.ValidationSet{mySnapAt7Valset}, ""}, + {[]*asserts.ValidationSet{mySnapAt7Valset, mySnapAt7Valset2}, ""}, + {[]*asserts.ValidationSet{mySnapAt7Valset, mySnapAt8Valset}, `(?ms)validation sets are in conflict:.*cannot constrain snap "my-snap" at different revisions 7 \(account-id/my-snap-ctl\), 8 \(account-id/my-snap-ctl-other\)`}, + {[]*asserts.ValidationSet{mySnapAt8Valset, mySnapAt8OptValset}, ""}, + {[]*asserts.ValidationSet{mySnapAt7Valset, mySnapAt8OptValset}, `(?ms)validation sets are in conflict:.*cannot constrain snap "my-snap" at different revisions 7 \(account-id/my-snap-ctl\), 8 \(account-id/my-snap-ctl-opt\)`}, + {[]*asserts.ValidationSet{mySnapAt7Valset, mySnapInvalidValset}, `(?ms)validation sets are in conflict:.*cannot constrain snap "my-snap" as both invalid \(account-id/my-snap-ctl-inv\) and required at revision 7 \(account-id/my-snap-ctl\)`}, + {[]*asserts.ValidationSet{mySnapInvalidValset, mySnapAt7Valset}, `(?ms)validation sets are in conflict:.*cannot constrain snap "my-snap" as both invalid \(account-id/my-snap-ctl-inv\) and required at revision 7 \(account-id/my-snap-ctl\)`}, + {[]*asserts.ValidationSet{mySnapAt8OptValset, mySnapInvalidValset}, ""}, + {[]*asserts.ValidationSet{mySnapInvalidValset, mySnapAt8OptValset}, ""}, + {[]*asserts.ValidationSet{mySnapAt7OptValset, mySnapAt8OptValset}, ""}, // no conflict but interpreted as invalid + {[]*asserts.ValidationSet{mySnapAt7OptValset, mySnapAt8OptValset, mySnapAt7Valset}, `(?ms)validation sets are in conflict:.*cannot constrain snap "my-snap" at different revisions 7 \(account-id/my-snap-ctl,account-id/my-snap-ctl-opt2\), 8 \(account-id/my-snap-ctl-opt\)`}, + {[]*asserts.ValidationSet{mySnapAt8OptValset, mySnapInvalidValset, mySnapAt7Valset}, `(?ms)validation sets are in conflict:.*cannot constrain snap "my-snap" as both invalid \(account-id/my-snap-ctl-inv\) and required at different revisions 7 \(account-id/my-snap-ctl\), 8 \(account-id/my-snap-ctl-opt\)`}, + {[]*asserts.ValidationSet{mySnapAt7Valset, mySnapReqValset}, ""}, + {[]*asserts.ValidationSet{mySnapReqValset, mySnapAt7Valset}, ""}, + {[]*asserts.ValidationSet{mySnapAt8OptValset, mySnapReqValset}, ""}, + {[]*asserts.ValidationSet{mySnapAt8OptValset, mySnapReqValset, mySnapAt7OptValset}, `(?ms)validation sets are in conflict:.*cannot constrain snap "my-snap" at different revisions 7 \(account-id/my-snap-ctl-opt2\), 8 \(account-id/my-snap-ctl-opt\) or required at any revision \(account-id/my-snap-ctl-req-only\)`}, + {[]*asserts.ValidationSet{mySnapAt8OptValset, mySnapAt7OptValset, mySnapReqValset}, `(?ms)validation sets are in conflict:.*cannot constrain snap "my-snap" at different revisions 7 \(account-id/my-snap-ctl-opt2\), 8 \(account-id/my-snap-ctl-opt\) or required at any revision \(account-id/my-snap-ctl-req-only\)`}, + {[]*asserts.ValidationSet{mySnapReqValset, mySnapInvalidValset}, `(?ms)validation sets are in conflict:.*cannot constrain snap "my-snap" as both invalid \(account-id/my-snap-ctl-inv\) and required at any revision \(account-id/my-snap-ctl-req-only\)`}, + {[]*asserts.ValidationSet{mySnapInvalidValset, mySnapReqValset}, `(?ms)validation sets are in conflict:.*cannot constrain snap "my-snap" as both invalid \(account-id/my-snap-ctl-inv\) and required at any revision \(account-id/my-snap-ctl-req-only\)`}, + {[]*asserts.ValidationSet{mySnapAt7Valset, mySnapAt8Valset, mySnapOptValset}, `(?ms)validation sets are in conflict:.*cannot constrain snap "my-snap" at different revisions 7 \(account-id/my-snap-ctl\), 8 \(account-id/my-snap-ctl-other\)`}, + {[]*asserts.ValidationSet{mySnapAt7Valset, mySnapOptValset}, ""}, + {[]*asserts.ValidationSet{mySnapOptValset, mySnapAt7Valset}, ""}, + {[]*asserts.ValidationSet{mySnapAt8OptValset, mySnapOptValset}, ""}, + {[]*asserts.ValidationSet{mySnapAt8OptValset, mySnapOptValset, mySnapAt7OptValset}, ""}, // no conflict but interpreted as invalid + {[]*asserts.ValidationSet{mySnapInvalidValset, mySnapOptValset}, ""}, + {[]*asserts.ValidationSet{mySnapOptValset, mySnapInvalidValset}, ""}, + {[]*asserts.ValidationSet{mySnapAt7Valset, mySnapAt8Valset, mySnapReqValset, mySnapInvalidValset}, `(?ms)validation sets are in conflict:.*cannot constrain snap "my-snap" as both invalid \(account-id/my-snap-ctl-inv\) and required at different revisions 7 \(account-id/my-snap-ctl\), 8 \(account-id/my-snap-ctl-other\) or at any revision \(account-id/my-snap-ctl-req-only\)`}, + } + + for _, t := range tests { + valsets := snapasserts.NewValidationSets() + cSets := make(map[string]*asserts.ValidationSet) + for _, valset := range t.sets { + err := valsets.Add(valset) + c.Assert(err, IsNil) + // mySnapOptValset never influcens an outcome + if valset != mySnapOptValset { + cSets[fmt.Sprintf("%s/%s", valset.AccountID(), valset.Name())] = valset + } + } + err := valsets.Conflict() + if t.conflictErr == "" { + c.Check(err, IsNil) + } else { + c.Check(err, ErrorMatches, t.conflictErr) + ce := err.(*snapasserts.ValidationSetsConflictError) + c.Check(ce.Sets, DeepEquals, cSets) + } + } +} + +func (s *validationSetsSuite) TestCheckInstalledSnapsNoValidationSets(c *C) { + valsets := snapasserts.NewValidationSets() + snaps := []*snapasserts.InstalledSnap{ + snapasserts.NewInstalledSnap("snap-a", "mysnapaaaaaaaaaaaaaaaaaaaaaaaaaa", snap.R(1)), + } + err := valsets.CheckInstalledSnaps(snaps, nil) + c.Assert(err, IsNil) +} + +func (s *validationSetsSuite) TestCheckInstalledSnaps(c *C) { + // require: snapB rev 3, snapC rev 2. + // invalid: snapA + vs1 := assertstest.FakeAssertion(map[string]interface{}{ + "type": "validation-set", + "authority-id": "acme", + "series": "16", + "account-id": "acme", + "name": "fooname", + "sequence": "1", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "snap-a", + "id": "mysnapaaaaaaaaaaaaaaaaaaaaaaaaaa", + "presence": "invalid", + }, + map[string]interface{}{ + "name": "snap-b", + "id": "mysnapbbbbbbbbbbbbbbbbbbbbbbbbbb", + "revision": "3", + "presence": "required", + }, + map[string]interface{}{ + "name": "snap-c", + "id": "mysnapcccccccccccccccccccccccccc", + "revision": "2", + "presence": "optional", + }, + }, + }).(*asserts.ValidationSet) + + // require: snapD any rev + // optional: snapE any rev + vs2 := assertstest.FakeAssertion(map[string]interface{}{ + "type": "validation-set", + "authority-id": "acme", + "series": "16", + "account-id": "acme", + "name": "barname", + "sequence": "3", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "snap-d", + "id": "mysnapdddddddddddddddddddddddddd", + "presence": "required", + }, + map[string]interface{}{ + "name": "snap-e", + "id": "mysnapeeeeeeeeeeeeeeeeeeeeeeeeee", + "presence": "optional", + }, + }, + }).(*asserts.ValidationSet) + + // optional: snapE any rev + // note: since it only has an optional snap, acme/bazname is not expected + // not be invalid by any of the checks. + vs3 := assertstest.FakeAssertion(map[string]interface{}{ + "type": "validation-set", + "authority-id": "acme", + "series": "16", + "account-id": "acme", + "name": "bazname", + "sequence": "2", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "snap-e", + "id": "mysnapeeeeeeeeeeeeeeeeeeeeeeeeee", + "presence": "optional", + }, + }, + }).(*asserts.ValidationSet) + + // invalid: snapA + vs4 := assertstest.FakeAssertion(map[string]interface{}{ + "type": "validation-set", + "authority-id": "acme", + "series": "16", + "account-id": "acme", + "name": "booname", + "sequence": "1", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "snap-a", + "id": "mysnapaaaaaaaaaaaaaaaaaaaaaaaaaa", + "presence": "invalid", + }, + }, + }).(*asserts.ValidationSet) + + vs5 := assertstest.FakeAssertion(map[string]interface{}{ + "type": "validation-set", + "authority-id": "acme", + "series": "16", + "account-id": "acme", + "name": "huhname", + "sequence": "1", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "snap-f", + "id": "mysnapffffffffffffffffffffffffff", + "revision": "4", + "presence": "required", + }, + }, + }).(*asserts.ValidationSet) + + vs6 := assertstest.FakeAssertion(map[string]interface{}{ + "type": "validation-set", + "authority-id": "acme", + "series": "16", + "account-id": "acme", + "name": "duhname", + "sequence": "1", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "snap-f", + "id": "mysnapffffffffffffffffffffffffff", + "revision": "4", + "presence": "required", + }, + }, + }).(*asserts.ValidationSet) + + vs7 := assertstest.FakeAssertion(map[string]interface{}{ + "type": "validation-set", + "authority-id": "acme", + "series": "16", + "account-id": "acme", + "name": "bahname", + "sequence": "1", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "snap-f", + "id": "mysnapffffffffffffffffffffffffff", + "presence": "required", + }, + }, + }).(*asserts.ValidationSet) + + valsets := snapasserts.NewValidationSets() + c.Assert(valsets.Add(vs1), IsNil) + c.Assert(valsets.Add(vs2), IsNil) + c.Assert(valsets.Add(vs3), IsNil) + c.Assert(valsets.Add(vs4), IsNil) + c.Assert(valsets.Add(vs5), IsNil) + c.Assert(valsets.Add(vs6), IsNil) + c.Assert(valsets.Add(vs7), IsNil) + + snapA := snapasserts.NewInstalledSnap("snap-a", "mysnapaaaaaaaaaaaaaaaaaaaaaaaaaa", snap.R(1)) + snapAlocal := snapasserts.NewInstalledSnap("snap-a", "", snap.R("x2")) + snapB := snapasserts.NewInstalledSnap("snap-b", "mysnapbbbbbbbbbbbbbbbbbbbbbbbbbb", snap.R(3)) + snapBinvRev := snapasserts.NewInstalledSnap("snap-b", "mysnapbbbbbbbbbbbbbbbbbbbbbbbbbb", snap.R(8)) + snapBlocal := snapasserts.NewInstalledSnap("snap-b", "", snap.R("x3")) + snapC := snapasserts.NewInstalledSnap("snap-c", "mysnapcccccccccccccccccccccccccc", snap.R(2)) + snapCinvRev := snapasserts.NewInstalledSnap("snap-c", "mysnapcccccccccccccccccccccccccc", snap.R(99)) + snapD := snapasserts.NewInstalledSnap("snap-d", "mysnapdddddddddddddddddddddddddd", snap.R(2)) + snapDrev99 := snapasserts.NewInstalledSnap("snap-d", "mysnapdddddddddddddddddddddddddd", snap.R(99)) + snapDlocal := snapasserts.NewInstalledSnap("snap-d", "", snap.R("x3")) + snapE := snapasserts.NewInstalledSnap("snap-e", "mysnapeeeeeeeeeeeeeeeeeeeeeeeeee", snap.R(2)) + snapF := snapasserts.NewInstalledSnap("snap-f", "mysnapffffffffffffffffffffffffff", snap.R(4)) + // extra snap, not referenced by any validation set + snapZ := snapasserts.NewInstalledSnap("snap-z", "mysnapzzzzzzzzzzzzzzzzzzzzzzzzzz", snap.R(1)) + + tests := []struct { + snaps []*snapasserts.InstalledSnap + expectedInvalid map[string][]string + expectedMissing map[string]map[snap.Revision][]string + expectedWrongRev map[string]map[snap.Revision][]string + }{ + { + // required snaps not installed + snaps: nil, + expectedMissing: map[string]map[snap.Revision][]string{ + "snap-b": { + snap.R(3): {"acme/fooname"}, + }, + "snap-d": { + snap.R(0): {"acme/barname"}, + }, + "snap-f": { + snap.R(0): {"acme/bahname"}, + snap.R(4): {"acme/duhname", "acme/huhname"}, + }, + }, + }, + { + // required snaps not installed + snaps: []*snapasserts.InstalledSnap{ + snapZ, + }, + expectedMissing: map[string]map[snap.Revision][]string{ + "snap-b": { + snap.R(3): {"acme/fooname"}, + }, + "snap-d": { + snap.R(0): {"acme/barname"}, + }, + "snap-f": { + snap.R(0): {"acme/bahname"}, + snap.R(4): {"acme/duhname", "acme/huhname"}, + }, + }, + }, + { + snaps: []*snapasserts.InstalledSnap{ + // covered by acme/fooname validation-set + snapB, + // covered by acme/barname validation-set. snap-e not installed but optional + snapDrev99, + // covered by acme/duhname and acme/huhname + snapF, + }, + // ale fine + }, + { + snaps: []*snapasserts.InstalledSnap{ + // covered by acme/fooname validation-set and acme/booname, snap-a presence is invalid + snapA, + snapB, + // covered by acme/barname validation-set. snap-e not installed but optional + snapDrev99, + // covered by acme/duhname and acme/huhname + snapF, + }, + expectedInvalid: map[string][]string{ + "snap-a": {"acme/booname", "acme/fooname"}, + }, + }, + { + snaps: []*snapasserts.InstalledSnap{ + // covered by acme/fooname and acme/booname validation-sets, snapB missing, snap-a presence is invalid + snapA, + // covered by acme/barname validation-set. snap-e not installed but optional + snapDrev99, + snapF, + }, + expectedInvalid: map[string][]string{ + "snap-a": {"acme/booname", "acme/fooname"}, + }, + expectedMissing: map[string]map[snap.Revision][]string{ + "snap-b": { + snap.R(3): {"acme/fooname"}, + }, + }, + }, + { + snaps: []*snapasserts.InstalledSnap{ + // covered by acme/fooname validation-set + snapB, + snapC, + // covered by acme/barname validation-set. snap-e not installed but optional + snapD, + // covered by acme/duhname and acme/huhname + snapF, + }, + // ale fine + }, + { + snaps: []*snapasserts.InstalledSnap{ + // covered by acme/fooname validation-set, snap-c optional but wrong revision + snapB, + snapCinvRev, + // covered by acme/barname validation-set. snap-e not installed but optional + snapD, + // covered by acme/duhname and acme/huhname + snapF, + }, + expectedWrongRev: map[string]map[snap.Revision][]string{ + "snap-c": { + snap.R(2): {"acme/fooname"}, + }, + }, + }, + { + snaps: []*snapasserts.InstalledSnap{ + // covered by acme/fooname validation-set but wrong revision + snapBinvRev, + // covered by acme/barname validation-set. + snapD, + // covered by acme/duhname and acme/huhname + snapF, + }, + expectedWrongRev: map[string]map[snap.Revision][]string{ + "snap-b": { + snap.R(3): {"acme/fooname"}, + }, + }, + }, + { + snaps: []*snapasserts.InstalledSnap{ + // covered by acme/fooname validation-set + snapB, + // covered by acme/barname validation-set. snap-d not installed. + snapE, + // covered by acme/duhname and acme/huhname + snapF, + }, + expectedMissing: map[string]map[snap.Revision][]string{ + "snap-d": { + snap.R(0): {"acme/barname"}, + }, + }, + }, + { + snaps: []*snapasserts.InstalledSnap{ + // required snaps from acme/fooname are not installed. + // covered by acme/barname validation-set + snapDrev99, + snapE, + // covered by acme/duhname and acme/huhname + snapF, + }, + expectedMissing: map[string]map[snap.Revision][]string{ + "snap-b": { + snap.R(3): {"acme/fooname"}, + }, + }, + }, + { + snaps: []*snapasserts.InstalledSnap{ + // covered by acme/fooname validation-set, required missing. + snapC, + // covered by acme/barname validation-set, required missing. + snapE, + // covered by acme/duhname and acme/huhname + snapF, + }, + expectedMissing: map[string]map[snap.Revision][]string{ + "snap-b": { + snap.R(3): {"acme/fooname"}, + }, + "snap-d": { + snap.R(0): {"acme/barname"}, + }, + }, + }, + // local snaps + { + snaps: []*snapasserts.InstalledSnap{ + // covered by acme/fooname validation-set. + snapB, + // covered by acme/barname validation-set, local snap-d. + snapDlocal, + // covered by acme/duhname and acme/huhname + snapF, + }, + // all fine + }, + { + snaps: []*snapasserts.InstalledSnap{ + // covered by acme/fooname validation-set, snap-a is invalid. + snapAlocal, + snapB, + // covered by acme/barname validation-set. + snapD, + snapF, + }, + expectedInvalid: map[string][]string{ + "snap-a": {"acme/booname", "acme/fooname"}, + }, + }, + { + snaps: []*snapasserts.InstalledSnap{ + // covered by acme/fooname validation-set, snap-b is wrong rev (local). + snapBlocal, + // covered by acme/barname validation-set. + snapD, + // covered by acme/duhname and acme/huhname + snapF, + }, + expectedWrongRev: map[string]map[snap.Revision][]string{ + "snap-b": { + snap.R(3): {"acme/fooname"}, + }, + }, + }, + } + + checkSets := func(snapsToValidationSets map[string][]string, vs map[string]*asserts.ValidationSet) { + for _, vsetKeys := range snapsToValidationSets { + for _, key := range vsetKeys { + vset, ok := vs[key] + c.Assert(ok, Equals, true) + c.Assert(vset.AccountID()+"/"+vset.Name(), Equals, key) + } + } + } + + expectedSets := make(map[string]*asserts.ValidationSet, 7) + for _, vs := range []*asserts.ValidationSet{vs1, vs2, vs3, vs4, vs5, vs6, vs7} { + expectedSets[fmt.Sprintf("%s/%s", vs.AccountID(), vs.Name())] = vs + } + + for i, tc := range tests { + err := valsets.CheckInstalledSnaps(tc.snaps, nil) + if err == nil { + c.Assert(tc.expectedInvalid, IsNil) + c.Assert(tc.expectedMissing, IsNil) + c.Assert(tc.expectedWrongRev, IsNil) + continue + } + verr, ok := err.(*snapasserts.ValidationSetsValidationError) + c.Assert(ok, Equals, true, Commentf("#%d", i)) + c.Assert(verr.InvalidSnaps, DeepEquals, tc.expectedInvalid, Commentf("#%d", i)) + c.Assert(verr.MissingSnaps, DeepEquals, tc.expectedMissing, Commentf("#%d", i)) + c.Assert(verr.WrongRevisionSnaps, DeepEquals, tc.expectedWrongRev, Commentf("#%d", i)) + c.Assert(verr.Sets, DeepEquals, expectedSets) + checkSets(verr.InvalidSnaps, verr.Sets) + } +} + +func (s *validationSetsSuite) TestCheckInstalledSnapsIgnoreValidation(c *C) { + // require: snapB rev 3, snapC rev 2. + // invalid: snapA + vs := assertstest.FakeAssertion(map[string]interface{}{ + "type": "validation-set", + "authority-id": "acme", + "series": "16", + "account-id": "acme", + "name": "fooname", + "sequence": "1", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "snap-a", + "id": "mysnapaaaaaaaaaaaaaaaaaaaaaaaaaa", + "presence": "invalid", + }, + map[string]interface{}{ + "name": "snap-b", + "id": "mysnapbbbbbbbbbbbbbbbbbbbbbbbbbb", + "revision": "3", + "presence": "required", + }, + map[string]interface{}{ + "name": "snap-c", + "id": "mysnapcccccccccccccccccccccccccc", + "revision": "2", + "presence": "optional", + }, + }, + }).(*asserts.ValidationSet) + + valsets := snapasserts.NewValidationSets() + c.Assert(valsets.Add(vs), IsNil) + + snapA := snapasserts.NewInstalledSnap("snap-a", "mysnapaaaaaaaaaaaaaaaaaaaaaaaaaa", snap.R(1)) + snapB := snapasserts.NewInstalledSnap("snap-b", "mysnapbbbbbbbbbbbbbbbbbbbbbbbbbb", snap.R(3)) + snapBinvRev := snapasserts.NewInstalledSnap("snap-b", "mysnapbbbbbbbbbbbbbbbbbbbbbbbbbb", snap.R(8)) + + // validity check + c.Check(valsets.CheckInstalledSnaps([]*snapasserts.InstalledSnap{snapA, snapB}, nil), ErrorMatches, "validation sets assertions are not met:\n"+ + "- invalid snaps:\n"+ + " - snap-a \\(invalid for sets acme/fooname\\)") + // snapA is invalid but ignore-validation is set so it's ok + c.Check(valsets.CheckInstalledSnaps([]*snapasserts.InstalledSnap{snapA, snapB}, map[string]bool{"snap-a": true}), IsNil) + + // validity check + c.Check(valsets.CheckInstalledSnaps([]*snapasserts.InstalledSnap{snapBinvRev}, nil), ErrorMatches, "validation sets assertions are not met:\n"+ + "- snaps at wrong revisions:\n"+ + " - snap-b \\(required at revision 3 by sets acme/fooname\\)") + // snapB is at the wrong revision, but ignore-validation is set so it's ok + c.Check(valsets.CheckInstalledSnaps([]*snapasserts.InstalledSnap{snapBinvRev}, map[string]bool{"snap-b": true}), IsNil) +} + +func (s *validationSetsSuite) TestCheckInstalledSnapsErrorFormat(c *C) { + vs1 := assertstest.FakeAssertion(map[string]interface{}{ + "type": "validation-set", + "authority-id": "acme", + "series": "16", + "account-id": "acme", + "name": "fooname", + "sequence": "1", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "snap-a", + "id": "mysnapaaaaaaaaaaaaaaaaaaaaaaaaaa", + "presence": "invalid", + }, + map[string]interface{}{ + "name": "snap-b", + "id": "mysnapbbbbbbbbbbbbbbbbbbbbbbbbbb", + "revision": "3", + "presence": "required", + }, + }, + }).(*asserts.ValidationSet) + vs2 := assertstest.FakeAssertion(map[string]interface{}{ + "type": "validation-set", + "authority-id": "acme", + "series": "16", + "account-id": "acme", + "name": "barname", + "sequence": "2", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "snap-b", + "id": "mysnapbbbbbbbbbbbbbbbbbbbbbbbbbb", + "presence": "required", + }, + }, + }).(*asserts.ValidationSet) + + valsets := snapasserts.NewValidationSets() + c.Assert(valsets.Add(vs1), IsNil) + c.Assert(valsets.Add(vs2), IsNil) + + // not strictly important, but ensures test data makes sense and avoids confusing results + c.Assert(valsets.Conflict(), IsNil) + + snapA := snapasserts.NewInstalledSnap("snap-a", "mysnapaaaaaaaaaaaaaaaaaaaaaaaaaa", snap.R(1)) + snapBlocal := snapasserts.NewInstalledSnap("snap-b", "", snap.R("x3")) + + tests := []struct { + snaps []*snapasserts.InstalledSnap + errorMsg string + }{ + { + nil, + "validation sets assertions are not met:\n" + + "- missing required snaps:\n" + + " - snap-b \\(required at any revision by sets acme/barname, at revision 3 by sets acme/fooname\\)", + }, + { + []*snapasserts.InstalledSnap{snapA}, + "validation sets assertions are not met:\n" + + "- missing required snaps:\n" + + " - snap-b \\(required at any revision by sets acme/barname, at revision 3 by sets acme/fooname\\)\n" + + "- invalid snaps:\n" + + " - snap-a \\(invalid for sets acme/fooname\\)", + }, + { + []*snapasserts.InstalledSnap{snapBlocal}, + "validation sets assertions are not met:\n" + + "- snaps at wrong revisions:\n" + + " - snap-b \\(required at revision 3 by sets acme/fooname\\)", + }, + } + + for i, tc := range tests { + err := valsets.CheckInstalledSnaps(tc.snaps, nil) + c.Assert(err, NotNil, Commentf("#%d", i)) + c.Assert(err, ErrorMatches, tc.errorMsg, Commentf("#%d: ", i)) + } +} + +func (s *validationSetsSuite) TestSortByRevision(c *C) { + revs := []snap.Revision{snap.R(10), snap.R(4), snap.R(5), snap.R(-1)} + + sort.Sort(snapasserts.ByRevision(revs)) + c.Assert(revs, DeepEquals, []snap.Revision{snap.R(-1), snap.R(4), snap.R(5), snap.R(10)}) +} + +func (s *validationSetsSuite) TestCheckPresenceRequired(c *C) { + valset1 := assertstest.FakeAssertion(map[string]interface{}{ + "type": "validation-set", + "authority-id": "account-id", + "series": "16", + "account-id": "account-id", + "name": "my-snap-ctl", + "sequence": "1", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "my-snap", + "id": "mysnapididididididididididididid", + "presence": "required", + "revision": "7", + }, + map[string]interface{}{ + "name": "other-snap", + "id": "123456ididididididididididididid", + "presence": "optional", + }, + }, + }).(*asserts.ValidationSet) + + valset2 := assertstest.FakeAssertion(map[string]interface{}{ + "type": "validation-set", + "authority-id": "account-id", + "series": "16", + "account-id": "account-id", + "name": "my-snap-ctl2", + "sequence": "2", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "my-snap", + "id": "mysnapididididididididididididid", + "presence": "required", + "revision": "7", + }, + map[string]interface{}{ + "name": "other-snap", + "id": "123456ididididididididididididid", + "presence": "invalid", + }, + }, + }).(*asserts.ValidationSet) + + // my-snap required but no specific revision set. + valset3 := assertstest.FakeAssertion(map[string]interface{}{ + "type": "validation-set", + "authority-id": "account-id", + "series": "16", + "account-id": "account-id", + "name": "my-snap-ctl3", + "sequence": "1", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "my-snap", + "id": "mysnapididididididididididididid", + "presence": "required", + }, + }, + }).(*asserts.ValidationSet) + + valsets := snapasserts.NewValidationSets() + + // no validation sets + vsKeys, _, err := valsets.CheckPresenceRequired(naming.Snap("my-snap")) + c.Assert(err, IsNil) + c.Check(vsKeys, HasLen, 0) + + c.Assert(valsets.Add(valset1), IsNil) + c.Assert(valsets.Add(valset2), IsNil) + c.Assert(valsets.Add(valset3), IsNil) + + // validity + c.Assert(valsets.Conflict(), IsNil) + + vsKeys, rev, err := valsets.CheckPresenceRequired(naming.Snap("my-snap")) + c.Assert(err, IsNil) + c.Check(rev, DeepEquals, snap.Revision{N: 7}) + c.Check(vsKeys, DeepEquals, []snapasserts.ValidationSetKey{"16/account-id/my-snap-ctl/1", "16/account-id/my-snap-ctl2/2", "16/account-id/my-snap-ctl3/1"}) + + vsKeys, rev, err = valsets.CheckPresenceRequired(naming.NewSnapRef("my-snap", "mysnapididididididididididididid")) + c.Assert(err, IsNil) + c.Check(rev, DeepEquals, snap.Revision{N: 7}) + c.Check(vsKeys, DeepEquals, []snapasserts.ValidationSetKey{"16/account-id/my-snap-ctl/1", "16/account-id/my-snap-ctl2/2", "16/account-id/my-snap-ctl3/1"}) + + // other-snap is not required + vsKeys, rev, err = valsets.CheckPresenceRequired(naming.Snap("other-snap")) + c.Assert(err, ErrorMatches, `unexpected presence "invalid" for snap "other-snap"`) + pr, ok := err.(*snapasserts.PresenceConstraintError) + c.Assert(ok, Equals, true) + c.Check(pr.SnapName, Equals, "other-snap") + c.Check(pr.Presence, Equals, asserts.PresenceInvalid) + c.Check(rev, DeepEquals, snap.Revision{N: 0}) + c.Check(vsKeys, HasLen, 0) + + // unknown snap is not required + vsKeys, rev, err = valsets.CheckPresenceRequired(naming.NewSnapRef("unknown-snap", "00000000idididididididididididid")) + c.Assert(err, IsNil) + c.Check(rev, DeepEquals, snap.Revision{N: 0}) + c.Check(vsKeys, HasLen, 0) + + // just one set, required but no revision specified + valsets = snapasserts.NewValidationSets() + c.Assert(valsets.Add(valset3), IsNil) + vsKeys, rev, err = valsets.CheckPresenceRequired(naming.Snap("my-snap")) + c.Assert(err, IsNil) + c.Check(rev, DeepEquals, snap.Revision{N: 0}) + c.Check(vsKeys, DeepEquals, []snapasserts.ValidationSetKey{"16/account-id/my-snap-ctl3/1"}) +} + +func (s *validationSetsSuite) TestIsPresenceInvalid(c *C) { + valset1 := assertstest.FakeAssertion(map[string]interface{}{ + "type": "validation-set", + "authority-id": "account-id", + "series": "16", + "account-id": "account-id", + "name": "my-snap-ctl", + "sequence": "1", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "my-snap", + "id": "mysnapididididididididididididid", + "presence": "invalid", + }, + map[string]interface{}{ + "name": "other-snap", + "id": "123456ididididididididididididid", + "presence": "optional", + }, + }, + }).(*asserts.ValidationSet) + + valset2 := assertstest.FakeAssertion(map[string]interface{}{ + "type": "validation-set", + "authority-id": "account-id", + "series": "16", + "account-id": "account-id", + "name": "my-snap-ctl2", + "sequence": "2", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "my-snap", + "id": "mysnapididididididididididididid", + "presence": "invalid", + }, + }, + }).(*asserts.ValidationSet) + + valsets := snapasserts.NewValidationSets() + + // no validation sets + vsKeys, err := valsets.CheckPresenceInvalid(naming.Snap("my-snap")) + c.Assert(err, IsNil) + c.Check(vsKeys, HasLen, 0) + + c.Assert(valsets.Add(valset1), IsNil) + c.Assert(valsets.Add(valset2), IsNil) + + // validity + c.Assert(valsets.Conflict(), IsNil) + + // invalid in two sets + vsKeys, err = valsets.CheckPresenceInvalid(naming.Snap("my-snap")) + c.Assert(err, IsNil) + c.Check(vsKeys, DeepEquals, []snapasserts.ValidationSetKey{"16/account-id/my-snap-ctl/1", "16/account-id/my-snap-ctl2/2"}) + + vsKeys, err = valsets.CheckPresenceInvalid(naming.NewSnapRef("my-snap", "mysnapididididididididididididid")) + c.Assert(err, IsNil) + c.Check(vsKeys, DeepEquals, []snapasserts.ValidationSetKey{"16/account-id/my-snap-ctl/1", "16/account-id/my-snap-ctl2/2"}) + + // other-snap isn't invalid + vsKeys, err = valsets.CheckPresenceInvalid(naming.Snap("other-snap")) + c.Assert(err, ErrorMatches, `unexpected presence "optional" for snap "other-snap"`) + pr, ok := err.(*snapasserts.PresenceConstraintError) + c.Assert(ok, Equals, true) + c.Check(pr.SnapName, Equals, "other-snap") + c.Check(pr.Presence, Equals, asserts.PresenceOptional) + c.Check(vsKeys, HasLen, 0) + + vsKeys, err = valsets.CheckPresenceInvalid(naming.NewSnapRef("other-snap", "123456ididididididididididididid")) + c.Assert(err, ErrorMatches, `unexpected presence "optional" for snap "other-snap"`) + c.Check(vsKeys, HasLen, 0) + + // unknown snap isn't invalid + vsKeys, err = valsets.CheckPresenceInvalid(naming.NewSnapRef("unknown-snap", "00000000idididididididididididid")) + c.Assert(err, IsNil) + c.Check(vsKeys, HasLen, 0) +} + +func (s *validationSetsSuite) TestParseValidationSet(c *C) { + for _, tc := range []struct { + input string + errMsg string + account string + name string + sequence int + }{ + { + input: "foo/bar", + account: "foo", + name: "bar", + }, + { + input: "foo/bar=9", + account: "foo", + name: "bar", + sequence: 9, + }, + { + input: "foo", + errMsg: `cannot parse validation set "foo": expected a single account/name`, + }, + { + input: "foo/bar/baz", + errMsg: `cannot parse validation set "foo/bar/baz": expected a single account/name`, + }, + { + input: "", + errMsg: `cannot parse validation set "": expected a single account/name`, + }, + { + input: "foo=1", + errMsg: `cannot parse validation set "foo=1": expected a single account/name`, + }, + { + input: "foo/bar=x", + errMsg: `cannot parse validation set "foo/bar=x": invalid sequence: strconv.Atoi: parsing "x": invalid syntax`, + }, + { + input: "foo=bar=", + errMsg: `cannot parse validation set "foo=bar=": expected account/name=seq`, + }, + { + input: "$foo/bar", + errMsg: `cannot parse validation set "\$foo/bar": invalid account ID "\$foo"`, + }, + { + input: "foo/$bar", + errMsg: `cannot parse validation set "foo/\$bar": invalid validation set name "\$bar"`, + }, + } { + account, name, seq, err := snapasserts.ParseValidationSet(tc.input) + if tc.errMsg != "" { + c.Assert(err, ErrorMatches, tc.errMsg) + } else { + c.Assert(err, IsNil) + } + c.Check(account, Equals, tc.account) + c.Check(name, Equals, tc.name) + c.Check(seq, Equals, tc.sequence) + } +} + +func (s *validationSetsSuite) TestValidationSetKeyFormat(c *C) { + series, acc, name := "a", "b", "c" + sequence := 1 + + valSet := assertstest.FakeAssertion(map[string]interface{}{ + "type": "validation-set", + "authority-id": acc, + "series": series, + "account-id": acc, + "name": name, + "sequence": strconv.Itoa(sequence), + "snaps": []interface{}{ + map[string]interface{}{ + "name": "my-snap", + "id": "mysnapididididididididididididid", + "presence": "required", + }, + }, + }).(*asserts.ValidationSet) + + valSetKey := snapasserts.NewValidationSetKey(valSet) + c.Assert(valSetKey.String(), Equals, fmt.Sprintf("%s/%s/%s/%d", series, acc, name, sequence)) +} + +func (s *validationSetsSuite) TestValidationSetKeySliceSort(c *C) { + valSets := snapasserts.ValidationSetKeySlice([]snapasserts.ValidationSetKey{"1/a/a/1", "1/a/b/1", "1/a/b/2", "2/a/a/1", "2/a/a/2", "a/a/a/1"}) + rand.Shuffle(len(valSets), func(x, y int) { + valSets[x], valSets[y] = valSets[y], valSets[x] + }) + + sort.Sort(valSets) + c.Assert(valSets, DeepEquals, snapasserts.ValidationSetKeySlice([]snapasserts.ValidationSetKey{"1/a/a/1", "1/a/b/1", "1/a/b/2", "2/a/a/1", "2/a/a/2", "a/a/a/1"})) +} + +func (s *validationSetsSuite) TestValidationSetKeySliceCommaSeparated(c *C) { + valSets := snapasserts.ValidationSetKeySlice([]snapasserts.ValidationSetKey{"1/a/a/1", "1/a/b/1", "1/a/b/2", "2/a/a/1"}) + c.Assert(valSets.CommaSeparated(), Equals, "1/a/a/1,1/a/b/1,1/a/b/2,2/a/a/1") +} + +func (s *validationSetsSuite) TestValidationSetKeyComponents(c *C) { + valsetKey := snapasserts.NewValidationSetKey(assertstest.FakeAssertion(map[string]interface{}{ + "type": "validation-set", + "series": "a", + "authority-id": "b", + "account-id": "b", + "name": "c", + "sequence": "13", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "my-snap", + "id": "mysnapididididididididididididid", + "presence": "required", + }, + }, + }).(*asserts.ValidationSet)) + c.Assert(valsetKey.Components(), DeepEquals, []string{"a", "b", "c", "13"}) +} + +func (s *validationSetsSuite) TestRevisions(c *C) { + valset1 := assertstest.FakeAssertion(map[string]interface{}{ + "type": "validation-set", + "authority-id": "account-id", + "series": "16", + "account-id": "account-id", + "name": "my-snap-ctl", + "sequence": "1", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "my-snap", + "id": snaptest.AssertedSnapID("my-snap"), + "presence": "optional", + "revision": "10", + }, + map[string]interface{}{ + "name": "other-snap", + "id": snaptest.AssertedSnapID("other-snap"), + "presence": "required", + }, + // invalid snap should not be present in the result of (*ValidationSets).Revisions() + map[string]interface{}{ + "name": "invalid-snap", + "id": snaptest.AssertedSnapID("invalid-snap"), + "presence": "invalid", + }, + }, + }).(*asserts.ValidationSet) + + valset2 := assertstest.FakeAssertion(map[string]interface{}{ + "type": "validation-set", + "authority-id": "account-id", + "series": "16", + "account-id": "account-id", + "name": "my-snap-ctl2", + "sequence": "2", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "other-snap", + "id": snaptest.AssertedSnapID("other-snap"), + "presence": "optional", + "revision": "11", + }, + map[string]interface{}{ + "name": "another-snap", + "id": snaptest.AssertedSnapID("another-snap"), + "presence": "required", + "revision": "12", + }, + }, + }).(*asserts.ValidationSet) + + valsets := snapasserts.NewValidationSets() + + // no validation sets + revisions, err := valsets.Revisions() + c.Assert(err, IsNil) + c.Check(revisions, HasLen, 0) + + c.Assert(valsets.Add(valset1), IsNil) + c.Assert(valsets.Add(valset2), IsNil) + + // validity + c.Assert(valsets.Conflict(), IsNil) + + revisions, err = valsets.Revisions() + c.Assert(err, IsNil) + c.Check(revisions, HasLen, 3) + + c.Check(revisions, DeepEquals, map[string]snap.Revision{ + "my-snap": snap.R(10), + "other-snap": snap.R(11), + "another-snap": snap.R(12), + }) +} + +func (s *validationSetsSuite) TestCanBePresent(c *C) { + var snaps []*asserts.ValidationSetSnap + valset1 := assertstest.FakeAssertion(map[string]interface{}{ + "type": "validation-set", + "authority-id": "account-id", + "series": "16", + "account-id": "account-id", + "name": "my-snap-ctl", + "sequence": "1", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "my-snap", + "id": snaptest.AssertedSnapID("my-snap"), + "presence": "invalid", + }, + map[string]interface{}{ + "name": "other-snap", + "id": snaptest.AssertedSnapID("other-snap"), + "presence": "required", + }, + }, + }).(*asserts.ValidationSet) + + snaps = append(snaps, valset1.Snaps()...) + + valset2 := assertstest.FakeAssertion(map[string]interface{}{ + "type": "validation-set", + "authority-id": "account-id", + "series": "16", + "account-id": "account-id", + "name": "my-snap-ctl2", + "sequence": "2", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "other-snap", + "id": snaptest.AssertedSnapID("other-snap"), + "presence": "optional", + }, + map[string]interface{}{ + "name": "another-snap", + "id": snaptest.AssertedSnapID("another-snap"), + "presence": "invalid", + }, + }, + }).(*asserts.ValidationSet) + + snaps = append(snaps, valset2.Snaps()...) + + valsets := snapasserts.NewValidationSets() + + c.Assert(valsets.Add(valset1), IsNil) + c.Assert(valsets.Add(valset2), IsNil) + + // validity + c.Assert(valsets.Conflict(), IsNil) + + for _, sn := range snaps { + c.Check(valsets.CanBePresent(sn), Equals, sn.Presence != asserts.PresenceInvalid) + } +} + +func (s *validationSetsSuite) TestKeys(c *C) { + valset1 := assertstest.FakeAssertion(map[string]interface{}{ + "type": "validation-set", + "authority-id": "account-id", + "series": "16", + "account-id": "account-id", + "name": "my-snap-ctl", + "sequence": "1", + "snaps": []interface{}{}, + }).(*asserts.ValidationSet) + + valset2 := assertstest.FakeAssertion(map[string]interface{}{ + "type": "validation-set", + "authority-id": "account-id", + "series": "16", + "account-id": "account-id", + "name": "my-snap-ctl2", + "sequence": "2", + "snaps": []interface{}{}, + }).(*asserts.ValidationSet) + + valsets := snapasserts.NewValidationSets() + + c.Assert(valsets.Add(valset1), IsNil) + c.Assert(valsets.Add(valset2), IsNil) + + c.Check(valsets.Keys(), testutil.DeepUnsortedMatches, []snapasserts.ValidationSetKey{ + "16/account-id/my-snap-ctl2/2", + "16/account-id/my-snap-ctl/1", + }) +} + +func (s *validationSetsSuite) TestRequiredSnapNames(c *C) { + valset1 := assertstest.FakeAssertion(map[string]interface{}{ + "type": "validation-set", + "authority-id": "account-id", + "series": "16", + "account-id": "account-id", + "name": "my-snap-ctl", + "sequence": "1", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "my-snap", + "id": snaptest.AssertedSnapID("my-snap"), + "presence": "invalid", + }, + map[string]interface{}{ + "name": "other-snap", + "id": snaptest.AssertedSnapID("other-snap"), + "presence": "required", + }, + }, + }).(*asserts.ValidationSet) + + valset2 := assertstest.FakeAssertion(map[string]interface{}{ + "type": "validation-set", + "authority-id": "account-id", + "series": "16", + "account-id": "account-id", + "name": "my-snap-ctl2", + "sequence": "2", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "other-snap", + "id": snaptest.AssertedSnapID("other-snap"), + "presence": "optional", + }, + map[string]interface{}{ + "name": "another-snap", + "id": snaptest.AssertedSnapID("another-snap"), + "presence": "required", + }, + }, + }).(*asserts.ValidationSet) + + valsets := snapasserts.NewValidationSets() + + c.Assert(valsets.Add(valset1), IsNil) + c.Assert(valsets.Add(valset2), IsNil) + + c.Check(valsets.RequiredSnaps(), testutil.DeepUnsortedMatches, []string{ + "other-snap", + "another-snap", + }) +} + +func (s *validationSetsSuite) TestRevisionsConflict(c *C) { + valset1 := assertstest.FakeAssertion(map[string]interface{}{ + "type": "validation-set", + "authority-id": "account-id", + "series": "16", + "account-id": "account-id", + "name": "my-snap-ctl", + "sequence": "1", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "my-snap", + "id": snaptest.AssertedSnapID("my-snap"), + "presence": "required", + "revision": "10", + }, + }, + }).(*asserts.ValidationSet) + + valset2 := assertstest.FakeAssertion(map[string]interface{}{ + "type": "validation-set", + "authority-id": "account-id", + "series": "16", + "account-id": "account-id", + "name": "my-snap-ctl2", + "sequence": "2", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "my-snap", + "id": snaptest.AssertedSnapID("my-snap"), + "presence": "required", + "revision": "11", + }, + }, + }).(*asserts.ValidationSet) + + valsets := snapasserts.NewValidationSets() + c.Assert(valsets.Add(valset1), IsNil) + c.Assert(valsets.Add(valset2), IsNil) + + _, err := valsets.Revisions() + c.Assert(err, testutil.ErrorIs, &snapasserts.ValidationSetsConflictError{}) +} + +func (s *validationSetsSuite) TestValidationSetsConflictErrorIs(c *C) { + err := &snapasserts.ValidationSetsConflictError{} + + c.Check(err.Is(&snapasserts.ValidationSetsConflictError{}), Equals, true) + c.Check(err.Is(errors.New("other error")), Equals, false) + + wrapped := fmt.Errorf("wrapped error: %w", err) + c.Check(wrapped, testutil.ErrorIs, &snapasserts.ValidationSetsConflictError{}) +} + +func (s *validationSetsSuite) TestSets(c *C) { + valset1 := assertstest.FakeAssertion(map[string]interface{}{ + "type": "validation-set", + "authority-id": "account-id", + "series": "16", + "account-id": "account-id", + "name": "my-snap-ctl", + "sequence": "1", + "snaps": []interface{}{}, + }).(*asserts.ValidationSet) + + valset2 := assertstest.FakeAssertion(map[string]interface{}{ + "type": "validation-set", + "authority-id": "account-id", + "series": "16", + "account-id": "account-id", + "name": "my-snap-ctl2", + "sequence": "2", + "snaps": []interface{}{}, + }).(*asserts.ValidationSet) + + valsets := snapasserts.NewValidationSets() + + c.Assert(valsets.Add(valset1), IsNil) + c.Assert(valsets.Add(valset2), IsNil) + + sets := valsets.Sets() + c.Assert(sets, testutil.DeepUnsortedMatches, []*asserts.ValidationSet{valset1, valset2}) +} + +func (s *validationSetsSuite) TestSnapConstrained(c *C) { + valset1 := assertstest.FakeAssertion(map[string]interface{}{ + "type": "validation-set", + "authority-id": "account-id", + "series": "16", + "account-id": "account-id", + "name": "my-snap-ctl", + "sequence": "1", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "my-snap", + "id": snaptest.AssertedSnapID("my-snap"), + "presence": "invalid", + }, + map[string]interface{}{ + "name": "other-snap", + "id": snaptest.AssertedSnapID("other-snap"), + "presence": "required", + }, + }, + }).(*asserts.ValidationSet) + + valset2 := assertstest.FakeAssertion(map[string]interface{}{ + "type": "validation-set", + "authority-id": "account-id", + "series": "16", + "account-id": "account-id", + "name": "my-snap-ctl2", + "sequence": "2", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "other-snap", + "id": snaptest.AssertedSnapID("other-snap"), + "presence": "optional", + }, + map[string]interface{}{ + "name": "another-snap", + "id": snaptest.AssertedSnapID("another-snap"), + "presence": "required", + }, + }, + }).(*asserts.ValidationSet) + + valsets := snapasserts.NewValidationSets() + + c.Assert(valsets.Add(valset1), IsNil) + c.Assert(valsets.Add(valset2), IsNil) + + for _, name := range []string{"my-snap", "other-snap", "another-snap"} { + c.Check(valsets.SnapConstrained(&asserts.ModelSnap{ + Name: name, + }), Equals, true) + } + + c.Check(valsets.SnapConstrained(&asserts.ModelSnap{ + Name: "unknown-snap", + }), Equals, false) +} diff --git a/asserts/store_asserts.go b/asserts/store_asserts.go new file mode 100644 index 00000000..8fb7340b --- /dev/null +++ b/asserts/store_asserts.go @@ -0,0 +1,163 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts + +import ( + "errors" + "fmt" + "net/url" + "time" +) + +// Store holds a store assertion, defining the configuration needed to connect +// a device to the store or relative to a non-default store. +type Store struct { + assertionBase + url *url.URL + friendlyStores []string + timestamp time.Time +} + +// Store returns the identifying name of the operator's store. +func (store *Store) Store() string { + return store.HeaderString("store") +} + +// OperatorID returns the account id of the store's operator. +func (store *Store) OperatorID() string { + return store.HeaderString("operator-id") +} + +// URL returns the URL of the store's API. +func (store *Store) URL() *url.URL { + return store.url +} + +// FriendlyStores returns stores holding snaps that are also exposed +// through this one. +func (store *Store) FriendlyStores() []string { + return store.friendlyStores +} + +// Location returns a summary of the store's location/purpose. +func (store *Store) Location() string { + return store.HeaderString("location") +} + +// Timestamp returns the time when the store assertion was issued. +func (store *Store) Timestamp() time.Time { + return store.timestamp +} + +func (store *Store) checkConsistency(db RODatabase, acck *AccountKey) error { + // Will be applied to a system's snapd or influence snapd + // policy decisions (via friendly-stores) so must be signed by a trusted + // authority! + if !db.IsTrustedAccount(store.AuthorityID()) { + return fmt.Errorf("store assertion %q is not signed by a directly trusted authority: %s", + store.Store(), store.AuthorityID()) + } + + _, err := db.Find(AccountType, map[string]string{"account-id": store.OperatorID()}) + if err != nil { + if errors.Is(err, &NotFoundError{}) { + return fmt.Errorf( + "store assertion %q does not have a matching account assertion for the operator %q", + store.Store(), store.OperatorID()) + } + return err + } + + return nil +} + +// Prerequisites returns references to this store's prerequisite assertions. +func (store *Store) Prerequisites() []*Ref { + return []*Ref{ + {AccountType, []string{store.OperatorID()}}, + } +} + +// checkStoreURL validates the "url" header and returns a full URL or nil. +func checkStoreURL(headers map[string]interface{}) (*url.URL, error) { + s, err := checkOptionalString(headers, "url") + if err != nil { + return nil, err + } + + if s == "" { + return nil, nil + } + + errWhat := `"url" header` + + u, err := url.Parse(s) + if err != nil { + return nil, fmt.Errorf("%s must be a valid URL: %s", errWhat, s) + } + if u.Scheme != "http" && u.Scheme != "https" { + return nil, fmt.Errorf(`%s scheme must be "https" or "http": %s`, errWhat, s) + } + if u.Host == "" { + return nil, fmt.Errorf(`%s must have a host: %s`, errWhat, s) + } + if u.RawQuery != "" { + return nil, fmt.Errorf(`%s must not have a query: %s`, errWhat, s) + } + if u.Fragment != "" { + return nil, fmt.Errorf(`%s must not have a fragment: %s`, errWhat, s) + } + + return u, nil +} + +func assembleStore(assert assertionBase) (Assertion, error) { + _, err := checkNotEmptyString(assert.headers, "operator-id") + if err != nil { + return nil, err + } + + url, err := checkStoreURL(assert.headers) + if err != nil { + return nil, err + } + + friendlyStores, err := checkStringList(assert.headers, "friendly-stores") + if err != nil { + return nil, err + } + + _, err = checkOptionalString(assert.headers, "location") + if err != nil { + return nil, err + } + + timestamp, err := checkRFC3339Date(assert.headers, "timestamp") + if err != nil { + return nil, err + } + + return &Store{ + assertionBase: assert, + url: url, + friendlyStores: friendlyStores, + timestamp: timestamp, + }, nil +} diff --git a/asserts/store_asserts_test.go b/asserts/store_asserts_test.go new file mode 100644 index 00000000..9f12f5c3 --- /dev/null +++ b/asserts/store_asserts_test.go @@ -0,0 +1,236 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts_test + +import ( + "fmt" + "strings" + "time" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/asserts/assertstest" +) + +var _ = Suite(&storeSuite{}) + +type storeSuite struct { + ts time.Time + tsLine string + validExample string +} + +func (s *storeSuite) SetUpSuite(c *C) { + s.ts = time.Now().Truncate(time.Second).UTC() + s.tsLine = "timestamp: " + s.ts.Format(time.RFC3339) + "\n" + s.validExample = "type: store\n" + + "authority-id: canonical\n" + + "store: store1\n" + + "operator-id: op-id1\n" + + "url: https://store.example.com\n" + + "location: upstairs\n" + + s.tsLine + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij\n" + + "\n" + + "AXNpZw==" +} + +func (s *storeSuite) TestDecodeOK(c *C) { + a, err := asserts.Decode([]byte(s.validExample)) + c.Assert(err, IsNil) + c.Check(a.Type(), Equals, asserts.StoreType) + store := a.(*asserts.Store) + + c.Check(store.OperatorID(), Equals, "op-id1") + c.Check(store.Store(), Equals, "store1") + c.Check(store.URL().String(), Equals, "https://store.example.com") + c.Check(store.Location(), Equals, "upstairs") + c.Check(store.Timestamp().Equal(s.ts), Equals, true) + c.Check(store.FriendlyStores(), HasLen, 0) +} + +var storeErrPrefix = "assertion store: " + +func (s *storeSuite) TestDecodeInvalidHeaders(c *C) { + tests := []struct{ original, invalid, expectedErr string }{ + {"store: store1\n", "", `"store" header is mandatory`}, + {"store: store1\n", "store: \n", `"store" header should not be empty`}, + {"operator-id: op-id1\n", "", `"operator-id" header is mandatory`}, + {"operator-id: op-id1\n", "operator-id: \n", `"operator-id" header should not be empty`}, + {"url: https://store.example.com\n", "url:\n - foo\n", `"url" header must be a string`}, + {"location: upstairs\n", "location:\n - foo\n", `"location" header must be a string`}, + {s.tsLine, "", `"timestamp" header is mandatory`}, + {s.tsLine, "timestamp: \n", `"timestamp" header should not be empty`}, + {s.tsLine, "timestamp: 12:30\n", `"timestamp" header is not a RFC3339 date: .*`}, + {"url: https://store.example.com\n", "friendly-stores: foo\n", `"friendly-stores" header must be a list of strings`}, + } + + for _, test := range tests { + invalid := strings.Replace(s.validExample, test.original, test.invalid, 1) + _, err := asserts.Decode([]byte(invalid)) + c.Check(err, ErrorMatches, storeErrPrefix+test.expectedErr) + } +} + +func (s *storeSuite) TestURLOptional(c *C) { + tests := []string{"", "url: \n"} + for _, test := range tests { + encoded := strings.Replace(s.validExample, "url: https://store.example.com\n", test, 1) + assert, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + store := assert.(*asserts.Store) + c.Check(store.URL(), IsNil) + } +} + +func (s *storeSuite) TestURL(c *C) { + tests := []struct { + url string + err string + }{ + // Valid URLs. + {"http://example.com/", ""}, + {"https://example.com/", ""}, + {"https://example.com/some/path/", ""}, + {"https://example.com:443/", ""}, + {"https://example.com:1234/", ""}, + {"https://user:pass@example.com/", ""}, + {"https://token@example.com/", ""}, + + // Invalid URLs. + {"://example.com", `"url" header must be a valid URL`}, + {"example.com", `"url" header scheme must be "https" or "http"`}, + {"//example.com", `"url" header scheme must be "https" or "http"`}, + {"ftp://example.com", `"url" header scheme must be "https" or "http"`}, + {"mailto:someone@example.com", `"url" header scheme must be "https" or "http"`}, + {"https://", `"url" header must have a host`}, + {"https:///", `"url" header must have a host`}, + {"https:///some/path", `"url" header must have a host`}, + {"https://example.com/?foo=bar", `"url" header must not have a query`}, + {"https://example.com/#fragment", `"url" header must not have a fragment`}, + } + + for _, test := range tests { + encoded := strings.Replace( + s.validExample, "url: https://store.example.com\n", + fmt.Sprintf("url: %s\n", test.url), 1) + assert, err := asserts.Decode([]byte(encoded)) + if test.err != "" { + c.Assert(err, NotNil) + c.Check(err.Error(), Equals, storeErrPrefix+test.err+": "+test.url) + } else { + c.Assert(err, IsNil) + c.Check(assert.(*asserts.Store).URL().String(), Equals, test.url) + } + } +} + +func (s *storeSuite) TestLocationOptional(c *C) { + encoded := strings.Replace(s.validExample, "location: upstairs\n", "", 1) + _, err := asserts.Decode([]byte(encoded)) + c.Check(err, IsNil) +} + +func (s *storeSuite) TestLocation(c *C) { + for _, test := range []string{"foo", "bar", ""} { + encoded := strings.Replace( + s.validExample, "location: upstairs\n", + fmt.Sprintf("location: %s\n", test), 1) + assert, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + store := assert.(*asserts.Store) + c.Check(store.Location(), Equals, test) + } +} + +func (s *storeSuite) TestCheckAuthority(c *C) { + storeDB, db := makeStoreAndCheckDB(c) + + // Add account for operator. + operator := assertstest.NewAccount(storeDB, "op-id1", nil, "") + err := db.Add(operator) + c.Assert(err, IsNil) + + otherDB := setup3rdPartySigning(c, "other", storeDB, db) + + storeHeaders := map[string]interface{}{ + "store": "store1", + "operator-id": operator.HeaderString("account-id"), + "timestamp": time.Now().Format(time.RFC3339), + } + + // store signed by some other account fails. + store, err := otherDB.Sign(asserts.StoreType, storeHeaders, nil, "") + c.Assert(err, IsNil) + err = db.Check(store) + c.Assert(err, ErrorMatches, `store assertion "store1" is not signed by a directly trusted authority: other`) + + // but succeeds when signed by a trusted authority. + store, err = storeDB.Sign(asserts.StoreType, storeHeaders, nil, "") + c.Assert(err, IsNil) + err = db.Check(store) + c.Assert(err, IsNil) +} + +func (s *storeSuite) TestFriendlyStores(c *C) { + encoded := strings.Replace(s.validExample, "url: https://store.example.com\n", `friendly-stores: + - store1 + - store2 + - store3 +`, 1) + assert, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + store := assert.(*asserts.Store) + c.Check(store.URL(), IsNil) + c.Check(store.FriendlyStores(), DeepEquals, []string{"store1", "store2", "store3"}) +} + +func (s *storeSuite) TestCheckOperatorAccount(c *C) { + storeDB, db := makeStoreAndCheckDB(c) + + store, err := storeDB.Sign(asserts.StoreType, map[string]interface{}{ + "store": "store1", + "operator-id": "op-id1", + "timestamp": time.Now().Format(time.RFC3339), + }, nil, "") + c.Assert(err, IsNil) + + // No account for operator op-id1 yet, so Check fails. + err = db.Check(store) + c.Assert(err, ErrorMatches, `store assertion "store1" does not have a matching account assertion for the operator "op-id1"`) + + // Add the op-id1 account. + operator := assertstest.NewAccount(storeDB, "op-id1", map[string]interface{}{"account-id": "op-id1"}, "") + err = db.Add(operator) + c.Assert(err, IsNil) + + // Now the operator exists so Check succeeds. + err = db.Check(store) + c.Assert(err, IsNil) +} + +func (s *storeSuite) TestPrerequisites(c *C) { + assert, err := asserts.Decode([]byte(s.validExample)) + c.Assert(err, IsNil) + c.Assert(assert.Prerequisites(), DeepEquals, []*asserts.Ref{ + {Type: asserts.AccountType, PrimaryKey: []string{"op-id1"}}, + }) +} diff --git a/asserts/sysdb/generic.go b/asserts/sysdb/generic.go new file mode 100644 index 00000000..22ca76ff --- /dev/null +++ b/asserts/sysdb/generic.go @@ -0,0 +1,196 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2017-2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package sysdb + +import ( + "fmt" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/snapdenv" +) + +const ( + encodedGenericAccount = `type: account +authority-id: canonical +account-id: generic +display-name: Generic +timestamp: 2017-07-27T00:00:00.0Z +username: generic +validation: certified +sign-key-sha3-384: -CvQKAwRQ5h3Ffn10FILJoEZUXOv6km9FwA80-Rcj-f-6jadQ89VRswHNiEB9Lxk + +AcLDXAQAAQoABgUCWYuVIgAKCRDUpVvql9g3II66IACcoxSoX8+PQLa9TNuNBUs3bdTW6V5ZOdE8 +vnziIg+yqu3qYfWHcRf1qu7K9Igv5lH3uM5jh2AHlndaoX4Qg1Rm9rOZCkRr1dDUmdRDBXN2pdTA +oydd0Ivpeai4ATbSZs11h50/vN/mxBwM6TzdGHqRNt6lvygAPe7VtfchSW/J0NsSIHr9SUeuIHkJ +C79DV27B+9/m8pnpKJo/Fv8nKGs4sMduKVjrj9Po3UhpZEQWf3I3SeDI5IE4TgoDe+O7neGUtT6W +D9wnMWLphC+rHbJguxXG/fmnUYiM2U8o4WVrs/fjF0zDRH7rY3tbLPbFXf2OD4qfOvS//VLQWeCK +KAgKhwz0d5CqaHyKSplywSvwO/dxlrqOjt39k3EjYxVuNS5UQk/BzPoDZD5maisCFm9JZwqBlWHP +6XTj8rhHSkNAPXezs2ZpVSsdtNYmpLLzWIFsAviuoMjYYDyL6jZrD4RBNrNOvSNQGLezB+eyI5DW +9vr2ppCw8zr49epPvJ4uqj/AILgr52zworl7v/27X67BOSoRMmE4AOnvjSJ8cN6Yt83AuEI4aZbP +DlF2Znqp8o/srtmJ3ZMpsjIsAqVhCeTU6eWXbYfNUlIMSmC6CDwQQzsukU4M6NEwUQbWddiM3iNL +FdeFsBscXg4Qm/0Y3PULriDoct+VpBUhzwVXG+Lj6rjtcX7n1C/7u9i/+WIBJ7jU4FBjwOdgpSCQ +DSCb0PgTM2PfbScFpn3KVYs0kT/Jc40Lpw6CUG9iUIdz5qlJzhbRiuhU8yjEg9q/5lWizAuxcP+P +anNhmNXsme46IJh7WnlzPAVMsToz8bWY01LC3t33pPGlRJo109PMbNK7reMIb4KFiL4Hy7gVmTj9 +uydReVBUTZuMLRq1ShAJNScZ+HTpWruLoiC87Rf1++1KakahmtWYCdlJv/JSOyjSh8D9h0GEmqON +lKmzrNgQS8QhLh5uBcITN2Kt1UFGu2o9I8l0TgD5Uh9fG/R/A536fpcvIzOA/lhVttO6P9POwUVv +RIBZ3TpVOSzQ+ADpDexRUouPLPkgUwVBSctcgafMaj/w8DsULvlOYd3Sqq9a+zg6bZr9oPBKutUn +YkIUWmLW1OsdQ2eBS9dFqzJTAOELxNOUq37UGnIrMbk3Vn8hLK+S/+W9XL6WVxzmL1PT9FJZZ41p +KdaFV+mvrTfyoxuzXxkWbMwQkc56Ifn+IojbDwMI4FcTcl4dOeUrlnqwBJmTTwEhLVkYDvzYsVV9 +4joFUWhp10JMm3lO+3596m0kYWMhyvGfYnH7QcQ3GtMAz82yRHc1X+seeWyD/aIjlHYNYfaJ5Ogs +VC76lXi7swMtA9jV5FJIGmQufLo9f93NSYxqwpa8 +` + + encodedGenericModelsAccountKey = `type: account-key +authority-id: canonical +public-key-sha3-384: d-JcZF9nD9eBw7bwMnH61x-bklnQOhQud1Is6o_cn2wTj8EYDi9musrIT9z2MdAa +account-id: generic +name: models +since: 2017-07-27T00:00:00.0Z +body-length: 717 +sign-key-sha3-384: -CvQKAwRQ5h3Ffn10FILJoEZUXOv6km9FwA80-Rcj-f-6jadQ89VRswHNiEB9Lxk + +AcbBTQRWhcGAARAAoRakbLAMMoPeMz5MLCzAR6ALu/xxP9PuCdkknHH5lJrKE2adFj22DMwjWKj6 +0pZU1Ushv4r7eb1NmFfl7a6Pz5ert+O5Qt53feK30+yiZF+Pgsx46SVTGy8QvicxhDhChdJ7ugW2 +Vbz8dXDT9gv1E5hLl2BiuxxZHtMMTitO3bCtQcM/YwUeFljZZYd1FwxtgolnA5IUcHomIEQ5Xw6X +dCYGNkVjenb8aLBfi/ZZ84LHQjSbo3b87KP7syeEH2uuFJ2W8ZwGfUCll84gF+lYiLO6BQk8psIR +aRqnPfdjeuYg0ZLhdNV2Gu6GTNYMSrGLJ4vafAoIoMOifeIfK/DjN0XpfUIYwrM3UIvssEaLyE0L +i30PN5bpmmyfj5EDkJj9DqHzBly1kc20ciEtVCwOUijhQr4UjjfPiJFyed1/yndY1z/L85iATcsb +mwAw/wOyHKge/mlVztXV2H8DywcLV8Kbo5/ZZzcdKhDgL9URosQ5bMeYDPWwPsS02exHFl150dpR +p6MmeSCFPiQQjDrM3nWXLv/uemBE1IgX5q2eW6kJbSvRn519O3OrFEs2NBMEgvE3mIvewNlxFbDj +96Oj54Zh3rVtYu/g9yo2Bb2uf9gpOGS6TxrqN3aP5FigZzxkMCGFG8UOOFI7k2eQjMd8va5V8JTZ +ijWZgBjDB1YuQ1MAEQEAAQ== + +AcLDXAQAAQoABgUCWYuUigAKCRDUpVvql9g3IOobH/wLm7sfLu3A/QWrdrMB1xRe6JOKuOQoNEt0 +Vhg8q4MgOt1mxPzBUMGBJCcq9EiTYaUT4eDXSJL1OKFgh42oK5uY+GLsPWamxBY1Rg6QoESjJPcS +2niwTOjjTdpIrZ5M3pKRmxTxT+Wsq9j+1t4jvy/baI6+uO6KQh0UIMyOEhG+uJ8aJ2OcF3uV5gtF +fL1Y4Jr1Ir/4B2K7s8OhlrO1Yw3woB+YIkOjJ6oAOfQx5B/p1vK4uXOCIZarcfYX4XOhNgvPGaeL +O+NHk3GwTmEBngs49E8zq8ii8OoqIT6YzUd4taqHvZD4inTlw6MKGld7myCbZVZ3b0NXosplwYXa +jVL9ZBWTJukcIs4jEJ0XkTEuwvOpiGbtXdmDDlOSYkhZQdmQn3CIveGLRFa6pCi9a/jstyB+4sgk +MnwmJxEg8L3i1OvjgUM8uexCfg4cBVP9fCKuaC26uAXUiiHz7mIZhVSlLXHgUgMn5jekluPndgRZ +D2mGG0WscTMTb9uOpbLo6BWCwM7rGaZQgVSZsIj1cise05fjGpOozeqDhG25obcUXxhIUStztc9t +Z9MwSz9xdsUqV8XztEhkgfc7dh2fPWluiE9hLrdzyoU1xE6syujm8HE+bIJnDFYoE/Kw6WqIEm/3 +mWhnOmi9uZsMBErKZKO4sqcLfR/zIn2Lx0ivg/yZzHHnDY5hwdrhQtn+AHCb+QJ9AJVte9hI+kt+ +Fv8neohiMTCY8XxjrdB3QBPGesVsIMI5zAd14X4MqNKBYb4Ucg8YCIj7WLkQHbHO1GQwhPY8Tl9u +QqysZo/WnLVuvaruEBsBBGUJ7Ju5GtFKdWMdoH3YQmYHdxxxK37NPqBY70OrTSFJU5QT6PGFSvif +aMDg0X/aRj2uE3vgTI5hdqI4JYv1Mt1gYOPv4AMx/o/2q9dVENFYMTXcYBITMScUVV8NzmH8SNge +w7AWUPlQvWGZbTz62lYXHuUX1cdzz37B0LrEjh1ZC1V8emzfkLzEFYP/qUk1c4NjKsTjj5d463Gq +cn31Mr83tt5l7HWwP8bvTMIj98bOIJapsncGOzPYhs8cjZeOy0Q7EcvHjGRrj26CGWZacT3f0A0e +kb66ocAxV4nH1FDsfn8KdLKFgmSmW6SXkD2nqY94/pommJzUBF6s54DijZMXqHRwIRyPA8ymrCGt +t4shJh7dobC8Tg6RA84Bf9HkeqI97PQYFYMuNX0U59x2s0IQsOAYjH53NIf/jSPC4GDvLs7k+O76 +R2PJK1VN6/ckJZAb3Rum5Ak5sbLTpRAVHIAVU1NAjHc5lYUHhxXJmJsbw6Jawb9Xb3T96s+WdD3Y +062upMY95pr0ZPf1tVGgzpcVCEw7yAOw+SkMksx+ +` + + encodedGenericClassicModel = `type: model +authority-id: generic +series: 16 +brand-id: generic +model: generic-classic +classic: true +timestamp: 2017-07-27T00:00:00.0Z +sign-key-sha3-384: d-JcZF9nD9eBw7bwMnH61x-bklnQOhQud1Is6o_cn2wTj8EYDi9musrIT9z2MdAa + +AcLBXAQAAQoABgUCWYuXiAAKCRAdLQyY+/mCiST0D/0XGQauzV2bbTEy6DkrR1jlNbI6x8vfIdS8 +KvEWYvzOWNhNlVSfwNOkFjs3uMHgCO6/fCg03wGXTyV9D7ZgrMeUzWrYp6EmXk8/LQSaBnff86XO +4/vYyfyvEYavhF0kQ6QGg8Cqr0EaMyw0x9/zWEO/Ll9fH/8nv9qcQq8N4AbebNvNxtGsCmJuXpSe +2rxl3Dw8XarYBmqgcBQhXxRNpa6/AgaTNBpPOTqgNA8ZtmbZwYLuaFjpZP410aJSs+evSKepy/ce ++zTA7RB3384YQVeZDdTudX2fGtuCnBZBAJ+NYlk0t8VFXxyOhyMSXeylSpNSx4pCqmUZRyaf5SDS +g1XxJet4IP0stZH1SfPOwc9oE81/bJlKsb9QIQKQRewvtUCLfe9a6Vy/CYd2elvcWOmeANVrJK0m +nRaz6VBm09RJTuwUT6vNugXSOCeF7W3WN1RHJuex0zw+nP3eCehxFSr33YrVniaA7zGfjXvS8tKx +AINNQB4g2fpfet4na6lPPMYM41WHIHPCMTz/fJQ6dZBSEg6UUZ/GiQhGEfWPBteK7yd9pQ8qB3fj +ER4UvKnR7hcVI26e3NGNkXP5kp0SFCkV5NQs8rzXzokpB7p/V5Pnqp3Km6wu45cU6UiTZFhR2IMT +l+6AMtrS4gDGHktOhwfmOMWqmhvR/INF+TjaWbsB6g== +` +) + +var ( + genericAssertions []asserts.Assertion + genericStagingAssertions []asserts.Assertion + genericExtraAssertions []asserts.Assertion + + genericClassicModel *asserts.Model + genericStagingClassicModel *asserts.Model + genericClassicModelOverride *asserts.Model +) + +func init() { + genericAccount, err := asserts.Decode([]byte(encodedGenericAccount)) + if err != nil { + panic(fmt.Sprintf(`cannot decode "generic"'s account: %v`, err)) + } + genericModelsAccountKey, err := asserts.Decode([]byte(encodedGenericModelsAccountKey)) + if err != nil { + panic(fmt.Sprintf(`cannot decode "generic"'s "models" account-key: %v`, err)) + } + + genericAssertions = []asserts.Assertion{genericAccount, genericModelsAccountKey} + + a, err := asserts.Decode([]byte(encodedGenericClassicModel)) + if err != nil { + panic(fmt.Sprintf(`cannot decode "generic"'s "generic-classic" model: %v`, err)) + } + genericClassicModel = a.(*asserts.Model) +} + +// Generic returns a copy of the current set of predefined assertions for the 'generic' authority as used by Open. +func Generic() []asserts.Assertion { + generic := []asserts.Assertion(nil) + if !snapdenv.UseStagingStore() { + generic = append(generic, genericAssertions...) + } else { + generic = append(generic, genericStagingAssertions...) + } + generic = append(generic, genericExtraAssertions...) + return generic +} + +// InjectGeneric injects further predefined assertions into the set used Open. +// Returns a restore function to reinstate the previous set. Useful +// for tests or called globally without worrying about restoring. +func InjectGeneric(extra []asserts.Assertion) (restore func()) { + prev := genericExtraAssertions + genericExtraAssertions = make([]asserts.Assertion, len(prev)+len(extra)) + copy(genericExtraAssertions, prev) + copy(genericExtraAssertions[len(prev):], extra) + return func() { + genericExtraAssertions = prev + } +} + +// GenericClassicModel returns the model assertion for the "generic"'s "generic-classic" fallback model. +func GenericClassicModel() *asserts.Model { + if genericClassicModelOverride != nil { + return genericClassicModelOverride + } + if !snapdenv.UseStagingStore() { + return genericClassicModel + } else { + return genericStagingClassicModel + } +} + +// MockGenericClassicModel mocks the predefined generic-classic model returned by GenericClassicModel. +func MockGenericClassicModel(mod *asserts.Model) (restore func()) { + prevOverride := genericClassicModelOverride + genericClassicModelOverride = mod + return func() { + genericClassicModelOverride = prevOverride + } +} diff --git a/asserts/sysdb/staging.go b/asserts/sysdb/staging.go new file mode 100644 index 00000000..ff196e69 --- /dev/null +++ b/asserts/sysdb/staging.go @@ -0,0 +1,183 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- +//go:build withtestkeys || withstagingkeys + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package sysdb + +import ( + "fmt" + + "github.com/snapcore/snapd/asserts" +) + +const ( + encodedStagingTrustedAccount = `type: account +authority-id: canonical +account-id: canonical +display-name: Canonical +timestamp: 2016-04-01T00:00:00.0Z +username: canonical +validation: certified +sign-key-sha3-384: e2r8on4LgdxQSaW5T8mBD5oC2fSTktTVxOYa5w3kIP4_nNF6L7mt6fOJShdOGkKu + +AcLBXAQAAQoABgUCV640ggAKCRAHKljtl9kuLrQtEADBji8VwAuislurkFORTmcXV/DOkvyvAYEN +mB/MLniK4MlLX+RDncDBmF38IK9SRkxbwwJuKgvsjwsYJ3w1P7SGvVfNyU2hLRFtdxDMVC7+A9g3 +N1VW9W+IOWmYeBgXiveqAlSJ9GUvLQiBgUWRBkbyAT6aLkSZrTSjxGRGW/uoNfjj+CbAR4HGbRnn +IOxDuQyw6rOXQZKfZvkD1NiH+0QzXLv0RivE8+V5uVN+ooUFRoVQmqbj7orvPS9iTY5AMVjCgfo0 +UiWiN6NyCfDBDz0bZhIZlBU4JF5W0I/sEwsuYCxIhFi5uPNmQXqqb5d9Y3bsxIUdMR0+pai1A3eI +HQmYX12wCnb276R5Adz4iol19oKAR2Qf3VJBvPccdIFU7Qu5FOOihQdMRxULBBXGn1HQF1uW+ue3 +ZQ3x6e8s3XjdDQE/kHCDUkmzhbk1SErgndg6Q1ipKJ+4G6dOc16s66bSFA4QzW53Y40NP0HRWxe2 +tK9VOJ+z9GvGYp5H1ZXbbs78t9bUwL7L6z/eXM6BRho6YY9X7nImpByIkdcV47dCyVFol6NrM5NS +NSpdtRStGqo7tjPaBf86p2vLOAbwFUuaE3rwf5g/agz4S/v5G5E2tKmfQs6vGYrfVtlOzr8gEoXH ++/hOEC3wYEJjpXmFRjUjJwr0Fbej2TpoITpfzbySpg== +` + encodedStagingRootAccountKey = `type: account-key +authority-id: canonical +revision: 3 +public-key-sha3-384: e2r8on4LgdxQSaW5T8mBD5oC2fSTktTVxOYa5w3kIP4_nNF6L7mt6fOJShdOGkKu +account-id: canonical +name: staging-root +since: 2016-04-01T00:00:00.0Z +body-length: 717 +sign-key-sha3-384: e2r8on4LgdxQSaW5T8mBD5oC2fSTktTVxOYa5w3kIP4_nNF6L7mt6fOJShdOGkKu + +AcbBTQRWhcGAARAA4wh+b9nyRdZj9gNKuHz8BTNZsLOVv2VJseHBoMNc4aA8EgmLwMF/aP+q1tAQ +VOeynhfSecIK/2aWKKX+dmU/rfAbnbdHX1NT8OnG2z3qdYdqw1EreN8LcY4DBDfa1RNKcjFvBu+Q +jxpU289m1yUjjc7yHie84BoYRgDl0icar8KF7vKx44wNhzbca+lw4xGSA5gpDZ1i1smdxdpOSsUY +WT70ZcJBN1oyKiiiCJUNLwCPzaPsH1i3WwDSaGsbjl8gjf2+LFNFPwdsWRbn3RLlFcFbET2bFe5y +v6UN+0cSh9qLJeLR2h0WDaVBp5Gx4PAYAfpIIF8EH3YbvI8uuTmBza8Ni0yozOZ2cXCSdezLGW2m +b6itOq/taBhgl8gzhKqki9jAOWmDBeBIbe2rUuNJrfHVH8+lWTzuzJIcHSHeAjFG1xid+HOOsw0e +Ag3JMjJaqCGCp0Oc9/WBtHV6jB30jLzht5QjJZ6izIKswRrvt0nCowp74FZ1l1ekXZPhhkA5MBMb +AoTiz9UvRZAWBPa5gX4R7eaekGjCPWI8NpJ7pT3Xh3NIHIsjyf0JcysoH2V1+A9qT1LOCyczf1Uc +9d8PXap1zhhQuczZcnw7vAwLEIwldfp08x6klsgiP6jqIB4XKJCjBDu/gn682ydWzfLT8echVHpg +uI62X67Ns1ZbFWMAEQEAAQ== + +AcLBXAQAAQoABgUCV86jSgAKCRAHKljtl9kuLpV6EADO8Q1WKJwoTfeIpBpQfDhdhqJLmW86Qrjq +P9ZsndN8eA4uSbo08yg9jxi6Q3J/A5QK6rhTz5Nu41frKVpgFr80BpIx8cHsY2dZNyKCm70Jjy4h +cxteK7mwdAzdWG/Dg7Nr4fhOmpepsh1gIXvjWhTkT226DIO6l45o6N2hMKKkWmqJYqVD6i7UE4Ed +xmC+IoluhnKGGwM6JpyOw0RViXbLjVDR58n4q1xmK7cFduMoLKszVY4/KGmKT8gA6D4pUOa62F84 +Ejh6akRum7uqygBibYT/DP+KA+MhHvpQ8XZem7IVIEnMJr7U2gde3brbVr0oiCl7FzfiBNy6qw92 +cTsE8o3JV0Lc106SWU28GuWPgyXjoH8imzSmWlpQtlPlKEDwMQt31XDKUKp0ZKiEax3cQ6VjMv1f +PV3bHNjD+tBq5e1xm/UWyGu7J2N4VPLgUK7F4TPUJk5lwKjmII8KD3KA/IeHnZVN6vmC2nKfhGvw ++rJllQQ0IWY9RfIdzFHpVvthe48g27ok5yEgovAc/s7xWZ6CBgyzYWLQMNFvENj04CzGvxirKwuJ +Fy5UJIEKB0j0R2qnCz6HZkyQrUsz5HiIIlks18FfOZwuIc4GGPbwwQBoXW7a6KQg0aa62BPj5Iww +3w60rtTSUsjINkZ/GXLodfzPglOl6VLF7bWx2hGesg== +` + encodedStagingGenericAccount = `type: account +authority-id: canonical +account-id: generic +display-name: Generic +timestamp: 2017-07-27T00:00:00.0Z +username: generic +validation: certified +sign-key-sha3-384: e2r8on4LgdxQSaW5T8mBD5oC2fSTktTVxOYa5w3kIP4_nNF6L7mt6fOJShdOGkKu + +AcLBXAQAAQoABgUCWXmmFAAKCRAHKljtl9kuLkAWD/98LgECwAN8S09o4aEFpdGXgWpx8z58wl6T +5mZVDyYpCV9ugC2DqBqGQxp4X1P7Wn9+weXw8nmL7IywVn/hCVHJOmBLJSr3wLjpVBY9RrIHYoXi +k9W7IFo4ggw1j1FRLg2tKk81MnK0fK/Qws9OXzilDir5R2bQ/E0sodGW3NpbwtbpkY/BtP6YPoJ/ +1+205KG5m6oG8y6mf74bjMGfJ+iFFpIDayIpXl+YTkJ25BOVGcuC66cIrmdc63rBIHL2tU/3GUMB +xZGiyG9Fuli1uV4ALhN9j43hxAtVwXOn/qgOiN8TGQz3OvlVUXTuFVmkdvCdfT2XHrJjFmEs9SlL +u2EEmvaNFJ61lQG/VrN6O0BswenTlIO0tTFe126o/cTmKg8/ga4v2WjMlcOCzfu+cIZIzTTnn4Le +iXdQ6+c3QN+Co4SI0UvgJ4nGWQ9W+4q4xVJTliKTzK2BZ40vHUi51rMC/puqsMpnAbHSn4iy8vpf +CyJh7jyuITPEzfpurNMb+VD+1Brd2DJCVnlwQq+rzNerXd5xcHCdZsfX+ATukHgYTZWa467ZEFhI +Bk1xUWAYs8r2JDFb5YPtZuW7Vt1UUpFdx6DroL6OODvZ6mDUtsOa8nm7G1l4uRJtqunplPyCDjnL +aQhlAouLMltWeGITO+5jePHJKTnYQAFEvo0WIgEYpA== +` + encodedStagingGenericModelsAccountKey = `type: account-key +authority-id: canonical +public-key-sha3-384: 93jDrIGOXymDg9BPCLES5mAr6aGXU7e0wwXeJlIYIWbUzM_kB81CiqX7cTlB9Y1z +account-id: generic +name: models +since: 2017-07-27T00:00:00.0Z +body-length: 717 +sign-key-sha3-384: e2r8on4LgdxQSaW5T8mBD5oC2fSTktTVxOYa5w3kIP4_nNF6L7mt6fOJShdOGkKu + +AcbBTQRWhcGAARAAxcyFC13COEmIwWwLsjp4AAILhWSp8/dQ6cOzY3T7tqqoSn9iKyidpJTfrtml +DKHZe0zC10fog2Mvp1AO7dNqK9kHUdCQE+YatHmkm1a3QoZqwJsj77w09Q+l1uvDjrfrF0S/KcYa +0hfDZQ+51T1msbatWN2qU42dX280IMV+zo1GpKK8z6br4glY2tki4CJokVAAt+bl4bBqDZ4EoBYe +9CsACmNhw2d/fOAlis2jwG3tWXMORX9FcGRx/COasvRb7rjA0DJfKxOnTw7uC0UjDUB6bU6O0smS +Q5oK3V5fJIAcMBXNe5MkdTKGLY61hTFLiw4F6MkrM3O8dnXtexCojV+QtROTIM4R2dJTOv7r2s/9 +KT4wIQmOcjMEWxyq1H1rqjCHBjGnKa6GC1j/4NwqlxUEiqZMYs12px9ypeEqjL3tKURCanPOlwXO +p2E1i+V53XznnS3RA7I6Aa37w/9clTJk5vzVT8G6+k6xsB9zwKYOsipG0zHjyuW9Qtkd15bA0Iv9 +MrZGE4U7RwEnt4jBa98rcLs1sCkJEau0hEU4MiPyqi8XL2b/TtPnCwN8rQRVQvakzGw83Ol/B8ZI +2OGu0aB6HAWbdy81yXIUES9ZtH7nK5X7dSdJu92wXBMOyel9cryHzlYFjSlPKyqRx2lsYk4K6Hiq +VRY3L12yjXkcHcsAEQEAAQ== + +AcLBXAQAAQoABgUCWXmevAAKCRAHKljtl9kuLltVEADAfpBlY4b4oImKPq8Pp6UKFjgcMVjJLcSI +EOfAMygIaZwzNSuOh2wPRBAMMZlcFlBEFLfGbh7R2RG1/R7PSR4q+gMZZ3qJ5QUjUUuGnkSfCLhK +jVtPlX8qdPWTdEgUeTEKNzHogP0MiIChHdeuv/iQ9fSgdw/lBZsblKAdrHv00ZQHup9XGWdZ4Fnb +cSiK9tZ3/nZAG18PErEh7wJntwygqcjScS0jTSa5BecQoy8O6wxKafQgxuixdHw+dt6sa34qzwel +ROb1VmNcmMGsv2YuPsRcqjgvL7drDzXRRcYhmiSCUFhGPx3RY0UWO9G9Pzok64l/1D7o6Xah3h4D +oxkepM5JTAiy165kfQzEFGMtvlv0d3mOCLMWqJzjzhhn/bPcoh5MO/PhpSR1y71tjjWtKR4SD1K/ +feR+KE73gEgqmHLss3TF/O2RxvbV15W0paxmiffUyeE/uQ1p5ddmBwke/gM/OOgUA4G3g9vgTeaQ +YVFCD0h75mX9GylVXUMmxlYSRVO59JsxCpWSqXWh4xkigbBZSAOPn6vkM/4nxZLf/ufVyNqnwo2r +Si/lWgk/lJoHaTVsqm9n0DxiR8lH54eFghprkN+KgGFMKlY127n50CEwqG1j4gKfjxmRycgKbx5O +a0IGmGjVnVmGOdX9wpg6fBHbhczXZtId02Q7yzF87A== +` + + encodedStagingGenericClassicModel = `type: model +authority-id: generic +series: 16 +brand-id: generic +model: generic-classic +classic: true +timestamp: 2017-07-27T00:00:00.0Z +sign-key-sha3-384: 93jDrIGOXymDg9BPCLES5mAr6aGXU7e0wwXeJlIYIWbUzM_kB81CiqX7cTlB9Y1z + +AcLBXAQAAQoABgUCWXnYbAAKCRDqhmvwxUsbelvOD/4qxiDs4blJoRSXmzvsKTyM/Z2QuLw9bqUj +QXKoCSB78ATFwr01kvqJMzwJ1eT4zKOajUERKPN9fN1af0w07DoYG5bt/Pb7s/UFDmwIQg244wLI +lQ/NPCAm4SEvN1GEe0OxdCpMuPe+x++FvFtnF7CXJPmLdHln6A1eMhwyxGX+el1QxhiR+mWLCCNp +B4ndjh154H5SXRw1lmUiYdE/kCsOqGeZ5ljTni+Rh8xDYxmVCthrLCUVtHhMVrKeylDwwS7Sf/HV +GY9r/C9r07xRom06bBN/vQwdoLzGuU3SS7UsN0Ud95tJhAUtP5jW1dN8otviMcOAdtj7jTwSX4FY +pdgmkldjaCRaHxBA923cjGgl98LCjbdG5KmmKoT6DTb3AyFOT2XwlRl/MaRJBK2Tp1nVNZDjLY4j +VfRETt17ZCONt3yn/OhQk8bV6EsdJvT2/nMlNejXgnMtLfbH8v6xWLKrLOVOjILVF5zgK8+z4+d2 +ILIZupGooMouhddmcHem76lSnS+y75NMQXg5lBrUU2xAQRloWTw0oF+Hr5vcZkX5f4R/yH8Zz1Dt ++zRs2zqOK5hjdejhU5x/N3KSBLy+TUMk7JsdVv0nhdpJUKrFyGWn+YzBNE2GgEfPfXnkaU91/AD2 +SWyt8kWVPmT3DCzs7u5IXYIVxcq4FjkmeU9sTrn88g== +` +) + +func init() { + stagingTrustedAccount, err := asserts.Decode([]byte(encodedStagingTrustedAccount)) + if err != nil { + panic(fmt.Sprintf("cannot decode trusted assertion: %v", err)) + } + stagingRootAccountKey, err := asserts.Decode([]byte(encodedStagingRootAccountKey)) + if err != nil { + panic(fmt.Sprintf("cannot decode trusted assertion: %v", err)) + } + trustedStagingAssertions = []asserts.Assertion{stagingTrustedAccount, stagingRootAccountKey} + + genericAccount, err := asserts.Decode([]byte(encodedStagingGenericAccount)) + if err != nil { + panic(fmt.Sprintf(`cannot decode "generic"'s account: %v`, err)) + } + genericModelsAccountKey, err := asserts.Decode([]byte(encodedStagingGenericModelsAccountKey)) + if err != nil { + panic(fmt.Sprintf(`cannot decode "generic"'s "models" account-key: %v`, err)) + } + + genericStagingAssertions = []asserts.Assertion{genericAccount, genericModelsAccountKey} + + a, err := asserts.Decode([]byte(encodedStagingGenericClassicModel)) + if err != nil { + panic(fmt.Sprintf(`cannot decode "generic"'s "generic-classic" model: %v`, err)) + } + genericStagingClassicModel = a.(*asserts.Model) +} diff --git a/asserts/sysdb/sysdb.go b/asserts/sysdb/sysdb.go new file mode 100644 index 00000000..b3b39f2f --- /dev/null +++ b/asserts/sysdb/sysdb.go @@ -0,0 +1,56 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2015-2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +// Package sysdb supports the system-wide assertion database with ways to open it and to manage the trusted set of assertions founding it. +package sysdb + +import ( + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/dirs" +) + +func openDatabaseAt(path string, cfg *asserts.DatabaseConfig) (*asserts.Database, error) { + bs, err := asserts.OpenFSBackstore(path) + if err != nil { + return nil, err + } + keypairMgr, err := asserts.OpenFSKeypairManager(path) + if err != nil { + return nil, err + } + cfg.Backstore = bs + cfg.KeypairManager = keypairMgr + return asserts.OpenDatabase(cfg) +} + +// OpenAt opens a system assertion database at the given location with +// the trusted assertions set configured. +func OpenAt(path string) (*asserts.Database, error) { + cfg := &asserts.DatabaseConfig{ + Trusted: Trusted(), + OtherPredefined: Generic(), + } + return openDatabaseAt(path, cfg) +} + +// Open opens the system-wide assertion database with the trusted assertions +// set configured. +func Open() (*asserts.Database, error) { + return OpenAt(dirs.SnapAssertsDBDir) +} diff --git a/asserts/sysdb/sysdb_test.go b/asserts/sysdb/sysdb_test.go new file mode 100644 index 00000000..e7991006 --- /dev/null +++ b/asserts/sysdb/sysdb_test.go @@ -0,0 +1,215 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2015-2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package sysdb_test + +import ( + "os" + "path/filepath" + "syscall" + "testing" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/asserts/assertstest" + "github.com/snapcore/snapd/asserts/sysdb" + "github.com/snapcore/snapd/dirs" +) + +func TestSysDB(t *testing.T) { TestingT(t) } + +type sysDBSuite struct { + extraTrusted []asserts.Assertion + extraGeneric []asserts.Assertion + otherModel *asserts.Model + probeAssert asserts.Assertion +} + +var _ = Suite(&sysDBSuite{}) + +func (sdbs *sysDBSuite) SetUpTest(c *C) { + tmpdir := c.MkDir() + + pk, _ := assertstest.GenerateKey(752) + + signingDB := assertstest.NewSigningDB("can0nical", pk) + + trustedAcct := assertstest.NewAccount(signingDB, "can0nical", map[string]interface{}{ + "account-id": "can0nical", + "validation": "verified", + "timestamp": "2015-11-20T15:04:00Z", + }, "") + + trustedAccKey := assertstest.NewAccountKey(signingDB, trustedAcct, map[string]interface{}{ + "account-id": "can0nical", + "since": "2015-11-20T15:04:00Z", + "until": "2500-11-20T15:04:00Z", + }, pk.PublicKey(), "") + + sdbs.extraTrusted = []asserts.Assertion{trustedAcct, trustedAccKey} + + otherAcct := assertstest.NewAccount(signingDB, "gener1c", map[string]interface{}{ + "account-id": "gener1c", + "validation": "verified", + "timestamp": "2015-11-20T15:04:00Z", + }, "") + + sdbs.extraGeneric = []asserts.Assertion{otherAcct} + + a, err := signingDB.Sign(asserts.ModelType, map[string]interface{}{ + "series": "16", + "brand-id": "can0nical", + "model": "other-model", + "classic": "true", + "timestamp": "2015-11-20T15:04:00Z", + }, nil, "") + c.Assert(err, IsNil) + sdbs.otherModel = a.(*asserts.Model) + + fakeRoot := filepath.Join(tmpdir, "root") + + err = os.Mkdir(fakeRoot, os.ModePerm) + c.Assert(err, IsNil) + dirs.SetRootDir(fakeRoot) + + sdbs.probeAssert = assertstest.NewAccount(signingDB, "probe", nil, "") +} + +func (sdbs *sysDBSuite) TearDownTest(c *C) { + dirs.SetRootDir("/") +} + +func (sdbs *sysDBSuite) TestTrusted(c *C) { + trusted := sysdb.Trusted() + c.Check(trusted, HasLen, 2) + + restore := sysdb.InjectTrusted(sdbs.extraTrusted) + defer restore() + + trustedEx := sysdb.Trusted() + c.Check(trustedEx, HasLen, 4) +} + +func (sdbs *sysDBSuite) TestGeneric(c *C) { + generic := sysdb.Generic() + c.Check(generic, HasLen, 2) + + restore := sysdb.InjectGeneric(sdbs.extraGeneric) + defer restore() + + genericEx := sysdb.Generic() + c.Check(genericEx, HasLen, 3) +} + +func (sdbs *sysDBSuite) TestGenericClassicModel(c *C) { + m := sysdb.GenericClassicModel() + c.Assert(m, NotNil) + + c.Check(m.AuthorityID(), Equals, "generic") + c.Check(m.BrandID(), Equals, "generic") + c.Check(m.Model(), Equals, "generic-classic") + c.Check(m.Classic(), Equals, true) + + r := sysdb.MockGenericClassicModel(sdbs.otherModel) + defer r() + + m = sysdb.GenericClassicModel() + c.Check(m, Equals, sdbs.otherModel) +} + +func (sdbs *sysDBSuite) TestOpenSysDatabase(c *C) { + db, err := sysdb.Open() + c.Assert(err, IsNil) + c.Check(db, NotNil) + + // check trusted + _, err = db.Find(asserts.AccountKeyType, map[string]string{ + "account-id": "canonical", + "public-key-sha3-384": "-CvQKAwRQ5h3Ffn10FILJoEZUXOv6km9FwA80-Rcj-f-6jadQ89VRswHNiEB9Lxk", + }) + c.Assert(err, IsNil) + + trustedAcc, err := db.Find(asserts.AccountType, map[string]string{ + "account-id": "canonical", + }) + c.Assert(err, IsNil) + + c.Check(trustedAcc.(*asserts.Account).Validation(), Equals, "verified") + + err = db.Check(trustedAcc) + c.Check(err, IsNil) + + // check generic + genericAcc, err := db.Find(asserts.AccountType, map[string]string{ + "account-id": "generic", + }) + c.Assert(err, IsNil) + _, err = db.FindMany(asserts.AccountKeyType, map[string]string{ + "account-id": "generic", + "name": "models", + }) + c.Assert(err, IsNil) + + c.Check(genericAcc.(*asserts.Account).Validation(), Equals, "verified") + + err = db.Check(genericAcc) + c.Check(err, IsNil) + + err = db.Check(sysdb.GenericClassicModel()) + c.Check(err, IsNil) + + // extraneous + err = db.Check(sdbs.probeAssert) + c.Check(err, ErrorMatches, "no matching public key.*") +} + +func (sdbs *sysDBSuite) TestOpenSysDatabaseExtras(c *C) { + restore := sysdb.InjectTrusted(sdbs.extraTrusted) + defer restore() + + db, err := sysdb.Open() + c.Assert(err, IsNil) + c.Check(db, NotNil) + + err = db.Check(sdbs.probeAssert) + c.Check(err, IsNil) +} + +func (sdbs *sysDBSuite) TestOpenSysDatabaseBackstoreOpenFail(c *C) { + // make it not world-writeable + oldUmask := syscall.Umask(0) + os.MkdirAll(filepath.Join(dirs.SnapAssertsDBDir, "asserts-v0"), 0777) + syscall.Umask(oldUmask) + + db, err := sysdb.Open() + c.Assert(err, ErrorMatches, "assert storage root unexpectedly world-writable: .*") + c.Check(db, IsNil) +} + +func (sdbs *sysDBSuite) TestOpenSysDatabaseKeypairManagerOpenFail(c *C) { + // make it not world-writeable + oldUmask := syscall.Umask(0) + os.MkdirAll(filepath.Join(dirs.SnapAssertsDBDir, "private-keys-v1"), 0777) + syscall.Umask(oldUmask) + + db, err := sysdb.Open() + c.Assert(err, ErrorMatches, "assert storage root unexpectedly world-writable: .*") + c.Check(db, IsNil) +} diff --git a/asserts/sysdb/testkeys.go b/asserts/sysdb/testkeys.go new file mode 100644 index 00000000..1d77bde8 --- /dev/null +++ b/asserts/sysdb/testkeys.go @@ -0,0 +1,30 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- +//go:build withtestkeys + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package sysdb + +import ( + "github.com/snapcore/snapd/asserts/systestkeys" +) + +// init will inject the test trusted assertions when this module build tag "withtestkeys" is defined. +func init() { + InjectTrusted(systestkeys.Trusted) +} diff --git a/asserts/sysdb/trusted.go b/asserts/sysdb/trusted.go new file mode 100644 index 00000000..33612a0c --- /dev/null +++ b/asserts/sysdb/trusted.go @@ -0,0 +1,156 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016-2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package sysdb + +import ( + "fmt" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/snapdenv" +) + +const ( + encodedCanonicalAccount = `type: account +authority-id: canonical +account-id: canonical +display-name: Canonical +timestamp: 2016-04-01T00:00:00.0Z +username: canonical +validation: certified +sign-key-sha3-384: -CvQKAwRQ5h3Ffn10FILJoEZUXOv6km9FwA80-Rcj-f-6jadQ89VRswHNiEB9Lxk + +AcLDXAQAAQoABgUCV7UYzwAKCRDUpVvql9g3IK7uH/4udqNOurx5WYVknzXdwekp0ovHCQJ0iBPw +TSFxEVr9faZSzb7eqJ1WicHsShf97PYS3ClRYAiluFsjRA8Y03kkSVJHjC+sIwGFubsnkmgflt6D +WEmYIl0UBmeaEDS8uY4Xvp9NsLTzNEj2kvzy/52gKaTc1ZSl5RDL9ppMav+0V9iBYpiDPBWH2rJ+ +aDSD8Rkyygm0UscfAKyDKH4lrvZ0WkYyi1YVNPrjQ/AtBySh6Q4iJ3LifzKa9woIyAuJET/4/FPY +oirqHAfuvNod36yNQIyNqEc20AvTvZNH0PSsg4rq3DLjIPzv5KbJO9lhsasNJK1OdL6x8Yqrdsbk +ldZp4qkzfjV7VOMQKaadfcZPRaVVeJWOBnBiaukzkhoNlQi1sdCdkBB/AJHZF8QXw6c7vPDcfnCV +1lW7ddQ2p8IsJbT6LzpJu3GW/P4xhNgCjtCJ1AJm9a9RqLwQYgdLZwwDa9iCRtqTbRXBlfy3apps +1VjbQ3h5iCd0hNfwDBnGVm1rhLKHCD1DUdNE43oN2ZlE7XGyh0HFV6vKlpqoW3eoXCIxWu+HBY96 ++LSl/jQgCkb0nxYyzEYK4Reb31D0mYw1Nji5W+MIF5E09+DYZoOT0UvR05YMwMEOeSdI/hLWg/5P +k+GDK+/KopMmpd4D1+jjtF7ZvqDpmAV98jJGB2F88RyVb4gcjmFFyTi4Kv6vzz/oLpbm0qrizC0W +HLGDN/ymGA5sHzEgEx7U540vz/q9VX60FKqL2YZr/DcyY9GKX5kCG4sNqIIHbcJneZ4frM99oVDu +7Jv+DIx/Di6D1ULXol2XjxbbJLKHFtHksR97ceaFvcZwTogC61IYUBJCvvMoqdXAWMhEXCr0QfQ5 +Xbi31XW2d4/lF/zWlAkRnGTzufIXFni7+nEuOK0SQEzO3/WaRedK1SGOOtTDjB8/3OJeW96AUYK5 +oTIynkYkEyHWMNCXALg+WQW6L4/YO7aUjZ97zOWIugd7Xy63aT3r/EHafqaY2nacOhLfkeKZ830b +o/ezjoZQAxbh6ce7JnXRgE9ELxjdAhBTpGjmmmN2sYrJ7zP9bOgly0BnEPXGSQfFA+NNNw1FADx1 +MUY8q9DBjmVtgqY+1KGTV5X8KvQCBMODZIf/XJPHdCRAHxMd8COypcwgL2vDIIXpOFbi1J/B0GF+ +eklxk9wzBA8AecBMCwCzIRHDNpD1oa2we38bVFrOug6e/VId1k1jYFJjiLyLCDmV8IMYwEllHSXp +LQAdm3xZ7t4WnxYC8YSCk9mXf3CZg59SpmnV5Q5Z6A5Pl7Nc3sj7hcsMBZEsOMPzNC9dPsBnZvjs +WpPUffJzEdhHBFhvYMuD4Vqj6ejUv9l3oTrjQWVC +` + + encodedCanonicalRootAccountKey = `type: account-key +authority-id: canonical +revision: 2 +public-key-sha3-384: -CvQKAwRQ5h3Ffn10FILJoEZUXOv6km9FwA80-Rcj-f-6jadQ89VRswHNiEB9Lxk +account-id: canonical +name: root +since: 2016-04-01T00:00:00.0Z +body-length: 1406 +sign-key-sha3-384: -CvQKAwRQ5h3Ffn10FILJoEZUXOv6km9FwA80-Rcj-f-6jadQ89VRswHNiEB9Lxk + +AcbDTQRWhcGAASAA4Zdo3CVpKmTecjd3VDBiFbZTKKhcG0UV3FXxyGIe2UsdnJIks4NkVYO+qYk0 +zW26Svpa5OIOJGO2NcgN9bpCYWZOufO1xTmC7jW/fEtqJpX8Kcq20+X5AarqJ5RBVnGLrlz+ZT99 +aHdRZ4YQ2XUZvhbelzWTdK5+2eMSXNrFjO6WwGh9NRekE/NIBNwvULAtJ5nv1KwZaSpZ+klJrstU +EHPhs+NGGm1Aru01FFl3cWUm5Ao8i9y+pFcPoaRatgtpYU8mg9gP594lvyJqjFofXvHPwztmySqf +FVAp4gLLfLvRxbXkOfPUz8guidqvg6r4DUD+kCBjKYoT44PjK6l51MzEL2IEy6jdnFTgjHbaYML8 +/5NpuPu8XiSjCpOTeNR+XKzXC2tHRU7j09Xd44vKRhPk0Hc4XsPNBWqfrcbdWmwsFhjfxFDJajOq +hzWVoiRc5opB5socbRjLf+gYtncxe99oC2FDA2FcftlFoyztho0bAzeFer1IHJIMYWxKMESjvJUE +pnMMKpIMYY0QfWEo5hXR0TaT+NxW2Z9Jqclgyw13y5iY72ZparHS66J+C7dxCEOswlw1ypNic6MM +/OzpafIQ10yAT3HeRCJQQOOSSTaold+WpWsQweYCywPcu9S+wCo6CrPzJCCIxOAnXjLYv2ykTJje +pNJ2+GZ1WH2UeJdJ5sR8fpxxRupqHuEKNRZ+2CqLmFC5kHNszoGolLEvGcK4BJciO4KihnKtxrdX +dUJIOPBLktA8XiiHSOmLzs2CFjcvlDuPSpe64HIL5yCxO1/GRux4A1Kht1+DqTrL7DjyIW+vIPro +A1PQwkcAJyScNRxT4bPpUj8geAXWd3n212W+7QVHuQEFezvXC5GbMyR+Xj47FOFcFcSZID1hTZEu +uMD+AxaBHQKwPfBx1arVKE1OhkuKHeSFtZRP8K8l3qj5W0sIxxIW19W8aziu8ZeDMT+nIEJrJvhx +zGEdxwCrp3k2/93oDV7g+nb1ZGfIhtmcrKziijghzPLaYaiM9LggqwTARelk3xSzd8+uk3LPXuVl +fP8/xHApss6sCE3xk4+F3OGbL7HbGuCnoulf795XKLRTy+xU/78piOMNJJQu+G0lMZIO3cZrP6io +MYDa+jDZw4V4fBRWce/FA3Ot1eIDxCq5v+vfKw+HfUlWcjm6VUQIFZYbK+Lzj6mpXn81BugG3d+M +0WNFObXIrUbhnKcYkus3TSJ9M1oMEIMp0WfFGAVTd61u36fdi2e+/xbLN0kbYcFRZwd9CmtEeDZ0 +eYx/pvKKaNz/DfUr0piVCRwxuxQ0kVppklHPO4sOTFZUId8KLHg28LbszvupSsHP/nHlW8l5/VK6 +4+KxRV2XofsUnwARAQAB + +AcLDXAQAAQoABgUCV83kkgAKCRDUpVvql9g3IA9hIADAkn4VXnJIFblhMSBe6hbTy7z6AfOhZxXR +Ds/mHsiWfFT6ifGi9SpZowhRX+ff57YvFCjlBqMYLKYE0NsFQYEUc5uBWiFZwC0ENydNhO23DV1B +elTSs6mr9duPm1eJAozFrQETOD1kz5BIamqBUeaTczjM+9l5i485Ffknbc+EaGOrtMEap0GqjByQ +u+ykZGvryVQ447avgjvFsMtA0quFi+SoW9PT/9D26e5rD7RIICYWG8mzFRn5Isqs/X4W1uAiKQe9 +pqHMbdNr/FCWX5ws0/nMaOq+b0z4EIIXIfT0JmIlFDQsAgFVnKwYw+zs32cTw4XuzvMhgMDtCowD +YodhiO/5AOMsMMV0qBsYxbIPJIEz7b6gwTYEJoTVkqTit6o3UgWrAy+p4Y7t0ickYIHgwiuKRS9E +fu0Ue+32NFp0XFqZElfXLK/U2yjto+fJXu6uAELsXesfFGIOp/nbRbNavUt9jAJeO7ftQczgf39T +YfA0OKerP5gAOd4+aO3gATPUjfWPsJ9908XC7QqK2BwS1kh/fMrd95mxcmXdF1bBElszKwaToBVQ +1m52EYp06kkPyOu+fGKFAoIMafcV/2Ztz1WMo/Vp0iP/r0WAtBDw6sDJyWOfRjUEvP7BBdEzraHV +VblbSrKzhYeEGdMDi6kFC+KEzfPDPFJX1l3saPBkz9VDuESbktyObQp9VfkFKYBgBnw3msQJk+6k +G4t0o3/DZ7qz/kTJXMogG26Z/FsMhPERsaLTbWRJ3WRyXX8COaTladSf8bG0Oib19outnjuvpjQ0 +qEV9eeGRBlx9mbidSYH95cj0zD2DKpeSZ83M5K1pFg+8RKToGElGTTk8vtdTfDVbmi3+QntfLq+z +ZMgs2+SmCWrV/MPC04Dl00CXywdKPyf6toomqRP7A5fS7W8P9fdPn+a8JCblcleGj9nvJXBQjue7 +97rofCEszhKhoE9fMCIUcSoTU9YAm5Jr+qclSEbV1pzwTvZ8auMIXtzEZV5n4aK4WPDV+lYCadrL +DlvJSJRuXRvIMbmvU9b8NxgG8AS88BkX3L9vlOpkMculwG1/iooQvxuFaJDargt370wAQo0lCpG3 +MxnsSusymwnYegvvvr7Xp/KBLZK1+8Djzm3fwAryp4qNo29ciVw3O9lFKmmuiIcxSY0bauXaK6kv +pTnYkmx7XGPF7Ahb7Ov0/0FE2Lx3JZXSEKeW+VrCcpYQOY++t67b+jf0AV4rZExcLFJzP6MPMimP +ZCd383NzlzkXK+vAdvTi40HPiM9FYOp6g8JTs5TTdx2/qs/SWFC8AkahIQmH0IpFBJep2JKl2kyr +FZMvASkHA9bR/UuXDvbMzsUmT/xnERZosQaZgFEO +` +) + +var ( + trustedAssertions []asserts.Assertion + trustedStagingAssertions []asserts.Assertion + trustedExtraAssertions []asserts.Assertion +) + +func init() { + canonicalAccount, err := asserts.Decode([]byte(encodedCanonicalAccount)) + if err != nil { + panic(fmt.Sprintf("cannot decode trusted assertion: %v", err)) + } + canonicalRootAccountKey, err := asserts.Decode([]byte(encodedCanonicalRootAccountKey)) + if err != nil { + panic(fmt.Sprintf("cannot decode trusted assertion: %v", err)) + } + trustedAssertions = []asserts.Assertion{canonicalAccount, canonicalRootAccountKey} +} + +// Trusted returns a copy of the current set of trusted assertions as used by Open. +func Trusted() []asserts.Assertion { + trusted := []asserts.Assertion(nil) + if !snapdenv.UseStagingStore() { + trusted = append(trusted, trustedAssertions...) + } else { + if len(trustedStagingAssertions) == 0 { + panic("cannot work with the staging store without a testing build with compiled-in staging keys") + } + trusted = append(trusted, trustedStagingAssertions...) + } + trusted = append(trusted, trustedExtraAssertions...) + return trusted +} + +// InjectTrusted injects further assertions into the trusted set for Open. +// Returns a restore function to reinstate the previous set. Useful +// for tests or called globally without worrying about restoring. +func InjectTrusted(extra []asserts.Assertion) (restore func()) { + prev := trustedExtraAssertions + trustedExtraAssertions = make([]asserts.Assertion, len(prev)+len(extra)) + copy(trustedExtraAssertions, prev) + copy(trustedExtraAssertions[len(prev):], extra) + return func() { + trustedExtraAssertions = prev + } +} diff --git a/asserts/system_user.go b/asserts/system_user.go new file mode 100644 index 00000000..83ea08a4 --- /dev/null +++ b/asserts/system_user.go @@ -0,0 +1,356 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts + +import ( + "fmt" + "net/mail" + "regexp" + "strconv" + "strings" + "time" +) + +// validSystemUserUsernames matches the regex we allow by osutil/user.go:IsValidUsername +var validSystemUserUsernames = regexp.MustCompile(`^[a-z0-9][-a-z0-9._]*$`) + +// SystemUser holds a system-user assertion which allows creating local +// system users. +type SystemUser struct { + assertionBase + series []string + models []string + serials []string + sshKeys []string + since time.Time + until time.Time + expiration string + + forcePasswordChange bool +} + +// BrandID returns the brand identifier that signed this assertion. +func (su *SystemUser) BrandID() string { + return su.HeaderString("brand-id") +} + +// Email returns the email address that this assertion is valid for. +func (su *SystemUser) Email() string { + return su.HeaderString("email") +} + +// Series returns the series that this assertion is valid for. +func (su *SystemUser) Series() []string { + return su.series +} + +// Models returns the models that this assertion is valid for. +func (su *SystemUser) Models() []string { + return su.models +} + +// Serials returns the serials that this assertion is valid for. +func (su *SystemUser) Serials() []string { + return su.serials +} + +// Name returns the full name of the user (e.g. Random Guy). +func (su *SystemUser) Name() string { + return su.HeaderString("name") +} + +// Username returns the system user name that should be created (e.g. "foo"). +func (su *SystemUser) Username() string { + return su.HeaderString("username") +} + +// Password returns the crypt(3) compatible password for the user. +// Note that only ID: $6$ or stronger is supported (sha512crypt). +func (su *SystemUser) Password() string { + return su.HeaderString("password") +} + +// ForcePasswordChange returns true if the user needs to change the password +// after the first login. +func (su *SystemUser) ForcePasswordChange() bool { + return su.forcePasswordChange +} + +// SSHKeys returns the ssh keys for the user. +func (su *SystemUser) SSHKeys() []string { + return su.sshKeys +} + +// Since returns the time since the assertion is valid. +func (su *SystemUser) Since() time.Time { + return su.since +} + +// Until returns the time until the assertion is valid. +func (su *SystemUser) Until() time.Time { + return su.until +} + +// UserExpiration returns the expiration or validity duration of the user created. +// +// If no expiration was specified, this will return an zero time.Time structure. +// +// If expiration was set to 'until-expiration' then the .Until() time will be +// returned. +func (su *SystemUser) UserExpiration() time.Time { + if su.expiration == "until-expiration" { + return su.until + } + return time.Time{} +} + +// ValidAt returns whether the system-user is valid at 'when' time. +func (su *SystemUser) ValidAt(when time.Time) bool { + valid := when.After(su.since) || when.Equal(su.since) + if valid { + valid = when.Before(su.until) + } + return valid +} + +// Implement further consistency checks. +func (su *SystemUser) checkConsistency(db RODatabase, acck *AccountKey) error { + // Do the cross-checks when this assertion is actually used, + // i.e. in the create-user code. See also Model.checkConsitency + + return nil +} + +// expected interface is implemented +var _ consistencyChecker = (*SystemUser)(nil) + +type shadow struct { + ID string + Rounds string + Salt string + Hash string +} + +// crypt(3) compatible hashes have the forms: +// - $id$salt$hash +// - $id$rounds=N$salt$hash +func parseShadowLine(line string) (*shadow, error) { + l := strings.SplitN(line, "$", 5) + if len(l) != 4 && len(l) != 5 { + return nil, fmt.Errorf(`hashed password must be of the form "$integer-id$salt$hash", see crypt(3)`) + } + + // if rounds is the second field, the line must consist of 4 + if strings.HasPrefix(l[2], "rounds=") && len(l) == 4 { + return nil, fmt.Errorf(`missing hash field`) + } + + // shadow line without $rounds=N$ + if len(l) == 4 { + return &shadow{ + ID: l[1], + Salt: l[2], + Hash: l[3], + }, nil + } + // shadow line with rounds + return &shadow{ + ID: l[1], + Rounds: l[2], + Salt: l[3], + Hash: l[4], + }, nil +} + +// see crypt(3) for the legal chars +var isValidSaltAndHash = regexp.MustCompile(`^[a-zA-Z0-9./]+$`).MatchString + +func checkHashedPassword(headers map[string]interface{}, name string) (string, error) { + pw, err := checkOptionalString(headers, name) + if err != nil { + return "", err + } + // the pw string is optional, so just return if its empty + if pw == "" { + return "", nil + } + + // parse the shadow line + shd, err := parseShadowLine(pw) + if err != nil { + return "", fmt.Errorf(`%q header invalid: %s`, name, err) + } + + // and verify it + + // see crypt(3), ID 6 means SHA-512 (since glibc 2.7) + ID, err := strconv.Atoi(shd.ID) + if err != nil { + return "", fmt.Errorf(`%q header must start with "$integer-id$", got %q`, name, shd.ID) + } + // double check that we only allow modern hashes + if ID < 6 { + return "", fmt.Errorf("%q header only supports $id$ values of 6 (sha512crypt) or higher", name) + } + + // the $rounds=N$ part is optional + if strings.HasPrefix(shd.Rounds, "rounds=") { + rounds, err := strconv.Atoi(strings.SplitN(shd.Rounds, "=", 2)[1]) + if err != nil { + return "", fmt.Errorf("%q header has invalid number of rounds: %s", name, err) + } + if rounds < 5000 || rounds > 999999999 { + return "", fmt.Errorf("%q header rounds parameter out of bounds: %d", name, rounds) + } + } + + if !isValidSaltAndHash(shd.Salt) { + return "", fmt.Errorf("%q header has invalid chars in salt %q", name, shd.Salt) + } + if !isValidSaltAndHash(shd.Hash) { + return "", fmt.Errorf("%q header has invalid chars in hash %q", name, shd.Hash) + } + + return pw, nil +} + +func checkSystemUserPresence(assert assertionBase) (string, error) { + str, err := checkOptionalString(assert.headers, "user-presence") + if err != nil || str == "" { + return "", err + } + if assert.Format() < 2 { + return "", fmt.Errorf(`the "user-presence" header is only supported for format 2 or greater`) + } + + if str != "until-expiration" { + return "", fmt.Errorf(`invalid "user-presence" header, only explicit valid value is "until-expiration": %q`, str) + } + return str, nil +} + +func assembleSystemUser(assert assertionBase) (Assertion, error) { + // brand-id here can be different from authority-id, + // the code using the assertion must use the policy set + // by the model assertion system-user-authority header + email, err := checkNotEmptyString(assert.headers, "email") + if err != nil { + return nil, err + } + if _, err := mail.ParseAddress(email); err != nil { + return nil, fmt.Errorf(`"email" header must be a RFC 5322 compliant email address: %s`, err) + } + + series, err := checkStringList(assert.headers, "series") + if err != nil { + return nil, err + } + models, err := checkStringList(assert.headers, "models") + if err != nil { + return nil, err + } + serials, err := checkStringList(assert.headers, "serials") + if err != nil { + return nil, err + } + if len(serials) > 0 && assert.Format() < 1 { + return nil, fmt.Errorf(`the "serials" header is only supported for format 1 or greater`) + } + if len(serials) > 0 && len(models) != 1 { + return nil, fmt.Errorf(`in the presence of the "serials" header "models" must specify exactly one model`) + } + + if _, err := checkOptionalString(assert.headers, "name"); err != nil { + return nil, err + } + if _, err := checkStringMatches(assert.headers, "username", validSystemUserUsernames); err != nil { + return nil, err + } + password, err := checkHashedPassword(assert.headers, "password") + if err != nil { + return nil, err + } + forcePasswordChange, err := checkOptionalBool(assert.headers, "force-password-change") + if err != nil { + return nil, err + } + if forcePasswordChange && password == "" { + return nil, fmt.Errorf(`cannot use "force-password-change" with an empty "password"`) + } + + sshKeys, err := checkStringList(assert.headers, "ssh-keys") + if err != nil { + return nil, err + } + since, err := checkRFC3339Date(assert.headers, "since") + if err != nil { + return nil, err + } + until, err := checkRFC3339Date(assert.headers, "until") + if err != nil { + return nil, err + } + if until.Before(since) { + return nil, fmt.Errorf("'until' time cannot be before 'since' time") + } + expiration, err := checkSystemUserPresence(assert) + if err != nil { + return nil, err + } + + // "global" system-user assertion can only be valid for 1y + if len(models) == 0 && until.After(since.AddDate(1, 0, 0)) { + return nil, fmt.Errorf("'until' time cannot be more than 365 days in the future when no models are specified") + } + + return &SystemUser{ + assertionBase: assert, + series: series, + models: models, + serials: serials, + sshKeys: sshKeys, + since: since, + until: until, + expiration: expiration, + forcePasswordChange: forcePasswordChange, + }, nil +} + +func systemUserFormatAnalyze(headers map[string]interface{}, body []byte) (formatnum int, err error) { + formatnum = 0 + + serials, err := checkStringList(headers, "serials") + if err != nil { + return 0, err + } + if len(serials) > 0 { + formatnum = 1 + } + + presence, err := checkOptionalString(headers, "user-presence") + if err != nil { + return 0, err + } + if presence != "" { + formatnum = 2 + } + + return formatnum, nil +} diff --git a/asserts/system_user_test.go b/asserts/system_user_test.go new file mode 100644 index 00000000..f2c0df96 --- /dev/null +++ b/asserts/system_user_test.go @@ -0,0 +1,308 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts_test + +import ( + "fmt" + "strings" + "time" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/asserts" +) + +var ( + _ = Suite(&systemUserSuite{}) +) + +type systemUserSuite struct { + until time.Time + untilLine string + since time.Time + sinceLine string + userPresenceLine string + + formatLine string + modelsLine string + + systemUserStr string +} + +const systemUserExample = "type: system-user\n" + + "FORMATLINE\n" + + "authority-id: canonical\n" + + "brand-id: canonical\n" + + "email: foo@example.com\n" + + "series:\n" + + " - 16\n" + + "MODELSLINE\n" + + "name: Nice Guy\n" + + "username: guy\n" + + "password: $6$salt$hash\n" + + "ssh-keys:\n" + + " - ssh-rsa AAAABcdefg\n" + + "SINCELINE\n" + + "UNTILLINE\n" + + "USERVALIDFOR\n" + + "body-length: 0\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==" + +func (s *systemUserSuite) SetUpTest(c *C) { + s.since = time.Now().Truncate(time.Second) + s.sinceLine = fmt.Sprintf("since: %s\n", s.since.Format(time.RFC3339)) + s.until = time.Now().AddDate(0, 1, 0).Truncate(time.Second) + s.untilLine = fmt.Sprintf("until: %s\n", s.until.Format(time.RFC3339)) + s.modelsLine = "models:\n - frobinator\n" + s.formatLine = "format: 0\n" + s.userPresenceLine = "user-presence: \n" + s.systemUserStr = strings.Replace(systemUserExample, "UNTILLINE\n", s.untilLine, 1) + s.systemUserStr = strings.Replace(s.systemUserStr, "SINCELINE\n", s.sinceLine, 1) + s.systemUserStr = strings.Replace(s.systemUserStr, "MODELSLINE\n", s.modelsLine, 1) + s.systemUserStr = strings.Replace(s.systemUserStr, "FORMATLINE\n", s.formatLine, 1) + s.systemUserStr = strings.Replace(s.systemUserStr, "USERVALIDFOR\n", s.userPresenceLine, 1) +} + +func (s *systemUserSuite) TestDecodeOK(c *C) { + a, err := asserts.Decode([]byte(s.systemUserStr)) + c.Assert(err, IsNil) + c.Check(a.Type(), Equals, asserts.SystemUserType) + systemUser := a.(*asserts.SystemUser) + c.Check(systemUser.BrandID(), Equals, "canonical") + c.Check(systemUser.Email(), Equals, "foo@example.com") + c.Check(systemUser.Series(), DeepEquals, []string{"16"}) + c.Check(systemUser.Models(), DeepEquals, []string{"frobinator"}) + c.Check(systemUser.Name(), Equals, "Nice Guy") + c.Check(systemUser.Username(), Equals, "guy") + c.Check(systemUser.Password(), Equals, "$6$salt$hash") + c.Check(systemUser.SSHKeys(), DeepEquals, []string{"ssh-rsa AAAABcdefg"}) + c.Check(systemUser.Since().Equal(s.since), Equals, true) + c.Check(systemUser.Until().Equal(s.until), Equals, true) + c.Check(systemUser.UserExpiration().IsZero(), Equals, true) +} + +func (s *systemUserSuite) TestDecodePasswd(c *C) { + validTests := []struct{ original, valid string }{ + {"password: $6$salt$hash\n", "password: $6$rounds=9999$salt$hash\n"}, + {"password: $6$salt$hash\n", ""}, + } + for _, test := range validTests { + valid := strings.Replace(s.systemUserStr, test.original, test.valid, 1) + _, err := asserts.Decode([]byte(valid)) + c.Check(err, IsNil) + } +} + +func (s *systemUserSuite) TestDecodeForcePasswdChange(c *C) { + + old := "password: $6$salt$hash\n" + new := "password: $6$salt$hash\nforce-password-change: true\n" + + valid := strings.Replace(s.systemUserStr, old, new, 1) + a, err := asserts.Decode([]byte(valid)) + c.Check(err, IsNil) + systemUser := a.(*asserts.SystemUser) + c.Check(systemUser.ForcePasswordChange(), Equals, true) +} + +func (s *systemUserSuite) TestValidAt(c *C) { + a, err := asserts.Decode([]byte(s.systemUserStr)) + c.Assert(err, IsNil) + su := a.(*asserts.SystemUser) + + c.Check(su.ValidAt(su.Since()), Equals, true) + c.Check(su.ValidAt(su.Since().AddDate(0, 0, -1)), Equals, false) + c.Check(su.ValidAt(su.Since().AddDate(0, 0, 1)), Equals, true) + + c.Check(su.ValidAt(su.Until()), Equals, false) + c.Check(su.ValidAt(su.Until().AddDate(0, -1, 0)), Equals, true) + c.Check(su.ValidAt(su.Until().AddDate(0, 1, 0)), Equals, false) +} + +func (s *systemUserSuite) TestValidAtRevoked(c *C) { + // With since == until, i.e. system-user has been revoked. + revoked := strings.Replace(s.systemUserStr, s.sinceLine, fmt.Sprintf("since: %s\n", s.until.Format(time.RFC3339)), 1) + a, err := asserts.Decode([]byte(revoked)) + c.Assert(err, IsNil) + su := a.(*asserts.SystemUser) + + c.Check(su.ValidAt(su.Since()), Equals, false) + c.Check(su.ValidAt(su.Since().AddDate(0, 0, -1)), Equals, false) + c.Check(su.ValidAt(su.Since().AddDate(0, 0, 1)), Equals, false) + + c.Check(su.ValidAt(su.Until()), Equals, false) + c.Check(su.ValidAt(su.Until().AddDate(0, -1, 0)), Equals, false) + c.Check(su.ValidAt(su.Until().AddDate(0, 1, 0)), Equals, false) +} + +const ( + systemUserErrPrefix = "assertion system-user: " +) + +func (s *systemUserSuite) TestDecodeInvalid(c *C) { + invalidTests := []struct{ original, invalid, expectedErr string }{ + {"brand-id: canonical\n", "", `"brand-id" header is mandatory`}, + {"brand-id: canonical\n", "brand-id: \n", `"brand-id" header should not be empty`}, + {"email: foo@example.com\n", "", `"email" header is mandatory`}, + {"email: foo@example.com\n", "email: \n", `"email" header should not be empty`}, + {"email: foo@example.com\n", "email: \n", `"email" header must be a RFC 5322 compliant email address: mail: missing @ in addr-spec`}, + {"email: foo@example.com\n", "email: no-mail\n", `"email" header must be a RFC 5322 compliant email address:.*`}, + {"series:\n - 16\n", "series: \n", `"series" header must be a list of strings`}, + {"series:\n - 16\n", "series: something\n", `"series" header must be a list of strings`}, + {"models:\n - frobinator\n", "models: \n", `"models" header must be a list of strings`}, + {"models:\n - frobinator\n", "models: something\n", `"models" header must be a list of strings`}, + {"ssh-keys:\n - ssh-rsa AAAABcdefg\n", "ssh-keys: \n", `"ssh-keys" header must be a list of strings`}, + {"ssh-keys:\n - ssh-rsa AAAABcdefg\n", "ssh-keys: something\n", `"ssh-keys" header must be a list of strings`}, + {"name: Nice Guy\n", "name:\n - foo\n", `"name" header must be a string`}, + {"username: guy\n", "username:\n - foo\n", `"username" header must be a string`}, + {"username: guy\n", "username: bäää\n", `"username" header contains invalid characters: "bäää"`}, + {"username: guy\n", "", `"username" header is mandatory`}, + {"password: $6$salt$hash\n", "password:\n - foo\n", `"password" header must be a string`}, + {"password: $6$salt$hash\n", "password: cleartext\n", `"password" header invalid: hashed password must be of the form "\$integer-id\$salt\$hash", see crypt\(3\)`}, + {"password: $6$salt$hash\n", "password: $ni!$salt$hash\n", `"password" header must start with "\$integer-id\$", got "ni!"`}, + {"password: $6$salt$hash\n", "password: $3$salt$hash\n", `"password" header only supports \$id\$ values of 6 \(sha512crypt\) or higher`}, + {"password: $6$salt$hash\n", "password: $7$invalid-salt$hash\n", `"password" header has invalid chars in salt "invalid-salt"`}, + {"password: $6$salt$hash\n", "password: $8$salt$invalid-hash\n", `"password" header has invalid chars in hash "invalid-hash"`}, + {"password: $6$salt$hash\n", "password: $8$rounds=9999$hash\n", `"password" header invalid: missing hash field`}, + {"password: $6$salt$hash\n", "password: $8$rounds=xxx$salt$hash\n", `"password" header has invalid number of rounds:.*`}, + {"password: $6$salt$hash\n", "password: $8$rounds=1$salt$hash\n", `"password" header rounds parameter out of bounds: 1`}, + {"password: $6$salt$hash\n", "password: $8$rounds=1999999999$salt$hash\n", `"password" header rounds parameter out of bounds: 1999999999`}, + {"password: $6$salt$hash\n", "force-password-change: true\n", `cannot use "force-password-change" with an empty "password"`}, + {"password: $6$salt$hash\n", "password: $6$salt$hash\nforce-password-change: xxx\n", `"force-password-change" header must be 'true' or 'false'`}, + {s.sinceLine, "since: \n", `"since" header should not be empty`}, + {s.sinceLine, "since: 12:30\n", `"since" header is not a RFC3339 date: .*`}, + {s.untilLine, "until: \n", `"until" header should not be empty`}, + {s.untilLine, "until: 12:30\n", `"until" header is not a RFC3339 date: .*`}, + {s.untilLine, "until: 1002-11-01T22:08:41+00:00\n", `'until' time cannot be before 'since' time`}, + {s.modelsLine, s.modelsLine + "serials: \n", `"serials" header must be a list of strings`}, + {s.modelsLine, s.modelsLine + "serials: something\n", `"serials" header must be a list of strings`}, + {s.modelsLine, s.modelsLine + "serials:\n - 7c7f435d-ed28-4281-bd77-e271e0846904\n", `the "serials" header is only supported for format 1 or greater`}, + {s.userPresenceLine, "user-presence: until-expiration\n", `the "user-presence" header is only supported for format 2 or greater`}, + } + + for _, test := range invalidTests { + invalid := strings.Replace(s.systemUserStr, test.original, test.invalid, 1) + _, err := asserts.Decode([]byte(invalid)) + c.Check(err, ErrorMatches, systemUserErrPrefix+test.expectedErr) + } +} + +func (s *systemUserSuite) TestUntilNoModels(c *C) { + // no models is good for <1y + su := strings.Replace(s.systemUserStr, s.modelsLine, "", -1) + _, err := asserts.Decode([]byte(su)) + c.Check(err, IsNil) + + // but invalid for more than one year + oneYearPlusOne := time.Now().AddDate(1, 0, 1).Truncate(time.Second) + su = strings.Replace(su, s.untilLine, fmt.Sprintf("until: %s\n", oneYearPlusOne.Format(time.RFC3339)), -1) + _, err = asserts.Decode([]byte(su)) + c.Check(err, ErrorMatches, systemUserErrPrefix+"'until' time cannot be more than 365 days in the future when no models are specified") +} + +func (s *systemUserSuite) TestUntilWithModels(c *C) { + // with models it can be valid forever + oneYearPlusOne := time.Now().AddDate(10, 0, 1).Truncate(time.Second) + su := strings.Replace(s.systemUserStr, s.untilLine, fmt.Sprintf("until: %s\n", oneYearPlusOne.Format(time.RFC3339)), -1) + _, err := asserts.Decode([]byte(su)) + c.Check(err, IsNil) +} + +// The following tests deal with "format: 1" which adds support for +// tying system-user assertions to device serials. + +var serialsLine = "serials:\n - 7c7f435d-ed28-4281-bd77-e271e0846904\n" + +func (s *systemUserSuite) TestDecodeInvalidFormat1Serials(c *C) { + s.systemUserStr = strings.Replace(s.systemUserStr, s.formatLine, "format: 1\n", 1) + serialWithMultipleModels := "models:\n - m1\n - m2\n" + serialsLine + + invalidTests := []struct{ original, invalid, expectedErr string }{ + {s.modelsLine, serialWithMultipleModels, `in the presence of the "serials" header "models" must specify exactly one model`}, + } + for _, test := range invalidTests { + invalid := strings.Replace(s.systemUserStr, test.original, test.invalid, 1) + _, err := asserts.Decode([]byte(invalid)) + c.Check(err, ErrorMatches, systemUserErrPrefix+test.expectedErr) + } +} + +func (s *systemUserSuite) TestDecodeOKFormat1Serials(c *C) { + s.systemUserStr = strings.Replace(s.systemUserStr, s.formatLine, "format: 1\n", 1) + + s.systemUserStr = strings.Replace(s.systemUserStr, s.modelsLine, s.modelsLine+serialsLine, 1) + a, err := asserts.Decode([]byte(s.systemUserStr)) + c.Assert(err, IsNil) + c.Check(a.Type(), Equals, asserts.SystemUserType) + systemUser := a.(*asserts.SystemUser) + // just for double checking, already covered by "format: 0" tests + c.Check(systemUser.BrandID(), Equals, "canonical") + // new in "format: 1" + c.Check(systemUser.Serials(), DeepEquals, []string{"7c7f435d-ed28-4281-bd77-e271e0846904"}) + +} + +func (s *systemUserSuite) TestDecodeInvalidFormat2UserPresence(c *C) { + s.systemUserStr = strings.Replace(s.systemUserStr, s.formatLine, "format: 2\n", 1) + + invalidTests := []struct{ original, invalid, expectedErr string }{ + {s.userPresenceLine, "user-presence: tomorrow\n", `invalid "user-presence" header, only explicit valid value is "until-expiration": "tomorrow"`}, + {s.userPresenceLine, "user-presence: 0\n", `invalid "user-presence" header, only explicit valid value is "until-expiration": "0"`}, + } + for _, test := range invalidTests { + invalid := strings.Replace(s.systemUserStr, test.original, test.invalid, 1) + _, err := asserts.Decode([]byte(invalid)) + c.Check(err, ErrorMatches, systemUserErrPrefix+test.expectedErr) + } +} + +func (s *systemUserSuite) TestDecodeOKFormat2UserPresence(c *C) { + s.systemUserStr = strings.Replace(s.systemUserStr, s.formatLine, "format: 2\n", 1) + + s.systemUserStr = strings.Replace(s.systemUserStr, s.userPresenceLine, "user-presence: until-expiration\n", 1) + a, err := asserts.Decode([]byte(s.systemUserStr)) + c.Assert(err, IsNil) + c.Check(a.Type(), Equals, asserts.SystemUserType) + systemUser := a.(*asserts.SystemUser) + // new in "format: 2" + c.Check(systemUser.UserExpiration().Equal(systemUser.Until()), Equals, true) +} + +func (s *systemUserSuite) TestSuggestedFormat(c *C) { + fmtnum, err := asserts.SuggestFormat(asserts.SystemUserType, nil, nil) + c.Assert(err, IsNil) + c.Check(fmtnum, Equals, 0) + + headers := map[string]interface{}{ + "serials": []interface{}{"serialserial"}, + } + fmtnum, err = asserts.SuggestFormat(asserts.SystemUserType, headers, nil) + c.Assert(err, IsNil) + c.Check(fmtnum, Equals, 1) + + headers = map[string]interface{}{ + "user-presence": "until-expiration", + } + fmtnum, err = asserts.SuggestFormat(asserts.SystemUserType, headers, nil) + c.Assert(err, IsNil) + c.Check(fmtnum, Equals, 2) +} diff --git a/asserts/systestkeys/trusted.go b/asserts/systestkeys/trusted.go new file mode 100644 index 00000000..45e3e769 --- /dev/null +++ b/asserts/systestkeys/trusted.go @@ -0,0 +1,347 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +// Package systestkeys defines trusted assertions and keys to use in tests. +package systestkeys + +import ( + "fmt" + + "github.com/snapcore/snapd/asserts" +) + +const ( + TestRootPrivKey = `-----BEGIN PGP PRIVATE KEY BLOCK----- +Version: GnuPG v1 + +lQcYBAAAAAEBEADx0Loc/418zmw2AIcf5uxC/hgshHyCU98n4cRfJph007X6gXJf +ifHsKlXlSa5NizsM9WlOgCI3eyekF088q7lQTORDo4YO5x/ZtmcAiePtbMrAac4D +9j+5Ax24jJ4VniYudQ1wX4x7wtXRpL+lCER0FS5HEQ6L3OW/SntfVtSzoshRO5u7 +r6yYW1t0EE04P7Squ+N/sK+xJytOxCzC2/BwugHgZf3jArpFCuWSZgk9QVmqR1a3 +tynSKrx35OzxSdPyyBa4XOQwKAEquK1Lv/njmYTwATR+zIUa3n7SNyOCz0sOTmBE +7sSCgUtc+wQF2It1Wazs4YDA8YbTTB8VgveGjg8J8qr6YfSQ6BQDKeUnvHwwJH3Z +5YSL/KUdeI7SOdFjxSy62szvp4s3jWJSVr/qPkNyxfFAH/HOViRR21e1iufov8NO +yeLFyW7eiA/OU8QXJXG/S9YiCQotZePYlFG3a6p7crfdO90XQf6bqydlNK2ftVje +J/1+/LHXj60qHXq5x1BrXPMmhMpOphZf0H5l8Q0YolSeFM/THsKbqWDcRQZrL9vm +GwDgMGipKG5/83SNUuiN2HGLcKT8ME2WoIPTPLi7O+KeNf5vhrL4soETc3XkCx8S +RYjDMj7U50OU5Zao7EmQzqWtDmFFDV8dmgKIaMduN4TVEgU7ZMDDa2nJRwARAQAB +AA/+PAQDZRYR/iNXXRHFd6f/BGN/CXF6W3hIfuP8MmdoWDqBRGKjSc35UpVxSx59 +2bYQGlfAYqDPnTh+Lq4wVs0CCcmDr7vilklLsOOh7dLLVI53RckcvgP8bcU1t6uC +wrfFHyujAbxdKAxDuCvs+p8yKiNloHK9yv2wscjhFNj+onToxayHKs5fhlLKQGSZ +XbgF9Yf7XyIxgMTJbVuoBlbC9p9bvt9hY1m2dFNPhgW4DlFtWSMqhR87DHPZ4eHZ +4srhhTSe2vQHGGKdY4aBUDcd5JyiD1UlO8Ez2ebV0AOqVxlutebC4ujlscQ4OaP9 +LBxCBIaUshgHthtbzI5sepDOMMYJKV0R0+gtW6+rrVaudeSdt62yLF6a8n5m41dP +6OxGmO84ejoyw/EMutrVeraoz2b5bb35gx9bLEMRFr8XL2x1Ckdx2epNTL9aOVmA +JiCMGC0zFyt/jbNXnoOjD8tzUj44jrJnY2PcnJHgDogXMoIRduPDnwYaQtXkffkW +zsVbdUHvMkZuKXUBfsxCwFYgGm2i9y0dGnTSzI03TevRJ1FM2+TN8uQ8h4/C0xfZ +snXgvVHAwAOJwE8onul8AiepE1ihSWmaQfq/2Hn+0u+wbIsdrpP9xKB88KvZtgVe +mXj1vbDHw1nbORH63vgzfT8tyIhvR1RfDutQoGKkrZ4ZCIkIAPgDABPYucbnUpv/ +e2OSKd+Z/RGwUqghtp6recs3+9IdIoz/XPQHr9eqmgMUSikRFHLD6s0unIUm1b5s +Q+98OvadsP0D5EaKjAo0Za2PQVi8Na3eoGDs+DpX2+lhq5lvYCezGNoo50awKhzs +vRE4RU91bohfNvfJ9bY0AwyrYHDg67Jl/JzWtPNBqfAMlRW5WM9NYvp+Brk8JJLU ++Ncf5w//7S4lH5qBf3rXk6ur8ittIq28MGalW7T8Uk2F7VkrvCDaKkWPP8jwux79 +u1F22ADPYbdHB2RUSv0FGPrOItUyl81V6qTpAqO8iYQVol+B0J95B7Z0DLa+QecH +vVfaVS8IAPmaokwf3mk36dmbHvDIaPjloD1Gw3PCPZ+dpmGLfvcPm4YcA/uTzbNV +E46QlTZCny8+5W4xDaetpdODXRvCciwnjJ/wcdpSaMe0R5Res8weIcV2RAM9UNNb +q6BiTDqyBwk/dmFYY71xus/tuAnxmhZnXrJYjcA1CEsO+cu3SkwYM6dp3d1W0Bfh +li4b6eT3bC7IRD+KW+3Vdti8bShoLUkK2UwXHhnz0yBBE+8vQc8PoxOwt29EcQDf +GGL1Tz31yxRF+EADH4SL5ypUZFUctLkJ76WP9vNHqx5Tzrbt2aHqqbtvkxfzcB/m +k6cm8XzLVxttNHvZkvjwtvl76+X8d2kH/34hjWibosJueZb7HoFuJIoXXtPJ+sY5 +MSnY9+uGW4FgzgyUjWd5bfBCcCOGIqJFj37YVJwPKXaXBr0CzgaeJfLNRqz9Mt6d +OyqYLdb4ojvFSvhfN7bjAiBbwTbGVsOVVKgiNYudWH5lBS9yqxKyDQeUmwSmgaWa +Y1zMmK7J/syCqMBlizox3NIjGUsV7JGHzatSGksblTdTHTts3D52yTphonZueYVz +f27546ta7Fk9uEts8XVrs8YiJgZw8DHEugmuD5ZFb5WrpF96jqpaAuEhUye0fkfA +GvRP9FpVShfxVockrCrLgCaaDs+/kg7cZS+PDU8uLlXnsKqXvkkH7ip/irQOICh0 +ZXN0cm9vdG9yZymJAjgEEwECACIFAgAAAAECGy8GCwkIBwMCBhUIAgkKCwQWAgMB +Ah4BAheAAAoJEExxmnn3gXGkIyAQAMmpCPsk3FjfH2wHMxDozPZJmgoPwFBj4VEi +Qg4pp1pWtTHWPm7qN2bUL0WaJkvdPvvana7T5iGSlQHAjQRgPQfS42+0Nz17AInR +QbpovdE3S/02UOWaF+VgFrF7IKHQhbxbfmjPBQAr/9mWfe/JGyUqlc14a8IwxOmf +k4qf3WVj48NI6PdtMYpBKtSpghc7rKQwFLyxEauoBtoF6VLyhha7TFBGGM3LJ5uU +SPr8oVCybkZ9xbWdfcodbe3Ix/gbG1rvX7Jp/pIlG+7DVKn/0xkR7zPPfDmZOBGd +VFdg9X8L9+QH00Rverp0cCZ+fN97W13/Mb2/E9Px0y86Omwyhg5SVbikemmybrK8 +JHelbZ2NMmN7YHq2TB1idii30aX/1PN9jGyHHFMWPj2BJmK2aWhN0QSX8sxCoS9O +NCXwYU5hfRX5RjyWnI51XDhhfpMikqXnLrxzmPme4htaIqMl332MiqusFZ0D6UVw +Br2jeRhncvRrsscvAibbUWgbN6u70xBGjZZksvT8vkBipkikXWJ8SPm5DBfbRe85 +NnAkj2flf8ZFtNwrCy93JPVqY7j4Ip5AHUqhlUhYyPEMlcPEiNIhqZFUZvMYAIRL +68Hgqm/HlvtVLR/P7H6mDd7XhVFT5Qxz3f+AD+hmQFf8NN4MDbhCxjkUBsq+eyGG +97WP6Yv2 +=gJ0v +-----END PGP PRIVATE KEY BLOCK----- +` + + encodedTestRootAccount = `type: account +authority-id: testrootorg +account-id: testrootorg +display-name: Testrootorg +timestamp: 2018-06-27T14:25:40+02:00 +username: testrootorg +validation: verified +sign-key-sha3-384: hIedp1AvrWlcDI4uS_qjoFLzjKl5enu4G2FYJpgB3Pj-tUzGlTQBxMBsBmi-tnJR + +AcLBUgQAAQoABgUCWzOCRAAA31gQAE5QgyBuxF3DGlMP32+3G5soq0uDLKG+sqFIEj/8j1dwLG0u +ut7UPEf5iTZFDqqyAaFBRUPxx1cGB/6WFrks3X3/325hVzv5DYA9d4508BXdlNBA++t7tTdb4rU6 +G57aVgbpMCdwdjRbRMv1LLVWnli1pj8Cvt/jiTMbJUwQ/CAO0UZA6EH+fAeHGB53NNvedAM1goWi +pS3XvruDtv8qTbVW9jNSIX1ADcLAbmM2xV2Vo54lfgN5NJd/4K4S7sPSsX7QLBghFkB0m9i5g/Qu +PecvJ9njebFF48yvG4W1owBNBfxD2oHNhK/GdtxsREDKgDXuIrhziXBzWNeYto8lCZ1D520k+xp+ +2rL1TYSy9IixOzAf2qBhUTQdXsoVfmBOyExlYVQDIFO+X4ufbhLzy2pTE4KWvFvF58HzGradbix6 +oUD5hiEjw1YoV8FKdMLDobcvGzgm+Kx/FQo2Iqm5GmfzPW/K3SntoptuHIDSk3B12F/F/EDoYiGS +MDWJJ4NMbFLMemJhvEI23IuZOTBEt27sGcgOju4wYkcsaHPEeXTGUBUgQADugBTwJtWmuybkmovM +aLn1kVYpht+0cZeAQR5L3nSOK7T3V+QvWSXt6PAiHJv+HnemrYarSmGVTDcpj1QuWXyX026RnkvP +SD73HCe5QPTjrvFvIa6o6n9khFgs +` + + TestRootKeyID = "hIedp1AvrWlcDI4uS_qjoFLzjKl5enu4G2FYJpgB3Pj-tUzGlTQBxMBsBmi-tnJR" + + encodedTestRootAccountKey = `type: account-key +authority-id: testrootorg +public-key-sha3-384: hIedp1AvrWlcDI4uS_qjoFLzjKl5enu4G2FYJpgB3Pj-tUzGlTQBxMBsBmi-tnJR +account-id: testrootorg +name: test-root +since: 2016-08-11T18:30:57+02:00 +body-length: 717 +sign-key-sha3-384: hIedp1AvrWlcDI4uS_qjoFLzjKl5enu4G2FYJpgB3Pj-tUzGlTQBxMBsBmi-tnJR + +AcbBTQRWhcGAARAA8dC6HP+NfM5sNgCHH+bsQv4YLIR8glPfJ+HEXyaYdNO1+oFyX4nx7CpV5Umu +TYs7DPVpToAiN3snpBdPPKu5UEzkQ6OGDucf2bZnAInj7WzKwGnOA/Y/uQMduIyeFZ4mLnUNcF+M +e8LV0aS/pQhEdBUuRxEOi9zlv0p7X1bUs6LIUTubu6+smFtbdBBNOD+0qrvjf7CvsScrTsQswtvw +cLoB4GX94wK6RQrlkmYJPUFZqkdWt7cp0iq8d+Ts8UnT8sgWuFzkMCgBKritS7/545mE8AE0fsyF +Gt5+0jcjgs9LDk5gRO7EgoFLXPsEBdiLdVms7OGAwPGG00wfFYL3ho4PCfKq+mH0kOgUAynlJ7x8 +MCR92eWEi/ylHXiO0jnRY8UsutrM76eLN41iUla/6j5DcsXxQB/xzlYkUdtXtYrn6L/DTsnixclu +3ogPzlPEFyVxv0vWIgkKLWXj2JRRt2uqe3K33TvdF0H+m6snZTStn7VY3if9fvyx14+tKh16ucdQ +a1zzJoTKTqYWX9B+ZfENGKJUnhTP0x7Cm6lg3EUGay/b5hsA4DBoqShuf/N0jVLojdhxi3Ck/DBN +lqCD0zy4uzvinjX+b4ay+LKBE3N15AsfEkWIwzI+1OdDlOWWqOxJkM6lrQ5hRQ1fHZoCiGjHbjeE +1RIFO2TAw2tpyUcAEQEAAQ== + +AcLBXAQAAQoABgUCV8656QAKCRBMcZp594FxpNWlEADQgBlROdBTHpdZ3/9BbasxenUC3VXusMeK +0DmnsHrsAsyVk6xiHQQ3hWxvXKWoDkDsOhUqcQTsDBcIaZ18+qwpQciyItd+w3d7SSJ+MKSUpwsB +NOdgw1ykj7l1M/W7xAAPscFoV1xVSk9+rsLYFYDe23R+ecyotSmF+4QHj5b+hXeVIOUaqQTl5xPC +h0zVYNIUWv42q4Z+hiBS8+8UJ0G+7z/27XORkGHY6TXCt0aph7s5egr8Lm+/jq7c95HVsa7DwSpv +SqPajRnlyLiHFXUYAUPEU9oDgPwtLsqUkFfrv1WZ3ja1rDexgKBta+8BRyCAq3gPcMAjhiHXdjoW +90p893l9N6K82RiEOO9ic0pEezjQldg97oU+ajXNm3ryns+HX6hRd39rpzIsrbVdbCqun4RwMbCM +EVxgC/cuxMGcS40Co3O8wG3H/WIWOqcRQfolQTexmyzQljYt9WyWJdXmtPtaMzQGbOqE/dIjOK9j +xvrghVU4kX6fJFwPi+azMrluHV+WGSVxPCuLW8o2aipjOd1/bUQCL5OwRuaEWuLCiV01J8H/JjWV +hL4gGVqEM2KEPIDwY2yqX36jE7uN9O+mIPnS4Tdj0JQ5ZD1qh34wv+4QvhgNeyP120nuS1ykO9X0 +A806uPC5QK1+cgRMUz8zJ0afDNwE/DvpBQvE5CIi9A== +` + + TestStorePrivKey = `-----BEGIN PGP PRIVATE KEY BLOCK----- +Version: GnuPG v1 + +lQcYBAAAAAEBEACYmqZm+xLnwg1Oz5RD6N+jzfq8FLm2RT+GTtzSG5l7dKjaBz2R +om+OSOFnqDTT+QaiJ3DeLZaR0wSn4m29T1m196782f86qRJzcCnUoCaovg6WU9Ug +jwfr3DbOq+aj49yofRK8cBUSg4LZOhc/TAQecBmxtW7noAqvCkcOmk8Qi9pLqCWu +wRfUBek54wdktVG1+wEHp2Ute66VrVStIAtEUISNe2peo62jlWj0LynreUsHLX2J +/Pg6uJYAYGpm8V0i2ajxUg9dIN2AwwcGW7YxI0kdV+jrrKlu6izlCzo+VUBEAIsm +DOCmUjmwNvNe1XHk71DxgmPPg19TRY5Zg9a+YA1cN4w2LFaha+6LFi+xdobHqZ0P +seH+CLymuRCZnuDFbUwQ5X0lOECpiOOzZrIZUPvcQjawpjFXASDeIlOhD9wTPc7Z +TUd2ZiNB9EMmJfcYQ8Fde20Ots8zjZIcSWi6V2Yn4+QkMt2QaYDznFhSgQod0QUi +SMVK1BzI7kKTI1k3tIeIAjADgOkYyYUnbqZqpXMm6Iu+JyuLYVH+wlpIDbg3wdsa +d7eBJLtatJBL6Mp7chk9XLrg0Kga+taj8e9N6qwh+KEo8SlebxBW2M2G2RWfdF0h +SA5o1bIB+dnh1bVNUgBN744cPDZM3IiZOMTTHvmcvoHX9Guf71U/1LCG/wARAQAB +AA//R+eWwK9NGSa2XowwsE7qEaTcoAKj/t5iMEa4hce7ahBt/02qFRUUu1Zb3xvC +yJ5uIbmz1PxmFg/4AaMPUkQxYSxzp3CQcnN33izbiPRtQtVKykp2AgFjGh+JM5iL +9G1Ja5qDWYb2ZuLQpMpaadjHmA/6C2IR/9HJNvEAykCrQIClO0DfgJg7QgwG+N+g +fDNzbOv4cELPyb6dZKlnXKvcozPNQV0FodI93vZnnacbeXiNgbRNktc/n2uaQlMr +z5Wq7ODiWdLwqlDyDdnXVYehMUYPDWR+u41/yGNPBB1mNDi3L1OSPTuUHspfpEhA +JE8ue1DIMwPdQ8oDAJmlmUglxpP1dnR3Q3XqUbsJMT6kAdqc4OSXF+L+E9j7EiA1 +UaXiiK+srj/GWFFdKlSf1JLYX3kOvrH/M1xMB6cmUshuWDfiJUGz9rPhPOIAvK11 ++Gog6kV+0JJXBe7oWEf8oewONLg7KtU1sSlHeuECpR+Pi652wXnAMeeHFjeCirp+ +jRPla+oKhrYMfLxk+x2YgMK4usoY6Q/KNTcHNs/FeRpzt50OFIaRbKL/I/CY1pB8 +oakl45D0+c38+6MZVkbPwDRN5ixUJfHwSBwl5qFyF3abP/N0gJVsdfPO0QyDbihm +1yo5Tvihd7aUkfTAF+E2BkZLIfuY5kREENxY/EHceST20gEIAMOjPOwYkN+V25o+ +MSIj9EBq9xEMpddHilpVXNkRHF2i89CFCUCKcIGe7wROvrqxQSqVrEDET4ZU6iqB +zsaA5RD4Fia3+eoZjvy4563H54XX2Wp89Qs2T0PREems5UMoeho/kCzSKdnYhhll +kbekWEqZAOzyCaBjzu7YowjrcUuceUbiDSsh6ds4/goS4h1AO/oroYawZQhvUfaf +W7ExpOsxuFa7S4N7mLywpeGaWcOuZt3r/EfM4gHpJaEntgqhjfiEtEkfO4dGKiAU ++hg+LmVPyBjQnVhK5NXSBc/zXaXOWqrVEkqTEQcZ5WsmpcB9hzqZIaFw9cAF4PKh +xm1ZOnkIAMewViBcogHUEzzn9ZxTXKi45po45g5qxsoifNlN3ZfShdrxOjXjYos2 +UujGfN+gZN8vV4bnD3Q6CbpioBT7lTZhweZVRwx/eQa/yQv20ZewL/CJduME8DZj +rQtyy4MRBhaNf3A8Gvx/CXJZaIHYfldRJYIrq9OuK4ael3Zf0uZwm9AleT5baFz8 +T8iRlojlzhT2+xi+Y/yLCCYFESkxgdXPkhUfYkh/O5NPWxSXnohDgKAtKj4gDe2c +Qs/zUI5Q+p8qucWbcbASZurDthTD80G6zGYNWX0e/6k45k/tatf0zJGLZVww02uc +Kq6MVafir1FzkOPxq41zmie8zPTe7zcIAL4m/lnWww+jPxM+LffdtgDqOeRxjgo6 +MV3576MqUakeIGVfnlW7SJCyjN2mnf0JbzrVgv7XxEcZIJrIePutMqdKm1YAt2YR +1TuU/rsKpUQt+d8t9rWfCYd1xeSn6IdNtoBaMeu6vI13pV1dghPAnQyovUK0xzI6 +seLeVhTU3wG9zZHJBycyE8PDTqE3awEetYLGFkz6DruIjYwylYRPZwSC1xpPcirf +nkSAeE2U9nmnxDWUQNhWzFTazYr7QQAUzghX3Mf2ZYeoDBBqDg9lQMy2oUJrJtfv +vqmejP39c3+fJiXlT2k2o0V6B8aZTNVaRn00E3hE+e1Obaa1lV1EWxaDcrQUICh0 +ZXN0cm9vdG9yZyBzdG9yZSmJAjgEEwECACIFAgAAAAECGy8GCwkIBwMCBhUIAgkK +CwQWAgMBAh4BAheAAAoJEN2glF+93m+NRIEP/2AxZS9tmJ6l7oltpYTEhAQdytAE +eqahcBYIARSTgvy3YJlOzdKdIoYsGogVvNZ7ashaFCpQtNaNezI7Mhz5cuVoHyYl +hEctEXSeTNUmxNekdksoBm2QHfnxFHbKLV4Kvj7dlvMhNVbpaMe/qI1SykddGBvh +woEp2HnHe3lGhlU84+XopEijphI8BXQ2so8bA0jEcuDJOAEXtVzj14miP6nZCsDD +EKHriukohhCQQUZVm0VOKLfdoi4QuAWbehBmlrhcvRDLvcr6p7jY00803jvaGBjD +XmS0DT51tNg6W2COQ5xlM9+hjK5n6nyZdT/OYeu+TqtdnpHcZxsF7qKsUBbKeQtA +Abh0wqtD58Kqp9UTovMVho/+/VEH9+gpfpvrieQvjrpZki2ZVnEhqlINOVwCYH0j +wC5qKcFeUmHHGhE1ShMKypZvLgqfc0soK8vaz+njN4IYrsWaI0iCQmr6FfV7Q8Ih +XAcSt/73baWnQsiBWWgl+FOxChDfwEWZaGFgtzyjexLpbi1V+Usuwd0+pX3U/+A6 +uXw5t77PXE4nW73a8EDM2nkG5ru+KswmOC0G7ULB2Cs9UOWqN+XChdii+VC68MMK +O0gyQlMQf+OPtU18Nff7hfKGY1ZCUbCwvb/+bHBvzpjmtWEuIOwPC0CBgU9G9FcX +o7ZSZ/h/bUY1EjE2 +=Nc2M +-----END PGP PRIVATE KEY BLOCK----- +` + + TestRepairRootPrivKey = `-----BEGIN PGP PRIVATE KEY----- + +xcZYBFaFwYABEACqlUwW5sMvifyx1pEz5z/KbUeH8dOhZFS89D1WEYBFre0FlfTJ +4pkhZuq21dSCSvcUfHrP69+JrztndLkTojrRf6FIpXpGZLa443X2s8mLQpnRMYYB +8XSt+TdZilVb4zbyyYw+plSMvyygNh4/4tGuo8IHK8jYK+ABs2RAZk1kmhg+wShb +V6QN1V7D2e83UxGQyKH6Er9MyTvSMaLGxtE0Q6D2dD0d0PC/R92UX2gVN/PwZ8ga +0yU9sWD38b8BuJieceGjkkOCkn6nKMCSgL0K207cVRziaAQRj7pLC4kfnjyA+DqD +BLUBOLr6XyE3FQJRCUi9pMdG9DT36QJ9u74HtGod/TK4kQfvgfR+qD7SXuD9HBTU +i/jKWQqw73DxiMCEbMt2gA5nSP0TAOlGveNSP+wTuxjjvrQLp1wJ7yYLzee5qJEy +/NK1RzK1pr4qqnAV6nHNm15EzYnMIDSH0b3IXTyIA2fOA9IGgjpi+zE+Rco0lTw+ +s4Ie+1mORMYlHVvWyDpj0C8YZMuxf4P8oc+jHVNW4lC6UjeWcHbM3B/31zfjwpIV +Kh5++WFtbDEsSvNzaiC3QvZ/slxjiru9x6ChdQugPWm/LPONWWNPM15Di2OuU0ob +WxWvEmZ6QyPOAS0m+5OWMuPE8HRAOp5XI/afklQo/eLwnwkf1eVP6Cm/jQARAQAB +ABAApuMbpwgrC3ZvX7lxI5tpYGzbX5fqmWokMRyuaWcD3KfVTPKxo1XqxLAAj9HR +b4tSAZvrN0In13c1ofijHR9Jdi7sprsmTno3/dijTzIDyxfkjrJpzbrhkVdRnGtn +KVe5KXyflad67pwWV8O6gnww8i/KIuPmQf9iz6cnPI4Zx4Oulq65AexTVylZ5jhv +/etqMwDm31a6C7CQswrWmqxmfkBv2M5OAL6q2ijAEmno1WGBacDPF9ddBudj3A+J +9HAZ+GGoBDSTkcoq2PVYubvztwxqMcufT6291USNWOA3TlSsEu1HqWjQgRp0a4Do +aRBHzOpNXSQ5xiQjMiunwUUNGrmU1n5G2aDS7bqxPqlJtd6zUkO+8roz4I37HsTU +4XF8ju9PCyydUr/fPtZWCq4zRN63acSIwL9YH+mVOc9v0/9d1fCPNsAzic6ls5sN +RuLYwGR0AUgIVd34sDfO1ObJOMp+kUsBIJaylzNFT/4JVwBTEb8soVOKWxXrdVLS +0nf1A6Y9tuc7uWqwPKaelIUETET4+Slk4VgzpsHO2VEHF5KlYWi22EsNsqcGhyEH +Qh1q8AZaEqHKlbmpshiBNfiZfn0q4KSKNz3tPOyaJ4uTa9aa1SJbmFzHZuzDZQjJ +wkVakLVlVYs1elJhsFpeTbvZsB6qwMf4k3+Ub2jrLFUvcQEIAMvUFVtaoEdAEzBe +86xg+eibkWsF8P3xppu2yNlPBRNXoQRDU+dqmWb8GoKTj2W1OxA8ao7N7Yah3EGf +PEiPnp9+/wCLU6f/bjU/JJwsKeCBRIqdO261cHjrFJfXJi6y5V0EqiBYF5+mrsvi +dIggd2ZMg+QIDi/SG6avY6Zn+PrCV2a6GA4W9ZUzqQ8/grdBpHDcSqcV/5w2BxIZ +uMB5AZDcycb44pQO32NsjQJsaMQXdhfMSIftFgKeEdqx6Gn/hzXednrvJqiCcuH7 +nW7YH5Pdpvrab3hbU3QY14XrzVyri2nqGWhPm5k+TR+0w/Q6v3uKDXF3IaxOtm7x +EPBK78EIANY+zxKuMykO7KuZha/50FTKpzsDWyfSAHDPFyQAJPImUPsFIv4uJFHt +XRpuEU6ZBtw0pdTSmMHrZywwrpGjrCta+BQWuW5UW4xGFjHiB26iG/DyjaFvf6Xr +z6yvqP73Ss/s0h9HEaMeFVF2PChz3heHO4NxJ/MKj5vyvqzByP5HslrKvkXmGMuq +nBG5MLg7PKnNCb0LPsTrOZuIiNvsTgoYHqZfiwA+Mgb0CJXi64n4LR34GfVIC1WA +slut7XVdMsRubLEQNAEo7pJBcp7E5m1iqZJ2lXsqjdBe1knFSLPRpix7nJAlPwJ2 +LWHiT9MyhsyAdeBh2EAiipmdecBYQs0H/3POy9rOHToB09RSS/kxhU50eV6gO6gO +v6CgqR6VWth3N2yTpVprTBv1HP5QWSefitrffSqC5+rbLRsSkrNIOJOgWwcXHiAE +pXcehjbjD0NqAPLxRG45VCTqK7WnbLZFWMdMkl1/38CZ1kSSuipYguR/KFqQ4AW8 +Z5dl5JsjFbyubw9/gI1sbhd9kGHW0UynnCyXnFyDCfxcwliXjAOdpr5rKyVPue7Z +EGVzsvcH/zGzJB+2nVrF7SMMK9Aei254K+vr2ESX5Nwkxjqmgv7SNOTJ8fNgA+2K +1QO+79B6/NXEGq0KY1bd/IRSwt/ASf5DnTsYc9Y+KfERCBidXUN7kTd/3w== +=3Jq1 +-----END PGP PRIVATE KEY----- +` + + TestStoreKeyID = "XCIC_Wvj9_hiAt0b10sDon74oGr3a6xGODkMZqrj63ZzNYUD5N87-ojjPoeN7f1Y" + + encodedTestStoreAccountKey = `type: account-key +authority-id: testrootorg +public-key-sha3-384: XCIC_Wvj9_hiAt0b10sDon74oGr3a6xGODkMZqrj63ZzNYUD5N87-ojjPoeN7f1Y +account-id: testrootorg +name: test-store +since: 2016-08-11T18:42:22+02:00 +body-length: 717 +sign-key-sha3-384: hIedp1AvrWlcDI4uS_qjoFLzjKl5enu4G2FYJpgB3Pj-tUzGlTQBxMBsBmi-tnJR + +AcbBTQRWhcGAARAAmJqmZvsS58INTs+UQ+jfo836vBS5tkU/hk7c0huZe3So2gc9kaJvjkjhZ6g0 +0/kGoidw3i2WkdMEp+JtvU9Ztfeu/Nn/OqkSc3Ap1KAmqL4OllPVII8H69w2zqvmo+PcqH0SvHAV +EoOC2ToXP0wEHnAZsbVu56AKrwpHDppPEIvaS6glrsEX1AXpOeMHZLVRtfsBB6dlLXuula1UrSAL +RFCEjXtqXqOto5Vo9C8p63lLBy19ifz4OriWAGBqZvFdItmo8VIPXSDdgMMHBlu2MSNJHVfo66yp +buos5Qs6PlVARACLJgzgplI5sDbzXtVx5O9Q8YJjz4NfU0WOWYPWvmANXDeMNixWoWvuixYvsXaG +x6mdD7Hh/gi8prkQmZ7gxW1MEOV9JThAqYjjs2ayGVD73EI2sKYxVwEg3iJToQ/cEz3O2U1HdmYj +QfRDJiX3GEPBXXttDrbPM42SHElouldmJ+PkJDLdkGmA85xYUoEKHdEFIkjFStQcyO5CkyNZN7SH +iAIwA4DpGMmFJ26maqVzJuiLvicri2FR/sJaSA24N8HbGne3gSS7WrSQS+jKe3IZPVy64NCoGvrW +o/HvTeqsIfihKPEpXm8QVtjNhtkVn3RdIUgOaNWyAfnZ4dW1TVIATe+OHDw2TNyImTjE0x75nL6B +1/Rrn+9VP9Swhv8AEQEAAQ== + +AcLBXAQAAQoABgUCV866kwAKCRBMcZp594FxpHWHD/9AaZXqyT/Zsmq/VzmAMpd9JvCH4PHQKtAP +bXfP2Dnpa2wk2wuzQuSWunR8NDRyVh/aNVeTEZ9dFm/B8LR+U2O4rsHmFSeicmsTmo9u/HouRdEU +zeSc6cbAxMPpfNSjr5J+URLjGRT6oX5fEBmRPx/OC9pEIScMx7uKmTKEnuyMzLRNN/6HiGWKrFCo +nJdKkwRXrkCHyXWAOv1GumT7NDuyFcjAqt/UdHliTZkDBImKOsBmBVXMUjg7HCSS2uq/5WjStJ+B +JHQ4GSsXBvVINs6BncNWcvV6mCQ73D57MzGhqo997Zb4tSrn7UNGWK7GLCzV3e/pFlG7pw6HbgnQ ++rxU2Oj/TPVw0tcnUiRl2ttKpm+nua0Cl+MD+Gx0KXLAVp0ZGOQ9yGyP9AePFzcOR8SlRIgxi0EI +iJkSeYilqoKo3AJhnICRiqvAca2TGJoiJUryEgZ8jbTOElfaF2p+y0xvXGlWbKZm1gzGyvFM5fV5 +hJTlp/am+2uVn6U8wPACir4PrbuXYo7L4MIXww2OEO0ruBIaLARbc5IutSWmw6AEYQUxtsa9bdHV +Zin7LGbEj6lZm8GycWQwh4B6Vnt6dJRIyPc/9G7uM8Ds/2Wa7+yAxhiPqm8DwlbOYh1npw4X4TLD +IMGnTv5N3zllI+Xz4rqJzNTzEbvOIcrqWxCedQe79A== +` + + TestRepairKeyID = "3m-CaG9w6CoHbGp8ctHH-sqNj-sBa_M65ekmqPncNN7wOa7NQvYN4J3NMyd_DmYz" + + encodedTestRepairRootAccountKey = `type: account-key +authority-id: testrootorg +public-key-sha3-384: 3m-CaG9w6CoHbGp8ctHH-sqNj-sBa_M65ekmqPncNN7wOa7NQvYN4J3NMyd_DmYz +account-id: testrootorg +name: test-root-repair +since: 2021-02-12T13:16:51-06:00 +body-length: 717 +sign-key-sha3-384: hIedp1AvrWlcDI4uS_qjoFLzjKl5enu4G2FYJpgB3Pj-tUzGlTQBxMBsBmi-tnJR + +AcbBTQRWhcGAARAAqpVMFubDL4n8sdaRM+c/ym1Hh/HToWRUvPQ9VhGARa3tBZX0yeKZIWbqttXU +gkr3FHx6z+vfia87Z3S5E6I60X+hSKV6RmS2uON19rPJi0KZ0TGGAfF0rfk3WYpVW+M28smMPqZU +jL8soDYeP+LRrqPCByvI2CvgAbNkQGZNZJoYPsEoW1ekDdVew9nvN1MRkMih+hK/TMk70jGixsbR +NEOg9nQ9HdDwv0fdlF9oFTfz8GfIGtMlPbFg9/G/AbiYnnHho5JDgpJ+pyjAkoC9CttO3FUc4mgE +EY+6SwuJH548gPg6gwS1ATi6+l8hNxUCUQlIvaTHRvQ09+kCfbu+B7RqHf0yuJEH74H0fqg+0l7g +/RwU1Iv4ylkKsO9w8YjAhGzLdoAOZ0j9EwDpRr3jUj/sE7sY4760C6dcCe8mC83nuaiRMvzStUcy +taa+KqpwFepxzZteRM2JzCA0h9G9yF08iANnzgPSBoI6YvsxPkXKNJU8PrOCHvtZjkTGJR1b1sg6 +Y9AvGGTLsX+D/KHPox1TVuJQulI3lnB2zNwf99c348KSFSoefvlhbWwxLErzc2ogt0L2f7JcY4q7 +vcegoXULoD1pvyzzjVljTzNeQ4tjrlNKG1sVrxJmekMjzgEtJvuTljLjxPB0QDqeVyP2n5JUKP3i +8J8JH9XlT+gpv40AEQEAAQ== + +AcLBUgQAAQoABgUCYCbUIwAA+LMQAF/J4VOh3YXz9GGOrPlFQHBUzDzqiwFJBl7mdUMJ8AgBoFvk +bKm1Y3tIl4VX7Ajft6RyjpQ/O/RsvmNeQbb409FWvuUxuRUbguXHR/m1WeWH3EGEfQi1MYpAY+ed +IwAVyA/RCrn+1PnSu4YVtDUJHIJ5NHpcMXufpeo1Ek1S18T7prHezEq+QTgJ6iCSMZPSmhIEBQVm +V7+8Ui+uKuqqPReN/KmQEedZxH6sKTHxZe3mQ0wyM7YOzL19nECSoWAHiaSErSdVzhYpQIiZjeGs +7LroIKZY2zJkZhWPlAzDDjlSMWSFmh6P2B6Fsg45ozDw4JuXE0O7+wESEA6Gh3S9xaTk/QU2g8yu +NSWtQ0zGSyLknVRfNEtWTiYR1ZJ6HVAsAH+zILE88LQnIhPFb4ruF4w30LYgJk9RWTqRAtxnnu9E +K7GR/NWNqLpjaWZ/z+KTCPSnj0o7GnVjH99uVOIbVQMd5WxYbGrM/i9+5Q+E94WK3Dhr5erdEIx3 +8MahXGKpzhnGLlHHERWKupgU9HOZFi+jyaHs2gr5rXfYyaf5wPuy6nFiE04mJCR5nhmvxROMBcmZ +J2ZZ3ST8ihwC0d6L038zp/oWDNJHIPpp/hWI82f2diR9soJGpKvFS+rNxPBrL1l2KkNqdMRffIHE +W9mKvTpJVq095x2hhfHD/4VHLGIm +` +) + +var ( + TestRootAccount asserts.Assertion + TestRootAccountKey asserts.Assertion + // here for convenience, does not need to be in the trusted set + TestStoreAccountKey asserts.Assertion + TestRepairRootAccountKey asserts.Assertion + // Testing-only trusted assertions for injecting in the system trusted set. + Trusted []asserts.Assertion +) + +func init() { + acct, err := asserts.Decode([]byte(encodedTestRootAccount)) + if err != nil { + panic(fmt.Sprintf("cannot decode trusted assertion: %v", err)) + } + accKey, err := asserts.Decode([]byte(encodedTestRootAccountKey)) + if err != nil { + panic(fmt.Sprintf("cannot decode trusted assertion: %v", err)) + } + storeAccKey, err := asserts.Decode([]byte(encodedTestStoreAccountKey)) + if err != nil { + panic(fmt.Sprintf("cannot decode test store assertion: %v", err)) + } + repairAccKey, err := asserts.Decode([]byte(encodedTestRepairRootAccountKey)) + if err != nil { + panic(fmt.Sprintf("cannot decode test repair root assertion: %v", err)) + } + + TestRootAccount = acct + TestRootAccountKey = accKey + TestStoreAccountKey = storeAccKey + TestRepairRootAccountKey = repairAccKey + Trusted = []asserts.Assertion{TestRootAccount, TestRootAccountKey} +} diff --git a/asserts/validation_set.go b/asserts/validation_set.go new file mode 100644 index 00000000..e2831517 --- /dev/null +++ b/asserts/validation_set.go @@ -0,0 +1,275 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts + +import ( + "fmt" + "regexp" + "strings" + "time" + + "github.com/snapcore/snapd/snap/naming" + "github.com/snapcore/snapd/strutil" +) + +// Presence represents a presence constraint. +type Presence string + +const ( + PresenceRequired Presence = "required" + PresenceOptional Presence = "optional" + PresenceInvalid Presence = "invalid" +) + +func presencesAsStrings(presences ...Presence) []string { + strs := make([]string, len(presences)) + for i, pres := range presences { + strs[i] = string(pres) + } + return strs +} + +var validValidationSetSnapPresences = presencesAsStrings(PresenceRequired, PresenceOptional, PresenceInvalid) + +func checkPresence(snap map[string]interface{}, which string, valid []string) (Presence, error) { + presence, err := checkOptionalStringWhat(snap, "presence", which) + if err != nil { + return Presence(""), err + } + if presence != "" && !strutil.ListContains(valid, presence) { + return Presence(""), fmt.Errorf("presence %s must be one of %s", which, strings.Join(valid, "|")) + } + return Presence(presence), nil +} + +// ValidationSetSnap holds the details about a snap constrained by a validation-set assertion. +type ValidationSetSnap struct { + Name string + SnapID string + + Presence Presence + + Revision int +} + +// SnapName implements naming.SnapRef. +func (s *ValidationSetSnap) SnapName() string { + return s.Name +} + +// ID implements naming.SnapRef. +func (s *ValidationSetSnap) ID() string { + return s.SnapID +} + +func checkValidationSetSnap(snap map[string]interface{}) (*ValidationSetSnap, error) { + name, err := checkNotEmptyStringWhat(snap, "name", "of snap") + if err != nil { + return nil, err + } + if err := naming.ValidateSnap(name); err != nil { + return nil, fmt.Errorf("invalid snap name %q", name) + } + + what := fmt.Sprintf("of snap %q", name) + + snapID, err := checkStringMatchesWhat(snap, "id", what, naming.ValidSnapID) + if err != nil { + return nil, err + } + + presence, err := checkPresence(snap, what, validValidationSetSnapPresences) + if err != nil { + return nil, err + } + + var snapRevision int + if _, ok := snap["revision"]; ok { + var err error + snapRevision, err = checkSnapRevisionWhat(snap, "revision", what) + if err != nil { + return nil, err + } + } + if snapRevision != 0 && presence == PresenceInvalid { + return nil, fmt.Errorf(`cannot specify revision %s at the same time as stating its presence is invalid`, what) + } + + return &ValidationSetSnap{ + Name: name, + SnapID: snapID, + Presence: presence, + Revision: snapRevision, + }, nil +} + +func checkValidationSetSnaps(snapList interface{}) ([]*ValidationSetSnap, error) { + const wrongHeaderType = `"snaps" header must be a list of maps` + + entries, ok := snapList.([]interface{}) + if !ok { + return nil, fmt.Errorf(wrongHeaderType) + } + + seen := make(map[string]bool, len(entries)) + seenIDs := make(map[string]string, len(entries)) + snaps := make([]*ValidationSetSnap, 0, len(entries)) + for _, entry := range entries { + snap, ok := entry.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf(wrongHeaderType) + } + valSetSnap, err := checkValidationSetSnap(snap) + if err != nil { + return nil, err + } + + if seen[valSetSnap.Name] { + return nil, fmt.Errorf("cannot list the same snap %q multiple times", valSetSnap.Name) + } + seen[valSetSnap.Name] = true + snapID := valSetSnap.SnapID + if underName := seenIDs[snapID]; underName != "" { + return nil, fmt.Errorf("cannot specify the same snap id %q multiple times, specified for snaps %q and %q", snapID, underName, valSetSnap.Name) + } + seenIDs[snapID] = valSetSnap.Name + + if valSetSnap.Presence == "" { + valSetSnap.Presence = PresenceRequired + } + + snaps = append(snaps, valSetSnap) + } + + return snaps, nil +} + +// ValidationSet holds a validation-set assertion, which is a +// statement by an account about a set snaps and possibly revisions +// for which an extrinsic/implied property is valid (e.g. they work +// well together). validation-sets are organized in sequences under a +// name. +type ValidationSet struct { + assertionBase + + seq int + + snaps []*ValidationSetSnap + + timestamp time.Time +} + +// SequenceKey returns the sequence key for this validation set. +func (vs *ValidationSet) SequenceKey() string { + return vsSequenceKey(vs.Series(), vs.AccountID(), vs.Name()) +} + +func vsSequenceKey(series, accountID, name string) string { + return fmt.Sprintf("%s/%s/%s", series, accountID, name) +} + +// Series returns the series for which the snap in the set are declared. +func (vs *ValidationSet) Series() string { + return vs.HeaderString("series") +} + +// AccountID returns the identifier of the account that signed this assertion. +func (vs *ValidationSet) AccountID() string { + return vs.HeaderString("account-id") +} + +// Name returns the name under which the validation-set is organized. +func (vs *ValidationSet) Name() string { + return vs.HeaderString("name") +} + +// Sequence returns the sequential number of the validation-set in its +// named sequence. +func (vs *ValidationSet) Sequence() int { + return vs.seq +} + +// Snaps returns the constrained snaps by the validation-set. +func (vs *ValidationSet) Snaps() []*ValidationSetSnap { + return vs.snaps +} + +// Timestamp returns the time when the validation-set was issued. +func (vs *ValidationSet) Timestamp() time.Time { + return vs.timestamp +} + +func checkSequence(headers map[string]interface{}, name string) (int, error) { + seqnum, err := checkInt(headers, name) + if err != nil { + return -1, err + } + if seqnum < 1 { + return -1, fmt.Errorf("%q must be >=1: %v", name, seqnum) + } + return seqnum, nil +} + +var ( + validValidationSetName = regexp.MustCompile("^[a-z0-9](?:-?[a-z0-9])*$") +) + +func assembleValidationSet(assert assertionBase) (Assertion, error) { + authorityID := assert.AuthorityID() + accountID := assert.HeaderString("account-id") + if accountID != authorityID { + return nil, fmt.Errorf("authority-id and account-id must match, validation-set assertions are expected to be signed by the issuer account: %q != %q", authorityID, accountID) + } + + _, err := checkStringMatches(assert.headers, "name", validValidationSetName) + if err != nil { + return nil, err + } + + seq, err := checkSequence(assert.headers, "sequence") + if err != nil { + return nil, err + } + + snapList, ok := assert.headers["snaps"] + if !ok { + return nil, fmt.Errorf(`"snaps" header is mandatory`) + } + snaps, err := checkValidationSetSnaps(snapList) + if err != nil { + return nil, err + } + + timestamp, err := checkRFC3339Date(assert.headers, "timestamp") + if err != nil { + return nil, err + } + + return &ValidationSet{ + assertionBase: assert, + seq: seq, + snaps: snaps, + timestamp: timestamp, + }, nil +} + +func IsValidValidationSetName(name string) bool { + return validValidationSetName.MatchString(name) +} diff --git a/asserts/validation_set_test.go b/asserts/validation_set_test.go new file mode 100644 index 00000000..5d9c222f --- /dev/null +++ b/asserts/validation_set_test.go @@ -0,0 +1,202 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package asserts_test + +import ( + "strings" + "time" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/asserts" +) + +type validationSetSuite struct { + ts time.Time + tsLine string +} + +var _ = Suite(&validationSetSuite{}) + +func (vss *validationSetSuite) SetUpSuite(c *C) { + vss.ts = time.Now().Truncate(time.Second).UTC() + vss.tsLine = "timestamp: " + vss.ts.Format(time.RFC3339) + "\n" +} + +const ( + validationSetExample = `type: validation-set +authority-id: brand-id1 +series: 16 +account-id: brand-id1 +name: baz-3000-good +sequence: 2 +snaps: + - + name: baz-linux + id: bazlinuxidididididididididididid + presence: optional + revision: 99 +OTHER` + "TSLINE" + + "body-length: 0\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==" +) + +func (vss *validationSetSuite) TestDecodeOK(c *C) { + encoded := strings.Replace(validationSetExample, "TSLINE", vss.tsLine, 1) + encoded = strings.Replace(encoded, "OTHER", "", 1) + + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + c.Check(a.Type(), Equals, asserts.ValidationSetType) + _, ok := a.(asserts.SequenceMember) + c.Assert(ok, Equals, true) + valset := a.(*asserts.ValidationSet) + c.Check(valset.AuthorityID(), Equals, "brand-id1") + c.Check(valset.Timestamp(), Equals, vss.ts) + c.Check(valset.Series(), Equals, "16") + c.Check(valset.AccountID(), Equals, "brand-id1") + c.Check(valset.Name(), Equals, "baz-3000-good") + c.Check(valset.Sequence(), Equals, 2) + snaps := valset.Snaps() + c.Assert(snaps, DeepEquals, []*asserts.ValidationSetSnap{ + { + Name: "baz-linux", + SnapID: "bazlinuxidididididididididididid", + Presence: asserts.PresenceOptional, + Revision: 99, + }, + }) + c.Check(snaps[0].SnapName(), Equals, "baz-linux") + c.Check(snaps[0].ID(), Equals, "bazlinuxidididididididididididid") +} + +func (vss *validationSetSuite) TestDecodeInvalid(c *C) { + const validationSetErrPrefix = "assertion validation-set: " + + encoded := strings.Replace(validationSetExample, "TSLINE", vss.tsLine, 1) + + snapsStanza := encoded[strings.Index(encoded, "snaps:"):strings.Index(encoded, "timestamp:")] + + invalidTests := []struct{ original, invalid, expectedErr string }{ + {"series: 16\n", "", `"series" header is mandatory`}, + {"series: 16\n", "series: \n", `"series" header should not be empty`}, + {"account-id: brand-id1\n", "", `"account-id" header is mandatory`}, + {"account-id: brand-id1\n", "account-id: \n", `"account-id" header should not be empty`}, + {"account-id: brand-id1\n", "account-id: random\n", `authority-id and account-id must match, validation-set assertions are expected to be signed by the issuer account: "brand-id1" != "random"`}, + {"name: baz-3000-good\n", "", `"name" header is mandatory`}, + {"name: baz-3000-good\n", "name: \n", `"name" header should not be empty`}, + {"name: baz-3000-good\n", "name: baz/3000/good\n", `"name" primary key header cannot contain '/'`}, + {"name: baz-3000-good\n", "name: baz+3000+good\n", `"name" header contains invalid characters: "baz\+3000\+good"`}, + {vss.tsLine, "timestamp: 12:30\n", `"timestamp" header is not a RFC3339 date: .*`}, + {"sequence: 2\n", "", `"sequence" header is mandatory`}, + {"sequence: 2\n", "sequence: one\n", `"sequence" header is not an integer: one`}, + {"sequence: 2\n", "sequence: 0\n", `"sequence" must be >=1: 0`}, + {"sequence: 2\n", "sequence: -1\n", `"sequence" must be >=1: -1`}, + {"sequence: 2\n", "sequence: 00\n", `"sequence" header has invalid prefix zeros: 00`}, + {"sequence: 2\n", "sequence: 01\n", `"sequence" header has invalid prefix zeros: 01`}, + {"sequence: 2\n", "sequence: 010\n", `"sequence" header has invalid prefix zeros: 010`}, + {snapsStanza, "", `"snaps" header is mandatory`}, + {snapsStanza, "snaps: snap\n", `"snaps" header must be a list of maps`}, + {snapsStanza, "snaps:\n - snap\n", `"snaps" header must be a list of maps`}, + {"name: baz-linux\n", "other: 1\n", `"name" of snap is mandatory`}, + {"name: baz-linux\n", "name: linux_2\n", `invalid snap name "linux_2"`}, + {"id: bazlinuxidididididididididididid\n", "id: 2\n", `"id" of snap "baz-linux" contains invalid characters: "2"`}, + {" id: bazlinuxidididididididididididid\n", "", `"id" of snap "baz-linux" is mandatory`}, + {"OTHER", " -\n name: baz-linux\n id: bazlinuxidididididididididididid\n", `cannot list the same snap "baz-linux" multiple times`}, + {"OTHER", " -\n name: baz-linux2\n id: bazlinuxidididididididididididid\n", `cannot specify the same snap id "bazlinuxidididididididididididid" multiple times, specified for snaps "baz-linux" and "baz-linux2"`}, + {"presence: optional\n", "presence:\n - opt\n", `"presence" of snap "baz-linux" must be a string`}, + {"presence: optional\n", "presence: no\n", `"presence" of snap "baz-linux" must be one of must be one of required|optional|invalid`}, + {"revision: 99\n", "revision: 0\n", `"revision" of snap "baz-linux" must be >=1: 0`}, + {"presence: optional\n", "presence: invalid\n", `cannot specify revision of snap "baz-linux" at the same time as stating its presence is invalid`}, + } + + for _, test := range invalidTests { + invalid := strings.Replace(encoded, test.original, test.invalid, 1) + invalid = strings.Replace(invalid, "OTHER", "", 1) + _, err := asserts.Decode([]byte(invalid)) + c.Check(err, ErrorMatches, validationSetErrPrefix+test.expectedErr) + } + +} + +func (vss *validationSetSuite) TestSnapPresenceOptionalDefaultRequired(c *C) { + encoded := strings.Replace(validationSetExample, "TSLINE", vss.tsLine, 1) + encoded = strings.Replace(encoded, "OTHER", "", 1) + encoded = strings.Replace(encoded, " presence: optional\n", "", 1) + + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + c.Check(a.Type(), Equals, asserts.ValidationSetType) + valset := a.(*asserts.ValidationSet) + snaps := valset.Snaps() + c.Assert(snaps, HasLen, 1) + c.Check(snaps[0].Presence, Equals, asserts.PresenceRequired) +} + +func (vss *validationSetSuite) TestSnapRevisionOptional(c *C) { + encoded := strings.Replace(validationSetExample, "TSLINE", vss.tsLine, 1) + encoded = strings.Replace(encoded, "OTHER", "", 1) + encoded = strings.Replace(encoded, " revision: 99\n", "", 1) + + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + c.Check(a.Type(), Equals, asserts.ValidationSetType) + valset := a.(*asserts.ValidationSet) + snaps := valset.Snaps() + c.Assert(snaps, HasLen, 1) + // 0 means unset + c.Check(snaps[0].Revision, Equals, 0) +} + +func (vss *validationSetSuite) TestIsValidValidationSetName(c *C) { + names := []struct { + name string + valid bool + }{ + {"", false}, + {"abA", false}, + {"-a", false}, + {"1", true}, + {"a", true}, + {"ab", true}, + {"foo1-bar0", true}, + } + + for i, name := range names { + c.Assert(asserts.IsValidValidationSetName(name.name), Equals, name.valid, Commentf("%d: %s", i, name.name)) + } +} + +func (vss *validationSetSuite) TestValidationSetSequenceKey(c *C) { + encoded := strings.Replace(validationSetExample, "TSLINE", vss.tsLine, 1) + encoded = strings.Replace(encoded, "OTHER", "", 1) + + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + + _, ok := a.(asserts.SequenceMember) + c.Assert(ok, Equals, true) + + valset := a.(*asserts.ValidationSet) + + c.Check(valset.SequenceKey(), Equals, "16/brand-id1/baz-3000-good") +} diff --git a/boot/assets.go b/boot/assets.go new file mode 100644 index 00000000..818d3999 --- /dev/null +++ b/boot/assets.go @@ -0,0 +1,908 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package boot + +import ( + "crypto" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "path/filepath" + + _ "golang.org/x/crypto/sha3" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/bootloader" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/gadget" + "github.com/snapcore/snapd/gadget/device" + "github.com/snapcore/snapd/logger" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/secboot/keys" + "github.com/snapcore/snapd/strutil" +) + +type trustedAssetsCache struct { + cacheDir string + hash crypto.Hash +} + +func newTrustedAssetsCache(cacheDir string) *trustedAssetsCache { + return &trustedAssetsCache{cacheDir: cacheDir, hash: crypto.SHA3_384} +} + +func (c *trustedAssetsCache) tempAssetRelPath(blName, assetName string) string { + return filepath.Join(blName, assetName+".temp") +} + +func (c *trustedAssetsCache) pathInCache(part string) string { + return filepath.Join(c.cacheDir, part) +} + +func trustedAssetCacheRelPath(blName, assetName, assetHash string) string { + return filepath.Join(blName, fmt.Sprintf("%s-%s", assetName, assetHash)) +} + +// fileHash calculates the hash of an arbitrary file using the same hash method +// as the cache. +func (c *trustedAssetsCache) fileHash(name string) (string, error) { + digest, _, err := osutil.FileDigest(name, c.hash) + if err != nil { + return "", err + } + return hex.EncodeToString(digest), nil +} + +// Add entry for a new named asset owned by a particular bootloader, with the +// binary content of the located at a given path. The cache ensures that only +// one entry for given tuple of (bootloader name, asset name, content-hash) +// exists in the cache. +func (c *trustedAssetsCache) Add(assetPath, blName, assetName string) (*trackedAsset, error) { + if err := os.MkdirAll(c.pathInCache(blName), 0755); err != nil { + return nil, fmt.Errorf("cannot create cache directory: %v", err) + } + + // input + inf, err := os.Open(assetPath) + if err != nil { + return nil, fmt.Errorf("cannot open asset file: %v", err) + } + defer inf.Close() + // temporary output + tempPath := c.pathInCache(c.tempAssetRelPath(blName, assetName)) + outf, err := osutil.NewAtomicFile(tempPath, 0644, 0, osutil.NoChown, osutil.NoChown) + if err != nil { + return nil, fmt.Errorf("cannot create temporary cache file: %v", err) + } + defer outf.Cancel() + + // copy and hash at the same time + h := c.hash.New() + tr := io.TeeReader(inf, h) + if _, err := io.Copy(outf, tr); err != nil { + return nil, fmt.Errorf("cannot copy trusted asset to cache: %v", err) + } + hashStr := hex.EncodeToString(h.Sum(nil)) + cacheKey := trustedAssetCacheRelPath(blName, assetName, hashStr) + + ta := &trackedAsset{ + blName: blName, + name: assetName, + hash: hashStr, + } + + targetName := c.pathInCache(cacheKey) + if osutil.FileExists(targetName) { + // asset is already cached + return ta, nil + } + // commit under a new name + if err := outf.CommitAs(targetName); err != nil { + return nil, fmt.Errorf("cannot commit file to assets cache: %v", err) + } + return ta, nil +} + +func (c *trustedAssetsCache) Remove(blName, assetName, hashStr string) error { + cacheKey := trustedAssetCacheRelPath(blName, assetName, hashStr) + if err := os.Remove(c.pathInCache(cacheKey)); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +// CopyBootAssetsCacheToRoot copies the boot assets cache to a corresponding +// location under a new root directory. +func CopyBootAssetsCacheToRoot(dstRoot string) error { + if !osutil.IsDirectory(dirs.SnapBootAssetsDir) { + // nothing to copy + return nil + } + + newCacheRoot := dirs.SnapBootAssetsDirUnder(dstRoot) + if err := os.MkdirAll(newCacheRoot, 0755); err != nil { + return fmt.Errorf("cannot create cache directory under new root: %v", err) + } + err := filepath.Walk(dirs.SnapBootAssetsDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + relPath, err := filepath.Rel(dirs.SnapBootAssetsDir, path) + if err != nil { + return err + } + if info.IsDir() { + if err := os.MkdirAll(filepath.Join(newCacheRoot, relPath), info.Mode()); err != nil { + return fmt.Errorf("cannot recreate cache directory %q: %v", relPath, err) + } + return nil + } + if !info.Mode().IsRegular() { + return fmt.Errorf("unsupported non-file entry %q mode %v", relPath, info.Mode()) + } + if err := osutil.CopyFile(path, filepath.Join(newCacheRoot, relPath), osutil.CopyFlagPreserveAll); err != nil { + return fmt.Errorf("cannot copy boot asset cache file %q: %v", relPath, err) + } + return nil + }) + return err +} + +// ErrObserverNotApplicable indicates that observer is not applicable for use +// with the model. +var ErrObserverNotApplicable = errors.New("observer not applicable") + +// TrustedAssetsInstallObserverForModel returns a new trusted assets observer +// for use during installation of the run mode system to track trusted and +// control managed assets, provided the device model indicates this might be +// needed. Otherwise, nil and ErrObserverNotApplicable is returned. +func TrustedAssetsInstallObserverForModel(model *asserts.Model, gadgetDir string, useEncryption bool) (*TrustedAssetsInstallObserver, error) { + if model.Grade() == asserts.ModelGradeUnset { + // no need to observe updates when assets are not managed + return nil, ErrObserverNotApplicable + } + if gadgetDir == "" { + return nil, fmt.Errorf("internal error: gadget dir not provided") + } + // TODO:UC20: clarify use of empty rootdir when getting the lists of + // managed and trusted assets + runBl, runTrusted, runManaged, err := gadgetMaybeTrustedBootloaderAndAssets(gadgetDir, "", + &bootloader.Options{ + Role: bootloader.RoleRunMode, + NoSlashBoot: true, + }) + if err != nil { + return nil, err + } + // and the recovery bootloader, seed is mounted during install + seedBl, seedTrusted, _, err := gadgetMaybeTrustedBootloaderAndAssets(gadgetDir, InitramfsUbuntuSeedDir, + &bootloader.Options{ + Role: bootloader.RoleRecovery, + }) + if err != nil { + return nil, err + } + if !useEncryption { + // we do not care about trusted assets when not encrypting data + // partition + runTrusted = nil + seedTrusted = nil + } + hasManaged := len(runManaged) > 0 + hasTrusted := len(runTrusted) > 0 || len(seedTrusted) > 0 + if !hasManaged && !hasTrusted && !useEncryption { + // no managed assets, and no trusted assets or we are not + // tracking them due to no encryption to data partition + return nil, ErrObserverNotApplicable + } + + return &TrustedAssetsInstallObserver{ + model: model, + cache: newTrustedAssetsCache(dirs.SnapBootAssetsDir), + gadgetDir: gadgetDir, + + blName: runBl.Name(), + managedAssets: runManaged, + trustedAssets: runTrusted, + + recoveryBlName: seedBl.Name(), + trustedRecoveryAssets: seedTrusted, + }, nil +} + +type trackedAsset struct { + blName, name, hash string +} + +func isAssetAlreadyTracked(bam bootAssetsMap, newAsset *trackedAsset) bool { + return isAssetHashTrackedInMap(bam, newAsset.name, newAsset.hash) +} + +func isAssetHashTrackedInMap(bam bootAssetsMap, assetName, assetHash string) bool { + if bam == nil { + return false + } + hashes, ok := bam[assetName] + if !ok { + return false + } + return strutil.ListContains(hashes, assetHash) +} + +// TrustedAssetsInstallObserver tracks the installation of trusted or managed +// boot assets. +type TrustedAssetsInstallObserver struct { + model *asserts.Model + gadgetDir string + cache *trustedAssetsCache + + blName string + managedAssets []string + // trustedAssets records all trusted run asset mapping their + // relative path to identifier used in the modeenv + trustedAssets map[string]string + trackedAssets bootAssetsMap + + recoveryBlName string + // trustedRecoveryAssets records all trusted recovery asset mapping their + // relative path to identifier used in the modeenv + trustedRecoveryAssets map[string]string + trackedRecoveryAssets bootAssetsMap + + dataEncryptionKey keys.EncryptionKey + saveEncryptionKey keys.EncryptionKey +} + +// Observe observes the operation related to the content of a given gadget +// structure. In particular, the TrustedAssetsInstallObserver tracks writing of +// trusted or managed boot assets, such as the bootloader binary which is +// measured as part of the secure boot or the bootloader configuration. +// +// Implements gadget.ContentObserver. +func (o *TrustedAssetsInstallObserver) Observe(op gadget.ContentOperation, partRole, root, relativeTarget string, data *gadget.ContentChange) (gadget.ContentChangeAction, error) { + if partRole != gadget.SystemBoot { + // only care about system-boot + return gadget.ChangeApply, nil + } + + if len(o.managedAssets) != 0 && strutil.ListContains(o.managedAssets, relativeTarget) { + // this asset is managed by bootloader installation + return gadget.ChangeIgnore, nil + } + trustedAssetName, isTrustedAsset := o.trustedAssets[relativeTarget] + if !isTrustedAsset { + // not one of the trusted assets + return gadget.ChangeApply, nil + } + ta, err := o.cache.Add(data.After, o.blName, trustedAssetName) + if err != nil { + return gadget.ChangeAbort, err + } + // during installation, modeenv is written out later, at this point we + // only care that the same file may appear multiple times in gadget + // structure content, so make sure we are not tracking it yet + if !isAssetAlreadyTracked(o.trackedAssets, ta) { + if o.trackedAssets == nil { + o.trackedAssets = bootAssetsMap{} + } + if len(o.trackedAssets[ta.name]) > 0 { + return gadget.ChangeAbort, fmt.Errorf("cannot reuse asset name %q", ta.name) + } + o.trackedAssets[ta.name] = append(o.trackedAssets[ta.name], ta.hash) + } + return gadget.ChangeApply, nil +} + +// ObserveExistingTrustedRecoveryAssets observes existing trusted assets of a +// recovery bootloader located inside a given root directory. +func (o *TrustedAssetsInstallObserver) ObserveExistingTrustedRecoveryAssets(recoveryRootDir string) error { + if len(o.trustedRecoveryAssets) == 0 { + // not a trusted assets bootloader or has no trusted assets + return nil + } + for trustedAsset, trustedAssetName := range o.trustedRecoveryAssets { + path := filepath.Join(recoveryRootDir, trustedAsset) + if !osutil.FileExists(path) { + continue + } + ta, err := o.cache.Add(path, o.recoveryBlName, trustedAssetName) + if err != nil { + return err + } + if !isAssetAlreadyTracked(o.trackedRecoveryAssets, ta) { + if o.trackedRecoveryAssets == nil { + o.trackedRecoveryAssets = bootAssetsMap{} + } + if len(o.trackedRecoveryAssets[ta.name]) > 0 { + return fmt.Errorf("cannot reuse recovery asset name %q", ta.name) + } + o.trackedRecoveryAssets[ta.name] = append(o.trackedRecoveryAssets[ta.name], ta.hash) + } + } + return nil +} + +func (o *TrustedAssetsInstallObserver) currentTrustedBootAssetsMap() bootAssetsMap { + return o.trackedAssets +} + +func (o *TrustedAssetsInstallObserver) currentTrustedRecoveryBootAssetsMap() bootAssetsMap { + return o.trackedRecoveryAssets +} + +func (o *TrustedAssetsInstallObserver) ChosenEncryptionKeys(key, saveKey keys.EncryptionKey) { + o.dataEncryptionKey = key + o.saveEncryptionKey = saveKey +} + +// TrustedAssetsUpdateObserverForModel returns a new trusted assets observer for +// tracking changes to the trusted boot assets and preserving managed assets, +// provided the device model indicates this might be needed. Otherwise, nil and +// ErrObserverNotApplicable is returned. +func TrustedAssetsUpdateObserverForModel(model *asserts.Model, gadgetDir string) (*TrustedAssetsUpdateObserver, error) { + if model.Grade() == asserts.ModelGradeUnset { + // no need to observe updates when assets are not managed + return nil, ErrObserverNotApplicable + } + // trusted assets need tracking only when the system is using encryption + // for its data partitions + trackTrustedAssets := false + _, err := device.SealedKeysMethod(dirs.GlobalRootDir) + switch { + case err == nil: + trackTrustedAssets = true + case err == device.ErrNoSealedKeys: + // nothing to do + case err != nil: + // all other errors + return nil, err + } + + // see what we need to observe for the run bootloader + runBl, runTrusted, runManaged, err := gadgetMaybeTrustedBootloaderAndAssets(gadgetDir, InitramfsUbuntuBootDir, + &bootloader.Options{ + Role: bootloader.RoleRunMode, + NoSlashBoot: true, + }) + if err != nil { + return nil, err + } + + // and the recovery bootloader + seedBl, seedTrusted, seedManaged, err := gadgetMaybeTrustedBootloaderAndAssets(gadgetDir, InitramfsUbuntuSeedDir, + &bootloader.Options{ + Role: bootloader.RoleRecovery, + }) + if err != nil { + return nil, err + } + + hasManaged := len(runManaged) > 0 || len(seedManaged) > 0 + hasTrusted := len(runTrusted) > 0 || len(seedTrusted) > 0 + if !hasManaged { + // no managed assets + if !hasTrusted || !trackTrustedAssets { + // no trusted assets or we are not tracking them either + return nil, ErrObserverNotApplicable + } + } + + obs := &TrustedAssetsUpdateObserver{ + cache: newTrustedAssetsCache(dirs.SnapBootAssetsDir), + model: model, + + bootBootloader: runBl, + bootManagedAssets: runManaged, + + seedBootloader: seedBl, + seedManagedAssets: seedManaged, + } + if trackTrustedAssets { + obs.seedTrustedAssets = seedTrusted + obs.bootTrustedAssets = runTrusted + } + return obs, nil +} + +// TrustedAssetsUpdateObserver tracks the updates of trusted boot assets and +// attempts to reseal when needed or preserves managed boot assets. +type TrustedAssetsUpdateObserver struct { + cache *trustedAssetsCache + model *asserts.Model + + bootBootloader bootloader.Bootloader + // bootTrustedAssets records all trusted run asset mapping their + // relative path to identifier used in the modeenv + bootTrustedAssets map[string]string + bootManagedAssets []string + changedAssets []*trackedAsset + + seedBootloader bootloader.Bootloader + // seedTrustedAssets records all trusted recovery asset mapping their + // relative path to identifier used in the modeenv + seedTrustedAssets map[string]string + seedManagedAssets []string + seedChangedAssets []*trackedAsset + + modeenv *Modeenv + modeenvLocked bool +} + +// Done must be called when done with the observer if any of the +// gadget.ContenUpdateObserver methods might have been called. +func (o *TrustedAssetsUpdateObserver) Done() { + if o.modeenvLocked { + o.modeenvUnlock() + } +} + +func (o *TrustedAssetsUpdateObserver) modeenvUnlock() { + modeenvUnlock() + o.modeenvLocked = false +} + +func trustedAndManagedAssetsOfBootloader(bl bootloader.Bootloader) (trustedAssets map[string]string, managedAssets []string, err error) { + tbl, ok := bl.(bootloader.TrustedAssetsBootloader) + if ok { + trustedAssets, err = tbl.TrustedAssets() + if err != nil { + return nil, nil, fmt.Errorf("cannot list %q bootloader trusted assets: %v", bl.Name(), err) + } + managedAssets = tbl.ManagedAssets() + } + return trustedAssets, managedAssets, nil +} + +func findMaybeTrustedBootloaderAndAssets(rootDir string, opts *bootloader.Options) (foundBl bootloader.Bootloader, trustedAssets map[string]string, err error) { + foundBl, err = bootloader.Find(rootDir, opts) + if err != nil { + return nil, nil, fmt.Errorf("cannot find bootloader: %v", err) + } + trustedAssets, _, err = trustedAndManagedAssetsOfBootloader(foundBl) + return foundBl, trustedAssets, err +} + +func gadgetMaybeTrustedBootloaderAndAssets(gadgetDir, rootDir string, opts *bootloader.Options) (foundBl bootloader.Bootloader, trustedAssets map[string]string, managedAssets []string, err error) { + foundBl, err = bootloader.ForGadget(gadgetDir, rootDir, opts) + if err != nil { + return nil, nil, nil, fmt.Errorf("cannot find bootloader: %v", err) + } + trustedAssets, managedAssets, err = trustedAndManagedAssetsOfBootloader(foundBl) + return foundBl, trustedAssets, managedAssets, err +} + +// Observe observes the operation related to the update or rollback of the +// content of a given gadget structure. In particular, the +// TrustedAssetsUpdateObserver tracks updates of trusted boot assets such as +// bootloader binaries, or preserves managed assets such as boot configuration. +// +// Implements gadget.ContentUpdateObserver. +func (o *TrustedAssetsUpdateObserver) Observe(op gadget.ContentOperation, partRole, root, relativeTarget string, data *gadget.ContentChange) (gadget.ContentChangeAction, error) { + var whichBootloader bootloader.Bootloader + var whichTrustedAssets map[string]string + var whichManagedAssets []string + var err error + var isRecovery bool + + logger.Debugf("observing role %q (root %q, target %q", partRole, root, relativeTarget) + switch partRole { + case gadget.SystemBoot: + whichBootloader = o.bootBootloader + whichTrustedAssets = o.bootTrustedAssets + whichManagedAssets = o.bootManagedAssets + case gadget.SystemSeed, gadget.SystemSeedNull: + whichBootloader = o.seedBootloader + whichTrustedAssets = o.seedTrustedAssets + whichManagedAssets = o.seedManagedAssets + isRecovery = true + default: + // only system-seed and system-boot are of interest + return gadget.ChangeApply, nil + } + // maybe an asset that we manage? + if len(whichManagedAssets) != 0 && strutil.ListContains(whichManagedAssets, relativeTarget) { + // this asset is managed directly by the bootloader, preserve it + if op != gadget.ContentUpdate { + return gadget.ChangeAbort, fmt.Errorf("internal error: managed bootloader asset change for non update operation %v", op) + } + return gadget.ChangeIgnore, nil + } + + if len(whichTrustedAssets) == 0 { + // the system is not using encryption for data partitions, so + // we're done at this point + return gadget.ChangeApply, nil + } + + trustedAssetName, hasTrustedAsset := whichTrustedAssets[relativeTarget] + // maybe an asset that is trusted in the boot process? + if !hasTrustedAsset { + // not one of the trusted assets + return gadget.ChangeApply, nil + } + if o.modeenv == nil { + // we've hit a trusted asset, so a modeenv is needed now too + modeenvLock() + o.modeenvLocked = true + o.modeenv, err = ReadModeenv("") + if err != nil { + // for test convenience + o.modeenvUnlock() + return gadget.ChangeAbort, fmt.Errorf("cannot load modeenv: %v", err) + } + } + switch op { + case gadget.ContentUpdate: + return o.observeUpdate(whichBootloader, isRecovery, trustedAssetName, data) + case gadget.ContentRollback: + return o.observeRollback(whichBootloader, isRecovery, root, relativeTarget, trustedAssetName) + default: + // we only care about update and rollback actions + return gadget.ChangeApply, nil + } +} + +func (o *TrustedAssetsUpdateObserver) observeUpdate(bl bootloader.Bootloader, recovery bool, trustedAssetName string, change *gadget.ContentChange) (gadget.ContentChangeAction, error) { + modeenvBefore, err := o.modeenv.Copy() + if err != nil { + return gadget.ChangeAbort, fmt.Errorf("cannot copy modeenv: %v", err) + } + + // we may be running after a mid-update reboot, where a successful boot + // would have trimmed the tracked assets hash lists to contain only the + // asset we booted with + + var taBefore *trackedAsset + if change.Before != "" { + // make sure that the original copy is present in the cache if + // it existed + taBefore, err = o.cache.Add(change.Before, bl.Name(), trustedAssetName) + if err != nil { + return gadget.ChangeAbort, err + } + } + + ta, err := o.cache.Add(change.After, bl.Name(), trustedAssetName) + if err != nil { + return gadget.ChangeAbort, err + } + + trustedAssets := &o.modeenv.CurrentTrustedBootAssets + changedAssets := &o.changedAssets + if recovery { + trustedAssets = &o.modeenv.CurrentTrustedRecoveryBootAssets + changedAssets = &o.seedChangedAssets + } + // keep track of the change for cancellation purpose + *changedAssets = append(*changedAssets, ta) + + if *trustedAssets == nil { + *trustedAssets = bootAssetsMap{} + } + + if taBefore != nil && !isAssetAlreadyTracked(*trustedAssets, taBefore) { + // make sure that the boot asset that was was in the filesystem + // before the update, is properly tracked until either a + // successful boot or the update is canceled + // the original asset hash is listed first + (*trustedAssets)[taBefore.name] = append([]string{taBefore.hash}, (*trustedAssets)[taBefore.name]...) + } + + if !isAssetAlreadyTracked(*trustedAssets, ta) { + if len((*trustedAssets)[ta.name]) > 1 { + // we expect at most 2 different blobs for a given asset + // name, the current one and one that will be installed + // during an update; more entries indicates that the + // same asset name is used multiple times with different + // content + return gadget.ChangeAbort, fmt.Errorf("cannot reuse asset name %q", ta.name) + } + // The order of assets is important. Changing it would + // change assumptions in + // bootAssetsToLoadChains + (*trustedAssets)[ta.name] = append((*trustedAssets)[ta.name], ta.hash) + } + + if o.modeenv.deepEqual(modeenvBefore) { + return gadget.ChangeApply, nil + } + if err := o.modeenv.Write(); err != nil { + return gadget.ChangeAbort, fmt.Errorf("cannot write modeeenv: %v", err) + } + return gadget.ChangeApply, nil +} + +func (o *TrustedAssetsUpdateObserver) observeRollback(bl bootloader.Bootloader, recovery bool, root, relativeTarget string, trustedAssetName string) (gadget.ContentChangeAction, error) { + trustedAssets := &o.modeenv.CurrentTrustedBootAssets + otherTrustedAssets := o.modeenv.CurrentTrustedRecoveryBootAssets + if recovery { + trustedAssets = &o.modeenv.CurrentTrustedRecoveryBootAssets + otherTrustedAssets = o.modeenv.CurrentTrustedBootAssets + } + + hashList, ok := (*trustedAssets)[trustedAssetName] + if !ok || len(hashList) == 0 { + // asset not tracked in modeenv + return gadget.ChangeApply, nil + } + + // new assets are appended to the list + expectedOldHash := hashList[0] + // validity check, make sure that the current file is what we expect + newlyAdded := false + ondiskHash, err := o.cache.fileHash(filepath.Join(root, relativeTarget)) + if err != nil { + // file may not exist if it was added by the update, that's ok + if !os.IsNotExist(err) { + return gadget.ChangeAbort, fmt.Errorf("cannot calculate the digest of current asset: %v", err) + } + newlyAdded = true + if len(hashList) > 1 { + // we have more than 1 hash of the asset, so we expected + // a previous revision to be restored, but got nothing + // instead + return gadget.ChangeAbort, fmt.Errorf("tracked asset %q is unexpectedly missing from disk", + trustedAssetName) + } + } else { + if ondiskHash != expectedOldHash { + // this is unexpected, a different file exists on disk? + return gadget.ChangeAbort, fmt.Errorf("unexpected content of existing asset %q", relativeTarget) + } + } + + newHash := "" + if len(hashList) == 1 { + if newlyAdded { + newHash = hashList[0] + } + } else { + newHash = hashList[1] + } + if newHash != "" && !isAssetHashTrackedInMap(otherTrustedAssets, trustedAssetName, newHash) { + // asset revision is not used used elsewhere, we can remove it from the cache + if err := o.cache.Remove(bl.Name(), trustedAssetName, newHash); err != nil { + // XXX: should this be a log instead? + return gadget.ChangeAbort, fmt.Errorf("cannot remove unused boot asset %v:%v: %v", trustedAssetName, newHash, err) + } + } + + // update modeenv content + if !newlyAdded { + (*trustedAssets)[trustedAssetName] = hashList[:1] + } else { + delete(*trustedAssets, trustedAssetName) + } + + if err := o.modeenv.Write(); err != nil { + return gadget.ChangeAbort, fmt.Errorf("cannot write modeeenv: %v", err) + } + + return gadget.ChangeApply, nil +} + +// BeforeWrite is called when the update process has been staged for execution. +func (o *TrustedAssetsUpdateObserver) BeforeWrite() error { + if o.modeenv == nil { + // modeenv wasn't even loaded yet, meaning none of the trusted + // boot assets was updated + return nil + } + const expectReseal = true + if err := resealKeyToModeenv(dirs.GlobalRootDir, o.modeenv, expectReseal, nil); err != nil { + return err + } + return nil +} + +func (o *TrustedAssetsUpdateObserver) canceledUpdate(recovery bool) { + trustedAssets := &o.modeenv.CurrentTrustedBootAssets + otherTrustedAssets := o.modeenv.CurrentTrustedRecoveryBootAssets + changedAssets := o.changedAssets + if recovery { + trustedAssets = &o.modeenv.CurrentTrustedRecoveryBootAssets + otherTrustedAssets = o.modeenv.CurrentTrustedBootAssets + changedAssets = o.seedChangedAssets + } + + if len(*trustedAssets) == 0 { + return + } + + for _, changed := range changedAssets { + hashList, ok := (*trustedAssets)[changed.name] + if !ok || len(hashList) == 0 { + // not tracked already, nothing to do + continue + } + if len(hashList) == 1 { + currentAssetHash := hashList[0] + if currentAssetHash != changed.hash { + // assets list has already been trimmed, nothing + // to do + continue + } else { + // asset was newly added + delete(*trustedAssets, changed.name) + } + } else { + // asset updates were appended to the list + (*trustedAssets)[changed.name] = hashList[:1] + } + if !isAssetHashTrackedInMap(otherTrustedAssets, changed.name, changed.hash) { + // asset revision is not used used elsewhere, we can remove it from the cache + if err := o.cache.Remove(changed.blName, changed.name, changed.hash); err != nil { + logger.Noticef("cannot remove unused boot asset %v:%v: %v", changed.name, changed.hash, err) + } + } + } +} + +// Canceled is called when the update has been canceled, or if changes +// were written and the update has been reverted. +func (o *TrustedAssetsUpdateObserver) Canceled() error { + if o.modeenv == nil { + // modeenv wasn't even loaded yet, meaning none of the boot + // assets was updated + return nil + } + for _, isRecovery := range []bool{false, true} { + o.canceledUpdate(isRecovery) + } + + if err := o.modeenv.Write(); err != nil { + return fmt.Errorf("cannot write modeeenv: %v", err) + } + + const expectReseal = true + if err := resealKeyToModeenv(dirs.GlobalRootDir, o.modeenv, expectReseal, nil); err != nil { + return fmt.Errorf("while canceling gadget update: %v", err) + } + return nil +} + +func observeSuccessfulBootAssetsForBootloader(m *Modeenv, root string, opts *bootloader.Options) (drop []*trackedAsset, err error) { + trustedAssetsMap := &m.CurrentTrustedBootAssets + otherTrustedAssetsMap := m.CurrentTrustedRecoveryBootAssets + whichBootloader := "run mode" + if opts != nil && opts.Role == bootloader.RoleRecovery { + trustedAssetsMap = &m.CurrentTrustedRecoveryBootAssets + otherTrustedAssetsMap = m.CurrentTrustedBootAssets + whichBootloader = "recovery" + } + + if len(*trustedAssetsMap) == 0 { + // bootloader may have trusted assets, but we are not tracking + // any for the boot process + return nil, nil + } + + // let's find the bootloader first + bl, trustedAssets, err := findMaybeTrustedBootloaderAndAssets(root, opts) + if err != nil { + return nil, err + } + if len(trustedAssets) == 0 { + // not a trusted assets bootloader, nothing to do + return nil, nil + } + + cache := newTrustedAssetsCache(dirs.SnapBootAssetsDir) + + alreadySeenAssetNames := make(map[string]bool) + for trustedAsset, assetName := range trustedAssets { + _, alreadySeen := alreadySeenAssetNames[assetName] + if alreadySeen { + // TrustedAssetsBootloader.TrustedAssets + // should not map different paths to the same + // name. If it does it is a bug. + return nil, fmt.Errorf("internal error: bootloader %s has several asset of the same name %s", whichBootloader, assetName) + } + assetHash, err := cache.fileHash(filepath.Join(root, trustedAsset)) + if err != nil { + if !os.IsNotExist(err) { + return nil, fmt.Errorf("cannot calculate the digest of existing trusted asset: %v", err) + } + logger.Noticef("system booted without %v bootloader trusted asset %q", whichBootloader, trustedAsset) + // Asset names are supposed to be unique, that + // is no 2 different paths can used the same + // name. If this path is not used, it is safe + // to say that asset name will not be used + // either. So we can safely removed it from + // the trusted asset map. + delete(*trustedAssetsMap, assetName) + continue + } + + // this is what we booted with + bootedWith := []string{assetHash} + // one of these was expected during boot + hashList := (*trustedAssetsMap)[assetName] + + assetFound := false + // find out if anything needs to be dropped + for _, hash := range hashList { + if hash == assetHash { + assetFound = true + continue + } + if !isAssetHashTrackedInMap(otherTrustedAssetsMap, assetName, hash) { + // asset can be dropped + drop = append(drop, &trackedAsset{ + blName: bl.Name(), + name: assetName, + hash: hash, + }) + } + } + + if !assetFound { + // unexpected, we have booted with an asset whose hash + // is not listed among the ones we expect + + // TODO:UC20: try to restore the asset from cache + return nil, fmt.Errorf("system booted with unexpected %v bootloader asset %q hash %v", whichBootloader, trustedAsset, assetHash) + } + + // update the list of what we booted with + (*trustedAssetsMap)[assetName] = bootedWith + + } + return drop, nil +} + +// observeSuccessfulBootAssets observes the state of the trusted boot assets +// after a successful boot. Returns a modified modeenv reflecting a new state, +// and a list of assets that can be dropped from the cache. +func observeSuccessfulBootAssets(m *Modeenv) (newM *Modeenv, drop []*trackedAsset, err error) { + // TODO:UC20 only care about run mode for now + if m.Mode != "run" { + return m, nil, nil + } + + newM, err = m.Copy() + if err != nil { + return nil, nil, err + } + + for _, bl := range []struct { + root string + opts *bootloader.Options + }{ + { + // ubuntu-boot bootloader + root: InitramfsUbuntuBootDir, + opts: &bootloader.Options{Role: bootloader.RoleRunMode, NoSlashBoot: true}, + }, { + // ubuntu-seed bootloader + root: InitramfsUbuntuSeedDir, + opts: &bootloader.Options{Role: bootloader.RoleRecovery, NoSlashBoot: true}, + }, + } { + dropForBootloader, err := observeSuccessfulBootAssetsForBootloader(newM, bl.root, bl.opts) + if err != nil { + return nil, nil, err + } + drop = append(drop, dropForBootloader...) + } + return newM, drop, nil +} diff --git a/boot/assets_test.go b/boot/assets_test.go new file mode 100644 index 00000000..fa110f0c --- /dev/null +++ b/boot/assets_test.go @@ -0,0 +1,2805 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package boot_test + +import ( + "fmt" + "os" + "path/filepath" + "syscall" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/arch/archtest" + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/boot" + "github.com/snapcore/snapd/boot/boottest" + "github.com/snapcore/snapd/bootloader" + "github.com/snapcore/snapd/bootloader/bootloadertest" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/gadget" + "github.com/snapcore/snapd/logger" + "github.com/snapcore/snapd/secboot" + "github.com/snapcore/snapd/secboot/keys" + "github.com/snapcore/snapd/seed" + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/testutil" + "github.com/snapcore/snapd/timings" +) + +type assetsSuite struct { + baseBootenvSuite +} + +var _ = Suite(&assetsSuite{}) + +func (s *assetsSuite) SetUpTest(c *C) { + s.baseBootenvSuite.SetUpTest(c) + c.Assert(os.MkdirAll(boot.InitramfsUbuntuBootDir, 0755), IsNil) + c.Assert(os.MkdirAll(boot.InitramfsUbuntuSeedDir, 0755), IsNil) + + restore := boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { return nil }) + s.AddCleanup(restore) + + s.AddCleanup(archtest.MockArchitecture("amd64")) +} + +func checkContentGlob(c *C, glob string, expected []string) { + l, err := filepath.Glob(glob) + c.Assert(err, IsNil) + c.Check(l, DeepEquals, expected) +} + +func (s *assetsSuite) uc20UpdateObserverEncryptedSystemMockedBootloader(c *C) (*boot.TrustedAssetsUpdateObserver, *asserts.Model) { + // checked by TrustedAssetsUpdateObserverForModel and + // resealKeyToModeenv + s.stampSealedKeys(c, dirs.GlobalRootDir) + return s.uc20UpdateObserver(c, c.MkDir()) +} + +func (s *assetsSuite) uc20UpdateObserver(c *C, gadgetDir string) (*boot.TrustedAssetsUpdateObserver, *asserts.Model) { + uc20Model := boottest.MakeMockUC20Model() + obs, err := boot.TrustedAssetsUpdateObserverForModel(uc20Model, gadgetDir) + c.Assert(obs, NotNil) + c.Assert(err, IsNil) + s.AddCleanup(obs.Done) + return obs, uc20Model +} + +func (s *assetsSuite) bootloaderWithTrustedAssets(trustedAssets map[string]string) *bootloadertest.MockTrustedAssetsBootloader { + tab := bootloadertest.Mock("trusted", "").WithTrustedAssets() + bootloader.Force(tab) + tab.TrustedAssetsMap = trustedAssets + s.AddCleanup(func() { bootloader.Force(nil) }) + return tab +} + +func (s *assetsSuite) TestAssetsCacheAddRemove(c *C) { + cacheDir := c.MkDir() + d := c.MkDir() + + cache := boot.NewTrustedAssetsCache(cacheDir) + + data := []byte("foobar") + // SHA3-384 + hash := "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8" + err := os.WriteFile(filepath.Join(d, "foobar"), data, 0644) + c.Assert(err, IsNil) + + // add a new file + ta, err := cache.Add(filepath.Join(d, "foobar"), "grub", "grubx64.efi") + c.Assert(err, IsNil) + c.Check(filepath.Join(cacheDir, "grub", fmt.Sprintf("grubx64.efi-%s", hash)), testutil.FileEquals, string(data)) + c.Check(ta, NotNil) + + // try the same file again + taAgain, err := cache.Add(filepath.Join(d, "foobar"), "grub", "grubx64.efi") + c.Assert(err, IsNil) + // file already cached + c.Check(filepath.Join(cacheDir, "grub", fmt.Sprintf("grubx64.efi-%s", hash)), testutil.FileEquals, string(data)) + // and there's just one entry in the cache + checkContentGlob(c, filepath.Join(cacheDir, "grub", "*"), []string{ + filepath.Join(cacheDir, "grub", fmt.Sprintf("grubx64.efi-%s", hash)), + }) + // let go-check do the deep equals check + c.Check(taAgain, DeepEquals, ta) + + // same data but different asset name + taDifferentAsset, err := cache.Add(filepath.Join(d, "foobar"), "grub", "bootx64.efi") + c.Assert(err, IsNil) + // new entry in cache + c.Check(filepath.Join(cacheDir, "grub", fmt.Sprintf("bootx64.efi-%s", hash)), testutil.FileEquals, string(data)) + // 2 files now + checkContentGlob(c, filepath.Join(cacheDir, "grub", "*"), []string{ + filepath.Join(cacheDir, "grub", fmt.Sprintf("bootx64.efi-%s", hash)), + filepath.Join(cacheDir, "grub", fmt.Sprintf("grubx64.efi-%s", hash)), + }) + c.Check(taDifferentAsset, NotNil) + + // same source, data (new hash), existing asset name + newData := []byte("new foobar") + newHash := "5aa87615f6613a37d63c9a29746ef57457286c37148a4ae78493b0face5976c1fea940a19486e6bef65d43aec6b8f5a2" + err = os.WriteFile(filepath.Join(d, "foobar"), newData, 0644) + c.Assert(err, IsNil) + + taExistingAssetName, err := cache.Add(filepath.Join(d, "foobar"), "grub", "bootx64.efi") + c.Assert(err, IsNil) + // new entry in cache + c.Check(taExistingAssetName, NotNil) + // we have both new and old asset + c.Check(filepath.Join(cacheDir, "grub", fmt.Sprintf("bootx64.efi-%s", newHash)), testutil.FileEquals, string(newData)) + c.Check(filepath.Join(cacheDir, "grub", fmt.Sprintf("bootx64.efi-%s", hash)), testutil.FileEquals, string(data)) + // 3 files in total + checkContentGlob(c, filepath.Join(cacheDir, "grub", "*"), []string{ + filepath.Join(cacheDir, "grub", fmt.Sprintf("bootx64.efi-%s", hash)), + filepath.Join(cacheDir, "grub", fmt.Sprintf("bootx64.efi-%s", newHash)), + filepath.Join(cacheDir, "grub", fmt.Sprintf("grubx64.efi-%s", hash)), + }) + + // drop + err = cache.Remove("grub", "bootx64.efi", newHash) + c.Assert(err, IsNil) + // asset bootx64.efi with given hash was dropped + c.Check(filepath.Join(cacheDir, "grub", fmt.Sprintf("bootx64.efi-%s", newHash)), testutil.FileAbsent) + // the other file still exists + c.Check(filepath.Join(cacheDir, "grub", fmt.Sprintf("bootx64.efi-%s", hash)), testutil.FileEquals, string(data)) + // remove it too + err = cache.Remove("grub", "bootx64.efi", hash) + c.Assert(err, IsNil) + c.Check(filepath.Join(cacheDir, "grub", fmt.Sprintf("bootx64.efi-%s", hash)), testutil.FileAbsent) + + // what is left is the grub assets only + checkContentGlob(c, filepath.Join(cacheDir, "grub", "*"), []string{ + filepath.Join(cacheDir, "grub", fmt.Sprintf("grubx64.efi-%s", hash)), + }) +} + +func (s *assetsSuite) TestAssetsCacheAddErr(c *C) { + cacheDir := c.MkDir() + d := c.MkDir() + cache := boot.NewTrustedAssetsCache(cacheDir) + + defer os.Chmod(cacheDir, 0755) + err := os.Chmod(cacheDir, 0000) + c.Assert(err, IsNil) + + if os.Geteuid() != 0 { + err = os.WriteFile(filepath.Join(d, "foobar"), []byte("foo"), 0644) + c.Assert(err, IsNil) + // cannot create bootloader subdirectory + ta, err := cache.Add(filepath.Join(d, "foobar"), "grub", "grubx64.efi") + c.Assert(err, ErrorMatches, "cannot create cache directory: mkdir .*/grub: permission denied") + c.Check(ta, IsNil) + } + + // fix it now + err = os.Chmod(cacheDir, 0755) + c.Assert(err, IsNil) + + _, err = cache.Add(filepath.Join(d, "no-file"), "grub", "grubx64.efi") + c.Assert(err, ErrorMatches, "cannot open asset file: open .*/no-file: no such file or directory") + + if os.Geteuid() != 0 { + blDir := filepath.Join(cacheDir, "grub") + defer os.Chmod(blDir, 0755) + err = os.Chmod(blDir, 0000) + c.Assert(err, IsNil) + + _, err = cache.Add(filepath.Join(d, "foobar"), "grub", "grubx64.efi") + c.Assert(err, ErrorMatches, `cannot create temporary cache file: open .*/grub/grubx64\.efi\.temp\.[a-zA-Z0-9]+~: permission denied`) + } +} + +func (s *assetsSuite) TestAssetsCacheRemoveErr(c *C) { + cacheDir := c.MkDir() + d := c.MkDir() + cache := boot.NewTrustedAssetsCache(cacheDir) + + data := []byte("foobar") + dataHash := "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8" + err := os.WriteFile(filepath.Join(d, "foobar"), data, 0644) + c.Assert(err, IsNil) + // cannot create bootloader subdirectory + _, err = cache.Add(filepath.Join(d, "foobar"), "grub", "grubx64.efi") + c.Assert(err, IsNil) + // validity + c.Check(filepath.Join(cacheDir, "grub", fmt.Sprintf("grubx64.efi-%s", dataHash)), testutil.FileEquals, string(data)) + + err = cache.Remove("grub", "no file", "some-hash") + c.Assert(err, IsNil) + + // different asset name but known hash + err = cache.Remove("grub", "different-name", dataHash) + c.Assert(err, IsNil) + c.Check(filepath.Join(cacheDir, "grub", fmt.Sprintf("grubx64.efi-%s", dataHash)), testutil.FileEquals, string(data)) +} + +func (s *assetsSuite) TestInstallObserverNew(c *C) { + d := c.MkDir() + // bootloader in gadget cannot be identified + uc20Model := boottest.MakeMockUC20Model() + for _, encryption := range []bool{true, false} { + obs, err := boot.TrustedAssetsInstallObserverForModel(uc20Model, d, encryption) + c.Assert(err, ErrorMatches, "cannot find bootloader: cannot determine bootloader") + c.Assert(obs, IsNil) + } + + // pretend grub is used + c.Assert(os.WriteFile(filepath.Join(d, "grub.conf"), nil, 0755), IsNil) + + for _, encryption := range []bool{true, false} { + obs, err := boot.TrustedAssetsInstallObserverForModel(uc20Model, d, encryption) + c.Assert(err, IsNil) + c.Assert(obs, NotNil) + } + + // but nil for non UC20 + nonUC20Model := boottest.MakeMockModel() + nonUC20obs, err := boot.TrustedAssetsInstallObserverForModel(nonUC20Model, d, false) + c.Assert(err, Equals, boot.ErrObserverNotApplicable) + c.Assert(nonUC20obs, IsNil) + + // listing trusted assets fails + tab := s.bootloaderWithTrustedAssets(map[string]string{ + "asset": "asset", + }) + tab.TrustedAssetsErr = fmt.Errorf("fail") + obs, err := boot.TrustedAssetsInstallObserverForModel(uc20Model, d, true) + c.Assert(err, ErrorMatches, `cannot list "trusted" bootloader trusted assets: fail`) + c.Assert(obs, IsNil) + // failed when listing run bootloader assets + c.Check(tab.TrustedAssetsCalls, Equals, 1) + + // force an error + bootloader.ForceError(fmt.Errorf("fail bootloader")) + obs, err = boot.TrustedAssetsInstallObserverForModel(uc20Model, d, true) + c.Assert(err, ErrorMatches, `cannot find bootloader: fail bootloader`) + c.Assert(obs, IsNil) +} + +func (s *assetsSuite) TestInstallObserverObserveSystemBootRealGrub(c *C) { + d := c.MkDir() + + // mock a bootloader that uses trusted assets + err := os.WriteFile(filepath.Join(d, "grub.conf"), nil, 0644) + c.Assert(err, IsNil) + + // we get an observer for UC20 + uc20Model := boottest.MakeMockUC20Model() + useEncryption := true + obs, err := boot.TrustedAssetsInstallObserverForModel(uc20Model, d, useEncryption) + c.Assert(err, IsNil) + c.Assert(obs, NotNil) + + data := []byte("foobar") + // SHA3-384 + dataHash := "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8" + err = os.WriteFile(filepath.Join(d, "foobar"), data, 0644) + c.Assert(err, IsNil) + + otherData := []byte("other foobar") + err = os.WriteFile(filepath.Join(d, "other-foobar"), otherData, 0644) + c.Assert(err, IsNil) + + writeChange := &gadget.ContentChange{ + // file that contains the data of the installed file + After: filepath.Join(d, "foobar"), + // there is no original file in place + Before: "", + } + // only grubx64.efi gets installed to system-boot + res, err := obs.Observe(gadget.ContentWrite, gadget.SystemBoot, boot.InitramfsUbuntuBootDir, + "EFI/boot/grubx64.efi", writeChange) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + // Observe is called when populating content, but one can freely specify + // overlapping content entries, so a same file may be observed more than + // once + res, err = obs.Observe(gadget.ContentWrite, gadget.SystemBoot, boot.InitramfsUbuntuBootDir, + "EFI/boot/grubx64.efi", writeChange) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + // try with one more file, which is not a trusted asset of a run mode, so it is ignored + res, err = obs.Observe(gadget.ContentWrite, gadget.SystemBoot, boot.InitramfsUbuntuBootDir, + "EFI/boot/bootx64.efi", writeChange) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + // a managed boot asset is to be held + res, err = obs.Observe(gadget.ContentWrite, gadget.SystemBoot, boot.InitramfsUbuntuBootDir, + "EFI/ubuntu/grub.cfg", writeChange) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeIgnore) + + // a single file in cache + checkContentGlob(c, filepath.Join(dirs.SnapBootAssetsDir, "grub", "*"), []string{ + filepath.Join(dirs.SnapBootAssetsDir, "grub", fmt.Sprintf("grubx64.efi-%s", dataHash)), + }) + + // and one more, a non system-boot structure, so the file is ignored + otherWriteChange := &gadget.ContentChange{ + After: filepath.Join(d, "other-foobar"), + } + // set a non system-boot structure, so the file is ignored + res, err = obs.Observe(gadget.ContentWrite, gadget.SystemSeed, boot.InitramfsUbuntuBootDir, + "EFI/boot/grubx64.efi", otherWriteChange) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + // still, only one entry in the cache + checkContentGlob(c, filepath.Join(dirs.SnapBootAssetsDir, "grub", "*"), []string{ + filepath.Join(dirs.SnapBootAssetsDir, "grub", fmt.Sprintf("grubx64.efi-%s", dataHash)), + }) + + // let's see what the observer has tracked + tracked := obs.CurrentTrustedBootAssetsMap() + c.Check(tracked, DeepEquals, boot.BootAssetsMap{ + "grubx64.efi": []string{dataHash}, + }) +} + +func (s *assetsSuite) TestInstallObserverObserveSystemBootMocked(c *C) { + d := c.MkDir() + + tab := s.bootloaderWithTrustedAssets(map[string]string{ + "asset": "asset", + "nested/other-asset": "other-asset", + }) + + // we get an observer for UC20 + uc20Model := boottest.MakeMockUC20Model() + useEncryption := true + obs, err := boot.TrustedAssetsInstallObserverForModel(uc20Model, d, useEncryption) + c.Assert(err, IsNil) + c.Assert(obs, NotNil) + // the list of trusted assets was asked for run and recovery bootloaders + c.Check(tab.TrustedAssetsCalls, Equals, 2) + + data := []byte("foobar") + // SHA3-384 + dataHash := "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8" + err = os.WriteFile(filepath.Join(d, "foobar"), data, 0644) + c.Assert(err, IsNil) + + writeChange := &gadget.ContentChange{ + // file that contains the data of the installed file + After: filepath.Join(d, "foobar"), + // there is no original file in place + Before: "", + } + res, err := obs.Observe(gadget.ContentWrite, gadget.SystemBoot, boot.InitramfsUbuntuBootDir, + "asset", writeChange) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + // observe same asset again + res, err = obs.Observe(gadget.ContentWrite, gadget.SystemBoot, boot.InitramfsUbuntuBootDir, + "asset", writeChange) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + // different one + res, err = obs.Observe(gadget.ContentWrite, gadget.SystemBoot, boot.InitramfsUbuntuBootDir, + "nested/other-asset", writeChange) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + // a non trusted asset + res, err = obs.Observe(gadget.ContentWrite, gadget.SystemBoot, boot.InitramfsUbuntuBootDir, + "non-trusted", writeChange) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + // a single file in cache + checkContentGlob(c, filepath.Join(dirs.SnapBootAssetsDir, "trusted", "*"), []string{ + filepath.Join(dirs.SnapBootAssetsDir, "trusted", fmt.Sprintf("asset-%s", dataHash)), + filepath.Join(dirs.SnapBootAssetsDir, "trusted", fmt.Sprintf("other-asset-%s", dataHash)), + }) + // let's see what the observer has tracked + tracked := obs.CurrentTrustedBootAssetsMap() + c.Check(tracked, DeepEquals, boot.BootAssetsMap{ + "asset": []string{dataHash}, + "other-asset": []string{dataHash}, + }) +} + +func (s *assetsSuite) TestInstallObserverObserveSystemBootMockedNoEncryption(c *C) { + d := c.MkDir() + s.bootloaderWithTrustedAssets(map[string]string{ + "asset": "asset", + }) + uc20Model := boottest.MakeMockUC20Model() + useEncryption := false + obs, err := boot.TrustedAssetsInstallObserverForModel(uc20Model, d, useEncryption) + c.Assert(err, Equals, boot.ErrObserverNotApplicable) + c.Assert(obs, IsNil) +} + +func (s *assetsSuite) TestInstallObserverObserveSystemBootMockedUnencryptedWithManaged(c *C) { + d := c.MkDir() + tab := s.bootloaderWithTrustedAssets(map[string]string{ + "asset": "asset", + }) + tab.ManagedAssetsList = []string{"managed"} + uc20Model := boottest.MakeMockUC20Model() + useEncryption := false + obs, err := boot.TrustedAssetsInstallObserverForModel(uc20Model, d, useEncryption) + c.Assert(err, IsNil) + c.Assert(obs, NotNil) + + c.Assert(os.WriteFile(filepath.Join(d, "foobar"), nil, 0755), IsNil) + writeChange := &gadget.ContentChange{ + // file that contains the data of the installed file + After: filepath.Join(d, "foobar"), + // there is no original file in place + Before: "", + } + res, err := obs.Observe(gadget.ContentWrite, gadget.SystemBoot, boot.InitramfsUbuntuBootDir, + "managed", writeChange) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeIgnore) +} + +func (s *assetsSuite) TestInstallObserverNonTrustedBootloader(c *C) { + // bootloader is not a trusted assets one, but we use encryption, one + // may try setting encryption key on the observer + + d := c.MkDir() + + // MockBootloader does not implement trusted assets + bootloader.Force(bootloadertest.Mock("mock", "")) + defer bootloader.Force(nil) + + // we get an observer for UC20 + uc20Model := boottest.MakeMockUC20Model() + useEncryption := true + obs, err := boot.TrustedAssetsInstallObserverForModel(uc20Model, d, useEncryption) + c.Assert(err, IsNil) + c.Assert(obs, NotNil) + obs.ChosenEncryptionKeys(keys.EncryptionKey{1, 2, 3, 4}, keys.EncryptionKey{5, 6, 7, 8}) + c.Check(obs.CurrentDataEncryptionKey(), DeepEquals, keys.EncryptionKey{1, 2, 3, 4}) + c.Check(obs.CurrentSaveEncryptionKey(), DeepEquals, keys.EncryptionKey{5, 6, 7, 8}) +} + +func (s *assetsSuite) TestInstallObserverTrustedButNoAssets(c *C) { + // bootloader has no trusted assets, but encryption is enabled, and one + // may try setting a key on the observer + + d := c.MkDir() + + tab := bootloadertest.Mock("trusted-assets", "").WithTrustedAssets() + bootloader.Force(tab) + defer bootloader.Force(nil) + + // we get an observer for UC20 + uc20Model := boottest.MakeMockUC20Model() + useEncryption := true + obs, err := boot.TrustedAssetsInstallObserverForModel(uc20Model, d, useEncryption) + c.Assert(err, IsNil) + c.Assert(obs, NotNil) + obs.ChosenEncryptionKeys(keys.EncryptionKey{1, 2, 3, 4}, keys.EncryptionKey{5, 6, 7, 8}) + c.Check(obs.CurrentDataEncryptionKey(), DeepEquals, keys.EncryptionKey{1, 2, 3, 4}) + c.Check(obs.CurrentSaveEncryptionKey(), DeepEquals, keys.EncryptionKey{5, 6, 7, 8}) +} + +func (s *assetsSuite) TestInstallObserverTrustedReuseNameErr(c *C) { + d := c.MkDir() + + tab := s.bootloaderWithTrustedAssets(map[string]string{ + "asset": "asset", + "nested/asset": "asset", + }) + + // we get an observer for UC20 + uc20Model := boottest.MakeMockUC20Model() + useEncryption := true + obs, err := boot.TrustedAssetsInstallObserverForModel(uc20Model, d, useEncryption) + c.Assert(err, IsNil) + c.Assert(obs, NotNil) + // the list of trusted assets was asked for run and recovery bootloaders + c.Check(tab.TrustedAssetsCalls, Equals, 2) + + err = os.WriteFile(filepath.Join(d, "foobar"), []byte("foobar"), 0644) + c.Assert(err, IsNil) + err = os.WriteFile(filepath.Join(d, "other"), []byte("other"), 0644) + c.Assert(err, IsNil) + res, err := obs.Observe(gadget.ContentWrite, gadget.SystemBoot, boot.InitramfsUbuntuBootDir, "asset", + &gadget.ContentChange{After: filepath.Join(d, "foobar")}) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + // same asset name but different content + res, err = obs.Observe(gadget.ContentWrite, gadget.SystemBoot, boot.InitramfsUbuntuBootDir, "nested/asset", + &gadget.ContentChange{After: filepath.Join(d, "other")}) + c.Assert(err, ErrorMatches, `cannot reuse asset name "asset"`) + c.Check(res, Equals, gadget.ChangeAbort) +} + +func (s *assetsSuite) TestInstallObserverObserveExistingRecoveryMocked(c *C) { + d := c.MkDir() + + tab := s.bootloaderWithTrustedAssets(map[string]string{ + "asset": "asset", + "nested/other-asset": "other-asset", + "shim": "shim", + }) + + // we get an observer for UC20 + uc20Model := boottest.MakeMockUC20Model() + useEncryption := true + obs, err := boot.TrustedAssetsInstallObserverForModel(uc20Model, d, useEncryption) + c.Assert(err, IsNil) + c.Assert(obs, NotNil) + // trusted assets for the run and recovery bootloaders were asked for + c.Check(tab.TrustedAssetsCalls, Equals, 2) + + data := []byte("foobar") + // SHA3-384 + dataHash := "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8" + err = os.WriteFile(filepath.Join(d, "asset"), data, 0644) + c.Assert(err, IsNil) + err = os.Mkdir(filepath.Join(d, "nested"), 0755) + c.Assert(err, IsNil) + err = os.WriteFile(filepath.Join(d, "nested/other-asset"), data, 0644) + c.Assert(err, IsNil) + shim := []byte("shim") + shimHash := "dac0063e831d4b2e7a330426720512fc50fa315042f0bb30f9d1db73e4898dcb89119cac41fdfa62137c8931a50f9d7b" + err = os.WriteFile(filepath.Join(d, "shim"), shim, 0644) + c.Assert(err, IsNil) + + err = obs.ObserveExistingTrustedRecoveryAssets(d) + c.Assert(err, IsNil) + checkContentGlob(c, filepath.Join(dirs.SnapBootAssetsDir, "trusted", "*"), []string{ + filepath.Join(dirs.SnapBootAssetsDir, "trusted", fmt.Sprintf("asset-%s", dataHash)), + filepath.Join(dirs.SnapBootAssetsDir, "trusted", fmt.Sprintf("other-asset-%s", dataHash)), + filepath.Join(dirs.SnapBootAssetsDir, "trusted", fmt.Sprintf("shim-%s", shimHash)), + }) + // the list of trusted assets for recovery was asked for + c.Check(tab.TrustedAssetsCalls, Equals, 2) + // let's see what the observer has tracked + tracked := obs.CurrentTrustedRecoveryBootAssetsMap() + c.Check(tracked, DeepEquals, boot.BootAssetsMap{ + "asset": []string{dataHash}, + "other-asset": []string{dataHash}, + "shim": []string{shimHash}, + }) +} + +func (s *assetsSuite) TestInstallObserverObserveExistingRecoveryReuseNameErr(c *C) { + d := c.MkDir() + + tab := s.bootloaderWithTrustedAssets(map[string]string{ + "asset": "asset", + "nested/asset": "asset", + }) + // we get an observer for UC20 + uc20Model := boottest.MakeMockUC20Model() + useEncryption := true + obs, err := boot.TrustedAssetsInstallObserverForModel(uc20Model, d, useEncryption) + c.Assert(err, IsNil) + c.Assert(obs, NotNil) + // got the list of trusted assets for run and recovery bootloaders + c.Check(tab.TrustedAssetsCalls, Equals, 2) + + err = os.WriteFile(filepath.Join(d, "asset"), []byte("foobar"), 0644) + c.Assert(err, IsNil) + err = os.MkdirAll(filepath.Join(d, "nested"), 0755) + c.Assert(err, IsNil) + // same asset name but different content + err = os.WriteFile(filepath.Join(d, "nested/asset"), []byte("other"), 0644) + c.Assert(err, IsNil) + err = obs.ObserveExistingTrustedRecoveryAssets(d) + // same asset name but different content + c.Assert(err, ErrorMatches, `cannot reuse recovery asset name "asset"`) + // got the list of trusted assets for recovery bootloader + c.Check(tab.TrustedAssetsCalls, Equals, 2) +} + +func (s *assetsSuite) TestUpdateObserverNew(c *C) { + tab := s.bootloaderWithTrustedAssets(nil) + + uc20Model := boottest.MakeMockUC20Model() + + gadgetDir := c.MkDir() + + // no trusted or managed assets + obs, err := boot.TrustedAssetsUpdateObserverForModel(uc20Model, gadgetDir) + c.Assert(err, Equals, boot.ErrObserverNotApplicable) + c.Check(obs, IsNil) + + // no managed, some trusted assets, but we are not tracking them + tab.TrustedAssetsMap = map[string]string{"asset": "asset"} + obs, err = boot.TrustedAssetsUpdateObserverForModel(uc20Model, gadgetDir) + c.Assert(err, Equals, boot.ErrObserverNotApplicable) + c.Check(obs, IsNil) + + // let's see some managed assets, but not trusted assets + tab.ManagedAssetsList = []string{"managed"} + tab.TrustedAssetsMap = nil + obs, err = boot.TrustedAssetsUpdateObserverForModel(uc20Model, gadgetDir) + c.Assert(err, IsNil) + c.Check(obs, NotNil) + + // no managed, some trusted which we need to track + s.stampSealedKeys(c, dirs.GlobalRootDir) + tab.ManagedAssetsList = nil + tab.TrustedAssetsMap = map[string]string{"asset": "asset"} + obs, err = boot.TrustedAssetsUpdateObserverForModel(uc20Model, gadgetDir) + c.Assert(err, IsNil) + c.Assert(obs, NotNil) + + // but nil for non UC20 + nonUC20Model := boottest.MakeMockModel() + nonUC20obs, err := boot.TrustedAssetsUpdateObserverForModel(nonUC20Model, gadgetDir) + c.Assert(err, Equals, boot.ErrObserverNotApplicable) + c.Assert(nonUC20obs, IsNil) +} + +func (s *assetsSuite) TestUpdateObserverUpdateMockedWithResealSeed(c *C) { + s.testUpdateObserverUpdateMockedWithReseal(c, gadget.SystemSeed) +} + +func (s *assetsSuite) TestUpdateObserverUpdateMockedWithResealSeedNull(c *C) { + s.testUpdateObserverUpdateMockedWithReseal(c, gadget.SystemSeedNull) +} + +func (s *assetsSuite) testUpdateObserverUpdateMockedWithReseal(c *C, seedRole string) { + // observe an update where some of the assets exist and some are new, + // followed by reseal + + d := c.MkDir() + backups := c.MkDir() + root := c.MkDir() + + // try to arrange the backups like the updater would do it + before := []byte("before") + beforeHash := "2df0976fd45ba2392dc7985cdfb7c2d096c1ea4917929dd7a0e9bffae90a443271e702663fc6a4189c1f4ab3ce7daee3" + err := os.WriteFile(filepath.Join(backups, "asset.backup"), before, 0644) + c.Assert(err, IsNil) + + data := []byte("foobar") + // SHA3-384 + dataHash := "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8" + err = os.WriteFile(filepath.Join(d, "foobar"), data, 0644) + c.Assert(err, IsNil) + shim := []byte("shim") + shimHash := "dac0063e831d4b2e7a330426720512fc50fa315042f0bb30f9d1db73e4898dcb89119cac41fdfa62137c8931a50f9d7b" + err = os.WriteFile(filepath.Join(d, "shim"), shim, 0644) + c.Assert(err, IsNil) + + m := boot.Modeenv{ + Mode: "run", + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": []string{beforeHash}, + "shim": []string{"shim-hash"}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": []string{beforeHash}, + }, + } + err = m.WriteTo("") + c.Assert(err, IsNil) + + tab := s.bootloaderWithTrustedAssets(map[string]string{ + "asset": "asset", + "nested/other-asset": "other-asset", + "shim": "shim", + }) + tab.ManagedAssetsList = []string{ + "managed-asset", + } + + // we get an observer for UC20 + obs, _ := s.uc20UpdateObserverEncryptedSystemMockedBootloader(c) + // the list of trusted assets is obtained upfront + c.Check(tab.TrustedAssetsCalls, Equals, 2) + + res, err := obs.Observe(gadget.ContentUpdate, gadget.SystemBoot, root, "asset", + &gadget.ContentChange{ + After: filepath.Join(d, "foobar"), + // original content would get backed up by the updater + Before: filepath.Join(backups, "asset.backup"), + }) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + res, err = obs.Observe(gadget.ContentUpdate, gadget.SystemBoot, root, "shim", + &gadget.ContentChange{After: filepath.Join(d, "shim")}) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + // observe the recovery struct + res, err = obs.Observe(gadget.ContentUpdate, seedRole, root, "shim", + &gadget.ContentChange{After: filepath.Join(d, "shim")}) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + res, err = obs.Observe(gadget.ContentUpdate, seedRole, root, "asset", + &gadget.ContentChange{ + After: filepath.Join(d, "foobar"), + // original content + Before: filepath.Join(backups, "asset.backup"), + }) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + res, err = obs.Observe(gadget.ContentUpdate, seedRole, root, "nested/other-asset", + &gadget.ContentChange{After: filepath.Join(d, "foobar")}) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + // all files are in cache + checkContentGlob(c, filepath.Join(dirs.SnapBootAssetsDir, "trusted", "*"), []string{ + filepath.Join(dirs.SnapBootAssetsDir, "trusted", fmt.Sprintf("asset-%s", dataHash)), + filepath.Join(dirs.SnapBootAssetsDir, "trusted", fmt.Sprintf("asset-%s", beforeHash)), + filepath.Join(dirs.SnapBootAssetsDir, "trusted", fmt.Sprintf("other-asset-%s", dataHash)), + filepath.Join(dirs.SnapBootAssetsDir, "trusted", fmt.Sprintf("shim-%s", shimHash)), + }) + // check modeenv + newM, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Check(newM.CurrentTrustedBootAssets, DeepEquals, boot.BootAssetsMap{ + "asset": []string{beforeHash, dataHash}, + "shim": []string{"shim-hash", shimHash}, + }) + c.Check(newM.CurrentTrustedRecoveryBootAssets, DeepEquals, boot.BootAssetsMap{ + "asset": []string{beforeHash, dataHash}, + "shim": []string{shimHash}, + "other-asset": []string{dataHash}, + }) + + // verify that managed assets are to be preserved + res, err = obs.Observe(gadget.ContentUpdate, seedRole, root, "managed-asset", + &gadget.ContentChange{After: filepath.Join(d, "foobar")}) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeIgnore) + + // everything is set up, trigger a reseal + resealCalls := 0 + restore := boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + resealCalls++ + return nil + }) + defer restore() + + err = obs.BeforeWrite() + c.Assert(err, IsNil) + c.Check(resealCalls, Equals, 1) +} + +func (s *assetsSuite) TestUpdateObserverUpdateExistingAssetMocked(c *C) { + d := c.MkDir() + root := c.MkDir() + + tab := s.bootloaderWithTrustedAssets(map[string]string{ + "asset": "asset", + "shim": "shim", + }) + tab.ManagedAssetsList = []string{ + "managed-asset", + "nested/managed-asset", + } + + data := []byte("foobar") + // SHA3-384 + dataHash := "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8" + err := os.WriteFile(filepath.Join(d, "foobar"), data, 0644) + c.Assert(err, IsNil) + shim := []byte("shim") + shimHash := "dac0063e831d4b2e7a330426720512fc50fa315042f0bb30f9d1db73e4898dcb89119cac41fdfa62137c8931a50f9d7b" + err = os.WriteFile(filepath.Join(d, "shim"), shim, 0644) + c.Assert(err, IsNil) + + // add one file to the cache, as if the system got rebooted before + // modeenv got updated + cache := boot.NewTrustedAssetsCache(dirs.SnapBootAssetsDir) + _, err = cache.Add(filepath.Join(d, "foobar"), "trusted", "asset") + c.Assert(err, IsNil) + // file is in the cache + checkContentGlob(c, filepath.Join(dirs.SnapBootAssetsDir, "trusted", "*"), []string{ + filepath.Join(dirs.SnapBootAssetsDir, "trusted", fmt.Sprintf("asset-%s", dataHash)), + }) + + m := boot.Modeenv{ + Mode: "run", + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": []string{"asset-hash"}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + // shim with same hash is listed as trusted, but missing + // from cache + "shim": []string{shimHash}, + }, + } + err = m.WriteTo("") + c.Assert(err, IsNil) + + // we get an observer for UC20 + obs, _ := s.uc20UpdateObserverEncryptedSystemMockedBootloader(c) + + // observe the updates + res, err := obs.Observe(gadget.ContentUpdate, gadget.SystemBoot, root, "asset", + &gadget.ContentChange{After: filepath.Join(d, "foobar")}) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + res, err = obs.Observe(gadget.ContentUpdate, gadget.SystemSeed, root, "asset", + &gadget.ContentChange{After: filepath.Join(d, "foobar")}) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + res, err = obs.Observe(gadget.ContentUpdate, gadget.SystemSeed, root, "shim", + &gadget.ContentChange{After: filepath.Join(d, "shim")}) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + // trusted assets were asked for + c.Check(tab.TrustedAssetsCalls, Equals, 2) + // file is in the cache + checkContentGlob(c, filepath.Join(dirs.SnapBootAssetsDir, "trusted", "*"), []string{ + filepath.Join(dirs.SnapBootAssetsDir, "trusted", fmt.Sprintf("asset-%s", dataHash)), + // shim was added to cache + filepath.Join(dirs.SnapBootAssetsDir, "trusted", fmt.Sprintf("shim-%s", shimHash)), + }) + // check modeenv + newM, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Check(newM.CurrentTrustedBootAssets, DeepEquals, boot.BootAssetsMap{ + "asset": []string{"asset-hash", dataHash}, + }) + c.Check(newM.CurrentTrustedRecoveryBootAssets, DeepEquals, boot.BootAssetsMap{ + "asset": []string{dataHash}, + "shim": []string{shimHash}, + }) + + // verify that managed assets are to be preserved + res, err = obs.Observe(gadget.ContentUpdate, gadget.SystemSeed, root, "managed-asset", + &gadget.ContentChange{After: filepath.Join(d, "foobar")}) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeIgnore) + res, err = obs.Observe(gadget.ContentUpdate, gadget.SystemSeed, root, "nested/managed-asset", + &gadget.ContentChange{After: filepath.Join(d, "foobar")}) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeIgnore) + + // everything is set up, trigger reseal + resealCalls := 0 + restore := boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + resealCalls++ + return nil + }) + defer restore() + + // execute before-write action + err = obs.BeforeWrite() + c.Assert(err, IsNil) + c.Check(resealCalls, Equals, 1) +} + +func (s *assetsSuite) TestUpdateObserverUpdateNothingTrackedMocked(c *C) { + d := c.MkDir() + root := c.MkDir() + + tab := s.bootloaderWithTrustedAssets(map[string]string{ + "asset": "asset", + }) + + data := []byte("foobar") + // SHA3-384 + dataHash := "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8" + err := os.WriteFile(filepath.Join(d, "foobar"), data, 0644) + c.Assert(err, IsNil) + + m := boot.Modeenv{ + Mode: "run", + // nothing is tracked in modeenv yet + } + err = m.WriteTo("") + c.Assert(err, IsNil) + + // we get an observer for UC20 + obs, _ := s.uc20UpdateObserverEncryptedSystemMockedBootloader(c) + + // observe the updates + res, err := obs.Observe(gadget.ContentUpdate, gadget.SystemBoot, root, "asset", + &gadget.ContentChange{After: filepath.Join(d, "foobar")}) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + res, err = obs.Observe(gadget.ContentUpdate, gadget.SystemSeed, root, "asset", + &gadget.ContentChange{After: filepath.Join(d, "foobar")}) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + // trusted assets were asked for + c.Check(tab.TrustedAssetsCalls, Equals, 2) + // file is in the cache + checkContentGlob(c, filepath.Join(dirs.SnapBootAssetsDir, "trusted", "*"), []string{ + filepath.Join(dirs.SnapBootAssetsDir, "trusted", fmt.Sprintf("asset-%s", dataHash)), + }) + // check modeenv + newM, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Check(newM.CurrentTrustedBootAssets, DeepEquals, boot.BootAssetsMap{ + "asset": []string{dataHash}, + }) + c.Check(newM.CurrentTrustedRecoveryBootAssets, DeepEquals, boot.BootAssetsMap{ + "asset": []string{dataHash}, + }) + + // reseal does nothing + err = obs.BeforeWrite() + c.Assert(err, IsNil) + c.Check(tab.RecoveryBootChainCalls, HasLen, 0) + c.Check(tab.BootChainKernelPath, HasLen, 0) +} + +func (s *assetsSuite) TestUpdateObserverUpdateOtherRoleStructMocked(c *C) { + d := c.MkDir() + root := c.MkDir() + + tab := s.bootloaderWithTrustedAssets(map[string]string{ + "asset": "asset", + }) + + // modeenv is not set up, but the observer should not care + + // we get an observer for UC20 + obs, _ := s.uc20UpdateObserverEncryptedSystemMockedBootloader(c) + // and once again for the recovery bootloader + c.Check(tab.TrustedAssetsCalls, Equals, 2) + + // observe the updates (system-data gets ignored) + res, err := obs.Observe(gadget.ContentUpdate, gadget.SystemData, root, "asset", + &gadget.ContentChange{After: filepath.Join(d, "foobar")}) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) +} + +func (s *assetsSuite) TestUpdateObserverUpdateTrivialErr(c *C) { + // test trivial error scenarios of the update observer + + s.stampSealedKeys(c, dirs.GlobalRootDir) + + d := c.MkDir() + root := c.MkDir() + gadgetDir := c.MkDir() + + uc20Model := boottest.MakeMockUC20Model() + + // first no bootloader + bootloader.ForceError(fmt.Errorf("bootloader fail")) + + obs, err := boot.TrustedAssetsUpdateObserverForModel(uc20Model, gadgetDir) + c.Assert(obs, IsNil) + c.Assert(err, ErrorMatches, "cannot find bootloader: bootloader fail") + + bootloader.ForceError(nil) + bl := bootloadertest.Mock("trusted", "").WithTrustedAssets() + bootloader.Force(bl) + defer bootloader.Force(nil) + + bl.TrustedAssetsErr = fmt.Errorf("fail") + obs, err = boot.TrustedAssetsUpdateObserverForModel(uc20Model, gadgetDir) + c.Assert(obs, IsNil) + c.Assert(err, ErrorMatches, `cannot list "trusted" bootloader trusted assets: fail`) + // failed listing trusted assets + c.Check(bl.TrustedAssetsCalls, Equals, 1) + + // grab a new bootloader mock + bl = bootloadertest.Mock("trusted", "").WithTrustedAssets() + bootloader.Force(bl) + bl.TrustedAssetsMap = map[string]string{"asset": "asset"} + + obs, err = boot.TrustedAssetsUpdateObserverForModel(uc20Model, gadgetDir) + c.Assert(err, IsNil) + c.Assert(obs, NotNil) + c.Check(bl.TrustedAssetsCalls, Equals, 2) + defer obs.Done() + + // no modeenv + res, err := obs.Observe(gadget.ContentUpdate, gadget.SystemBoot, root, "asset", + &gadget.ContentChange{After: filepath.Join(d, "foobar")}) + c.Assert(err, ErrorMatches, `cannot load modeenv: .* no such file or directory`) + c.Check(res, Equals, gadget.ChangeAbort) + res, err = obs.Observe(gadget.ContentUpdate, gadget.SystemSeed, root, "asset", + &gadget.ContentChange{After: filepath.Join(d, "foobar")}) + c.Assert(err, ErrorMatches, `cannot load modeenv: .* no such file or directory`) + c.Check(res, Equals, gadget.ChangeAbort) + + m := boot.Modeenv{ + Mode: "run", + } + err = m.WriteTo("") + c.Assert(err, IsNil) + + // no source file, hash will fail + res, err = obs.Observe(gadget.ContentUpdate, gadget.SystemBoot, root, "asset", + &gadget.ContentChange{After: filepath.Join(d, "foobar")}) + c.Assert(err, ErrorMatches, `cannot open asset file: .*/foobar: no such file or directory`) + c.Check(res, Equals, gadget.ChangeAbort) + res, err = obs.Observe(gadget.ContentUpdate, gadget.SystemBoot, root, "asset", + &gadget.ContentChange{Before: filepath.Join(d, "before"), After: filepath.Join(d, "foobar")}) + c.Assert(err, ErrorMatches, `cannot open asset file: .*/before: no such file or directory`) + c.Check(res, Equals, gadget.ChangeAbort) + res, err = obs.Observe(gadget.ContentUpdate, gadget.SystemSeed, root, "asset", + &gadget.ContentChange{After: filepath.Join(d, "foobar")}) + c.Assert(err, ErrorMatches, `cannot open asset file: .*/foobar: no such file or directory`) + c.Check(res, Equals, gadget.ChangeAbort) +} + +func (s *assetsSuite) TestUpdateObserverUpdateRepeatedAssetErr(c *C) { + d := c.MkDir() + root := c.MkDir() + + bl := bootloadertest.Mock("trusted", "").WithTrustedAssets() + bootloader.Force(bl) + defer bootloader.Force(nil) + bl.TrustedAssetsMap = map[string]string{"asset": "asset"} + + obs, _ := s.uc20UpdateObserverEncryptedSystemMockedBootloader(c) + + // we are already tracking 2 assets, this is an unexpected state for observing content updates + m := boot.Modeenv{ + Mode: "run", + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": []string{"one", "two"}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": []string{"one", "two"}, + }, + } + err := m.WriteTo("") + c.Assert(err, IsNil) + + // and the source file + err = os.WriteFile(filepath.Join(d, "foobar"), nil, 0644) + c.Assert(err, IsNil) + + res, err := obs.Observe(gadget.ContentUpdate, gadget.SystemBoot, root, "asset", + &gadget.ContentChange{After: filepath.Join(d, "foobar")}) + c.Assert(err, ErrorMatches, `cannot reuse asset name "asset"`) + c.Check(res, Equals, gadget.ChangeAbort) + res, err = obs.Observe(gadget.ContentUpdate, gadget.SystemSeed, root, "asset", + &gadget.ContentChange{After: filepath.Join(d, "foobar")}) + c.Assert(err, ErrorMatches, `cannot reuse asset name "asset"`) + c.Check(res, Equals, gadget.ChangeAbort) +} + +func (s *assetsSuite) TestUpdateObserverUpdateAfterSuccessfulBootMocked(c *C) { + //observe an update in a scenario when a mid-gadget-update reboot + //happened and we have successfully booted with new assets only, but the + //update is incomplete and gets started again + + d := c.MkDir() + backups := c.MkDir() + root := c.MkDir() + + // try to arrange the backups like the updater would do it + before := []byte("before") + beforeHash := "2df0976fd45ba2392dc7985cdfb7c2d096c1ea4917929dd7a0e9bffae90a443271e702663fc6a4189c1f4ab3ce7daee3" + err := os.WriteFile(filepath.Join(backups, "asset.backup"), before, 0644) + c.Assert(err, IsNil) + + data := []byte("foobar") + // SHA3-384 + dataHash := "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8" + err = os.WriteFile(filepath.Join(d, "foobar"), data, 0644) + c.Assert(err, IsNil) + + // pretend we rebooted mid update and have successfully booted with the + // new assets already, the old asset may have been dropped from the cache already + cache := boot.NewTrustedAssetsCache(dirs.SnapBootAssetsDir) + _, err = cache.Add(filepath.Join(d, "foobar"), "trusted", "asset") + c.Assert(err, IsNil) + // file is in the cache + checkContentGlob(c, filepath.Join(dirs.SnapBootAssetsDir, "trusted", "*"), []string{ + filepath.Join(dirs.SnapBootAssetsDir, "trusted", fmt.Sprintf("asset-%s", dataHash)), + }) + // and similarly, only the new asset in modeenv + m := boot.Modeenv{ + Mode: "run", + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": []string{dataHash}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": []string{dataHash}, + }, + } + err = m.WriteTo("") + c.Assert(err, IsNil) + + s.bootloaderWithTrustedAssets(map[string]string{ + "asset": "asset", + }) + + // we get an observer for UC20 + obs, _ := s.uc20UpdateObserverEncryptedSystemMockedBootloader(c) + + res, err := obs.Observe(gadget.ContentUpdate, gadget.SystemBoot, root, "asset", + &gadget.ContentChange{ + After: filepath.Join(d, "foobar"), + // original content would get backed up by the updater + Before: filepath.Join(backups, "asset.backup"), + }) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + res, err = obs.Observe(gadget.ContentUpdate, gadget.SystemSeed, root, "asset", + &gadget.ContentChange{ + After: filepath.Join(d, "foobar"), + // original content + Before: filepath.Join(backups, "asset.backup"), + }) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + + // all files are in cache + checkContentGlob(c, filepath.Join(dirs.SnapBootAssetsDir, "trusted", "*"), []string{ + filepath.Join(dirs.SnapBootAssetsDir, "trusted", fmt.Sprintf("asset-%s", dataHash)), + filepath.Join(dirs.SnapBootAssetsDir, "trusted", fmt.Sprintf("asset-%s", beforeHash)), + }) + // check modeenv + newM, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Check(newM.CurrentTrustedBootAssets, DeepEquals, boot.BootAssetsMap{ + // original asset is restored, listed first + "asset": []string{beforeHash, dataHash}, + }) + c.Check(newM.CurrentTrustedRecoveryBootAssets, DeepEquals, boot.BootAssetsMap{ + // same here + "asset": []string{beforeHash, dataHash}, + }) +} + +func (s *assetsSuite) TestUpdateObserverRollbackModeenvManipulationMocked(c *C) { + root := c.MkDir() + rootSeed := c.MkDir() + d := c.MkDir() + backups := c.MkDir() + + tab := s.bootloaderWithTrustedAssets(map[string]string{ + "asset": "asset", + "nested/other-asset": "other-asset", + "shim": "shim", + }) + + data := []byte("foobar") + // SHA3-384 + dataHash := "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8" + // file exists in both run and seed bootloader rootdirs + c.Assert(os.WriteFile(filepath.Join(root, "asset"), data, 0644), IsNil) + c.Assert(os.WriteFile(filepath.Join(rootSeed, "asset"), data, 0644), IsNil) + // and in the gadget + c.Assert(os.WriteFile(filepath.Join(d, "asset"), data, 0644), IsNil) + // would be listed as Before + c.Assert(os.WriteFile(filepath.Join(backups, "asset.backup"), data, 0644), IsNil) + + shim := []byte("shim") + shimHash := "dac0063e831d4b2e7a330426720512fc50fa315042f0bb30f9d1db73e4898dcb89119cac41fdfa62137c8931a50f9d7b" + // only exists in seed bootloader rootdir + c.Assert(os.WriteFile(filepath.Join(rootSeed, "shim"), shim, 0644), IsNil) + // and in the gadget + c.Assert(os.WriteFile(filepath.Join(d, "shim"), shim, 0644), IsNil) + // would be listed as Before + c.Assert(os.WriteFile(filepath.Join(backups, "shim.backup"), data, 0644), IsNil) + + c.Assert(os.MkdirAll(filepath.Join(dirs.SnapBootAssetsDir, "trusted"), 0755), IsNil) + // mock some files in cache + for _, name := range []string{ + fmt.Sprintf("asset-%s", dataHash), + fmt.Sprintf("shim-%s", shimHash), + "shim-newshimhash", + "asset-newhash", + "other-asset-newotherhash", + } { + err := os.WriteFile(filepath.Join(dirs.SnapBootAssetsDir, "trusted", name), nil, 0644) + c.Assert(err, IsNil) + } + + // we get an observer for UC20 + obs, _ := s.uc20UpdateObserverEncryptedSystemMockedBootloader(c) + // the list of trusted assets is obtained upfront + c.Check(tab.TrustedAssetsCalls, Equals, 2) + + m := boot.Modeenv{ + Mode: "run", + CurrentTrustedBootAssets: boot.BootAssetsMap{ + // new version added during update + "asset": []string{dataHash, "newhash"}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + // no new version added during update + "asset": []string{dataHash}, + // new version added during update + "shim": []string{shimHash, "newshimhash"}, + // completely new file + "other-asset": []string{"newotherhash"}, + }, + } + err := m.WriteTo("") + c.Assert(err, IsNil) + + res, err := obs.Observe(gadget.ContentRollback, gadget.SystemBoot, root, "asset", + &gadget.ContentChange{ + After: filepath.Join(d, "asset"), + Before: filepath.Join(backups, "asset.backup"), + }) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + res, err = obs.Observe(gadget.ContentRollback, gadget.SystemBoot, root, "shim", + &gadget.ContentChange{ + After: filepath.Join(d, "shim"), + // no before content, new file + }) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + // observe the recovery struct + res, err = obs.Observe(gadget.ContentRollback, gadget.SystemSeed, rootSeed, "shim", + &gadget.ContentChange{ + After: filepath.Join(d, "shim"), + Before: filepath.Join(backups, "shim.backup"), + }) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + res, err = obs.Observe(gadget.ContentRollback, gadget.SystemSeed, rootSeed, "asset", + &gadget.ContentChange{ + After: filepath.Join(d, "asset"), + Before: filepath.Join(backups, "asset.backup"), + }) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + res, err = obs.Observe(gadget.ContentRollback, gadget.SystemSeed, rootSeed, "nested/other-asset", + &gadget.ContentChange{ + After: filepath.Join(d, "asset"), + }) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + // all files are in cache + checkContentGlob(c, filepath.Join(dirs.SnapBootAssetsDir, "trusted", "*"), []string{ + filepath.Join(dirs.SnapBootAssetsDir, "trusted", fmt.Sprintf("asset-%s", dataHash)), + filepath.Join(dirs.SnapBootAssetsDir, "trusted", fmt.Sprintf("shim-%s", shimHash)), + }) + // check modeenv + newM, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Check(newM.CurrentTrustedBootAssets, DeepEquals, boot.BootAssetsMap{ + "asset": []string{dataHash}, + }) + c.Check(newM.CurrentTrustedRecoveryBootAssets, DeepEquals, boot.BootAssetsMap{ + "asset": []string{dataHash}, + "shim": []string{shimHash}, + }) +} + +func (s *assetsSuite) TestUpdateObserverRollbackFileValidity(c *C) { + root := c.MkDir() + + tab := s.bootloaderWithTrustedAssets(map[string]string{ + "asset": "asset", + }) + + // we get an observer for UC20 + obs, _ := s.uc20UpdateObserverEncryptedSystemMockedBootloader(c) + // list of trusted assets is obtained upfront + c.Check(tab.TrustedAssetsCalls, Equals, 2) + + // sane state of modeenv before rollback + m := boot.Modeenv{ + Mode: "run", + CurrentTrustedBootAssets: boot.BootAssetsMap{ + // only one hash is listed, indicating it's a new file + "asset": []string{"newhash"}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + // same thing + "asset": []string{"newhash"}, + }, + } + err := m.WriteTo("") + c.Assert(err, IsNil) + // file does not exist on disk + res, err := obs.Observe(gadget.ContentRollback, gadget.SystemBoot, root, "asset", + &gadget.ContentChange{}) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + // observe the recovery struct + res, err = obs.Observe(gadget.ContentRollback, gadget.SystemSeed, root, "asset", + &gadget.ContentChange{}) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + // check modeenv + newM, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Check(newM.CurrentTrustedBootAssets, HasLen, 0) + c.Check(newM.CurrentTrustedRecoveryBootAssets, HasLen, 0) + obs.Done() + + // new observer + obs, _ = s.uc20UpdateObserverEncryptedSystemMockedBootloader(c) + m = boot.Modeenv{ + Mode: "run", + CurrentTrustedBootAssets: boot.BootAssetsMap{ + // only one hash is listed, indicating it's a new file + "asset": []string{"newhash", "bogushash"}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + // same thing + "asset": []string{"newhash", "bogushash"}, + }, + } + err = m.WriteTo("") + c.Assert(err, IsNil) + // again, file does not exist on disk, but we expected it to be there + res, err = obs.Observe(gadget.ContentRollback, gadget.SystemBoot, root, "asset", + &gadget.ContentChange{}) + c.Assert(err, ErrorMatches, `tracked asset "asset" is unexpectedly missing from disk`) + c.Check(res, Equals, gadget.ChangeAbort) + res, err = obs.Observe(gadget.ContentRollback, gadget.SystemSeed, root, "asset", + &gadget.ContentChange{}) + c.Assert(err, ErrorMatches, `tracked asset "asset" is unexpectedly missing from disk`) + c.Check(res, Equals, gadget.ChangeAbort) + + // create the file which will fail checksum check + err = os.WriteFile(filepath.Join(root, "asset"), nil, 0644) + c.Assert(err, IsNil) + // once more, the file exists on disk, but has unexpected checksum + res, err = obs.Observe(gadget.ContentRollback, gadget.SystemBoot, root, "asset", + &gadget.ContentChange{}) + c.Assert(err, ErrorMatches, `unexpected content of existing asset "asset"`) + c.Check(res, Equals, gadget.ChangeAbort) + res, err = obs.Observe(gadget.ContentRollback, gadget.SystemSeed, root, "asset", + &gadget.ContentChange{}) + c.Assert(err, ErrorMatches, `unexpected content of existing asset "asset"`) + c.Check(res, Equals, gadget.ChangeAbort) +} + +func (s *assetsSuite) TestUpdateObserverUpdateRollbackGrub(c *C) { + // exercise a full update/rollback cycle with grub + + gadgetDir := c.MkDir() + bootDir := c.MkDir() + seedDir := c.MkDir() + + // prepare a marker for grub bootloader + c.Assert(os.WriteFile(filepath.Join(gadgetDir, "grub.conf"), nil, 0644), IsNil) + + // we get an observer for UC20 + s.stampSealedKeys(c, dirs.GlobalRootDir) + obs, _ := s.uc20UpdateObserver(c, gadgetDir) + + cache := boot.NewTrustedAssetsCache(dirs.SnapBootAssetsDir) + + for _, dir := range []struct { + root string + fileWithContent [][]string + addContentToCache bool + }{ + { + // data of boot bootloader + root: bootDir, + // SHA3-384: 0d0c6522fcc813770f2bb9ca68ad3b4f0ccc6b4bfbd2e8497030079e6146f92177ad8f6f83d96ab61d7d42f5228a4389 + fileWithContent: [][]string{ + {"EFI/boot/grubx64.efi", "grub efi"}, + }, + addContentToCache: true, + }, { + // data of seed bootloader + root: seedDir, + fileWithContent: [][]string{ + // SHA3-384: 6c3e6fc78ade5aadc5f9f0603a127346cc174436eb5e0188e108a376c3ba4d8951c460a8f51674e797c06951f74cb10d + {"EFI/boot/grubx64.efi", "recovery grub efi"}, + // SHA3-384: c0437507ac094a7e9c699725cc0a4726cd10799af9eb79bbeaa136c2773163c80432295c2a04d3aa2ddd535ce8f1a12b + {"EFI/boot/bootx64.efi", "recovery shim efi"}, + }, + addContentToCache: true, + }, { + // gadget content + root: gadgetDir, + fileWithContent: [][]string{ + // SHA3-384: f9554844308e89b565c1cdbcbdb9b09b8210dd2f1a11cb3b361de0a59f780ae3d4bd6941729a60e0f8ce15b2edef605d + {"grubx64.efi", "new grub efi"}, + // SHA3-384: cc0663cc7e6c7ada990261c3ff1d72da001dc02451558716422d3d2443b8789463363c9ff0cd1b853c6ced3e8e7dc39d + {"bootx64.efi", "new recovery shim efi"}, + {"grub.conf", "grub from gadget"}, + }, + }, + // just the markers + { + root: bootDir, + fileWithContent: [][]string{ + {"EFI/ubuntu/grub.cfg", "grub marker"}, + }, + }, { + root: seedDir, + fileWithContent: [][]string{ + {"EFI/ubuntu/grub.cfg", "grub marker"}, + }, + }, + } { + for _, f := range dir.fileWithContent { + p := filepath.Join(dir.root, f[0]) + err := os.MkdirAll(filepath.Dir(p), 0755) + c.Assert(err, IsNil) + err = os.WriteFile(p, []byte(f[1]), 0644) + c.Assert(err, IsNil) + if dir.addContentToCache { + _, err = cache.Add(p, "grub", filepath.Base(p)) + c.Assert(err, IsNil) + } + } + } + cacheContentBefore := []string{ + // recovery shim + filepath.Join(dirs.SnapBootAssetsDir, "grub", "bootx64.efi-c0437507ac094a7e9c699725cc0a4726cd10799af9eb79bbeaa136c2773163c80432295c2a04d3aa2ddd535ce8f1a12b"), + // boot bootloader + filepath.Join(dirs.SnapBootAssetsDir, "grub", "grubx64.efi-0d0c6522fcc813770f2bb9ca68ad3b4f0ccc6b4bfbd2e8497030079e6146f92177ad8f6f83d96ab61d7d42f5228a4389"), + // recovery bootloader + filepath.Join(dirs.SnapBootAssetsDir, "grub", "grubx64.efi-6c3e6fc78ade5aadc5f9f0603a127346cc174436eb5e0188e108a376c3ba4d8951c460a8f51674e797c06951f74cb10d"), + } + checkContentGlob(c, filepath.Join(dirs.SnapBootAssetsDir, "grub", "*"), cacheContentBefore) + // current files are tracked + m := boot.Modeenv{ + Mode: "run", + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "grubx64.efi": []string{"0d0c6522fcc813770f2bb9ca68ad3b4f0ccc6b4bfbd2e8497030079e6146f92177ad8f6f83d96ab61d7d42f5228a4389"}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "grubx64.efi": []string{"6c3e6fc78ade5aadc5f9f0603a127346cc174436eb5e0188e108a376c3ba4d8951c460a8f51674e797c06951f74cb10d"}, + "bootx64.efi": []string{"c0437507ac094a7e9c699725cc0a4726cd10799af9eb79bbeaa136c2773163c80432295c2a04d3aa2ddd535ce8f1a12b"}, + }, + } + err := m.WriteTo("") + c.Assert(err, IsNil) + + // updates first + res, err := obs.Observe(gadget.ContentUpdate, gadget.SystemBoot, bootDir, "EFI/boot/grubx64.efi", + &gadget.ContentChange{After: filepath.Join(gadgetDir, "grubx64.efi")}) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + res, err = obs.Observe(gadget.ContentUpdate, gadget.SystemSeed, seedDir, "EFI/boot/grubx64.efi", + &gadget.ContentChange{After: filepath.Join(gadgetDir, "grubx64.efi")}) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + res, err = obs.Observe(gadget.ContentUpdate, gadget.SystemSeed, seedDir, "EFI/boot/bootx64.efi", + &gadget.ContentChange{After: filepath.Join(gadgetDir, "bootx64.efi")}) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + // grub.cfg on ubuntu-seed and ubuntu-boot is managed by snapd + res, err = obs.Observe(gadget.ContentUpdate, gadget.SystemBoot, seedDir, "EFI/ubuntu/grub.cfg", + &gadget.ContentChange{After: filepath.Join(gadgetDir, "grub.conf")}) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeIgnore) + res, err = obs.Observe(gadget.ContentUpdate, gadget.SystemSeed, seedDir, "EFI/ubuntu/grub.cfg", + &gadget.ContentChange{After: filepath.Join(gadgetDir, "grub.conf")}) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeIgnore) + + // verify cache contents + checkContentGlob(c, filepath.Join(dirs.SnapBootAssetsDir, "grub", "*"), []string{ + // recovery shim + filepath.Join(dirs.SnapBootAssetsDir, "grub", "bootx64.efi-c0437507ac094a7e9c699725cc0a4726cd10799af9eb79bbeaa136c2773163c80432295c2a04d3aa2ddd535ce8f1a12b"), + // new recovery shim + filepath.Join(dirs.SnapBootAssetsDir, "grub", "bootx64.efi-cc0663cc7e6c7ada990261c3ff1d72da001dc02451558716422d3d2443b8789463363c9ff0cd1b853c6ced3e8e7dc39d"), + // boot bootloader + filepath.Join(dirs.SnapBootAssetsDir, "grub", "grubx64.efi-0d0c6522fcc813770f2bb9ca68ad3b4f0ccc6b4bfbd2e8497030079e6146f92177ad8f6f83d96ab61d7d42f5228a4389"), + // recovery bootloader + filepath.Join(dirs.SnapBootAssetsDir, "grub", "grubx64.efi-6c3e6fc78ade5aadc5f9f0603a127346cc174436eb5e0188e108a376c3ba4d8951c460a8f51674e797c06951f74cb10d"), + // new recovery and boot bootloader + filepath.Join(dirs.SnapBootAssetsDir, "grub", "grubx64.efi-f9554844308e89b565c1cdbcbdb9b09b8210dd2f1a11cb3b361de0a59f780ae3d4bd6941729a60e0f8ce15b2edef605d"), + }) + + // and modeenv contents + newM, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Check(newM.CurrentTrustedBootAssets, DeepEquals, boot.BootAssetsMap{ + "grubx64.efi": []string{ + // old hash + "0d0c6522fcc813770f2bb9ca68ad3b4f0ccc6b4bfbd2e8497030079e6146f92177ad8f6f83d96ab61d7d42f5228a4389", + // update + "f9554844308e89b565c1cdbcbdb9b09b8210dd2f1a11cb3b361de0a59f780ae3d4bd6941729a60e0f8ce15b2edef605d", + }, + }) + c.Check(newM.CurrentTrustedRecoveryBootAssets, DeepEquals, boot.BootAssetsMap{ + "grubx64.efi": []string{ + // old hash + "6c3e6fc78ade5aadc5f9f0603a127346cc174436eb5e0188e108a376c3ba4d8951c460a8f51674e797c06951f74cb10d", + // update + "f9554844308e89b565c1cdbcbdb9b09b8210dd2f1a11cb3b361de0a59f780ae3d4bd6941729a60e0f8ce15b2edef605d", + }, + "bootx64.efi": []string{ + // old hash + "c0437507ac094a7e9c699725cc0a4726cd10799af9eb79bbeaa136c2773163c80432295c2a04d3aa2ddd535ce8f1a12b", + // update + "cc0663cc7e6c7ada990261c3ff1d72da001dc02451558716422d3d2443b8789463363c9ff0cd1b853c6ced3e8e7dc39d", + }, + }) + + // hiya, update failed, pretend we do a rollback, files on disk are as + // if they were restored + + res, err = obs.Observe(gadget.ContentRollback, gadget.SystemBoot, bootDir, "EFI/boot/grubx64.efi", + &gadget.ContentChange{}) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + res, err = obs.Observe(gadget.ContentRollback, gadget.SystemSeed, seedDir, "EFI/boot/grubx64.efi", + &gadget.ContentChange{}) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + res, err = obs.Observe(gadget.ContentRollback, gadget.SystemSeed, seedDir, "EFI/boot/bootx64.efi", + &gadget.ContentChange{}) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + + // modeenv is back to the initial state + afterRollbackM, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Check(afterRollbackM.CurrentTrustedBootAssets, DeepEquals, m.CurrentTrustedBootAssets) + c.Check(afterRollbackM.CurrentTrustedRecoveryBootAssets, DeepEquals, m.CurrentTrustedRecoveryBootAssets) + // and cache is back to the same state as before + checkContentGlob(c, filepath.Join(dirs.SnapBootAssetsDir, "grub", "*"), cacheContentBefore) +} + +func (s *assetsSuite) TestUpdateObserverCanceledSimpleAfterBackupMocked(c *C) { + d := c.MkDir() + root := c.MkDir() + + m := boot.Modeenv{ + Mode: "run", + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": []string{"assethash"}, + "shim": []string{"shimhash"}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": []string{"recoveryhash"}, + }, + } + err := m.WriteTo("") + c.Assert(err, IsNil) + + // mock some files in cache + c.Assert(os.MkdirAll(filepath.Join(dirs.SnapBootAssetsDir, "trusted"), 0755), IsNil) + for _, name := range []string{ + "shim-shimhash", + "asset-assethash", + "asset-recoveryhash", + } { + err = os.WriteFile(filepath.Join(dirs.SnapBootAssetsDir, "trusted", name), nil, 0644) + c.Assert(err, IsNil) + } + + s.bootloaderWithTrustedAssets(map[string]string{ + "asset": "asset", + "shim": "shim", + }) + + // we get an observer for UC20 + obs, _ := s.uc20UpdateObserverEncryptedSystemMockedBootloader(c) + + data := []byte("foobar") + // SHA3-384 + dataHash := "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8" + err = os.WriteFile(filepath.Join(d, "foobar"), data, 0644) + c.Assert(err, IsNil) + shim := []byte("shim") + shimHash := "dac0063e831d4b2e7a330426720512fc50fa315042f0bb30f9d1db73e4898dcb89119cac41fdfa62137c8931a50f9d7b" + err = os.WriteFile(filepath.Join(d, "shim"), shim, 0644) + c.Assert(err, IsNil) + + res, err := obs.Observe(gadget.ContentUpdate, gadget.SystemBoot, root, "asset", + &gadget.ContentChange{After: filepath.Join(d, "foobar")}) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + res, err = obs.Observe(gadget.ContentUpdate, gadget.SystemBoot, root, "shim", + &gadget.ContentChange{After: filepath.Join(d, "shim")}) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + // observe the recovery struct + res, err = obs.Observe(gadget.ContentUpdate, gadget.SystemSeed, root, "shim", + &gadget.ContentChange{After: filepath.Join(d, "shim")}) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + res, err = obs.Observe(gadget.ContentUpdate, gadget.SystemSeed, root, "asset", + &gadget.ContentChange{After: filepath.Join(d, "foobar")}) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + // files are in cache + checkContentGlob(c, filepath.Join(dirs.SnapBootAssetsDir, "trusted", "*"), []string{ + filepath.Join(dirs.SnapBootAssetsDir, "trusted", fmt.Sprintf("asset-%s", dataHash)), + filepath.Join(dirs.SnapBootAssetsDir, "trusted", "asset-assethash"), + filepath.Join(dirs.SnapBootAssetsDir, "trusted", "asset-recoveryhash"), + filepath.Join(dirs.SnapBootAssetsDir, "trusted", fmt.Sprintf("shim-%s", shimHash)), + filepath.Join(dirs.SnapBootAssetsDir, "trusted", "shim-shimhash"), + }) + // check modeenv + newM, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Check(newM.CurrentTrustedBootAssets, DeepEquals, boot.BootAssetsMap{ + "asset": []string{"assethash", dataHash}, + "shim": []string{"shimhash", shimHash}, + }) + c.Check(newM.CurrentTrustedRecoveryBootAssets, DeepEquals, boot.BootAssetsMap{ + "asset": []string{"recoveryhash", dataHash}, + "shim": []string{shimHash}, + }) + resealCalls := 0 + restore := boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + resealCalls++ + return nil + }) + defer restore() + + // update is canceled + err = obs.Canceled() + c.Assert(err, IsNil) + // modeenv is back to initial state + afterCancelM, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Check(afterCancelM.CurrentTrustedBootAssets, DeepEquals, m.CurrentTrustedBootAssets) + c.Check(afterCancelM.CurrentTrustedRecoveryBootAssets, DeepEquals, m.CurrentTrustedRecoveryBootAssets) + // unused assets were dropped + checkContentGlob(c, filepath.Join(dirs.SnapBootAssetsDir, "trusted", "*"), []string{ + filepath.Join(dirs.SnapBootAssetsDir, "trusted", "asset-assethash"), + filepath.Join(dirs.SnapBootAssetsDir, "trusted", "asset-recoveryhash"), + filepath.Join(dirs.SnapBootAssetsDir, "trusted", "shim-shimhash"), + }) + + c.Check(resealCalls, Equals, 1) +} + +func (s *assetsSuite) TestUpdateObserverCanceledPartiallyUsedMocked(c *C) { + // cancel an update where one of the assets is already used and canceling does not remove it from the cache + + d := c.MkDir() + root := c.MkDir() + + s.bootloaderWithTrustedAssets(map[string]string{ + "asset": "asset", + "shim": "shim", + }) + + data := []byte("foobar") + // SHA3-384 + dataHash := "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8" + err := os.WriteFile(filepath.Join(d, "foobar"), data, 0644) + c.Assert(err, IsNil) + shim := []byte("shim") + shimHash := "dac0063e831d4b2e7a330426720512fc50fa315042f0bb30f9d1db73e4898dcb89119cac41fdfa62137c8931a50f9d7b" + err = os.WriteFile(filepath.Join(d, "shim"), shim, 0644) + c.Assert(err, IsNil) + + // mock some files in cache + c.Assert(os.MkdirAll(filepath.Join(dirs.SnapBootAssetsDir, "trusted"), 0755), IsNil) + for _, name := range []string{ + "shim-shimhash", + "asset-assethash", + fmt.Sprintf("shim-%s", shimHash), + } { + err = os.WriteFile(filepath.Join(dirs.SnapBootAssetsDir, "trusted", name), nil, 0644) + c.Assert(err, IsNil) + } + + // we get an observer for UC20 + obs, _ := s.uc20UpdateObserverEncryptedSystemMockedBootloader(c) + + m := boot.Modeenv{ + Mode: "run", + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": []string{"assethash"}, + "shim": []string{"shimhash"}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "shim": []string{shimHash}, + }, + } + err = m.WriteTo("") + c.Assert(err, IsNil) + + res, err := obs.Observe(gadget.ContentUpdate, gadget.SystemBoot, root, "asset", + &gadget.ContentChange{After: filepath.Join(d, "foobar")}) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + res, err = obs.Observe(gadget.ContentUpdate, gadget.SystemBoot, root, "shim", + &gadget.ContentChange{After: filepath.Join(d, "shim")}) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + // observe the recovery struct + // XXX: shim is not updated + res, err = obs.Observe(gadget.ContentUpdate, gadget.SystemSeed, root, "asset", + &gadget.ContentChange{After: filepath.Join(d, "foobar")}) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + // files are in cache + checkContentGlob(c, filepath.Join(dirs.SnapBootAssetsDir, "trusted", "*"), []string{ + filepath.Join(dirs.SnapBootAssetsDir, "trusted", fmt.Sprintf("asset-%s", dataHash)), + filepath.Join(dirs.SnapBootAssetsDir, "trusted", "asset-assethash"), + filepath.Join(dirs.SnapBootAssetsDir, "trusted", fmt.Sprintf("shim-%s", shimHash)), + filepath.Join(dirs.SnapBootAssetsDir, "trusted", "shim-shimhash"), + }) + // check modeenv + newM, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Check(newM.CurrentTrustedBootAssets, DeepEquals, boot.BootAssetsMap{ + "asset": []string{"assethash", dataHash}, + "shim": []string{"shimhash", shimHash}, + }) + c.Check(newM.CurrentTrustedRecoveryBootAssets, DeepEquals, boot.BootAssetsMap{ + "asset": []string{dataHash}, + "shim": []string{shimHash}, + }) + // update is canceled + err = obs.Canceled() + c.Assert(err, IsNil) + // modeenv is back to initial state + afterCancelM, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Check(afterCancelM.CurrentTrustedBootAssets, DeepEquals, m.CurrentTrustedBootAssets) + c.Check(afterCancelM.CurrentTrustedRecoveryBootAssets, DeepEquals, m.CurrentTrustedRecoveryBootAssets) + // unused assets were dropped + checkContentGlob(c, filepath.Join(dirs.SnapBootAssetsDir, "trusted", "*"), []string{ + filepath.Join(dirs.SnapBootAssetsDir, "trusted", "asset-assethash"), + filepath.Join(dirs.SnapBootAssetsDir, "trusted", fmt.Sprintf("shim-%s", shimHash)), + filepath.Join(dirs.SnapBootAssetsDir, "trusted", "shim-shimhash"), + }) +} + +func (s *assetsSuite) TestUpdateObserverCanceledNoActionsMocked(c *C) { + // make sure that when no ContentUpdate actions were registered, or some + // were registered for one bootloader, but not the other, is not + // triggering unwanted behavior on cancel + + d := c.MkDir() + root := c.MkDir() + + m := boot.Modeenv{ + Mode: "run", + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": []string{"assethash"}, + "shim": []string{"shimhash"}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": []string{"recoveryhash"}, + }, + } + err := m.WriteTo("") + c.Assert(err, IsNil) + + // mock the files in cache + c.Assert(os.MkdirAll(filepath.Join(dirs.SnapBootAssetsDir, "trusted"), 0755), IsNil) + for _, name := range []string{ + "shim-shimhash", + "asset-assethash", + "asset-recoveryhash", + } { + err = os.WriteFile(filepath.Join(dirs.SnapBootAssetsDir, "trusted", name), nil, 0644) + c.Assert(err, IsNil) + } + + s.bootloaderWithTrustedAssets(map[string]string{ + "asset": "asset", + "shim": "shim", + }) + // we get an observer for UC20 + obs, _ := s.uc20UpdateObserverEncryptedSystemMockedBootloader(c) + + resealCalls := 0 + restore := boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + resealCalls++ + return nil + }) + defer restore() + + // cancel the update + err = obs.Canceled() + c.Assert(err, IsNil) + // modeenv is unchanged + afterCancelM, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Check(afterCancelM.CurrentTrustedBootAssets, DeepEquals, m.CurrentTrustedBootAssets) + c.Check(afterCancelM.CurrentTrustedRecoveryBootAssets, DeepEquals, m.CurrentTrustedRecoveryBootAssets) + // unused assets were dropped + checkContentGlob(c, filepath.Join(dirs.SnapBootAssetsDir, "trusted", "*"), []string{ + filepath.Join(dirs.SnapBootAssetsDir, "trusted", "asset-assethash"), + filepath.Join(dirs.SnapBootAssetsDir, "trusted", "asset-recoveryhash"), + filepath.Join(dirs.SnapBootAssetsDir, "trusted", "shim-shimhash"), + }) + + c.Check(resealCalls, Equals, 0) + + err = os.WriteFile(filepath.Join(d, "shim"), []byte("shim"), 0644) + c.Assert(err, IsNil) + // observe only recovery bootloader update, no action for run bootloader + res, err := obs.Observe(gadget.ContentUpdate, gadget.SystemSeed, root, "shim", + &gadget.ContentChange{After: filepath.Join(d, "shim")}) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + // cancel again + err = obs.Canceled() + c.Assert(err, IsNil) + afterCancelM, err = boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Check(afterCancelM.CurrentTrustedBootAssets, DeepEquals, m.CurrentTrustedBootAssets) + c.Check(afterCancelM.CurrentTrustedRecoveryBootAssets, DeepEquals, m.CurrentTrustedRecoveryBootAssets) + checkContentGlob(c, filepath.Join(dirs.SnapBootAssetsDir, "trusted", "*"), []string{ + filepath.Join(dirs.SnapBootAssetsDir, "trusted", "asset-assethash"), + filepath.Join(dirs.SnapBootAssetsDir, "trusted", "asset-recoveryhash"), + filepath.Join(dirs.SnapBootAssetsDir, "trusted", "shim-shimhash"), + }) +} + +func (s *assetsSuite) TestUpdateObserverCanceledEmptyModeenvAssets(c *C) { + // cancel an update where the maps of trusted assets are nil/empty + d := c.MkDir() + root := c.MkDir() + m := boot.Modeenv{ + Mode: "run", + } + err := m.WriteTo("") + c.Assert(err, IsNil) + + s.bootloaderWithTrustedAssets(map[string]string{ + "asset": "asset", + "shim": "shim", + }) + // we get an observer for UC20 + obs, _ := s.uc20UpdateObserverEncryptedSystemMockedBootloader(c) + + // trigger loading modeenv and bootloader information + err = os.WriteFile(filepath.Join(d, "shim"), []byte("shim"), 0644) + c.Assert(err, IsNil) + // observe an update only for the recovery bootloader, the run bootloader trusted assets remain empty + res, err := obs.Observe(gadget.ContentUpdate, gadget.SystemSeed, root, "shim", + &gadget.ContentChange{After: filepath.Join(d, "shim")}) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + + // cancel the update + err = obs.Canceled() + c.Assert(err, IsNil) + afterCancelM, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Check(afterCancelM.CurrentTrustedBootAssets, HasLen, 0) + c.Check(afterCancelM.CurrentTrustedRecoveryBootAssets, HasLen, 0) + obs.Done() + + // get a new observer, and observe an update for run bootloader asset only + obs, _ = s.uc20UpdateObserverEncryptedSystemMockedBootloader(c) + res, err = obs.Observe(gadget.ContentUpdate, gadget.SystemBoot, root, "shim", + &gadget.ContentChange{After: filepath.Join(d, "shim")}) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + // cancel once more + err = obs.Canceled() + c.Assert(err, IsNil) + afterCancelM, err = boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Check(afterCancelM.CurrentTrustedBootAssets, HasLen, 0) + c.Check(afterCancelM.CurrentTrustedRecoveryBootAssets, HasLen, 0) +} + +func (s *assetsSuite) TestUpdateObserverCanceledAfterRollback(c *C) { + // pretend there are changed assets with hashes that are not listed in + // modeenv + d := c.MkDir() + root := c.MkDir() + + m := boot.Modeenv{ + Mode: "run", + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": []string{"assethash"}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": []string{"assethash"}, + }, + } + err := m.WriteTo("") + c.Assert(err, IsNil) + + s.bootloaderWithTrustedAssets(map[string]string{ + "asset": "asset", + "shim": "shim", + }) + // we get an observer for UC20 + obs, _ := s.uc20UpdateObserverEncryptedSystemMockedBootloader(c) + + // trigger loading modeenv and bootloader information + err = os.WriteFile(filepath.Join(d, "shim"), []byte("shim"), 0644) + c.Assert(err, IsNil) + res, err := obs.Observe(gadget.ContentUpdate, gadget.SystemSeed, root, "shim", + &gadget.ContentChange{After: filepath.Join(d, "shim")}) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + res, err = obs.Observe(gadget.ContentUpdate, gadget.SystemBoot, root, "shim", + &gadget.ContentChange{After: filepath.Join(d, "shim")}) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + + // procure the desired state by: + // injecting a changed asset for run bootloader + recoveryAsset := true + obs.InjectChangedAsset("trusted", "asset", "changehash", !recoveryAsset) + // and a changed asset for recovery bootloader + obs.InjectChangedAsset("trusted", "asset", "changehash", recoveryAsset) + // completely unknown + obs.InjectChangedAsset("trusted", "unknown", "somehash", !recoveryAsset) + + // cancel the update + err = obs.Canceled() + c.Assert(err, IsNil) + afterCancelM, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Check(afterCancelM.CurrentTrustedBootAssets, DeepEquals, m.CurrentTrustedBootAssets) + c.Check(afterCancelM.CurrentTrustedRecoveryBootAssets, DeepEquals, m.CurrentTrustedRecoveryBootAssets) +} + +func (s *assetsSuite) TestUpdateObserverCanceledUnhappyCacheStillProceeds(c *C) { + // make sure that trying to remove the file from cache will not break + // the cancellation + + if os.Geteuid() == 0 { + c.Skip("the test cannot be executed by the root user") + } + + logBuf, restore := logger.MockLogger() + defer restore() + + d := c.MkDir() + root := c.MkDir() + + m := boot.Modeenv{ + Mode: "run", + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": []string{"assethash"}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": []string{"recoveryhash"}, + }, + } + err := m.WriteTo("") + c.Assert(err, IsNil) + + // mock the files in cache + c.Assert(os.MkdirAll(filepath.Join(dirs.SnapBootAssetsDir, "trusted"), 0755), IsNil) + for _, name := range []string{ + "asset-assethash", + "asset-recoveryhash", + } { + err = os.WriteFile(filepath.Join(dirs.SnapBootAssetsDir, "trusted", name), nil, 0644) + c.Assert(err, IsNil) + } + + s.bootloaderWithTrustedAssets(map[string]string{ + "asset": "asset", + "shim": "shim", + }) + // we get an observer for UC20 + obs, _ := s.uc20UpdateObserverEncryptedSystemMockedBootloader(c) + + shim := []byte("shim") + shimHash := "dac0063e831d4b2e7a330426720512fc50fa315042f0bb30f9d1db73e4898dcb89119cac41fdfa62137c8931a50f9d7b" + err = os.WriteFile(filepath.Join(d, "shim"), shim, 0644) + c.Assert(err, IsNil) + res, err := obs.Observe(gadget.ContentUpdate, gadget.SystemSeed, root, "shim", + &gadget.ContentChange{After: filepath.Join(d, "shim")}) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + res, err = obs.Observe(gadget.ContentUpdate, gadget.SystemBoot, root, "shim", + &gadget.ContentChange{After: filepath.Join(d, "shim")}) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + // make sure that the cache directory state is as expected + checkContentGlob(c, filepath.Join(dirs.SnapBootAssetsDir, "trusted", "*"), []string{ + filepath.Join(dirs.SnapBootAssetsDir, "trusted", "asset-assethash"), + filepath.Join(dirs.SnapBootAssetsDir, "trusted", "asset-recoveryhash"), + filepath.Join(dirs.SnapBootAssetsDir, "trusted", fmt.Sprintf("shim-%s", shimHash)), + }) + // and the file is added to the assets map + newM, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Check(newM.CurrentTrustedBootAssets, DeepEquals, boot.BootAssetsMap{ + "asset": []string{"assethash"}, + "shim": []string{shimHash}, + }) + c.Check(newM.CurrentTrustedRecoveryBootAssets, DeepEquals, boot.BootAssetsMap{ + "asset": []string{"recoveryhash"}, + "shim": []string{shimHash}, + }) + + // make cache directory read only and thus cache.Remove() fail + c.Assert(os.Chmod(filepath.Join(dirs.SnapBootAssetsDir, "trusted"), 0444), IsNil) + defer os.Chmod(filepath.Join(dirs.SnapBootAssetsDir, "trusted"), 0755) + + // cancel should not fail, even though files cannot be removed from cache + err = obs.Canceled() + c.Assert(err, IsNil) + afterCancelM, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Check(afterCancelM.CurrentTrustedBootAssets, DeepEquals, m.CurrentTrustedBootAssets) + c.Check(afterCancelM.CurrentTrustedRecoveryBootAssets, DeepEquals, m.CurrentTrustedRecoveryBootAssets) + checkContentGlob(c, filepath.Join(dirs.SnapBootAssetsDir, "trusted", "*"), []string{ + filepath.Join(dirs.SnapBootAssetsDir, "trusted", "asset-assethash"), + filepath.Join(dirs.SnapBootAssetsDir, "trusted", "asset-recoveryhash"), + filepath.Join(dirs.SnapBootAssetsDir, "trusted", fmt.Sprintf("shim-%s", shimHash)), + }) + c.Check(logBuf.String(), Matches, fmt.Sprintf(`.* cannot remove unused boot asset shim:%s: .* permission denied\n`, shimHash)) +} + +func (s *assetsSuite) TestObserveSuccessfulBootNoTrusted(c *C) { + // call to observe successful boot without any trusted assets + + m := &boot.Modeenv{ + Mode: "run", + // no trusted assets + } + newM, drop, err := boot.ObserveSuccessfulBootWithAssets(m) + c.Assert(err, IsNil) + c.Check(drop, IsNil) + c.Check(newM, DeepEquals, m) +} + +func (s *assetsSuite) TestObserveSuccessfulBootNoAssetsOnDisk(c *C) { + // call to observe successful boot, but assets do not exist on disk + + s.bootloaderWithTrustedAssets(map[string]string{ + "asset": "asset", + }) + + m := &boot.Modeenv{ + Mode: "run", + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": []string{"assethash"}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": []string{"assethash"}, + }, + } + + newM, drop, err := boot.ObserveSuccessfulBootWithAssets(m) + c.Assert(err, IsNil) + c.Check(drop, IsNil) + // we booted without assets on disk nonetheless + c.Check(newM.CurrentTrustedBootAssets, HasLen, 0) + c.Check(newM.CurrentTrustedRecoveryBootAssets, HasLen, 0) +} + +func (s *assetsSuite) TestObserveSuccessfulBootAfterUpdate(c *C) { + // call to observe successful boot + + s.bootloaderWithTrustedAssets(map[string]string{ + "asset": "asset", + "shim": "shim", + }) + + data := []byte("foobar") + // SHA3-384 + dataHash := "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8" + shim := []byte("shim") + shimHash := "dac0063e831d4b2e7a330426720512fc50fa315042f0bb30f9d1db73e4898dcb89119cac41fdfa62137c8931a50f9d7b" + + // only asset for ubuntu-boot + c.Assert(os.WriteFile(filepath.Join(boot.InitramfsUbuntuBootDir, "asset"), data, 0644), IsNil) + // shim and asset for ubuntu-seed + c.Assert(os.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "asset"), data, 0644), IsNil) + c.Assert(os.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "shim"), shim, 0644), IsNil) + + m := &boot.Modeenv{ + Mode: "run", + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": []string{"assethash", dataHash}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": []string{"recoveryassethash", dataHash}, + "shim": []string{"recoveryshimhash", shimHash}, + }, + } + + newM, drop, err := boot.ObserveSuccessfulBootWithAssets(m) + c.Assert(err, IsNil) + c.Assert(newM, NotNil) + c.Check(newM.CurrentTrustedBootAssets, DeepEquals, boot.BootAssetsMap{ + "asset": []string{dataHash}, + }) + c.Check(newM.CurrentTrustedRecoveryBootAssets, DeepEquals, boot.BootAssetsMap{ + "asset": []string{dataHash}, + "shim": []string{shimHash}, + }) + c.Check(drop, HasLen, 3) + byHash := make(map[string]*boot.TrackedAsset) + for _, dropElement := range drop { + byHash[dropElement.GetHash()] = dropElement + } + for _, en := range []struct { + assetName, hash string + }{ + {"asset", "assethash"}, + {"asset", "recoveryassethash"}, + {"shim", "recoveryshimhash"}, + } { + c.Check(byHash[en.hash].Equals("trusted", en.assetName, en.hash), IsNil) + } +} + +func (s *assetsSuite) TestObserveSuccessfulBootWithUnexpected(c *C) { + // call to observe successful boot, but the asset we booted with is unexpected + + s.bootloaderWithTrustedAssets(map[string]string{ + "asset": "asset", + }) + + data := []byte("foobar") + dataHash := "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8" + unexpected := []byte("unexpected") + unexpectedHash := "2c823b62c52e614e48faac7e8b1fbb8ff3aee4d06b6f7fe5bd7d64953162b6e9879ead4827fa19c8c9a514585ddac94c" + + // asset for ubuntu-boot + c.Assert(os.WriteFile(filepath.Join(boot.InitramfsUbuntuBootDir, "asset"), unexpected, 0644), IsNil) + // and for ubuntu-seed + c.Assert(os.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "asset"), unexpected, 0644), IsNil) + + m := &boot.Modeenv{ + Mode: "run", + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": []string{"assethash", dataHash}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": []string{"recoveryassethash", dataHash}, + }, + } + + newM, drop, err := boot.ObserveSuccessfulBootWithAssets(m) + c.Assert(err, ErrorMatches, fmt.Sprintf(`system booted with unexpected run mode bootloader asset "asset" hash %v`, unexpectedHash)) + c.Assert(newM, IsNil) + c.Check(drop, HasLen, 0) + + // make the run bootloader asset an expected one, we should still fail + // on the recovery bootloader asset + c.Assert(os.WriteFile(filepath.Join(boot.InitramfsUbuntuBootDir, "asset"), data, 0644), IsNil) + + newM, drop, err = boot.ObserveSuccessfulBootWithAssets(m) + c.Assert(err, ErrorMatches, fmt.Sprintf(`system booted with unexpected recovery bootloader asset "asset" hash %v`, unexpectedHash)) + c.Assert(newM, IsNil) + c.Check(drop, HasLen, 0) +} + +func (s *assetsSuite) TestObserveSuccessfulBootSingleEntries(c *C) { + // call to observe successful boot + + s.bootloaderWithTrustedAssets(map[string]string{ + "asset": "asset", + "shim": "shim", + }) + + data := []byte("foobar") + // SHA3-384 + dataHash := "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8" + shim := []byte("shim") + shimHash := "dac0063e831d4b2e7a330426720512fc50fa315042f0bb30f9d1db73e4898dcb89119cac41fdfa62137c8931a50f9d7b" + + // only asset for ubuntu-boot + c.Assert(os.WriteFile(filepath.Join(boot.InitramfsUbuntuBootDir, "asset"), data, 0644), IsNil) + // shim and asset for ubuntu-seed + c.Assert(os.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "asset"), data, 0644), IsNil) + c.Assert(os.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "shim"), shim, 0644), IsNil) + + m := &boot.Modeenv{ + Mode: "run", + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": []string{dataHash}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": []string{dataHash}, + "shim": []string{shimHash}, + }, + } + + // nothing is changed + newM, drop, err := boot.ObserveSuccessfulBootWithAssets(m) + c.Assert(err, IsNil) + c.Assert(newM, NotNil) + c.Check(newM, DeepEquals, m) + c.Check(drop, HasLen, 0) +} + +func (s *assetsSuite) TestObserveSuccessfulBootDropCandidateUsedByOtherBootloader(c *C) { + // observe successful boot, an unused recovery asset of a recovery + // bootloader is used by the ubuntu-boot bootloader, so it cannot be + // dropped from cache + + s.bootloaderWithTrustedAssets(map[string]string{ + "asset": "asset", + }) + + maybeDrop := []byte("maybe-drop") + maybeDropHash := "08a99ce3af529ebbfb9a82df690007ac650635b165c3d1b416d471907fa3843270dce9cc001ea26f4afb4e0c5af05209" + data := []byte("foobar") + dataHash := "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8" + + // ubuntu-boot booted with maybe-drop asset + c.Assert(os.WriteFile(filepath.Join(boot.InitramfsUbuntuBootDir, "asset"), maybeDrop, 0644), IsNil) + + c.Assert(os.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "asset"), data, 0644), IsNil) + + m := &boot.Modeenv{ + Mode: "run", + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": []string{maybeDropHash}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": []string{maybeDropHash, dataHash}, + }, + } + + // nothing is changed + newM, drop, err := boot.ObserveSuccessfulBootWithAssets(m) + c.Assert(err, IsNil) + c.Assert(newM, NotNil) + c.Check(newM.CurrentTrustedBootAssets, DeepEquals, boot.BootAssetsMap{ + "asset": []string{maybeDropHash}, + }) + c.Check(newM.CurrentTrustedRecoveryBootAssets, DeepEquals, boot.BootAssetsMap{ + "asset": []string{dataHash}, + }) + // nothing get dropped, maybe-drop asset is still used by the + // ubuntu-boot bootloader + c.Check(drop, HasLen, 0) +} + +func (s *assetsSuite) TestObserveSuccessfulBootParallelUpdate(c *C) { + // call to observe successful boot + + s.bootloaderWithTrustedAssets(map[string]string{ + "asset": "asset", + "shim": "shim", + }) + + data := []byte("foobar") + // SHA3-384 + dataHash := "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8" + shim := []byte("shim") + shimHash := "dac0063e831d4b2e7a330426720512fc50fa315042f0bb30f9d1db73e4898dcb89119cac41fdfa62137c8931a50f9d7b" + + // only asset for ubuntu-boot + c.Assert(os.WriteFile(filepath.Join(boot.InitramfsUbuntuBootDir, "asset"), data, 0644), IsNil) + // shim and asset for ubuntu-seed + c.Assert(os.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "asset"), data, 0644), IsNil) + c.Assert(os.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "shim"), shim, 0644), IsNil) + + m := &boot.Modeenv{ + Mode: "run", + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": []string{"oldhash", dataHash}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": []string{"oldhash", dataHash}, + "shim": []string{shimHash}, + }, + } + + newM, drop, err := boot.ObserveSuccessfulBootWithAssets(m) + c.Assert(err, IsNil) + c.Assert(newM, NotNil) + c.Check(newM.CurrentTrustedBootAssets, DeepEquals, boot.BootAssetsMap{ + "asset": []string{dataHash}, + }) + c.Check(newM.CurrentTrustedRecoveryBootAssets, DeepEquals, boot.BootAssetsMap{ + "asset": []string{dataHash}, + "shim": []string{shimHash}, + }) + // asset was updated in parallel on both partition from the same + // oldhash that should be dropped now + c.Check(drop, HasLen, 1) + c.Check(drop[0].Equals("trusted", "asset", "oldhash"), IsNil) +} + +func (s *assetsSuite) TestObserveSuccessfulBootHashErr(c *C) { + // call to observe successful boot + + if os.Geteuid() == 0 { + c.Skip("the test cannot be executed by the root user") + } + + s.bootloaderWithTrustedAssets(map[string]string{ + "asset": "asset", + }) + + data := []byte("foobar") + dataHash := "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8" + + c.Assert(os.WriteFile(filepath.Join(boot.InitramfsUbuntuBootDir, "asset"), data, 0000), IsNil) + c.Assert(os.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "asset"), data, 0000), IsNil) + + m := &boot.Modeenv{ + Mode: "run", + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": []string{dataHash}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": []string{dataHash}, + }, + } + + // nothing is changed + _, _, err := boot.ObserveSuccessfulBootWithAssets(m) + c.Assert(err, ErrorMatches, "cannot calculate the digest of existing trusted asset: .*/asset: permission denied") +} + +func (s *assetsSuite) TestObserveSuccessfulBootDifferentMode(c *C) { + s.bootloaderWithTrustedAssets(map[string]string{ + "asset": "asset", + }) + + m := &boot.Modeenv{ + Mode: "recover", + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": []string{"hash-1", "hash-2"}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": []string{"hash-3", "hash-4"}, + }, + } + + // if we were in run mode, this would error out because the assets don't + // exist, but we are not in run mode + newM, drop, err := boot.ObserveSuccessfulBootWithAssets(m) + c.Assert(err, IsNil) + c.Assert(newM, DeepEquals, m) + c.Assert(drop, IsNil) +} + +func (s *assetsSuite) TestCopyBootAssetsCacheHappy(c *C) { + newRoot := c.MkDir() + // does not fail when dir does not exist + err := boot.CopyBootAssetsCacheToRoot(newRoot) + c.Assert(err, IsNil) + + // temporarily overide umask + oldUmask := syscall.Umask(0000) + defer syscall.Umask(oldUmask) + + entries := []struct { + name, content string + mode uint + }{ + {"foo/bar", "1234", 0644}, + {"grub/grubx64.efi-1234", "grub content", 0622}, + {"top-level", "top level content", 0666}, + {"deeply/nested/content", "deeply nested content", 0611}, + } + + for _, entry := range entries { + p := filepath.Join(dirs.SnapBootAssetsDir, entry.name) + err = os.MkdirAll(filepath.Dir(p), 0755) + c.Assert(err, IsNil) + err = os.WriteFile(p, []byte(entry.content), os.FileMode(entry.mode)) + c.Assert(err, IsNil) + } + + err = boot.CopyBootAssetsCacheToRoot(newRoot) + c.Assert(err, IsNil) + for _, entry := range entries { + p := filepath.Join(dirs.SnapBootAssetsDirUnder(newRoot), entry.name) + c.Check(p, testutil.FileEquals, entry.content) + fi, err := os.Stat(p) + c.Assert(err, IsNil) + c.Check(fi.Mode().Perm(), Equals, os.FileMode(entry.mode), + Commentf("unexpected mode of copied file %q: %v", entry.name, fi.Mode().Perm())) + } +} + +func (s *assetsSuite) TestCopyBootAssetsCacheUnhappy(c *C) { + // non-file + newRoot := c.MkDir() + dirs.SnapBootAssetsDir = c.MkDir() + p := filepath.Join(dirs.SnapBootAssetsDir, "fifo") + syscall.Mkfifo(p, 0644) + err := boot.CopyBootAssetsCacheToRoot(newRoot) + c.Assert(err, ErrorMatches, `unsupported non-file entry "fifo" mode prw-.*`) + + if os.Geteuid() == 0 { + // the rest of the test cannot be executed by root user + return + } + + // non-writable root + newRoot = c.MkDir() + nonWritableRoot := filepath.Join(newRoot, "non-writable") + err = os.MkdirAll(nonWritableRoot, 0000) + c.Assert(err, IsNil) + dirs.SnapBootAssetsDir = c.MkDir() + err = os.WriteFile(filepath.Join(dirs.SnapBootAssetsDir, "file"), nil, 0644) + c.Assert(err, IsNil) + err = boot.CopyBootAssetsCacheToRoot(nonWritableRoot) + c.Assert(err, ErrorMatches, `cannot create cache directory under new root: mkdir .*: permission denied`) + + // file cannot be read + newRoot = c.MkDir() + dirs.SnapBootAssetsDir = c.MkDir() + err = os.WriteFile(filepath.Join(dirs.SnapBootAssetsDir, "file"), nil, 0000) + c.Assert(err, IsNil) + err = boot.CopyBootAssetsCacheToRoot(newRoot) + c.Assert(err, ErrorMatches, `cannot copy boot asset cache file "file": failed to copy all: .*`) + + // directory at destination cannot be recreated + newRoot = c.MkDir() + dirs.SnapBootAssetsDir = c.MkDir() + // make a directory at destination non writable + err = os.MkdirAll(dirs.SnapBootAssetsDirUnder(newRoot), 0755) + c.Assert(err, IsNil) + err = os.Chmod(dirs.SnapBootAssetsDirUnder(newRoot), 0000) + c.Assert(err, IsNil) + err = os.MkdirAll(filepath.Join(dirs.SnapBootAssetsDir, "dir"), 0755) + c.Assert(err, IsNil) + err = os.WriteFile(filepath.Join(dirs.SnapBootAssetsDir, "dir", "file"), nil, 0000) + c.Assert(err, IsNil) + err = boot.CopyBootAssetsCacheToRoot(newRoot) + c.Assert(err, ErrorMatches, `cannot recreate cache directory "dir": .*: permission denied`) + +} + +func (s *assetsSuite) TestUpdateObserverReseal(c *C) { + // observe an update followed by reseal + + d := c.MkDir() + backups := c.MkDir() + root := c.MkDir() + + // try to arrange the backups like the updater would do it + before := []byte("before") + beforeHash := "2df0976fd45ba2392dc7985cdfb7c2d096c1ea4917929dd7a0e9bffae90a443271e702663fc6a4189c1f4ab3ce7daee3" + err := os.WriteFile(filepath.Join(backups, "asset.backup"), before, 0644) + c.Assert(err, IsNil) + + data := []byte("foobar") + // SHA3-384 + dataHash := "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8" + err = os.WriteFile(filepath.Join(d, "foobar"), data, 0644) + c.Assert(err, IsNil) + shim := []byte("shim") + shimHash := "dac0063e831d4b2e7a330426720512fc50fa315042f0bb30f9d1db73e4898dcb89119cac41fdfa62137c8931a50f9d7b" + err = os.WriteFile(filepath.Join(d, "shim"), shim, 0644) + c.Assert(err, IsNil) + + tab := s.bootloaderWithTrustedAssets(map[string]string{ + "asset": "asset", + "shim": "shim", + }) + + // we get an observer for UC20 + obs, uc20model := s.uc20UpdateObserverEncryptedSystemMockedBootloader(c) + + m := boot.Modeenv{ + Mode: "run", + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": []string{beforeHash}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": []string{beforeHash}, + }, + CurrentRecoverySystems: []string{"recovery-system-label"}, + CurrentKernels: []string{"pc-kernel_500.snap"}, + + Model: uc20model.Model(), + BrandID: uc20model.BrandID(), + Grade: string(uc20model.Grade()), + ModelSignKeyID: uc20model.SignKeyID(), + } + err = m.WriteTo("") + c.Assert(err, IsNil) + + res, err := obs.Observe(gadget.ContentUpdate, gadget.SystemBoot, root, "asset", + &gadget.ContentChange{ + After: filepath.Join(d, "foobar"), + // original content would get backed up by the updater + Before: filepath.Join(backups, "asset.backup"), + }) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + res, err = obs.Observe(gadget.ContentUpdate, gadget.SystemBoot, root, "shim", + &gadget.ContentChange{After: filepath.Join(d, "shim")}) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + // observe the recovery struct + res, err = obs.Observe(gadget.ContentUpdate, gadget.SystemSeed, root, "shim", + &gadget.ContentChange{After: filepath.Join(d, "shim")}) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + res, err = obs.Observe(gadget.ContentUpdate, gadget.SystemSeed, root, "asset", + &gadget.ContentChange{ + After: filepath.Join(d, "foobar"), + // original content + Before: filepath.Join(backups, "asset.backup"), + }) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + checkContentGlob(c, filepath.Join(dirs.SnapBootAssetsDir, "trusted", "*"), []string{ + filepath.Join(dirs.SnapBootAssetsDir, "trusted", fmt.Sprintf("asset-%s", dataHash)), + filepath.Join(dirs.SnapBootAssetsDir, "trusted", fmt.Sprintf("asset-%s", beforeHash)), + filepath.Join(dirs.SnapBootAssetsDir, "trusted", fmt.Sprintf("shim-%s", shimHash)), + }) + + restore := boot.MockSeedReadSystemEssential(func(seedDir, label string, essentialTypes []snap.Type, tm timings.Measurer) (*asserts.Model, []*seed.Snap, error) { + return uc20model, []*seed.Snap{mockKernelSeedSnap(snap.R(1)), mockGadgetSeedSnap(c, nil)}, nil + }) + defer restore() + + // everything is set up, trigger a reseal + + resealCalls := 0 + shimBf := bootloader.NewBootFile("", filepath.Join(dirs.SnapBootAssetsDir, "trusted", fmt.Sprintf("shim-%s", shimHash)), bootloader.RoleRecovery) + assetBf := bootloader.NewBootFile("", filepath.Join(dirs.SnapBootAssetsDir, "trusted", fmt.Sprintf("asset-%s", dataHash)), bootloader.RoleRecovery) + beforeAssetBf := bootloader.NewBootFile("", filepath.Join(dirs.SnapBootAssetsDir, "trusted", fmt.Sprintf("asset-%s", beforeHash)), bootloader.RoleRecovery) + recoveryKernelBf := bootloader.NewBootFile("/var/lib/snapd/seed/snaps/pc-kernel_1.snap", "kernel.efi", bootloader.RoleRecovery) + runKernelBf := bootloader.NewBootFile(filepath.Join(s.rootdir, "var/lib/snapd/snaps/pc-kernel_500.snap"), "kernel.efi", bootloader.RoleRunMode) + + tab.RecoveryBootChainList = []bootloader.BootFile{ + bootloader.NewBootFile("", "shim", bootloader.RoleRecovery), + bootloader.NewBootFile("", "asset", bootloader.RoleRecovery), + recoveryKernelBf, + } + tab.BootChainList = []bootloader.BootFile{ + bootloader.NewBootFile("", "shim", bootloader.RoleRecovery), + bootloader.NewBootFile("", "asset", bootloader.RoleRecovery), + runKernelBf, + } + + restore = boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + resealCalls++ + + c.Assert(params.ModelParams, HasLen, 1) + mp := params.ModelParams[0] + c.Check(mp.Model.Model(), Equals, uc20model.Model()) + for _, ch := range mp.EFILoadChains { + printChain(c, ch, "-") + } + switch resealCalls { + case 1: + c.Check(mp.EFILoadChains, DeepEquals, []*secboot.LoadChain{ + secboot.NewLoadChain(shimBf, + secboot.NewLoadChain(beforeAssetBf, + secboot.NewLoadChain(recoveryKernelBf)), + secboot.NewLoadChain(assetBf, + secboot.NewLoadChain(recoveryKernelBf))), + secboot.NewLoadChain(shimBf, + secboot.NewLoadChain(beforeAssetBf, + secboot.NewLoadChain(runKernelBf)), + secboot.NewLoadChain(assetBf, + secboot.NewLoadChain(runKernelBf))), + }) + case 2: + c.Check(mp.EFILoadChains, DeepEquals, []*secboot.LoadChain{ + secboot.NewLoadChain(shimBf, + secboot.NewLoadChain(beforeAssetBf, + secboot.NewLoadChain(recoveryKernelBf)), + secboot.NewLoadChain(assetBf, + secboot.NewLoadChain(recoveryKernelBf))), + }) + default: + c.Errorf("unexpected additional call to secboot.ResealKey (call # %d)", resealCalls) + } + return nil + }) + defer restore() + + err = obs.BeforeWrite() + c.Assert(err, IsNil) + c.Check(resealCalls, Equals, 2) +} + +func (s *assetsSuite) TestUpdateObserverCanceledReseal(c *C) { + // check that Canceled calls reseal when there were changes to the + // trusted boot assets + d := c.MkDir() + root := c.MkDir() + + // mock some files in cache + c.Assert(os.MkdirAll(filepath.Join(dirs.SnapBootAssetsDir, "trusted"), 0755), IsNil) + for _, name := range []string{ + "shim-shimhash", + "asset-assethash", + "asset-recoveryhash", + } { + err := os.WriteFile(filepath.Join(dirs.SnapBootAssetsDir, "trusted", name), nil, 0644) + c.Assert(err, IsNil) + } + + tab := s.bootloaderWithTrustedAssets(map[string]string{ + "asset": "asset", + "shim": "shim", + }) + + // we get an observer for UC20 + obs, uc20model := s.uc20UpdateObserverEncryptedSystemMockedBootloader(c) + + m := boot.Modeenv{ + Mode: "run", + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": []string{"assethash"}, + "shim": []string{"shimhash"}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": []string{"assethash"}, + "shim": []string{"shimhash"}, + }, + CurrentRecoverySystems: []string{"system"}, + CurrentKernels: []string{"pc-kernel_1.snap"}, + CurrentKernelCommandLines: boot.BootCommandLines{"snapd_recovery_mode=run"}, + + Model: uc20model.Model(), + BrandID: uc20model.BrandID(), + Grade: string(uc20model.Grade()), + ModelSignKeyID: uc20model.SignKeyID(), + } + err := m.WriteTo("") + c.Assert(err, IsNil) + + data := []byte("foobar") + err = os.WriteFile(filepath.Join(d, "foobar"), data, 0644) + c.Assert(err, IsNil) + shim := []byte("shim") + err = os.WriteFile(filepath.Join(d, "shim"), shim, 0644) + c.Assert(err, IsNil) + + // trigger a bunch of updates, so that we have things to cancel + res, err := obs.Observe(gadget.ContentUpdate, gadget.SystemBoot, root, "asset", + &gadget.ContentChange{After: filepath.Join(d, "foobar")}) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + res, err = obs.Observe(gadget.ContentUpdate, gadget.SystemBoot, root, "shim", + &gadget.ContentChange{After: filepath.Join(d, "shim")}) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + // observe the recovery struct + res, err = obs.Observe(gadget.ContentUpdate, gadget.SystemSeed, root, "shim", + &gadget.ContentChange{After: filepath.Join(d, "shim")}) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + res, err = obs.Observe(gadget.ContentUpdate, gadget.SystemSeed, root, "asset", + &gadget.ContentChange{After: filepath.Join(d, "foobar")}) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + + restore := boot.MockSeedReadSystemEssential(func(seedDir, label string, essentialTypes []snap.Type, tm timings.Measurer) (*asserts.Model, []*seed.Snap, error) { + return uc20model, []*seed.Snap{mockKernelSeedSnap(snap.R(1)), mockGadgetSeedSnap(c, nil)}, nil + }) + defer restore() + + shimBf := bootloader.NewBootFile("", filepath.Join(dirs.SnapBootAssetsDir, "trusted/shim-shimhash"), bootloader.RoleRecovery) + assetBf := bootloader.NewBootFile("", filepath.Join(dirs.SnapBootAssetsDir, "trusted/asset-assethash"), bootloader.RoleRecovery) + recoveryKernelBf := bootloader.NewBootFile("/var/lib/snapd/seed/snaps/pc-kernel_1.snap", "kernel.efi", bootloader.RoleRecovery) + runKernelBf := bootloader.NewBootFile(filepath.Join(s.rootdir, "var/lib/snapd/snaps/pc-kernel_500.snap"), "kernel.efi", bootloader.RoleRunMode) + tab.RecoveryBootChainList = []bootloader.BootFile{ + bootloader.NewBootFile("", "shim", bootloader.RoleRecovery), + bootloader.NewBootFile("", "asset", bootloader.RoleRecovery), + recoveryKernelBf, + } + tab.BootChainList = []bootloader.BootFile{ + bootloader.NewBootFile("", "shim", bootloader.RoleRecovery), + bootloader.NewBootFile("", "asset", bootloader.RoleRecovery), + runKernelBf, + } + + resealCalls := 0 + restore = boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + resealCalls++ + c.Assert(params.ModelParams, HasLen, 1) + mp := params.ModelParams[0] + c.Check(mp.Model.Model(), Equals, uc20model.Model()) + for _, ch := range mp.EFILoadChains { + printChain(c, ch, "-") + } + switch resealCalls { + case 1: + c.Check(mp.EFILoadChains, DeepEquals, []*secboot.LoadChain{ + secboot.NewLoadChain(shimBf, + secboot.NewLoadChain(assetBf, + secboot.NewLoadChain(recoveryKernelBf))), + secboot.NewLoadChain(shimBf, + secboot.NewLoadChain(assetBf, + secboot.NewLoadChain(runKernelBf))), + }) + case 2: + c.Check(mp.EFILoadChains, DeepEquals, []*secboot.LoadChain{ + secboot.NewLoadChain(shimBf, + secboot.NewLoadChain(assetBf, + secboot.NewLoadChain(recoveryKernelBf))), + }) + default: + c.Errorf("unexpected additional call to secboot.ResealKey (call # %d)", resealCalls) + } + return nil + }) + defer restore() + + // update is canceled + err = obs.Canceled() + c.Assert(err, IsNil) + // modeenv is back to initial state + afterCancelM, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Check(afterCancelM.CurrentTrustedBootAssets, DeepEquals, m.CurrentTrustedBootAssets) + c.Check(afterCancelM.CurrentTrustedRecoveryBootAssets, DeepEquals, m.CurrentTrustedRecoveryBootAssets) + // unused assets were dropped + checkContentGlob(c, filepath.Join(dirs.SnapBootAssetsDir, "trusted", "*"), []string{ + filepath.Join(dirs.SnapBootAssetsDir, "trusted", "asset-assethash"), + filepath.Join(dirs.SnapBootAssetsDir, "trusted", "asset-recoveryhash"), + filepath.Join(dirs.SnapBootAssetsDir, "trusted", "shim-shimhash"), + }) + + c.Check(resealCalls, Equals, 2) +} + +func (s *assetsSuite) TestUpdateObserverUpdateMockedNonEncryption(c *C) { + // observe an update on a system where encryption is not used + + d := c.MkDir() + backups := c.MkDir() + root := c.MkDir() + + // try to arrange the backups like the updater would do it + data := []byte("foobar") + err := os.WriteFile(filepath.Join(d, "foobar"), data, 0644) + c.Assert(err, IsNil) + + m := boot.Modeenv{ + Mode: "run", + } + err = m.WriteTo("") + c.Assert(err, IsNil) + + tab := s.bootloaderWithTrustedAssets(map[string]string{ + "asset": "asset", + }) + tab.ManagedAssetsList = []string{ + "managed-asset", + } + + // we get an observer for UC20, bootloader is mocked + obs, _ := s.uc20UpdateObserver(c, c.MkDir()) + + // asset is ignored, and the change is applied + change := &gadget.ContentChange{ + After: filepath.Join(d, "foobar"), + // original content would get backed up by the updater + Before: filepath.Join(backups, "asset.backup"), + } + res, err := obs.Observe(gadget.ContentUpdate, gadget.SystemBoot, root, "asset", change) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + res, err = obs.Observe(gadget.ContentUpdate, gadget.SystemSeed, root, "asset", change) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeApply) + // trusted assets were asked for when setting up bootloader context + c.Check(tab.TrustedAssetsCalls, Equals, 2) + // but nothing is really tracked + checkContentGlob(c, filepath.Join(dirs.SnapBootAssetsDir, "trusted", "*"), nil) + // check modeenv + newM, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Check(newM.CurrentTrustedBootAssets, HasLen, 0) + c.Check(newM.CurrentTrustedRecoveryBootAssets, HasLen, 0) + + // verify that managed assets are to be preserved + res, err = obs.Observe(gadget.ContentUpdate, gadget.SystemBoot, root, "managed-asset", + &gadget.ContentChange{After: filepath.Join(d, "foobar")}) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeIgnore) + res, err = obs.Observe(gadget.ContentUpdate, gadget.SystemSeed, root, "managed-asset", + &gadget.ContentChange{After: filepath.Join(d, "foobar")}) + c.Assert(err, IsNil) + c.Check(res, Equals, gadget.ChangeIgnore) + + // make sure that no reseal is triggered + resealCalls := 0 + restore := boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + resealCalls++ + return nil + }) + defer restore() + + err = obs.BeforeWrite() + c.Assert(err, IsNil) + c.Check(resealCalls, Equals, 0) + + err = obs.Canceled() + c.Assert(err, IsNil) + c.Check(resealCalls, Equals, 0) +} diff --git a/boot/boot.go b/boot/boot.go new file mode 100644 index 00000000..98bbea10 --- /dev/null +++ b/boot/boot.go @@ -0,0 +1,580 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2019-2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package boot + +import ( + "errors" + "fmt" + + "github.com/snapcore/snapd/bootloader" + "github.com/snapcore/snapd/snap" +) + +// Unlocker functions are passed from code using boot to indicate that global +// state should be unlocked during slow operations, e.g sealing/unsealing. +// Boot code is then expected to call the unlocker around the slow section and +// relock using the returned function. Unlocker being nil indicates not to do +// this. +type Unlocker func() (relock func()) + +const ( + // DefaultStatus is the value of a status boot variable when nothing is + // being tried + DefaultStatus = "" + // TryStatus is the value of a status boot variable when something is about + // to be tried + TryStatus = "try" + // TryingStatus is the value of a status boot variable after we have + // attempted a boot with a try snap - this status is only set in the early + // boot sequence (bootloader, initramfs, etc.) + TryingStatus = "trying" +) + +// RebootInfo contains information about how to perform a reboot if +// required. +type RebootInfo struct { + // RebootRequired is true if we need to reboot after an update. + RebootRequired bool + // BootloaderOptions will be used to find the correct bootloader when + // checking for any set reboot arguments. + BootloaderOptions *bootloader.Options +} + +// NextBootContext carries additional significative information used when +// setting the next boot. +type NextBootContext struct { + // BootWithoutTry is sets if we don't want to use the "try" logic. This + // is useful if the next boot is part of an installation undo. + BootWithoutTry bool +} + +// A BootParticipant handles the boot process details for a snap involved in it. +type BootParticipant interface { + // SetNextBoot will schedule the snap to be used in the next + // boot. bootCtx contains context information that influences how the + // next boot is performed. For base snaps it is up to the caller to + // select the right bootable base (from the model assertion). It is a + // noop for not relevant snaps. Otherwise it returns whether a reboot + // is required. + SetNextBoot(bootCtx NextBootContext) (rebootInfo RebootInfo, err error) + + // Is this a trivial implementation of the interface? + IsTrivial() bool +} + +// A BootKernel handles the bootloader setup of a kernel. +type BootKernel interface { + // RemoveKernelAssets removes the unpacked kernel/initrd for the given + // kernel snap. + RemoveKernelAssets() error + // ExtractKernelAssets extracts kernel/initrd/dtb data from the given + // kernel snap, if required, to a versioned bootloader directory so + // that the bootloader can use it. + ExtractKernelAssets(snap.Container) error + // Is this a trivial implementation of the interface? + IsTrivial() bool +} + +type trivial struct{} + +func (trivial) SetNextBoot(bootCtx NextBootContext) (RebootInfo, error) { + return RebootInfo{RebootRequired: false}, nil +} +func (trivial) IsTrivial() bool { return true } +func (trivial) RemoveKernelAssets() error { return nil } +func (trivial) ExtractKernelAssets(snap.Container) error { return nil } + +// ensure trivial is a BootParticipant +var _ BootParticipant = trivial{} + +// ensure trivial is a Kernel +var _ BootKernel = trivial{} + +// Participant figures out what the BootParticipant is for the given +// arguments, and returns it. If the snap does _not_ participate in +// the boot process, the returned object will be a NOP, so it's safe +// to call anything on it always. +// +// Currently, on classic, nothing is a boot participant (returned will +// always be NOP). +func Participant(s snap.PlaceInfo, t snap.Type, dev snap.Device) BootParticipant { + if applicable(s, t, dev) { + bs, err := bootStateFor(t, dev) + if err != nil { + // all internal errors at this point + panic(err) + } + return &coreBootParticipant{s: s, bs: bs} + } + return trivial{} +} + +// bootloaderOptionsForDeviceKernel returns a set of bootloader options that +// enable correct kernel extraction and removal for given device +func bootloaderOptionsForDeviceKernel(dev snap.Device) *bootloader.Options { + if !dev.HasModeenv() { + return nil + } + // find the run-mode bootloader with its kernel support for UC20 + return &bootloader.Options{ + Role: bootloader.RoleRunMode, + } +} + +// Kernel checks that the given arguments refer to a kernel snap +// that participates in the boot process, and returns the associated +// BootKernel, or a trivial implementation otherwise. +func Kernel(s snap.PlaceInfo, t snap.Type, dev snap.Device) BootKernel { + if t == snap.TypeKernel && applicable(s, t, dev) { + return &coreKernel{s: s, bopts: bootloaderOptionsForDeviceKernel(dev)} + } + return trivial{} +} + +// SnapTypeParticipatesInBoot returns whether a snap type participates in the +// boot for a given device. +func SnapTypeParticipatesInBoot(t snap.Type, dev snap.Device) bool { + if dev.IsClassicBoot() { + return false + } + switch t { + case snap.TypeBase, snap.TypeOS: + // Bases are not boot participants for classic with modes + return !dev.Classic() + case snap.TypeKernel, snap.TypeGadget: + return true + } + + return false +} + +func applicable(s snap.PlaceInfo, t snap.Type, dev snap.Device) bool { + if !SnapTypeParticipatesInBoot(t, dev) { + return false + } + // In ephemeral modes we never need to care about updating the boot + // config. This will be done via boot.MakeBootable(). + if !dev.RunMode() { + return false + } + + switch t { + case snap.TypeKernel: + if s.InstanceName() != dev.Kernel() { + // a remodel might leave behind installed a kernel that + // is not the device kernel anymore, ignore such a + // kernel by checking the name + return false + } + case snap.TypeBase, snap.TypeOS: + base := dev.Base() + if base == "" { + base = "core" + } + if s.InstanceName() != base { + return false + } + case snap.TypeGadget: + // First condition: gadget is not a boot participant for UC16/18 + // Second condition: a remodel might leave behind installed a + // gadget that is not the device gadget anymore, ignore such a + // gadget by checking the name + if !dev.HasModeenv() || s.InstanceName() != dev.Gadget() { + return false + } + default: + return false + } + + return true +} + +// bootState exposes the boot state for a type of boot snap during +// normal running state, i.e. after the pivot_root and after the initramfs. +type bootState interface { + // revisions retrieves the revisions of the current snap and + // the try snap (only the latter might not be set), and + // the status of the trying snap. + // Note that the error could be only specific to the try snap, in which case + // curSnap may still be non-nil and valid. Callers concerned with robustness + // should always inspect a non-nil error with isTrySnapError, and use + // curSnap instead if the error is only for the trySnap or tryingStatus. + revisions() (curSnap, trySnap snap.PlaceInfo, tryingStatus string, err error) + + // setNext lazily implements setting the next boot target for the type's + // boot snap. bootCtx specifies additional information bits we might + // need. Actually committing the update is done via the returned + // bootStateUpdate's commit method. It will return information for + // rebooting if necessary. + setNext(s snap.PlaceInfo, bootCtx NextBootContext) (rbi RebootInfo, u bootStateUpdate, err error) + + // markSuccessful lazily implements marking the boot + // successful for the type's boot snap. The actual committing + // of the update is done via bootStateUpdate's commit, that + // way different markSuccessful can be folded together. + markSuccessful(bootStateUpdate) (bootStateUpdate, error) +} + +// successfulBootState exposes the state of resources requiring bookkeeping on a +// successful boot. +type successfulBootState interface { + // markSuccessful lazily implements marking the boot + // successful for the given type of resource. + markSuccessful(bootStateUpdate) (bootStateUpdate, error) +} + +// bootStateFor finds the right bootState implementation of the given +// snap type and Device, if applicable. +func bootStateFor(typ snap.Type, dev snap.Device) (s bootState, err error) { + if !dev.RunMode() { + return nil, fmt.Errorf("internal error: no boot state handling for ephemeral modes") + } + if typ == snap.TypeOS { + typ = snap.TypeBase + } + newBootState := newBootState16 + participantTypes := []snap.Type{snap.TypeBase, snap.TypeKernel} + if dev.HasModeenv() { + newBootState = newBootState20 + participantTypes = append(participantTypes, snap.TypeGadget) + } + for _, partTyp := range participantTypes { + if typ == partTyp { + return newBootState(typ, dev), nil + } + } + return nil, fmt.Errorf("internal error: no boot state handling for snap type %q", typ) +} + +// InUseFunc is a function to check if the snap is in use or not. +type InUseFunc func(name string, rev snap.Revision) bool + +func fixedInUse(inUse bool) InUseFunc { + return func(string, snap.Revision) bool { + return inUse + } +} + +// InUse returns a checker for whether a given name/revision is used in the +// boot environment for snaps of the relevant snap type. +func InUse(typ snap.Type, dev snap.Device) (InUseFunc, error) { + modeenvLock() + defer modeenvUnlock() + + if !dev.RunMode() { + // ephemeral mode, block manipulations for now + return fixedInUse(true), nil + } + if !SnapTypeParticipatesInBoot(typ, dev) || typ == snap.TypeGadget { + return fixedInUse(false), nil + } + cands := make([]snap.PlaceInfo, 0, 2) + s, err := bootStateFor(typ, dev) + if err != nil { + return nil, err + } + cand, tryCand, _, err := s.revisions() + if err != nil { + return nil, err + } + cands = append(cands, cand) + if tryCand != nil { + cands = append(cands, tryCand) + } + + return func(name string, rev snap.Revision) bool { + for _, cand := range cands { + if cand.SnapName() == name && cand.SnapRevision() == rev { + return true + } + } + return false + }, nil +} + +var ( + // ErrBootNameAndRevisionNotReady is returned when the boot revision is not + // established yet. + ErrBootNameAndRevisionNotReady = errors.New("boot revision not yet established") +) + +// GetCurrentBoot returns the currently set name and revision for boot for the given +// type of snap, which can be snap.TypeBase (or snap.TypeOS), or snap.TypeKernel. +// Returns ErrBootNameAndRevisionNotReady if the values are temporarily not established. +func GetCurrentBoot(t snap.Type, dev snap.Device) (snap.PlaceInfo, error) { + modeenvLock() + defer modeenvUnlock() + + s, err := bootStateFor(t, dev) + if err != nil { + return nil, err + } + + snap, _, status, err := s.revisions() + if err != nil { + return nil, err + } + + if status == TryingStatus { + return nil, ErrBootNameAndRevisionNotReady + } + + return snap, nil +} + +// bootStateUpdate carries the state for an on-going boot state update. +// At the end it can be used to commit it. +type bootStateUpdate interface { + commit() error +} + +// MarkBootSuccessful marks the current boot as successful. This means +// that snappy will consider this combination of kernel/os a valid +// target for rollback. +// +// The states that a boot goes through for UC16/18 are the following: +// - By default snap_mode is "" in which case the bootloader loads +// two squashfs'es denoted by variables snap_core and snap_kernel. +// - On a refresh of core/kernel snapd will set snap_mode=try and +// will also set snap_try_{core,kernel} to the core/kernel that +// will be tried next. +// - On reboot the bootloader will inspect the snap_mode and if the +// mode is set to "try" it will set "snap_mode=trying" and then +// try to boot the snap_try_{core,kernel}". +// - On a successful boot snapd resets snap_mode to "" and copies +// snap_try_{core,kernel} to snap_{core,kernel}. The snap_try_* +// values are cleared afterwards. +// - On a failing boot the bootloader will see snap_mode=trying which +// means snapd did not start successfully. In this case the bootloader +// will set snap_mode="" and the system will boot with the known good +// values from snap_{core,kernel} +func MarkBootSuccessful(dev snap.Device) error { + modeenvLock() + defer modeenvUnlock() + + const errPrefix = "cannot mark boot successful: %s" + + var u bootStateUpdate + for _, t := range []snap.Type{snap.TypeBase, snap.TypeKernel} { + if !SnapTypeParticipatesInBoot(t, dev) { + continue + } + s, err := bootStateFor(t, dev) + if err != nil { + return err + } + u, err = s.markSuccessful(u) + if err != nil { + return fmt.Errorf(errPrefix, err) + } + } + + if dev.HasModeenv() { + for _, bs := range []successfulBootState{ + trustedAssetsBootState(dev), + trustedCommandLineBootState(dev), + recoverySystemsBootState(dev), + modelBootState(dev), + } { + var err error + u, err = bs.markSuccessful(u) + if err != nil { + return fmt.Errorf(errPrefix, err) + } + } + } + + if u != nil { + if err := u.commit(); err != nil { + return fmt.Errorf(errPrefix, err) + } + } + return nil +} + +var ErrUnsupportedSystemMode = errors.New("system mode is unsupported") + +// SetRecoveryBootSystemAndMode configures the recovery bootloader to boot into +// the given recovery system in a particular mode. Returns +// ErrUnsupportedSystemMode when booting into a recovery system is not supported +// by the device. +func SetRecoveryBootSystemAndMode(dev snap.Device, systemLabel, mode string) error { + if !dev.HasModeenv() { + // only UC20 devices are supported + return ErrUnsupportedSystemMode + } + if systemLabel == "" { + return fmt.Errorf("internal error: system label is unset") + } + if mode == "" { + return fmt.Errorf("internal error: system mode is unset") + } + + opts := &bootloader.Options{ + // setup the recovery bootloader + Role: bootloader.RoleRecovery, + } + // TODO:UC20: should the recovery partition stay around as RW during run + // mode all the time? + bl, err := bootloader.Find(InitramfsUbuntuSeedDir, opts) + if err != nil { + return err + } + + m := map[string]string{ + "snapd_recovery_system": systemLabel, + "snapd_recovery_mode": mode, + } + return bl.SetBootVars(m) +} + +// UpdateManagedBootConfigs updates managed boot config assets if +// those are present for the ubuntu-boot bootloader. To do this it +// needs information from the model, the gadget we are updating to, +// and any additional kernel command line arguments coming from system +// options. Returns true when an update was carried out. +func UpdateManagedBootConfigs(dev snap.Device, gadgetSnapOrDir, cmdlineAppend string) (updated bool, err error) { + if !dev.HasModeenv() { + // only UC20 devices use managed boot config + return false, nil + } + if !dev.RunMode() { + return false, fmt.Errorf("internal error: boot config can only be updated in run mode") + } + modeenvLock() + defer modeenvUnlock() + + return updateManagedBootConfigForBootloader(dev, ModeRun, gadgetSnapOrDir, cmdlineAppend) +} + +func updateCmdlineVars(tbl bootloader.TrustedAssetsBootloader, gadgetSnapOrDir, cmdlineAppend string, candidate bool, dev snap.Device) error { + defaultCmdLine, err := tbl.DefaultCommandLine(candidate) + if err != nil { + return err + } + + cmdlineVars, err := bootVarsForTrustedCommandLineFromGadget(gadgetSnapOrDir, cmdlineAppend, defaultCmdLine, dev.Model()) + if err != nil { + return fmt.Errorf("cannot prepare bootloader variables for kernel command line: %v", err) + } + + if err := tbl.SetBootVars(cmdlineVars); err != nil { + return fmt.Errorf("cannot set run system kernel command line arguments: %v", err) + } + + return nil +} + +func updateManagedBootConfigForBootloader(dev snap.Device, mode, gadgetSnapOrDir, cmdlineAppend string) (updated bool, err error) { + if mode != ModeRun { + return false, fmt.Errorf("internal error: updating boot config of recovery bootloader is not supported yet") + } + + opts := &bootloader.Options{ + Role: bootloader.RoleRunMode, + NoSlashBoot: true, + } + tbl, err := getBootloaderManagingItsAssets(InitramfsUbuntuBootDir, opts) + if err != nil { + if err == errBootConfigNotManaged { + // we're not managing this bootloader's boot config + return false, nil + } + return false, err + } + + // boot config update can lead to a change of kernel command line + cmdlineChange, err := observeCommandLineUpdate(dev.Model(), commandLineUpdateReasonSnapd, gadgetSnapOrDir, cmdlineAppend) + if err != nil { + return false, err + } + + if cmdlineChange { + candidate := true + if err := updateCmdlineVars(tbl, gadgetSnapOrDir, cmdlineAppend, candidate, dev); err != nil { + return false, err + } + } + + assetChange, err := tbl.UpdateBootConfig() + if err != nil { + return false, err + } + + return assetChange || cmdlineChange, nil +} + +// UpdateCommandLineForGadgetComponent handles the update of a gadget +// that contributes to the kernel command line of the run system +// (appending any additional kernel command line arguments coming from +// system options). Returns true when a change in command line has +// been observed and a reboot is needed. The reboot, if needed, should +// be requested at the the earliest possible occasion. +func UpdateCommandLineForGadgetComponent(dev snap.Device, gadgetSnapOrDir, cmdlineAppend string) (needsReboot bool, err error) { + if !dev.HasModeenv() { + // only UC20 devices are supported + return false, fmt.Errorf("internal error: command line component cannot be updated on pre-UC20 devices") + } + modeenvLock() + defer modeenvUnlock() + + opts := &bootloader.Options{ + Role: bootloader.RoleRunMode, + } + // TODO: add support for bootloaders that that do not have any managed + // assets + tbl, err := getBootloaderManagingItsAssets("", opts) + if err != nil { + if err == errBootConfigNotManaged { + // we're not managing this bootloader's boot config + return false, nil + } + return false, err + } + + // gadget update can lead to a change of kernel command line + cmdlineChange, err := observeCommandLineUpdate(dev.Model(), commandLineUpdateReasonGadget, gadgetSnapOrDir, cmdlineAppend) + if err != nil { + return false, err + } + + if !cmdlineChange { + return false, nil + } + + candidate := false + if err := updateCmdlineVars(tbl, gadgetSnapOrDir, cmdlineAppend, candidate, dev); err != nil { + return false, err + } + return cmdlineChange, nil +} + +// MarkFactoryResetComplete runs a series of steps in a run system that complete a +// factory reset process. +func MarkFactoryResetComplete(encrypted bool) error { + if !encrypted { + // there is nothing to do on an unencrypted system + return nil + } + if err := postFactoryResetCleanup(); err != nil { + return fmt.Errorf("cannot perform post factory reset boot cleanup: %v", err) + } + return nil +} diff --git a/boot/boot_robustness_test.go b/boot/boot_robustness_test.go new file mode 100644 index 00000000..71dfcaed --- /dev/null +++ b/boot/boot_robustness_test.go @@ -0,0 +1,324 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package boot_test + +import ( + "fmt" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/boot" + "github.com/snapcore/snapd/boot/boottest" + "github.com/snapcore/snapd/bootloader" + "github.com/snapcore/snapd/snap" +) + +// TODO:UC20: move this to bootloadertest package and use from i.e. managers_test.go ? +func runBootloaderLogic(c *C, bl bootloader.Bootloader) (snap.PlaceInfo, error) { + // switch on which kind of bootloader we have + ebl, ok := bl.(bootloader.ExtractedRunKernelImageBootloader) + if ok { + return extractedRunKernelImageBootloaderLogic(c, ebl) + } + + return pureenvBootloaderLogic(c, "kernel_status", bl) +} + +// runBootloaderLogic implements the logic from the gadget snap bootloader, +// namely that we transition kernel_status "try" -> "trying" and "trying" -> "" +// and use try-kernel.efi when kernel_status is "try" and kernel.efi in all +// other situations +func extractedRunKernelImageBootloaderLogic(c *C, ebl bootloader.ExtractedRunKernelImageBootloader) (snap.PlaceInfo, error) { + m, err := ebl.GetBootVars("kernel_status") + c.Assert(err, IsNil) + kernStatus := m["kernel_status"] + + kern, err := ebl.Kernel() + c.Assert(err, IsNil) + c.Assert(kern, Not(IsNil)) + + switch kernStatus { + case boot.DefaultStatus: + case boot.TryStatus: + // move to trying, use the try-kernel + m["kernel_status"] = boot.TryingStatus + + // ensure that the try-kernel exists + tryKern, err := ebl.TryKernel() + c.Assert(err, IsNil) + c.Assert(tryKern, Not(IsNil)) + kern = tryKern + + case boot.TryingStatus: + // boot failed, move back to default + m["kernel_status"] = boot.DefaultStatus + } + + err = ebl.SetBootVars(m) + c.Assert(err, IsNil) + + return kern, nil +} + +func pureenvBootloaderLogic(c *C, modeVar string, bl bootloader.Bootloader) (snap.PlaceInfo, error) { + m, err := bl.GetBootVars(modeVar, "snap_kernel", "snap_try_kernel") + c.Assert(err, IsNil) + var kern snap.PlaceInfo + + kernStatus := m[modeVar] + + kern, err = snap.ParsePlaceInfoFromSnapFileName(m["snap_kernel"]) + c.Assert(err, IsNil) + c.Assert(kern, Not(IsNil)) + + switch kernStatus { + case boot.DefaultStatus: + // nothing to do, use normal kernel + + case boot.TryStatus: + // move to trying, use the try-kernel + m[modeVar] = boot.TryingStatus + + tryKern, err := snap.ParsePlaceInfoFromSnapFileName(m["snap_try_kernel"]) + c.Assert(err, IsNil) + c.Assert(tryKern, Not(IsNil)) + kern = tryKern + + case boot.TryingStatus: + // boot failed, move back to default status + m[modeVar] = boot.DefaultStatus + + } + + err = bl.SetBootVars(m) + c.Assert(err, IsNil) + + return kern, nil +} + +// note: this could be implemented just as a function which takes a bootloader +// as an argument and then inspect the type of MockBootloader that was passed +// in, but the gains are little, since we don't need to use this function for +// the non-ExtractedRunKernelImageBootloader implementations, as those +// implementations just have one critical function to run which is just +// SetBootVars +func (s *bootenv20Suite) checkBootStateAfterUnexpectedRebootAndCleanup( + c *C, + dev snap.Device, + bootFunc func(snap.Device) error, + panicFunc string, + expectedBootedKernel snap.PlaceInfo, + expectedModeenvCurrentKernels []snap.PlaceInfo, + blKernelAfterReboot snap.PlaceInfo, + comment string, +) { + if panicFunc != "" { + // setup a panic during the given bootloader function + restoreBootloaderPanic := s.bootloader.SetMockToPanic(panicFunc) + + // run the boot function that will now panic + c.Assert( + func() { bootFunc(dev) }, + PanicMatches, + fmt.Sprintf("mocked reboot panic in %s", panicFunc), + Commentf(comment), + ) + + // don't panic anymore + restoreBootloaderPanic() + } else { + // just run the function directly + err := bootFunc(dev) + c.Assert(err, IsNil, Commentf(comment)) + } + + // do the bootloader kernel failover logic handling + nextBootingKernel, err := runBootloaderLogic(c, s.bootloader) + c.Assert(err, IsNil, Commentf(comment)) + + // check that the kernel we booted now is expected + c.Assert(nextBootingKernel, Equals, expectedBootedKernel, Commentf(comment)) + + // also check that the normal kernel on the bootloader is what we expect + kern, err := s.bootloader.Kernel() + c.Assert(err, IsNil, Commentf(comment)) + c.Assert(kern, Equals, blKernelAfterReboot, Commentf(comment)) + + // mark the boot successful like we were rebooted + err = boot.MarkBootSuccessful(dev) + c.Assert(err, IsNil, Commentf(comment)) + + // the boot vars should be empty now too + afterVars, err := s.bootloader.GetBootVars("kernel_status") + c.Assert(err, IsNil, Commentf(comment)) + c.Assert(afterVars["kernel_status"], DeepEquals, boot.DefaultStatus, Commentf(comment)) + + // the modeenv's setting for CurrentKernels also matches + m, err := boot.ReadModeenv("") + c.Assert(err, IsNil, Commentf(comment)) + // it's nicer to pass in just the snap.PlaceInfo's, but to compare we need + // the string filenames + currentKernels := make([]string, len(expectedModeenvCurrentKernels)) + for i, sn := range expectedModeenvCurrentKernels { + currentKernels[i] = sn.Filename() + } + c.Assert(m.CurrentKernels, DeepEquals, currentKernels, Commentf(comment)) + + // the final kernel on the bootloader should always match what we booted - + // after MarkSuccessful runs that is + afterKernel, err := s.bootloader.Kernel() + c.Assert(err, IsNil, Commentf(comment)) + c.Assert(afterKernel, DeepEquals, expectedBootedKernel, Commentf(comment)) + + // we should never have a leftover try kernel + _, err = s.bootloader.TryKernel() + c.Assert(err, Equals, bootloader.ErrNoTryKernelRef, Commentf(comment)) +} + +func (s *bootenv20Suite) TestHappyMarkBootSuccessful20KernelUpgradeUnexpectedReboots(c *C) { + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + + tt := []struct { + rebootBeforeFunc string + expBootKernel snap.PlaceInfo + expModeenvKernels []snap.PlaceInfo + expBlKernel snap.PlaceInfo + comment string + }{ + { + "", // don't do any reboots for the happy path + s.kern2, // we should boot the new kernel + []snap.PlaceInfo{s.kern2}, // expected modeenv kernel is new one + s.kern2, // after reboot, current kernel on bl is new one + "happy path", + }, + { + "SetBootVars", // reboot right before SetBootVars + s.kern1, // we should boot the old kernel + []snap.PlaceInfo{s.kern1}, // expected modeenv kernel is old one + s.kern1, // after reboot, current kernel on bl is old one + "reboot before SetBootVars results in old kernel", + }, + { + "EnableKernel", // reboot right before EnableKernel + s.kern1, // we should boot the old kernel + []snap.PlaceInfo{s.kern1}, // expected modeenv kernel is old one + s.kern1, // after reboot, current kernel on bl is old one + "reboot before EnableKernel results in old kernel", + }, + { + "DisableTryKernel", // reboot right before DisableTryKernel + s.kern2, // we should boot the new kernel + []snap.PlaceInfo{s.kern2}, // expected modeenv kernel is new one + s.kern2, // after reboot, current kernel on bl is new one + "reboot before DisableTryKernel results in new kernel", + }, + } + + for _, t := range tt { + // setup the bootloader per test + restore := setupUC20Bootenv( + c, + s.bootloader, + s.normalTryingKernelState, + ) + + s.checkBootStateAfterUnexpectedRebootAndCleanup( + c, + coreDev, + boot.MarkBootSuccessful, + t.rebootBeforeFunc, + t.expBlKernel, + t.expModeenvKernels, + t.expBlKernel, + t.comment, + ) + + restore() + } +} + +func (s *bootenv20Suite) TestHappySetNextBoot20KernelUpgradeUnexpectedReboots(c *C) { + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + + tt := []struct { + rebootBeforeFunc string + expBootKernel snap.PlaceInfo + expModeenvKernels []snap.PlaceInfo + expBlKernel snap.PlaceInfo + comment string + }{ + { + "", // don't do any reboots for the happy path + s.kern2, // we should boot the new kernel + []snap.PlaceInfo{s.kern2}, // final expected modeenv kernel is new one + s.kern1, // after reboot, current kernel on bl is old one + "happy path", + }, + { + "EnableTryKernel", // reboot right before EnableTryKernel + s.kern1, // we should boot the old kernel + []snap.PlaceInfo{s.kern1}, // final expected modeenv kernel is old one + s.kern1, // after reboot, current kernel on bl is old one + "reboot before EnableTryKernel results in old kernel", + }, + { + "SetBootVars", // reboot right before SetBootVars + s.kern1, // we should boot the old kernel + []snap.PlaceInfo{s.kern1}, // final expected modeenv kernel is old one + s.kern1, // after reboot, current kernel on bl is old one + "reboot before SetBootVars results in old kernel", + }, + } + + for _, t := range tt { + // setup the bootloader per test + restore := setupUC20Bootenv( + c, + s.bootloader, + s.normalDefaultState, + ) + + // get the boot kernel participant from our new kernel snap + bootKern := boot.Participant(s.kern2, snap.TypeKernel, coreDev) + // make sure it's not a trivial boot participant + c.Assert(bootKern.IsTrivial(), Equals, false) + + setNextFunc := func(snap.Device) error { + // we don't care about the reboot required logic here + _, err := bootKern.SetNextBoot(boot.NextBootContext{BootWithoutTry: false}) + return err + } + + s.checkBootStateAfterUnexpectedRebootAndCleanup( + c, + coreDev, + setNextFunc, + t.rebootBeforeFunc, + t.expBootKernel, + t.expModeenvKernels, + t.expBlKernel, + t.comment, + ) + + restore() + } +} diff --git a/boot/boot_test.go b/boot/boot_test.go new file mode 100644 index 00000000..4f0ced89 --- /dev/null +++ b/boot/boot_test.go @@ -0,0 +1,5550 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2014-2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package boot_test + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "testing" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/asserts/assertstest" + "github.com/snapcore/snapd/boot" + "github.com/snapcore/snapd/boot/boottest" + "github.com/snapcore/snapd/bootloader" + "github.com/snapcore/snapd/bootloader/bootloadertest" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/osutil/kcmdline" + "github.com/snapcore/snapd/release" + "github.com/snapcore/snapd/secboot" + "github.com/snapcore/snapd/seed" + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/snap/snaptest" + "github.com/snapcore/snapd/strutil" + "github.com/snapcore/snapd/testutil" + "github.com/snapcore/snapd/timings" +) + +func TestBoot(t *testing.T) { TestingT(t) } + +type baseBootenvSuite struct { + testutil.BaseTest + + rootdir string + bootdir string + cmdlineFile string +} + +func (s *baseBootenvSuite) SetUpTest(c *C) { + s.BaseTest.SetUpTest(c) + + restore := release.MockOnClassic(false) + s.AddCleanup(restore) + + s.rootdir = c.MkDir() + dirs.SetRootDir(s.rootdir) + s.AddCleanup(func() { dirs.SetRootDir("") }) + restore = snap.MockSanitizePlugsSlots(func(snapInfo *snap.Info) {}) + s.AddCleanup(restore) + + s.bootdir = filepath.Join(s.rootdir, "boot") + + s.cmdlineFile = filepath.Join(c.MkDir(), "cmdline") + restore = kcmdline.MockProcCmdline(s.cmdlineFile) + s.AddCleanup(restore) +} + +func (s *baseBootenvSuite) forceBootloader(bloader bootloader.Bootloader) { + bootloader.Force(bloader) + s.AddCleanup(func() { bootloader.Force(nil) }) +} + +func (s *baseBootenvSuite) stampSealedKeys(c *C, rootdir string) { + stamp := filepath.Join(dirs.SnapFDEDirUnder(rootdir), "sealed-keys") + c.Assert(os.MkdirAll(filepath.Dir(stamp), 0755), IsNil) + err := os.WriteFile(stamp, nil, 0644) + c.Assert(err, IsNil) +} + +func (s *baseBootenvSuite) mockCmdline(c *C, cmdline string) { + c.Assert(os.WriteFile(s.cmdlineFile, []byte(cmdline), 0644), IsNil) +} + +// mockAssetsCache mocks the listed assets in the boot assets cache by creating +// an empty file for each. +func mockAssetsCache(c *C, rootdir, bootloaderName string, cachedAssets []string) { + p := filepath.Join(dirs.SnapBootAssetsDirUnder(rootdir), bootloaderName) + err := os.MkdirAll(p, 0755) + c.Assert(err, IsNil) + for _, cachedAsset := range cachedAssets { + err = os.WriteFile(filepath.Join(p, cachedAsset), nil, 0644) + c.Assert(err, IsNil) + } +} + +type bootenvSuite struct { + baseBootenvSuite + + bootloader *bootloadertest.MockBootloader +} + +var _ = Suite(&bootenvSuite{}) + +func (s *bootenvSuite) SetUpTest(c *C) { + s.baseBootenvSuite.SetUpTest(c) + + s.bootloader = bootloadertest.Mock("mock", c.MkDir()) + s.forceBootloader(s.bootloader) +} + +type baseBootenv20Suite struct { + baseBootenvSuite + + kern1 snap.PlaceInfo + kern2 snap.PlaceInfo + ukern1 snap.PlaceInfo + ukern2 snap.PlaceInfo + base1 snap.PlaceInfo + base2 snap.PlaceInfo + gadget1 snap.PlaceInfo + gadget2 snap.PlaceInfo + + normalDefaultState *bootenv20Setup + normalTryingKernelState *bootenv20Setup +} + +func (s *baseBootenv20Suite) SetUpTest(c *C) { + s.baseBootenvSuite.SetUpTest(c) + + var err error + s.kern1, err = snap.ParsePlaceInfoFromSnapFileName("pc-kernel_1.snap") + c.Assert(err, IsNil) + s.kern2, err = snap.ParsePlaceInfoFromSnapFileName("pc-kernel_2.snap") + c.Assert(err, IsNil) + + s.ukern1, err = snap.ParsePlaceInfoFromSnapFileName("pc-kernel_x1.snap") + c.Assert(err, IsNil) + s.ukern2, err = snap.ParsePlaceInfoFromSnapFileName("pc-kernel_x2.snap") + c.Assert(err, IsNil) + + s.base1, err = snap.ParsePlaceInfoFromSnapFileName("core20_1.snap") + c.Assert(err, IsNil) + s.base2, err = snap.ParsePlaceInfoFromSnapFileName("core20_2.snap") + c.Assert(err, IsNil) + + s.gadget1, err = snap.ParsePlaceInfoFromSnapFileName("pc_1.snap") + c.Assert(err, IsNil) + s.gadget2, err = snap.ParsePlaceInfoFromSnapFileName("pc_2.snap") + c.Assert(err, IsNil) + + // default boot state for robustness tests, etc. + s.normalDefaultState = &bootenv20Setup{ + modeenv: &boot.Modeenv{ + // base is base1 + Base: s.base1.Filename(), + // no try base + TryBase: "", + // base status is default + BaseStatus: boot.DefaultStatus, + // gadget is gadget1 + Gadget: s.gadget1.Filename(), + // current kernels is just kern1 + CurrentKernels: []string{s.kern1.Filename()}, + // operating mode is run + Mode: "run", + // RecoverySystem is unset, as it should be during run mode + RecoverySystem: "", + }, + // enabled kernel is kern1 + kern: s.kern1, + // no try kernel enabled + tryKern: nil, + // kernel status is default + kernStatus: boot.DefaultStatus, + } + + // state for after trying a new kernel for robustness tests, etc. + s.normalTryingKernelState = &bootenv20Setup{ + modeenv: &boot.Modeenv{ + // operating mode is run + Mode: "run", + // base is base1 + Base: s.base1.Filename(), + // no try base + TryBase: "", + // base status is default + BaseStatus: boot.DefaultStatus, + // gadget is gadget2 + Gadget: s.gadget2.Filename(), + // current kernels is kern1 + kern2 + CurrentKernels: []string{s.kern1.Filename(), s.kern2.Filename()}, + }, + // enabled kernel is kern1 + kern: s.kern1, + // try kernel is kern2 + tryKern: s.kern2, + // kernel status is trying + kernStatus: boot.TryingStatus, + } + + s.mockCmdline(c, "snapd_recovery_mode=run") +} + +type bootenv20Suite struct { + baseBootenv20Suite + + bootloader *bootloadertest.MockExtractedRunKernelImageBootloader +} + +type bootenv20EnvRefKernelSuite struct { + baseBootenv20Suite + + bootloader *bootloadertest.MockBootloader +} + +type bootenv20RebootBootloaderSuite struct { + baseBootenv20Suite + + bootloader *bootloadertest.MockRebootBootloader +} + +var _ = Suite(&bootenv20Suite{}) +var _ = Suite(&bootenv20EnvRefKernelSuite{}) +var _ = Suite(&bootenv20RebootBootloaderSuite{}) + +func (s *bootenv20Suite) SetUpTest(c *C) { + s.baseBootenv20Suite.SetUpTest(c) + + s.bootloader = bootloadertest.Mock("mock", c.MkDir()).WithExtractedRunKernelImage() + s.forceBootloader(s.bootloader) +} + +func (s *bootenv20EnvRefKernelSuite) SetUpTest(c *C) { + s.baseBootenv20Suite.SetUpTest(c) + + s.bootloader = bootloadertest.Mock("mock", c.MkDir()) + s.forceBootloader(s.bootloader) +} + +func (s *bootenv20RebootBootloaderSuite) SetUpTest(c *C) { + s.baseBootenv20Suite.SetUpTest(c) + + s.bootloader = bootloadertest.Mock("mock", c.MkDir()).WithRebootBootloader() + s.forceBootloader(s.bootloader) +} + +type bootenv20Setup struct { + modeenv *boot.Modeenv + kern snap.PlaceInfo + tryKern snap.PlaceInfo + kernStatus string +} + +func setupUC20Bootenv(c *C, bl bootloader.Bootloader, opts *bootenv20Setup) (restore func()) { + var cleanups []func() + + // write the modeenv + if opts.modeenv != nil { + c.Assert(opts.modeenv.WriteTo(""), IsNil) + // this isn't strictly necessary since the modeenv will be written to + // the test's private dir anyways, but it's nice to have so we can write + // multiple modeenvs from a single test and just call the restore + // function in between the parts of the test that use different modeenvs + r := func() { + defaultModeenv := &boot.Modeenv{Mode: "run"} + c.Assert(defaultModeenv.WriteTo(""), IsNil) + } + cleanups = append(cleanups, r) + } + + // set the status + origEnv, err := bl.GetBootVars("kernel_status") + c.Assert(err, IsNil) + + err = bl.SetBootVars(map[string]string{"kernel_status": opts.kernStatus}) + c.Assert(err, IsNil) + cleanups = append(cleanups, func() { + err := bl.SetBootVars(origEnv) + c.Assert(err, IsNil) + }) + + // check what kind of real mock bootloader we have to use different methods + // to set the kernel snaps are if they're non-nil + switch vbl := bl.(type) { + case *bootloadertest.MockExtractedRunKernelImageBootloader: + // then we can use the advanced methods on it + if opts.kern != nil { + r := vbl.SetEnabledKernel(opts.kern) + cleanups = append(cleanups, r) + } + + if opts.tryKern != nil { + r := vbl.SetEnabledTryKernel(opts.tryKern) + cleanups = append(cleanups, r) + } + + // don't count any calls to SetBootVars made thus far + vbl.SetBootVarsCalls = 0 + + case *bootloadertest.MockBootloader: + // for non-extracted, we need to use the bootenv to set the current kernels + r := setupUC20MockBootloaderEnv(c, bl, opts) + cleanups = append(cleanups, r) + // don't count any calls to SetBootVars made thus far + vbl.SetBootVarsCalls = 0 + case *bootloadertest.MockRebootBootloader: + // for non-extracted, we need to use the bootenv to set the current kernels + r := setupUC20MockBootloaderEnv(c, bl, opts) + cleanups = append(cleanups, r) + // don't count any calls to SetBootVars made thus far + vbl.SetBootVarsCalls = 0 + default: + c.Fatalf("unsupported bootloader %T", bl) + } + + return func() { + for _, r := range cleanups { + r() + } + } +} + +func setupUC20MockBootloaderEnv(c *C, bl bootloader.Bootloader, opts *bootenv20Setup) (restore func()) { + origEnv, err := bl.GetBootVars("snap_kernel", "snap_try_kernel") + c.Assert(err, IsNil) + m := make(map[string]string, 2) + if opts.kern != nil { + m["snap_kernel"] = opts.kern.Filename() + } else { + m["snap_kernel"] = "" + } + + if opts.tryKern != nil { + m["snap_try_kernel"] = opts.tryKern.Filename() + } else { + m["snap_try_kernel"] = "" + } + + err = bl.SetBootVars(m) + c.Assert(err, IsNil) + + return func() { + err := bl.SetBootVars(origEnv) + c.Assert(err, IsNil) + } +} + +func (s *bootenvSuite) TestInUseClassic(c *C) { + classicDev := boottest.MockDevice("") + + // make bootloader.Find fail but shouldn't matter + bootloader.ForceError(errors.New("broken bootloader")) + + inUse, err := boot.InUse(snap.TypeBase, classicDev) + c.Assert(err, IsNil) + c.Check(inUse("core18", snap.R(41)), Equals, false) +} + +func (s *bootenvSuite) TestInUseIrrelevantTypes(c *C) { + coreDev := boottest.MockDevice("some-snap") + + // make bootloader.Find fail but shouldn't matter + bootloader.ForceError(errors.New("broken bootloader")) + + inUse, err := boot.InUse(snap.TypeGadget, coreDev) + c.Assert(err, IsNil) + c.Check(inUse("gadget", snap.R(41)), Equals, false) +} + +func (s *bootenvSuite) TestInUse(c *C) { + coreDev := boottest.MockDevice("some-snap") + + for _, t := range []struct { + bootVarKey string + bootVarValue string + + snapName string + snapRev snap.Revision + + inUse bool + }{ + // in use + {"snap_kernel", "kernel_41.snap", "kernel", snap.R(41), true}, + {"snap_try_kernel", "kernel_82.snap", "kernel", snap.R(82), true}, + {"snap_core", "core_21.snap", "core", snap.R(21), true}, + {"snap_try_core", "core_42.snap", "core", snap.R(42), true}, + // not in use + {"snap_core", "core_111.snap", "core", snap.R(21), false}, + {"snap_try_core", "core_111.snap", "core", snap.R(21), false}, + {"snap_kernel", "kernel_111.snap", "kernel", snap.R(1), false}, + {"snap_try_kernel", "kernel_111.snap", "kernel", snap.R(1), false}, + } { + typ := snap.TypeBase + if t.snapName == "kernel" { + typ = snap.TypeKernel + } + s.bootloader.BootVars[t.bootVarKey] = t.bootVarValue + inUse, err := boot.InUse(typ, coreDev) + c.Assert(err, IsNil) + c.Assert(inUse(t.snapName, t.snapRev), Equals, t.inUse, Commentf("unexpected result: %s %s %v", t.snapName, t.snapRev, t.inUse)) + } +} + +func (s *bootenv20Suite) TestInUseCore20(c *C) { + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + c.Assert(coreDev.IsCoreBoot(), Equals, true) + + r := setupUC20Bootenv( + c, + s.bootloader, + &bootenv20Setup{ + modeenv: &boot.Modeenv{ + // base is base1 + Base: s.base1.Filename(), + // no try base + TryBase: "", + // gadget is gadget1 + Gadget: s.gadget1.Filename(), + // current kernels is just kern1 + CurrentKernels: []string{s.kern1.Filename()}, + // operating mode is run + Mode: "run", + // RecoverySystem is unset, as it should be during run mode + RecoverySystem: "", + }, + // enabled kernel is kern1 + kern: s.kern1, + // no try kernel enabled + tryKern: nil, + // kernel status is default + kernStatus: boot.DefaultStatus, + }) + defer r() + + inUse, err := boot.InUse(snap.TypeKernel, coreDev) + c.Check(err, IsNil) + c.Check(inUse(s.kern1.SnapName(), s.kern1.SnapRevision()), Equals, true) + c.Check(inUse(s.kern2.SnapName(), s.kern2.SnapRevision()), Equals, false) + + _, err = boot.InUse(snap.TypeBase, coreDev) + c.Check(err, IsNil) +} + +func (s *bootenvSuite) TestInUseEphemeral(c *C) { + coreDev := boottest.MockDevice("some-snap@install") + + // make bootloader.Find fail but shouldn't matter + bootloader.ForceError(errors.New("broken bootloader")) + + inUse, err := boot.InUse(snap.TypeBase, coreDev) + c.Assert(err, IsNil) + c.Check(inUse("whatever", snap.R(0)), Equals, true) +} + +func (s *bootenvSuite) TestInUseUnhappy(c *C) { + coreDev := boottest.MockDevice("some-snap") + + // make GetVars fail + s.bootloader.GetErr = errors.New("zap") + _, err := boot.InUse(snap.TypeKernel, coreDev) + c.Check(err, ErrorMatches, `cannot get boot variables: zap`) + + // make bootloader.Find fail + bootloader.ForceError(errors.New("broken bootloader")) + _, err = boot.InUse(snap.TypeKernel, coreDev) + c.Check(err, ErrorMatches, `cannot get boot settings: broken bootloader`) +} + +func (s *bootenvSuite) TestCurrentBootNameAndRevision(c *C) { + coreDev := boottest.MockDevice("some-snap") + + s.bootloader.BootVars["snap_core"] = "core_2.snap" + s.bootloader.BootVars["snap_kernel"] = "canonical-pc-linux_2.snap" + + current, err := boot.GetCurrentBoot(snap.TypeOS, coreDev) + c.Check(err, IsNil) + c.Check(current.SnapName(), Equals, "core") + c.Check(current.SnapRevision(), Equals, snap.R(2)) + + current, err = boot.GetCurrentBoot(snap.TypeKernel, coreDev) + c.Check(err, IsNil) + c.Check(current.SnapName(), Equals, "canonical-pc-linux") + c.Check(current.SnapRevision(), Equals, snap.R(2)) + + s.bootloader.BootVars["snap_mode"] = boot.TryingStatus + _, err = boot.GetCurrentBoot(snap.TypeKernel, coreDev) + c.Check(err, Equals, boot.ErrBootNameAndRevisionNotReady) +} + +func (s *bootenv20Suite) TestCurrentBoot20NameAndRevision(c *C) { + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + + r := setupUC20Bootenv( + c, + s.bootloader, + s.normalDefaultState, + ) + defer r() + + current, err := boot.GetCurrentBoot(snap.TypeBase, coreDev) + c.Check(err, IsNil) + c.Check(current.SnapName(), Equals, s.base1.SnapName()) + c.Check(current.SnapRevision(), Equals, snap.R(1)) + + current, err = boot.GetCurrentBoot(snap.TypeKernel, coreDev) + c.Check(err, IsNil) + c.Check(current.SnapName(), Equals, s.kern1.SnapName()) + c.Check(current.SnapRevision(), Equals, snap.R(1)) + + s.bootloader.BootVars["kernel_status"] = boot.TryingStatus + _, err = boot.GetCurrentBoot(snap.TypeKernel, coreDev) + c.Check(err, Equals, boot.ErrBootNameAndRevisionNotReady) +} + +// only difference between this test and TestCurrentBoot20NameAndRevision is the +// base bootloader which doesn't support ExtractedRunKernelImageBootloader. +func (s *bootenv20EnvRefKernelSuite) TestCurrentBoot20NameAndRevision(c *C) { + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + + r := setupUC20Bootenv( + c, + s.bootloader, + s.normalDefaultState, + ) + defer r() + + current, err := boot.GetCurrentBoot(snap.TypeKernel, coreDev) + c.Assert(err, IsNil) + c.Assert(current.SnapName(), Equals, s.kern1.SnapName()) + c.Assert(current.SnapRevision(), Equals, snap.R(1)) +} + +func (s *bootenvSuite) TestCurrentBootNameAndRevisionUnhappy(c *C) { + coreDev := boottest.MockDevice("some-snap") + + _, err := boot.GetCurrentBoot(snap.TypeKernel, coreDev) + c.Check(err, ErrorMatches, `cannot get name and revision of kernel \(snap_kernel\): boot variable unset`) + + _, err = boot.GetCurrentBoot(snap.TypeOS, coreDev) + c.Check(err, ErrorMatches, `cannot get name and revision of boot base \(snap_core\): boot variable unset`) + + _, err = boot.GetCurrentBoot(snap.TypeBase, coreDev) + c.Check(err, ErrorMatches, `cannot get name and revision of boot base \(snap_core\): boot variable unset`) + + _, err = boot.GetCurrentBoot(snap.TypeApp, coreDev) + c.Check(err, ErrorMatches, `internal error: no boot state handling for snap type "app"`) + + // validity check + s.bootloader.BootVars["snap_kernel"] = "kernel_41.snap" + current, err := boot.GetCurrentBoot(snap.TypeKernel, coreDev) + c.Check(err, IsNil) + c.Check(current.SnapName(), Equals, "kernel") + c.Check(current.SnapRevision(), Equals, snap.R(41)) + + // make GetVars fail + s.bootloader.GetErr = errors.New("zap") + _, err = boot.GetCurrentBoot(snap.TypeKernel, coreDev) + c.Check(err, ErrorMatches, "cannot get boot variables: zap") + s.bootloader.GetErr = nil + + // make bootloader.Find fail + bootloader.ForceError(errors.New("broken bootloader")) + _, err = boot.GetCurrentBoot(snap.TypeKernel, coreDev) + c.Check(err, ErrorMatches, "cannot get boot settings: broken bootloader") +} + +func (s *bootenvSuite) TestSnapTypeParticipatesInBoot(c *C) { + classicDev := boottest.MockDevice("") + legacyCoreDev := boottest.MockDevice("some-snap") + coreDev := boottest.MockUC20Device("", nil) + coreDevInstallMode := boottest.MockUC20Device("install", nil) + + for _, typ := range []snap.Type{ + snap.TypeKernel, + snap.TypeOS, + snap.TypeBase, + } { + c.Check(boot.SnapTypeParticipatesInBoot(typ, classicDev), Equals, false) + c.Check(boot.SnapTypeParticipatesInBoot(typ, legacyCoreDev), Equals, true) + c.Check(boot.SnapTypeParticipatesInBoot(typ, coreDev), Equals, true) + c.Check(boot.SnapTypeParticipatesInBoot(typ, coreDevInstallMode), Equals, true) + } + + classicWithModesDev := boottest.MockClassicWithModesDevice("", nil) + c.Check(boot.SnapTypeParticipatesInBoot(snap.TypeKernel, classicWithModesDev), Equals, true) + c.Check(boot.SnapTypeParticipatesInBoot(snap.TypeOS, classicWithModesDev), Equals, false) + c.Check(boot.SnapTypeParticipatesInBoot(snap.TypeBase, classicWithModesDev), Equals, false) + + classicWithModesDevInstallMode := boottest.MockClassicWithModesDevice("install", nil) + c.Check(boot.SnapTypeParticipatesInBoot(snap.TypeKernel, classicWithModesDevInstallMode), Equals, true) +} + +func (s *bootenvSuite) TestParticipant(c *C) { + info := &snap.Info{} + info.RealName = "some-snap" + + coreDev := boottest.MockDevice("some-snap") + classicDev := boottest.MockDevice("") + + bp := boot.Participant(info, snap.TypeApp, coreDev) + c.Check(bp.IsTrivial(), Equals, true) + + for _, typ := range []snap.Type{ + snap.TypeKernel, + snap.TypeOS, + snap.TypeBase, + } { + bp = boot.Participant(info, typ, classicDev) + c.Check(bp.IsTrivial(), Equals, true) + + bp = boot.Participant(info, typ, coreDev) + c.Check(bp.IsTrivial(), Equals, false) + + c.Check(bp, DeepEquals, boot.NewCoreBootParticipant(info, typ, coreDev)) + } +} + +func (s *bootenvSuite) TestParticipantBaseWithModel(c *C) { + core := &snap.Info{SideInfo: snap.SideInfo{RealName: "core"}, SnapType: snap.TypeOS} + core18 := &snap.Info{SideInfo: snap.SideInfo{RealName: "core18"}, SnapType: snap.TypeBase} + core20 := &snap.Info{SideInfo: snap.SideInfo{RealName: "core20"}, SnapType: snap.TypeBase} + + type tableT struct { + with *snap.Info + model string + nop bool + } + + table := []tableT{ + { + with: core, + model: "", + nop: true, + }, { + with: core, + model: "core", + nop: false, + }, { + with: core, + model: "core18", + nop: true, + }, + { + with: core18, + model: "", + nop: true, + }, + { + with: core18, + model: "core", + nop: true, + }, + { + with: core18, + model: "core18", + nop: false, + }, + { + with: core18, + model: "core18@install", + nop: true, + }, + { + with: core, + model: "core@install", + nop: true, + }, + { + with: core20, + model: "core@run", + nop: true, + }, + } + + for i, t := range table { + dev := boottest.MockDevice(t.model) + bp := boot.Participant(t.with, t.with.Type(), dev) + c.Check(bp.IsTrivial(), Equals, t.nop, Commentf("%d", i)) + if !t.nop { + c.Check(bp, DeepEquals, boot.NewCoreBootParticipant(t.with, t.with.Type(), dev)) + } + } +} + +func (s *bootenvSuite) TestParticipantGadgetWithModel(c *C) { + gadget := &snap.Info{SideInfo: snap.SideInfo{RealName: "pc"}, SnapType: snap.TypeGadget} + + type tableT struct { + with *snap.Info + model string + nop bool + } + + table := []tableT{ + { + with: gadget, + model: "", + nop: true, + }, { + with: gadget, + model: "pc", + nop: true, + }, { + with: gadget, + model: "pc@run", + nop: false, + }, { + with: gadget, + model: "other-gadget", + nop: true, + }, + { + with: gadget, + model: "pc@install", + nop: true, + }, + } + + for i, t := range table { + dev := boottest.MockDevice(t.model) + bp := boot.Participant(t.with, t.with.Type(), dev) + c.Check(bp.IsTrivial(), Equals, t.nop, Commentf("%d", i)) + if !t.nop { + c.Check(bp, DeepEquals, boot.NewCoreBootParticipant(t.with, t.with.Type(), dev)) + } + } +} + +func (s *bootenvSuite) TestKernelWithModel(c *C) { + info := &snap.Info{} + info.RealName = "kernel" + + type tableT struct { + model string + nop bool + krn boot.BootKernel + } + + table := []tableT{ + { + model: "other-kernel", + nop: true, + krn: boot.Trivial{}, + }, { + model: "kernel", + nop: false, + krn: boot.NewCoreKernel(info, boottest.MockDevice("kernel")), + }, { + model: "", + nop: true, + krn: boot.Trivial{}, + }, { + model: "kernel@install", + nop: true, + krn: boot.Trivial{}, + }, + } + + for _, t := range table { + dev := boottest.MockDevice(t.model) + krn := boot.Kernel(info, snap.TypeKernel, dev) + c.Check(krn.IsTrivial(), Equals, t.nop) + c.Check(krn, DeepEquals, t.krn) + } +} + +func (s *bootenvSuite) TestParticipantClassicWithModesWithModel(c *C) { + modelHdrs := map[string]interface{}{ + "type": "model", + "authority-id": "brand", + "series": "16", + "brand-id": "brand", + "model": "baz-3000", + "architecture": "amd64", + "classic": "true", + "distribution": "ubuntu", + "base": "core22", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "kernel", + "id": "pclinuxdidididididididididididid", + "type": "kernel", + }, + map[string]interface{}{ + "name": "gadget", + "id": "pcididididididididididididididid", + "type": "gadget", + }, + }, + } + model := assertstest.FakeAssertion(modelHdrs).(*asserts.Model) + classicWithModesDev := boottest.MockClassicWithModesDevice("", model) + + tests := []struct { + name string + typ snap.Type + nonTrivial bool + }{ + {"some-snap", snap.TypeApp, false}, + {"core22", snap.TypeBase, false}, + {"kernel", snap.TypeKernel, true}, + {"gadget", snap.TypeGadget, true}, + } + + for _, t := range tests { + info := &snap.Info{} + info.RealName = t.name + + bp := boot.Participant(info, t.typ, classicWithModesDev) + if !t.nonTrivial { + c.Check(bp.IsTrivial(), Equals, true) + } else { + c.Check(bp, DeepEquals, boot.NewCoreBootParticipant(info, t.typ, classicWithModesDev)) + } + } +} + +func (s *bootenvSuite) TestMarkBootSuccessfulKernelStatusTryingNoTryKernelSnapCleansUp(c *C) { + coreDev := boottest.MockDevice("some-snap") + + // set all the same vars as if we were doing trying, except don't set a try + // kernel + + err := s.bootloader.SetBootVars(map[string]string{ + "snap_kernel": "kernel_41.snap", + "snap_mode": boot.TryingStatus, + }) + c.Assert(err, IsNil) + + // mark successful + err = boot.MarkBootSuccessful(coreDev) + c.Assert(err, IsNil) + + // check that the bootloader variables were cleaned + expected := map[string]string{ + "snap_mode": boot.DefaultStatus, + "snap_kernel": "kernel_41.snap", + "snap_try_kernel": "", + } + m, err := s.bootloader.GetBootVars("snap_mode", "snap_try_kernel", "snap_kernel") + c.Assert(err, IsNil) + c.Assert(m, DeepEquals, expected) + + // do it again, verify it's still okay + err = boot.MarkBootSuccessful(coreDev) + c.Assert(err, IsNil) + m2, err := s.bootloader.GetBootVars("snap_mode", "snap_try_kernel", "snap_kernel") + c.Assert(err, IsNil) + c.Assert(m2, DeepEquals, expected) +} + +func (s *bootenvSuite) TestMarkBootSuccessfulTryKernelKernelStatusDefaultCleansUp(c *C) { + coreDev := boottest.MockDevice("some-snap") + + // set an errant snap_try_kernel + err := s.bootloader.SetBootVars(map[string]string{ + "snap_kernel": "kernel_41.snap", + "snap_try_kernel": "kernel_42.snap", + "snap_mode": boot.DefaultStatus, + }) + c.Assert(err, IsNil) + + // mark successful + err = boot.MarkBootSuccessful(coreDev) + c.Assert(err, IsNil) + + // check that the bootloader variables were cleaned + expected := map[string]string{ + "snap_mode": boot.DefaultStatus, + "snap_kernel": "kernel_41.snap", + "snap_try_kernel": "", + } + m, err := s.bootloader.GetBootVars("snap_mode", "snap_try_kernel", "snap_kernel") + c.Assert(err, IsNil) + c.Assert(m, DeepEquals, expected) + + // do it again, verify it's still okay + err = boot.MarkBootSuccessful(coreDev) + c.Assert(err, IsNil) + m2, err := s.bootloader.GetBootVars("snap_mode", "snap_try_kernel", "snap_kernel") + c.Assert(err, IsNil) + c.Assert(m2, DeepEquals, expected) +} + +func (s *bootenv20Suite) TestCoreKernel20(c *C) { + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + + r := setupUC20Bootenv( + c, + s.bootloader, + s.normalDefaultState, + ) + defer r() + + // get the boot kernel from our kernel snap + bootKern := boot.Kernel(s.kern1, snap.TypeKernel, coreDev) + // can't use FitsTypeOf with coreKernel here, cause that causes an import + // loop as boottest imports boot and coreKernel is unexported + c.Assert(bootKern.IsTrivial(), Equals, false) + + // extract the kernel assets from the coreKernel + // the container here doesn't really matter since it's just being passed + // to the mock bootloader method anyways + kernelContainer := snaptest.MockContainer(c, nil) + err := bootKern.ExtractKernelAssets(kernelContainer) + c.Assert(err, IsNil) + + // make sure that the bootloader was told to extract some assets + c.Assert(s.bootloader.ExtractKernelAssetsCalls, DeepEquals, []snap.PlaceInfo{s.kern1}) + + // now remove the kernel assets and ensure that we get those calls + err = bootKern.RemoveKernelAssets() + c.Assert(err, IsNil) + + // make sure that the bootloader was told to remove assets + c.Assert(s.bootloader.RemoveKernelAssetsCalls, DeepEquals, []snap.PlaceInfo{s.kern1}) +} + +func (s *bootenv20Suite) TestCoreParticipant20SetNextSameKernelSnap(c *C) { + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + + r := setupUC20Bootenv( + c, + s.bootloader, + s.normalDefaultState, + ) + defer r() + + // get the boot kernel participant from our kernel snap + bootKern := boot.Participant(s.kern1, snap.TypeKernel, coreDev) + + // make sure it's not a trivial boot participant + c.Assert(bootKern.IsTrivial(), Equals, false) + + // make the kernel used on next boot + rebootRequired, err := bootKern.SetNextBoot(boot.NextBootContext{BootWithoutTry: false}) + c.Assert(err, IsNil) + c.Assert(rebootRequired, Equals, boot.RebootInfo{RebootRequired: false}) + + // make sure that the bootloader was asked for the current kernel + _, nKernelCalls := s.bootloader.GetRunKernelImageFunctionSnapCalls("Kernel") + c.Assert(nKernelCalls, Equals, 1) + + // ensure that kernel_status is still empty + c.Assert(s.bootloader.BootVars["kernel_status"], Equals, boot.DefaultStatus) + + // there was no attempt to enable a kernel + _, enableKernelCalls := s.bootloader.GetRunKernelImageFunctionSnapCalls("EnableTryKernel") + c.Assert(enableKernelCalls, Equals, 0) + + // the modeenv is still the same as well + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Assert(m2.CurrentKernels, DeepEquals, []string{s.kern1.Filename()}) + + // finally we didn't call SetBootVars on the bootloader because nothing + // changed + c.Assert(s.bootloader.SetBootVarsCalls, Equals, 0) +} + +func (s *bootenv20EnvRefKernelSuite) TestCoreParticipant20SetNextSameKernelSnap(c *C) { + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + + r := setupUC20Bootenv( + c, + s.bootloader, + s.normalDefaultState, + ) + defer r() + + // get the boot kernel participant from our kernel snap + bootKern := boot.Participant(s.kern1, snap.TypeKernel, coreDev) + + // make sure it's not a trivial boot participant + c.Assert(bootKern.IsTrivial(), Equals, false) + + // make the kernel used on next boot + rebootRequired, err := bootKern.SetNextBoot(boot.NextBootContext{BootWithoutTry: false}) + c.Assert(err, IsNil) + c.Assert(rebootRequired, Equals, boot.RebootInfo{RebootRequired: false}) + + // ensure that bootenv is unchanged + m, err := s.bootloader.GetBootVars("kernel_status", "snap_kernel", "snap_try_kernel") + c.Assert(err, IsNil) + c.Assert(m, DeepEquals, map[string]string{ + "kernel_status": boot.DefaultStatus, + "snap_kernel": s.kern1.Filename(), + "snap_try_kernel": "", + }) + + // the modeenv is still the same as well + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Assert(m2.CurrentKernels, DeepEquals, []string{s.kern1.Filename()}) + + // finally we didn't call SetBootVars on the bootloader because nothing + // changed + c.Assert(s.bootloader.SetBootVarsCalls, Equals, 0) +} + +func (s *bootenv20Suite) TestCoreParticipant20SetNextNewKernelSnap(c *C) { + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + + r := setupUC20Bootenv( + c, + s.bootloader, + s.normalDefaultState, + ) + defer r() + + // get the boot kernel participant from our new kernel snap + bootKern := boot.Participant(s.kern2, snap.TypeKernel, coreDev) + // make sure it's not a trivial boot participant + c.Assert(bootKern.IsTrivial(), Equals, false) + + // make the kernel used on next boot + rebootRequired, err := bootKern.SetNextBoot(boot.NextBootContext{BootWithoutTry: false}) + c.Assert(err, IsNil) + c.Assert(rebootRequired, DeepEquals, boot.RebootInfo{ + RebootRequired: true, + BootloaderOptions: &bootloader.Options{ + Role: bootloader.RoleRunMode, + }, + }) + + // make sure that the bootloader was asked for the current kernel + _, nKernelCalls := s.bootloader.GetRunKernelImageFunctionSnapCalls("Kernel") + c.Assert(nKernelCalls, Equals, 1) + + // ensure that kernel_status is now try + c.Assert(s.bootloader.BootVars["kernel_status"], Equals, boot.TryStatus) + + // and we were asked to enable kernel2 as the try kernel + actual, _ := s.bootloader.GetRunKernelImageFunctionSnapCalls("EnableTryKernel") + c.Assert(actual, DeepEquals, []snap.PlaceInfo{s.kern2}) + + // and that the modeenv now has this kernel listed + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Assert(m2.CurrentKernels, DeepEquals, []string{s.kern1.Filename(), s.kern2.Filename()}) +} + +func (s *bootenv20Suite) TestCoreParticipant20SetNextNewKernelSnapWithReseal(c *C) { + // checked by resealKeyToModeenv + s.stampSealedKeys(c, dirs.GlobalRootDir) + + tab := s.bootloaderWithTrustedAssets(c, map[string]string{ + "asset": "asset", + }) + + data := []byte("foobar") + // SHA3-384 + dataHash := "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8" + + c.Assert(os.MkdirAll(filepath.Join(boot.InitramfsUbuntuBootDir), 0755), IsNil) + c.Assert(os.MkdirAll(filepath.Join(boot.InitramfsUbuntuSeedDir), 0755), IsNil) + c.Assert(os.WriteFile(filepath.Join(boot.InitramfsUbuntuBootDir, "asset"), data, 0644), IsNil) + c.Assert(os.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "asset"), data, 0644), IsNil) + + // mock the files in cache + mockAssetsCache(c, dirs.GlobalRootDir, "trusted", []string{ + "asset-" + dataHash, + }) + + assetBf := bootloader.NewBootFile("", filepath.Join(dirs.SnapBootAssetsDir, "trusted", fmt.Sprintf("asset-%s", dataHash)), bootloader.RoleRunMode) + runKernelBf := bootloader.NewBootFile(filepath.Join(s.kern1.Filename()), "kernel.efi", bootloader.RoleRunMode) + + tab.BootChainList = []bootloader.BootFile{ + bootloader.NewBootFile("", "asset", bootloader.RoleRunMode), + // TODO:UC20: fix mocked trusted assets bootloader to actually + // geenerate kernel boot files + runKernelBf, + } + + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + model := coreDev.Model() + + m := &boot.Modeenv{ + Mode: "run", + Base: s.base1.Filename(), + CurrentKernels: []string{s.kern1.Filename()}, + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": []string{dataHash}, + }, + Model: model.Model(), + BrandID: model.BrandID(), + Grade: string(model.Grade()), + ModelSignKeyID: model.SignKeyID(), + } + + r := setupUC20Bootenv( + c, + tab.MockBootloader, + &bootenv20Setup{ + modeenv: m, + kern: s.kern1, + kernStatus: boot.DefaultStatus, + }, + ) + defer r() + + resealCalls := 0 + restore := boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + resealCalls++ + + c.Assert(params.ModelParams, HasLen, 1) + mp := params.ModelParams[0] + c.Check(mp.Model.Model(), Equals, model.Model()) + for _, ch := range mp.EFILoadChains { + printChain(c, ch, "-") + } + c.Check(mp.EFILoadChains, DeepEquals, []*secboot.LoadChain{ + secboot.NewLoadChain(assetBf, + secboot.NewLoadChain(runKernelBf)), + secboot.NewLoadChain(assetBf, + // TODO:UC20: once mock trusted assets + // bootloader can generated boot files for the + // kernel this will use candidate kernel + secboot.NewLoadChain(runKernelBf)), + }) + // actual paths are seen only here + c.Check(tab.BootChainKernelPath, DeepEquals, []string{ + s.kern1.MountFile(), + s.kern2.MountFile(), + }) + return nil + }) + defer restore() + + // get the boot kernel participant from our new kernel snap + bootKern := boot.Participant(s.kern2, snap.TypeKernel, coreDev) + // make sure it's not a trivial boot participant + c.Assert(bootKern.IsTrivial(), Equals, false) + + // make the kernel used on next boot + rebootRequired, err := bootKern.SetNextBoot(boot.NextBootContext{BootWithoutTry: false}) + c.Assert(err, IsNil) + c.Assert(rebootRequired, DeepEquals, boot.RebootInfo{ + RebootRequired: true, + BootloaderOptions: &bootloader.Options{ + Role: bootloader.RoleRunMode, + }, + }) + + // make sure the env was updated + bvars, err := tab.GetBootVars("kernel_status", "snap_kernel", "snap_try_kernel") + c.Assert(err, IsNil) + c.Assert(bvars, DeepEquals, map[string]string{ + "kernel_status": boot.TryStatus, + "snap_kernel": s.kern1.Filename(), + "snap_try_kernel": s.kern2.Filename(), + }) + + // and that the modeenv now has this kernel listed + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Assert(m2.CurrentKernels, DeepEquals, []string{s.kern1.Filename(), s.kern2.Filename()}) + + c.Check(resealCalls, Equals, 1) +} + +func (s *bootenv20Suite) TestCoreParticipant20SetNextNewUnassertedKernelSnapWithReseal(c *C) { + // checked by resealKeyToModeenv + s.stampSealedKeys(c, dirs.GlobalRootDir) + + tab := s.bootloaderWithTrustedAssets(c, map[string]string{ + "asset": "asset", + }) + + data := []byte("foobar") + // SHA3-384 + dataHash := "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8" + + c.Assert(os.MkdirAll(filepath.Join(boot.InitramfsUbuntuBootDir), 0755), IsNil) + c.Assert(os.MkdirAll(filepath.Join(boot.InitramfsUbuntuSeedDir), 0755), IsNil) + c.Assert(os.WriteFile(filepath.Join(boot.InitramfsUbuntuBootDir, "asset"), data, 0644), IsNil) + c.Assert(os.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "asset"), data, 0644), IsNil) + + // mock the files in cache + mockAssetsCache(c, dirs.GlobalRootDir, "trusted", []string{ + "asset-" + dataHash, + }) + + assetBf := bootloader.NewBootFile("", filepath.Join(dirs.SnapBootAssetsDir, "trusted", fmt.Sprintf("asset-%s", dataHash)), bootloader.RoleRunMode) + runKernelBf := bootloader.NewBootFile(filepath.Join(s.ukern1.Filename()), "kernel.efi", bootloader.RoleRunMode) + + tab.BootChainList = []bootloader.BootFile{ + bootloader.NewBootFile("", "asset", bootloader.RoleRunMode), + // TODO:UC20: fix mocked trusted assets bootloader to actually + // geenerate kernel boot files + runKernelBf, + } + + uc20Model := boottest.MakeMockUC20Model() + coreDev := boottest.MockUC20Device("", uc20Model) + c.Assert(coreDev.HasModeenv(), Equals, true) + + m := &boot.Modeenv{ + Mode: "run", + Base: s.base1.Filename(), + CurrentKernels: []string{s.ukern1.Filename()}, + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": []string{dataHash}, + }, + Model: uc20Model.Model(), + BrandID: uc20Model.BrandID(), + Grade: string(uc20Model.Grade()), + ModelSignKeyID: uc20Model.SignKeyID(), + } + + r := setupUC20Bootenv( + c, + tab.MockBootloader, + &bootenv20Setup{ + modeenv: m, + kern: s.ukern1, + kernStatus: boot.DefaultStatus, + }, + ) + defer r() + + resealCalls := 0 + restore := boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + resealCalls++ + + c.Assert(params.ModelParams, HasLen, 1) + mp := params.ModelParams[0] + c.Check(mp.Model.Model(), Equals, uc20Model.Model()) + for _, ch := range mp.EFILoadChains { + printChain(c, ch, "-") + } + c.Check(mp.EFILoadChains, DeepEquals, []*secboot.LoadChain{ + secboot.NewLoadChain(assetBf, + secboot.NewLoadChain(runKernelBf)), + secboot.NewLoadChain(assetBf, + // TODO:UC20: once mock trusted assets + // bootloader can generated boot files for the + // kernel this will use candidate kernel + secboot.NewLoadChain(runKernelBf)), + }) + // actual paths are seen only here + c.Check(tab.BootChainKernelPath, DeepEquals, []string{ + s.ukern1.MountFile(), + s.ukern2.MountFile(), + }) + return nil + }) + defer restore() + + // get the boot kernel participant from our new kernel snap + bootKern := boot.Participant(s.ukern2, snap.TypeKernel, coreDev) + // make sure it's not a trivial boot participant + c.Assert(bootKern.IsTrivial(), Equals, false) + + // make the kernel used on next boot + rebootRequired, err := bootKern.SetNextBoot(boot.NextBootContext{BootWithoutTry: false}) + c.Assert(err, IsNil) + c.Assert(rebootRequired, DeepEquals, boot.RebootInfo{ + RebootRequired: true, + BootloaderOptions: &bootloader.Options{ + Role: bootloader.RoleRunMode, + }, + }) + + bvars, err := tab.GetBootVars("kernel_status", "snap_kernel", "snap_try_kernel") + c.Assert(err, IsNil) + c.Assert(bvars, DeepEquals, map[string]string{ + "kernel_status": boot.TryStatus, + "snap_kernel": s.ukern1.Filename(), + "snap_try_kernel": s.ukern2.Filename(), + }) + + // and that the modeenv now has this kernel listed + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Assert(m2.CurrentKernels, DeepEquals, []string{s.ukern1.Filename(), s.ukern2.Filename()}) + + c.Check(resealCalls, Equals, 1) +} + +func (s *bootenv20Suite) TestCoreParticipant20SetNextSameKernelSnapNoReseal(c *C) { + // checked by resealKeyToModeenv + s.stampSealedKeys(c, dirs.GlobalRootDir) + + tab := s.bootloaderWithTrustedAssets(c, map[string]string{ + "asset": "asset", + }) + + data := []byte("foobar") + // SHA3-384 + dataHash := "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8" + + c.Assert(os.MkdirAll(filepath.Join(boot.InitramfsUbuntuBootDir), 0755), IsNil) + c.Assert(os.MkdirAll(filepath.Join(boot.InitramfsUbuntuSeedDir), 0755), IsNil) + c.Assert(os.WriteFile(filepath.Join(boot.InitramfsUbuntuBootDir, "asset"), data, 0644), IsNil) + c.Assert(os.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "asset"), data, 0644), IsNil) + + // mock the files in cache + mockAssetsCache(c, dirs.GlobalRootDir, "trusted", []string{ + "asset-" + dataHash, + }) + + runKernelBf := bootloader.NewBootFile(filepath.Join(s.kern1.Filename()), "kernel.efi", bootloader.RoleRunMode) + + tab.BootChainList = []bootloader.BootFile{ + bootloader.NewBootFile("", "asset", bootloader.RoleRunMode), + runKernelBf, + } + + uc20Model := boottest.MakeMockUC20Model() + coreDev := boottest.MockUC20Device("", uc20Model) + c.Assert(coreDev.HasModeenv(), Equals, true) + + m := &boot.Modeenv{ + Mode: "run", + Base: s.base1.Filename(), + CurrentKernels: []string{s.kern1.Filename()}, + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": []string{dataHash}, + }, + CurrentKernelCommandLines: boot.BootCommandLines{"snapd_recovery_mode=run"}, + + Model: uc20Model.Model(), + BrandID: uc20Model.BrandID(), + Grade: string(uc20Model.Grade()), + ModelSignKeyID: uc20Model.SignKeyID(), + } + + r := setupUC20Bootenv( + c, + tab.MockBootloader, + &bootenv20Setup{ + modeenv: m, + kern: s.kern1, + kernStatus: boot.DefaultStatus, + }, + ) + defer r() + + resealCalls := 0 + restore := boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + resealCalls++ + return fmt.Errorf("unexpected call") + }) + defer restore() + + // get the boot kernel participant from our kernel snap + bootKern := boot.Participant(s.kern1, snap.TypeKernel, coreDev) + // make sure it's not a trivial boot participant + c.Assert(bootKern.IsTrivial(), Equals, false) + + // write boot-chains for current state that will stay unchanged + bootChains := []boot.BootChain{{ + BrandID: "my-brand", + Model: "my-model-uc20", + Grade: "dangerous", + ModelSignKeyID: "Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij", + AssetChain: []boot.BootAsset{ + { + Role: bootloader.RoleRunMode, + Name: "asset", + Hashes: []string{ + "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8", + }, + }, + }, + Kernel: "pc-kernel", + KernelRevision: "1", + KernelCmdlines: []string{"snapd_recovery_mode=run"}, + }} + err := boot.WriteBootChains(bootChains, filepath.Join(dirs.SnapFDEDir, "boot-chains"), 0) + c.Assert(err, IsNil) + + // make the kernel used on next boot + rebootRequired, err := bootKern.SetNextBoot(boot.NextBootContext{BootWithoutTry: false}) + c.Assert(err, IsNil) + c.Assert(rebootRequired, Equals, boot.RebootInfo{RebootRequired: false}) + + // make sure the env is as expected + bvars, err := tab.GetBootVars("kernel_status", "snap_kernel", "snap_try_kernel") + c.Assert(err, IsNil) + c.Assert(bvars, DeepEquals, map[string]string{ + "kernel_status": boot.DefaultStatus, + "snap_kernel": s.kern1.Filename(), + "snap_try_kernel": "", + }) + + // and that the modeenv now has the one kernel listed + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Assert(m2.CurrentKernels, DeepEquals, []string{s.kern1.Filename()}) + + // boot chains were built + c.Check(tab.BootChainKernelPath, DeepEquals, []string{ + s.kern1.MountFile(), + }) + // no actual reseal + c.Check(resealCalls, Equals, 0) +} + +func (s *bootenv20Suite) TestCoreParticipant20SetNextSameUnassertedKernelSnapNoReseal(c *C) { + // checked by resealKeyToModeenv + s.stampSealedKeys(c, dirs.GlobalRootDir) + + tab := s.bootloaderWithTrustedAssets(c, map[string]string{ + "asset": "asset", + }) + + data := []byte("foobar") + // SHA3-384 + dataHash := "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8" + + c.Assert(os.MkdirAll(filepath.Join(boot.InitramfsUbuntuBootDir), 0755), IsNil) + c.Assert(os.MkdirAll(filepath.Join(boot.InitramfsUbuntuSeedDir), 0755), IsNil) + c.Assert(os.WriteFile(filepath.Join(boot.InitramfsUbuntuBootDir, "asset"), data, 0644), IsNil) + c.Assert(os.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "asset"), data, 0644), IsNil) + + // mock the files in cache + mockAssetsCache(c, dirs.GlobalRootDir, "trusted", []string{ + "asset-" + dataHash, + }) + + runKernelBf := bootloader.NewBootFile(filepath.Join(s.ukern1.Filename()), "kernel.efi", bootloader.RoleRunMode) + + tab.BootChainList = []bootloader.BootFile{ + bootloader.NewBootFile("", "asset", bootloader.RoleRunMode), + runKernelBf, + } + + uc20Model := boottest.MakeMockUC20Model() + coreDev := boottest.MockUC20Device("", uc20Model) + c.Assert(coreDev.HasModeenv(), Equals, true) + + m := &boot.Modeenv{ + Mode: "run", + Base: s.base1.Filename(), + CurrentKernels: []string{s.ukern1.Filename()}, + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": []string{dataHash}, + }, + CurrentKernelCommandLines: boot.BootCommandLines{"snapd_recovery_mode=run"}, + + Model: uc20Model.Model(), + BrandID: uc20Model.BrandID(), + Grade: string(uc20Model.Grade()), + ModelSignKeyID: uc20Model.SignKeyID(), + } + + r := setupUC20Bootenv( + c, + tab.MockBootloader, + &bootenv20Setup{ + modeenv: m, + kern: s.ukern1, + kernStatus: boot.DefaultStatus, + }, + ) + defer r() + + resealCalls := 0 + restore := boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + resealCalls++ + return fmt.Errorf("unexpected call") + }) + defer restore() + + // get the boot kernel participant from our kernel snap + bootKern := boot.Participant(s.ukern1, snap.TypeKernel, coreDev) + // make sure it's not a trivial boot participant + c.Assert(bootKern.IsTrivial(), Equals, false) + + // write boot-chains for current state that will stay unchanged + bootChains := []boot.BootChain{{ + BrandID: "my-brand", + Model: "my-model-uc20", + Grade: "dangerous", + ModelSignKeyID: "Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij", + AssetChain: []boot.BootAsset{ + { + Role: bootloader.RoleRunMode, + Name: "asset", + Hashes: []string{ + "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8", + }, + }, + }, + Kernel: "pc-kernel", + KernelRevision: "", + KernelCmdlines: []string{"snapd_recovery_mode=run"}, + }} + err := boot.WriteBootChains(bootChains, filepath.Join(dirs.SnapFDEDir, "boot-chains"), 0) + c.Assert(err, IsNil) + + // make the kernel used on next boot + rebootRequired, err := bootKern.SetNextBoot(boot.NextBootContext{BootWithoutTry: false}) + c.Assert(err, IsNil) + c.Assert(rebootRequired, Equals, boot.RebootInfo{RebootRequired: false}) + + // make sure the env is as expected + bvars, err := tab.GetBootVars("kernel_status", "snap_kernel", "snap_try_kernel") + c.Assert(err, IsNil) + c.Assert(bvars, DeepEquals, map[string]string{ + "kernel_status": boot.DefaultStatus, + "snap_kernel": s.ukern1.Filename(), + "snap_try_kernel": "", + }) + + // and that the modeenv now has the one kernel listed + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Assert(m2.CurrentKernels, DeepEquals, []string{s.ukern1.Filename()}) + + // boot chains were built + c.Check(tab.BootChainKernelPath, DeepEquals, []string{ + s.ukern1.MountFile(), + }) + // no actual reseal + c.Check(resealCalls, Equals, 0) +} + +func (s *bootenv20EnvRefKernelSuite) TestCoreParticipant20SetNextNewKernelSnap(c *C) { + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + + r := setupUC20Bootenv( + c, + s.bootloader, + s.normalDefaultState, + ) + defer r() + + // get the boot kernel participant from our new kernel snap + bootKern := boot.Participant(s.kern2, snap.TypeKernel, coreDev) + // make sure it's not a trivial boot participant + c.Assert(bootKern.IsTrivial(), Equals, false) + + // make the kernel used on next boot + rebootRequired, err := bootKern.SetNextBoot(boot.NextBootContext{BootWithoutTry: false}) + c.Assert(err, IsNil) + c.Assert(rebootRequired, DeepEquals, boot.RebootInfo{ + RebootRequired: true, + BootloaderOptions: &bootloader.Options{ + Role: bootloader.RoleRunMode, + }, + }) + + // make sure the env was updated + m := s.bootloader.BootVars + c.Assert(m, DeepEquals, map[string]string{ + "kernel_status": boot.TryStatus, + "snap_kernel": s.kern1.Filename(), + "snap_try_kernel": s.kern2.Filename(), + }) + + // and that the modeenv now has this kernel listed + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Assert(m2.CurrentKernels, DeepEquals, []string{s.kern1.Filename(), s.kern2.Filename()}) +} + +func (s *bootenv20Suite) TestMarkBootSuccessful20KernelStatusTryingNoKernelSnapCleansUp(c *C) { + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + + // set all the same vars as if we were doing trying, except don't set a try + // kernel + r := setupUC20Bootenv( + c, + s.bootloader, + &bootenv20Setup{ + modeenv: &boot.Modeenv{ + Mode: "run", + Base: s.base1.Filename(), + CurrentKernels: []string{s.kern1.Filename(), s.kern2.Filename()}, + }, + kern: s.kern1, + // no try-kernel + kernStatus: boot.TryingStatus, + }, + ) + defer r() + + // mark successful + err := boot.MarkBootSuccessful(coreDev) + c.Assert(err, IsNil) + + // check that the bootloader variable was cleaned + expected := map[string]string{"kernel_status": boot.DefaultStatus} + c.Assert(s.bootloader.BootVars, DeepEquals, expected) + + // check that MarkBootSuccessful didn't enable a kernel (since there was no + // try kernel) + _, nEnableCalls := s.bootloader.GetRunKernelImageFunctionSnapCalls("EnableKernel") + c.Assert(nEnableCalls, Equals, 0) + + // we will always end up disabling a try-kernel though as cleanup + _, nDisableTryCalls := s.bootloader.GetRunKernelImageFunctionSnapCalls("DisableTryKernel") + c.Assert(nDisableTryCalls, Equals, 1) + + // do it again, verify it's still okay + err = boot.MarkBootSuccessful(coreDev) + c.Assert(err, IsNil) + c.Assert(s.bootloader.BootVars, DeepEquals, expected) + + // no new enabled kernels + _, nEnableCalls = s.bootloader.GetRunKernelImageFunctionSnapCalls("EnableKernel") + c.Assert(nEnableCalls, Equals, 0) + + // again we will try to cleanup any leftover try-kernels + _, nDisableTryCalls = s.bootloader.GetRunKernelImageFunctionSnapCalls("DisableTryKernel") + c.Assert(nDisableTryCalls, Equals, 2) + + // check that the modeenv re-wrote the CurrentKernels + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Assert(m2.CurrentKernels, DeepEquals, []string{s.kern1.Filename()}) +} + +func (s *bootenv20EnvRefKernelSuite) TestMarkBootSuccessful20KernelStatusTryingNoKernelSnapCleansUp(c *C) { + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + + // set all the same vars as if we were doing trying, except don't set a try + // kernel + r := setupUC20Bootenv( + c, + s.bootloader, + &bootenv20Setup{ + modeenv: &boot.Modeenv{ + Mode: "run", + Base: s.base1.Filename(), + CurrentKernels: []string{s.kern1.Filename(), s.kern2.Filename()}, + }, + kern: s.kern1, + // no try-kernel + kernStatus: boot.TryingStatus, + }, + ) + defer r() + + // mark successful + err := boot.MarkBootSuccessful(coreDev) + c.Assert(err, IsNil) + + // make sure the env was updated + expected := map[string]string{ + "kernel_status": boot.DefaultStatus, + "snap_kernel": s.kern1.Filename(), + "snap_try_kernel": "", + } + c.Assert(s.bootloader.BootVars, DeepEquals, expected) + + // do it again, verify it's still okay + err = boot.MarkBootSuccessful(coreDev) + c.Assert(err, IsNil) + + c.Assert(s.bootloader.BootVars, DeepEquals, expected) + + // check that the modeenv re-wrote the CurrentKernels + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Assert(m2.CurrentKernels, DeepEquals, []string{s.kern1.Filename()}) +} + +func (s *bootenv20Suite) TestMarkBootSuccessful20BaseStatusTryingNoTryBaseSnapCleansUp(c *C) { + m := &boot.Modeenv{ + Mode: "run", + Base: s.base1.Filename(), + // no TryBase set + BaseStatus: boot.TryingStatus, + } + r := setupUC20Bootenv( + c, + s.bootloader, + &bootenv20Setup{ + modeenv: m, + // no kernel setup necessary + }, + ) + defer r() + + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + + // mark successful + err := boot.MarkBootSuccessful(coreDev) + c.Assert(err, IsNil) + + // check that the modeenv base_status was re-written to default + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Assert(m2.BaseStatus, Equals, boot.DefaultStatus) + c.Assert(m2.Base, Equals, m.Base) + c.Assert(m2.TryBase, Equals, m.TryBase) + + // do it again, verify it's still okay + err = boot.MarkBootSuccessful(coreDev) + c.Assert(err, IsNil) + + m3, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Assert(m3.BaseStatus, Equals, boot.DefaultStatus) + c.Assert(m3.Base, Equals, m.Base) + c.Assert(m3.TryBase, Equals, m.TryBase) +} + +func (s *bootenv20Suite) TestCoreParticipant20SetNextSameBaseSnap(c *C) { + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + + m := &boot.Modeenv{ + Mode: "run", + Base: s.base1.Filename(), + } + r := setupUC20Bootenv( + c, + s.bootloader, + &bootenv20Setup{ + modeenv: m, + // no kernel setup necessary + }, + ) + defer r() + + // get the boot base participant from our base snap + bootBase := boot.Participant(s.base1, snap.TypeBase, coreDev) + // make sure it's not a trivial boot participant + c.Assert(bootBase.IsTrivial(), Equals, false) + + // make the base used on next boot + rebootRequired, err := bootBase.SetNextBoot(boot.NextBootContext{BootWithoutTry: false}) + c.Assert(err, IsNil) + + // we don't need to reboot because it's the same base snap + c.Assert(rebootRequired, Equals, boot.RebootInfo{RebootRequired: false}) + + // make sure the modeenv wasn't changed + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Assert(m2.Base, Equals, m.Base) + c.Assert(m2.BaseStatus, Equals, m.BaseStatus) + c.Assert(m2.TryBase, Equals, m.TryBase) +} + +func (s *bootenv20Suite) TestCoreParticipant20SetNextNewBaseSnap(c *C) { + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + + // default state + m := &boot.Modeenv{ + Mode: "run", + Base: s.base1.Filename(), + } + r := setupUC20Bootenv( + c, + s.bootloader, + &bootenv20Setup{ + modeenv: m, + // no kernel setup necessary + }, + ) + defer r() + + // get the boot base participant from our new base snap + bootBase := boot.Participant(s.base2, snap.TypeBase, coreDev) + // make sure it's not a trivial boot participant + c.Assert(bootBase.IsTrivial(), Equals, false) + + // make the base used on next boot + rebootRequired, err := bootBase.SetNextBoot(boot.NextBootContext{BootWithoutTry: false}) + c.Assert(err, IsNil) + c.Assert(rebootRequired, Equals, boot.RebootInfo{RebootRequired: true}) + + // make sure the modeenv was updated + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Assert(m2.Base, Equals, m.Base) + c.Assert(m2.BaseStatus, Equals, boot.TryStatus) + c.Assert(m2.TryBase, Equals, s.base2.Filename()) +} + +func (s *bootenv20Suite) TestCoreParticipant20SetNextNewBaseSnapNoReseal(c *C) { + // checked by resealKeyToModeenv + s.stampSealedKeys(c, dirs.GlobalRootDir) + + // set up all the bits required for an encrypted system + tab := s.bootloaderWithTrustedAssets(c, map[string]string{ + "asset": "asset", + }) + data := []byte("foobar") + // SHA3-384 + dataHash := "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8" + c.Assert(os.MkdirAll(filepath.Join(boot.InitramfsUbuntuBootDir), 0755), IsNil) + c.Assert(os.WriteFile(filepath.Join(boot.InitramfsUbuntuBootDir, "asset"), data, 0644), IsNil) + // mock the files in cache + mockAssetsCache(c, dirs.GlobalRootDir, "trusted", []string{ + "asset-" + dataHash, + }) + runKernelBf := bootloader.NewBootFile(filepath.Join(s.kern1.Filename()), "kernel.efi", bootloader.RoleRunMode) + // write boot-chains for current state that will stay unchanged even + // though base is changed + bootChains := []boot.BootChain{{ + BrandID: "my-brand", + Model: "my-model-uc20", + Grade: "dangerous", + ModelSignKeyID: "Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij", + AssetChain: []boot.BootAsset{{ + Role: bootloader.RoleRunMode, Name: "asset", Hashes: []string{ + "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8", + }, + }}, + Kernel: "pc-kernel", + KernelRevision: "1", + KernelCmdlines: []string{"snapd_recovery_mode=run"}, + }} + + err := boot.WriteBootChains(boot.ToPredictableBootChains(bootChains), filepath.Join(dirs.SnapFDEDir, "boot-chains"), 0) + c.Assert(err, IsNil) + + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + model := coreDev.Model() + + resealCalls := 0 + restore := boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + resealCalls++ + return nil + }) + defer restore() + + tab.BootChainList = []bootloader.BootFile{ + bootloader.NewBootFile("", "asset", bootloader.RoleRunMode), + runKernelBf, + } + + // default state + m := &boot.Modeenv{ + Mode: "run", + Base: s.base1.Filename(), + CurrentKernels: []string{s.kern1.Filename()}, + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": []string{dataHash}, + }, + + Model: model.Model(), + BrandID: model.BrandID(), + Grade: string(model.Grade()), + ModelSignKeyID: model.SignKeyID(), + } + r := setupUC20Bootenv( + c, + tab.MockBootloader, + &bootenv20Setup{ + modeenv: m, + // no kernel setup necessary + }, + ) + defer r() + + // get the boot base participant from our new base snap + bootBase := boot.Participant(s.base2, snap.TypeBase, coreDev) + // make sure it's not a trivial boot participant + c.Assert(bootBase.IsTrivial(), Equals, false) + + // make the base used on next boot + rebootRequired, err := bootBase.SetNextBoot(boot.NextBootContext{BootWithoutTry: false}) + c.Assert(err, IsNil) + c.Assert(rebootRequired, Equals, boot.RebootInfo{RebootRequired: true}) + + // make sure the modeenv was updated + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Assert(m2.Base, Equals, m.Base) + c.Assert(m2.BaseStatus, Equals, boot.TryStatus) + c.Assert(m2.TryBase, Equals, s.base2.Filename()) + + // no reseal + c.Check(resealCalls, Equals, 0) +} + +func (s *bootenvSuite) TestMarkBootSuccessfulAllSnap(c *C) { + coreDev := boottest.MockDevice("some-snap") + + s.bootloader.BootVars["snap_mode"] = boot.TryingStatus + s.bootloader.BootVars["snap_try_core"] = "os1" + s.bootloader.BootVars["snap_try_kernel"] = "k1" + err := boot.MarkBootSuccessful(coreDev) + c.Assert(err, IsNil) + + expected := map[string]string{ + // cleared + "snap_mode": boot.DefaultStatus, + "snap_try_kernel": "", + "snap_try_core": "", + // updated + "snap_kernel": "k1", + "snap_core": "os1", + } + c.Assert(s.bootloader.BootVars, DeepEquals, expected) + + // do it again, verify its still valid + err = boot.MarkBootSuccessful(coreDev) + c.Assert(err, IsNil) + c.Assert(s.bootloader.BootVars, DeepEquals, expected) +} + +func (s *bootenv20Suite) TestMarkBootSuccessful20AllSnap(c *C) { + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + + // bonus points: we were trying both a base snap and a kernel snap + m := &boot.Modeenv{ + Mode: "run", + Base: s.base1.Filename(), + TryBase: s.base2.Filename(), + BaseStatus: boot.TryingStatus, + CurrentKernels: []string{s.kern1.Filename(), s.kern2.Filename()}, + } + r := setupUC20Bootenv( + c, + s.bootloader, + &bootenv20Setup{ + modeenv: m, + kern: s.kern1, + tryKern: s.kern2, + kernStatus: boot.TryingStatus, + }, + ) + defer r() + + err := boot.MarkBootSuccessful(coreDev) + c.Assert(err, IsNil) + + // check the bootloader variables + expected := map[string]string{ + // cleared + "kernel_status": boot.DefaultStatus, + } + c.Assert(s.bootloader.BootVars, DeepEquals, expected) + + // check that we called EnableKernel() on the try-kernel + actual, _ := s.bootloader.GetRunKernelImageFunctionSnapCalls("EnableKernel") + c.Assert(actual, DeepEquals, []snap.PlaceInfo{s.kern2}) + + // and that we disabled a try kernel + _, nDisableTryCalls := s.bootloader.GetRunKernelImageFunctionSnapCalls("DisableTryKernel") + c.Assert(nDisableTryCalls, Equals, 1) + + // also check that the modeenv was updated + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Assert(m2.Base, Equals, s.base2.Filename()) + c.Assert(m2.TryBase, Equals, "") + c.Assert(m2.BaseStatus, Equals, boot.DefaultStatus) + c.Assert(m2.CurrentKernels, DeepEquals, []string{s.kern2.Filename()}) + + // do it again, verify its still valid + err = boot.MarkBootSuccessful(coreDev) + c.Assert(err, IsNil) + c.Assert(s.bootloader.BootVars, DeepEquals, expected) + + // no new enabled kernels + actual, _ = s.bootloader.GetRunKernelImageFunctionSnapCalls("EnableKernel") + c.Assert(actual, DeepEquals, []snap.PlaceInfo{s.kern2}) + // we always disable the try kernel as a cleanup operation, so there's one + // more call here + _, nDisableTryCalls = s.bootloader.GetRunKernelImageFunctionSnapCalls("DisableTryKernel") + c.Assert(nDisableTryCalls, Equals, 2) +} + +func (s *bootenv20EnvRefKernelSuite) TestMarkBootSuccessful20AllSnap(c *C) { + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + + // bonus points: we were trying both a base snap and a kernel snap + m := &boot.Modeenv{ + Mode: "run", + Base: s.base1.Filename(), + TryBase: s.base2.Filename(), + BaseStatus: boot.TryingStatus, + CurrentKernels: []string{s.kern1.Filename(), s.kern2.Filename()}, + } + r := setupUC20Bootenv( + c, + s.bootloader, + &bootenv20Setup{ + modeenv: m, + kern: s.kern1, + tryKern: s.kern2, + kernStatus: boot.TryingStatus, + }, + ) + defer r() + + err := boot.MarkBootSuccessful(coreDev) + c.Assert(err, IsNil) + + // check the bootloader variables + expected := map[string]string{ + // cleared + "kernel_status": boot.DefaultStatus, + "snap_try_kernel": "", + // enabled new kernel + "snap_kernel": s.kern2.Filename(), + } + c.Assert(s.bootloader.BootVars, DeepEquals, expected) + + // also check that the modeenv was updated + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Assert(m2.Base, Equals, s.base2.Filename()) + c.Assert(m2.TryBase, Equals, "") + c.Assert(m2.BaseStatus, Equals, boot.DefaultStatus) + c.Assert(m2.CurrentKernels, DeepEquals, []string{s.kern2.Filename()}) + + // do it again, verify its still valid + err = boot.MarkBootSuccessful(coreDev) + c.Assert(err, IsNil) + c.Assert(s.bootloader.BootVars, DeepEquals, expected) +} + +func (s *bootenvSuite) TestMarkBootSuccessfulKernelUpdate(c *C) { + coreDev := boottest.MockDevice("some-snap") + + s.bootloader.BootVars["snap_mode"] = boot.TryingStatus + s.bootloader.BootVars["snap_core"] = "os1" + s.bootloader.BootVars["snap_kernel"] = "k1" + s.bootloader.BootVars["snap_try_core"] = "" + s.bootloader.BootVars["snap_try_kernel"] = "k2" + err := boot.MarkBootSuccessful(coreDev) + c.Assert(err, IsNil) + c.Assert(s.bootloader.BootVars, DeepEquals, map[string]string{ + // cleared + "snap_mode": boot.DefaultStatus, + "snap_try_kernel": "", + "snap_try_core": "", + // unchanged + "snap_core": "os1", + // updated + "snap_kernel": "k2", + }) +} + +func (s *bootenvSuite) TestMarkBootSuccessfulBaseUpdate(c *C) { + coreDev := boottest.MockDevice("some-snap") + + s.bootloader.BootVars["snap_mode"] = boot.TryingStatus + s.bootloader.BootVars["snap_core"] = "os1" + s.bootloader.BootVars["snap_kernel"] = "k1" + s.bootloader.BootVars["snap_try_core"] = "os2" + s.bootloader.BootVars["snap_try_kernel"] = "" + err := boot.MarkBootSuccessful(coreDev) + c.Assert(err, IsNil) + c.Assert(s.bootloader.BootVars, DeepEquals, map[string]string{ + // cleared + "snap_mode": boot.DefaultStatus, + "snap_try_core": "", + // unchanged + "snap_kernel": "k1", + "snap_try_kernel": "", + // updated + "snap_core": "os2", + }) +} + +func (s *bootenv20Suite) TestMarkBootSuccessful20KernelUpdate(c *C) { + // trying a kernel snap + m := &boot.Modeenv{ + Mode: "run", + Base: s.base1.Filename(), + CurrentKernels: []string{s.kern1.Filename(), s.kern2.Filename()}, + } + r := setupUC20Bootenv( + c, + s.bootloader, + &bootenv20Setup{ + modeenv: m, + kern: s.kern1, + tryKern: s.kern2, + kernStatus: boot.TryingStatus, + }, + ) + defer r() + + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + + // mark successful + err := boot.MarkBootSuccessful(coreDev) + c.Assert(err, IsNil) + + // check the bootloader variables + expected := map[string]string{"kernel_status": boot.DefaultStatus} + c.Assert(s.bootloader.BootVars, DeepEquals, expected) + + // check that MarkBootSuccessful enabled the try kernel + actual, _ := s.bootloader.GetRunKernelImageFunctionSnapCalls("EnableKernel") + c.Assert(actual, DeepEquals, []snap.PlaceInfo{s.kern2}) + + // and that we disabled a try kernel + _, nDisableTryCalls := s.bootloader.GetRunKernelImageFunctionSnapCalls("DisableTryKernel") + c.Assert(nDisableTryCalls, Equals, 1) + + // check that the new kernel is the only one in modeenv + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Assert(m2.CurrentKernels, DeepEquals, []string{s.kern2.Filename()}) + + // do it again, verify its still valid + err = boot.MarkBootSuccessful(coreDev) + c.Assert(err, IsNil) + c.Assert(s.bootloader.BootVars, DeepEquals, expected) + + // no new bootloader calls + actual, _ = s.bootloader.GetRunKernelImageFunctionSnapCalls("EnableKernel") + c.Assert(actual, DeepEquals, []snap.PlaceInfo{s.kern2}) + + // we did disable the kernel again because we always do this to cleanup in + // case there were leftovers + _, nDisableTryCalls = s.bootloader.GetRunKernelImageFunctionSnapCalls("DisableTryKernel") + c.Assert(nDisableTryCalls, Equals, 2) +} + +func (s *bootenv20Suite) TestMarkBootSuccessful20KernelUpdateWithReseal(c *C) { + // checked by resealKeyToModeenv + s.stampSealedKeys(c, dirs.GlobalRootDir) + + tab := s.bootloaderWithTrustedAssets(c, map[string]string{ + "asset": "asset", + }) + + data := []byte("foobar") + // SHA3-384 + dataHash := "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8" + + c.Assert(os.MkdirAll(filepath.Join(boot.InitramfsUbuntuBootDir), 0755), IsNil) + c.Assert(os.WriteFile(filepath.Join(boot.InitramfsUbuntuBootDir, "asset"), data, 0644), IsNil) + + // mock the files in cache + mockAssetsCache(c, dirs.GlobalRootDir, "trusted", []string{ + "asset-" + dataHash, + }) + + assetBf := bootloader.NewBootFile("", filepath.Join(dirs.SnapBootAssetsDir, "trusted", fmt.Sprintf("asset-%s", dataHash)), bootloader.RoleRunMode) + newRunKernelBf := bootloader.NewBootFile(filepath.Join(s.kern2.Filename()), "kernel.efi", bootloader.RoleRunMode) + + tab.BootChainList = []bootloader.BootFile{ + bootloader.NewBootFile("", "asset", bootloader.RoleRunMode), + newRunKernelBf, + } + + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + model := coreDev.Model() + + // trying a kernel snap + m := &boot.Modeenv{ + Mode: "run", + Base: s.base1.Filename(), + CurrentKernels: []string{s.kern1.Filename(), s.kern2.Filename()}, + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": []string{dataHash}, + }, + Model: model.Model(), + BrandID: model.BrandID(), + Grade: string(model.Grade()), + ModelSignKeyID: model.SignKeyID(), + } + r := setupUC20Bootenv( + c, + tab.MockBootloader, + &bootenv20Setup{ + modeenv: m, + kern: s.kern1, + tryKern: s.kern2, + kernStatus: boot.TryingStatus, + }, + ) + defer r() + + // write boot-chains that describe a state in which we have a new kernel + // candidate (pc-kernel_2) + bootChains := []boot.BootChain{{ + BrandID: "my-brand", + Model: "my-model-uc20", + Grade: "dangerous", + ModelSignKeyID: "Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij", + AssetChain: []boot.BootAsset{{ + Role: bootloader.RoleRunMode, Name: "asset", Hashes: []string{ + "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8", + }, + }}, + Kernel: "pc-kernel", + KernelRevision: "1", + KernelCmdlines: []string{"snapd_recovery_mode=run"}, + }, { + BrandID: "my-brand", + Model: "my-model-uc20", + Grade: "dangerous", + ModelSignKeyID: "Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij", + AssetChain: []boot.BootAsset{{ + Role: bootloader.RoleRunMode, Name: "asset", Hashes: []string{ + "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8", + }, + }}, + Kernel: "pc-kernel", + KernelRevision: "2", + KernelCmdlines: []string{"snapd_recovery_mode=run"}, + }} + + err := boot.WriteBootChains(boot.ToPredictableBootChains(bootChains), filepath.Join(dirs.SnapFDEDir, "boot-chains"), 0) + c.Assert(err, IsNil) + + resealCalls := 0 + restore := boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + resealCalls++ + + c.Assert(params.ModelParams, HasLen, 1) + mp := params.ModelParams[0] + c.Check(mp.Model.Model(), Equals, model.Model()) + for _, ch := range mp.EFILoadChains { + printChain(c, ch, "-") + } + c.Check(mp.EFILoadChains, DeepEquals, []*secboot.LoadChain{ + secboot.NewLoadChain(assetBf, + secboot.NewLoadChain(newRunKernelBf)), + }) + return nil + }) + defer restore() + + // mark successful + err = boot.MarkBootSuccessful(coreDev) + c.Assert(err, IsNil) + + c.Check(resealCalls, Equals, 1) + // check the bootloader variables + expected := map[string]string{ + "kernel_status": boot.DefaultStatus, + "snap_kernel": s.kern2.Filename(), + "snap_try_kernel": boot.DefaultStatus, + } + c.Assert(tab.BootVars, DeepEquals, expected) + c.Check(tab.BootChainKernelPath, DeepEquals, []string{s.kern2.MountFile()}) + + // check that the new kernel is the only one in modeenv + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Assert(m2.CurrentKernels, DeepEquals, []string{s.kern2.Filename()}) +} + +func (s *bootenv20EnvRefKernelSuite) TestMarkBootSuccessful20KernelUpdate(c *C) { + // trying a kernel snap + m := &boot.Modeenv{ + Mode: "run", + Base: s.base1.Filename(), + CurrentKernels: []string{s.kern1.Filename(), s.kern2.Filename()}, + } + r := setupUC20Bootenv( + c, + s.bootloader, + &bootenv20Setup{ + modeenv: m, + kern: s.kern1, + tryKern: s.kern2, + kernStatus: boot.TryingStatus, + }, + ) + defer r() + + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + + // mark successful + err := boot.MarkBootSuccessful(coreDev) + c.Assert(err, IsNil) + + // check the bootloader variables + expected := map[string]string{ + "kernel_status": boot.DefaultStatus, + "snap_kernel": s.kern2.Filename(), + "snap_try_kernel": "", + } + c.Assert(s.bootloader.BootVars, DeepEquals, expected) + + // check that the new kernel is the only one in modeenv + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Assert(m2.CurrentKernels, DeepEquals, []string{s.kern2.Filename()}) + + // do it again, verify its still valid + err = boot.MarkBootSuccessful(coreDev) + c.Assert(err, IsNil) + c.Assert(s.bootloader.BootVars, DeepEquals, expected) + c.Assert(s.bootloader.BootVars, DeepEquals, expected) +} + +func (s *bootenv20Suite) TestMarkBootSuccessful20BaseUpdate(c *C) { + // we were trying a base snap + m := &boot.Modeenv{ + Mode: "run", + Base: s.base1.Filename(), + TryBase: s.base2.Filename(), + BaseStatus: boot.TryingStatus, + CurrentKernels: []string{s.kern1.Filename()}, + } + r := setupUC20Bootenv( + c, + s.bootloader, + &bootenv20Setup{ + modeenv: m, + kern: s.kern1, + kernStatus: boot.DefaultStatus, + }, + ) + defer r() + + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + + // mark successful + err := boot.MarkBootSuccessful(coreDev) + c.Assert(err, IsNil) + + // check the modeenv + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Assert(m2.Base, Equals, s.base2.Filename()) + c.Assert(m2.TryBase, Equals, "") + c.Assert(m2.BaseStatus, Equals, "") + + // do it again, verify its still valid + err = boot.MarkBootSuccessful(coreDev) + c.Assert(err, IsNil) + + // check the modeenv again + m3, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Assert(m3.Base, Equals, s.base2.Filename()) + c.Assert(m3.TryBase, Equals, "") + c.Assert(m3.BaseStatus, Equals, "") +} + +func (s *bootenv20Suite) bootloaderWithTrustedAssets(c *C, trustedAssets map[string]string) *bootloadertest.MockTrustedAssetsBootloader { + // TODO:UC20: this should be an ExtractedRecoveryKernelImageBootloader + // because that would reflect our main currently supported + // trusted assets bootloader (grub) + tab := bootloadertest.Mock("trusted", "").WithTrustedAssets() + bootloader.Force(tab) + tab.TrustedAssetsMap = trustedAssets + s.AddCleanup(func() { bootloader.Force(nil) }) + return tab +} + +func (s *bootenv20Suite) TestMarkBootSuccessful20BootAssetsUpdateHappy(c *C) { + // checked by resealKeyToModeenv + s.stampSealedKeys(c, dirs.GlobalRootDir) + + tab := s.bootloaderWithTrustedAssets(c, map[string]string{ + "asset": "asset", + "shim": "shim", + }) + + data := []byte("foobar") + // SHA3-384 + dataHash := "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8" + shim := []byte("shim") + shimHash := "dac0063e831d4b2e7a330426720512fc50fa315042f0bb30f9d1db73e4898dcb89119cac41fdfa62137c8931a50f9d7b" + + c.Assert(os.MkdirAll(boot.InitramfsUbuntuBootDir, 0755), IsNil) + c.Assert(os.MkdirAll(boot.InitramfsUbuntuSeedDir, 0755), IsNil) + // only asset for ubuntu + c.Assert(os.WriteFile(filepath.Join(boot.InitramfsUbuntuBootDir, "asset"), data, 0644), IsNil) + // shim and asset for seed + c.Assert(os.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "asset"), data, 0644), IsNil) + c.Assert(os.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "shim"), shim, 0644), IsNil) + + // mock the files in cache + mockAssetsCache(c, dirs.GlobalRootDir, "trusted", []string{ + "shim-recoveryshimhash", + "shim-" + shimHash, + "asset-assethash", + "asset-recoveryassethash", + "asset-" + dataHash, + }) + + shimBf := bootloader.NewBootFile("", filepath.Join(dirs.SnapBootAssetsDir, "trusted", fmt.Sprintf("shim-%s", shimHash)), bootloader.RoleRecovery) + assetBf := bootloader.NewBootFile("", filepath.Join(dirs.SnapBootAssetsDir, "trusted", fmt.Sprintf("asset-%s", dataHash)), bootloader.RoleRecovery) + runKernelBf := bootloader.NewBootFile(filepath.Join(s.kern1.Filename()), "kernel.efi", bootloader.RoleRunMode) + recoveryKernelBf := bootloader.NewBootFile("pc-kernel_1.snap", "kernel.efi", bootloader.RoleRecovery) + + tab.BootChainList = []bootloader.BootFile{ + bootloader.NewBootFile("", "shim", bootloader.RoleRecovery), + bootloader.NewBootFile("", "asset", bootloader.RoleRecovery), + runKernelBf, + } + tab.RecoveryBootChainList = []bootloader.BootFile{ + bootloader.NewBootFile("", "shim", bootloader.RoleRecovery), + bootloader.NewBootFile("", "asset", bootloader.RoleRecovery), + recoveryKernelBf, + } + + uc20Model := boottest.MakeMockUC20Model() + + restore := boot.MockSeedReadSystemEssential(func(seedDir, label string, essentialTypes []snap.Type, tm timings.Measurer) (*asserts.Model, []*seed.Snap, error) { + return uc20Model, []*seed.Snap{mockKernelSeedSnap(snap.R(1)), mockGadgetSeedSnap(c, nil)}, nil + }) + defer restore() + + // we were trying an update of boot assets + m := &boot.Modeenv{ + Mode: "run", + Base: s.base1.Filename(), + CurrentKernels: []string{s.kern1.Filename()}, + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": []string{"assethash", dataHash}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": []string{"recoveryassethash", dataHash}, + "shim": []string{"recoveryshimhash", shimHash}, + }, + CurrentRecoverySystems: []string{"system"}, + + Model: uc20Model.Model(), + BrandID: uc20Model.BrandID(), + Grade: string(uc20Model.Grade()), + ModelSignKeyID: uc20Model.SignKeyID(), + } + r := setupUC20Bootenv( + c, + tab.MockBootloader, + &bootenv20Setup{ + modeenv: m, + kern: s.kern1, + kernStatus: boot.DefaultStatus, + }, + ) + defer r() + + coreDev := boottest.MockUC20Device("", uc20Model) + c.Assert(coreDev.HasModeenv(), Equals, true) + + resealCalls := 0 + restore = boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + resealCalls++ + + c.Assert(params.ModelParams, HasLen, 1) + mp := params.ModelParams[0] + c.Check(mp.Model.Model(), Equals, uc20Model.Model()) + for _, ch := range mp.EFILoadChains { + printChain(c, ch, "-") + } + switch resealCalls { + case 1: + c.Check(mp.EFILoadChains, DeepEquals, []*secboot.LoadChain{ + secboot.NewLoadChain(shimBf, + secboot.NewLoadChain(assetBf, + secboot.NewLoadChain(runKernelBf))), + secboot.NewLoadChain(shimBf, + secboot.NewLoadChain(assetBf, + secboot.NewLoadChain(recoveryKernelBf))), + }) + case 2: + c.Check(mp.EFILoadChains, DeepEquals, []*secboot.LoadChain{ + secboot.NewLoadChain(shimBf, + secboot.NewLoadChain(assetBf, + secboot.NewLoadChain(recoveryKernelBf))), + }) + default: + c.Errorf("unexpected additional call to secboot.ResealKey (call # %d)", resealCalls) + } + return nil + }) + defer restore() + + // mark successful + err := boot.MarkBootSuccessful(coreDev) + c.Assert(err, IsNil) + + // check the modeenv + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + // update assets are in the list + c.Check(m2.CurrentTrustedBootAssets, DeepEquals, boot.BootAssetsMap{ + "asset": []string{dataHash}, + }) + c.Check(m2.CurrentTrustedRecoveryBootAssets, DeepEquals, boot.BootAssetsMap{ + "asset": []string{dataHash}, + "shim": []string{shimHash}, + }) + // unused files were dropped from cache + checkContentGlob(c, filepath.Join(dirs.SnapBootAssetsDir, "trusted", "*"), []string{ + filepath.Join(dirs.SnapBootAssetsDir, "trusted", "asset-"+dataHash), + filepath.Join(dirs.SnapBootAssetsDir, "trusted", "shim-"+shimHash), + }) + c.Check(resealCalls, Equals, 2) +} + +func (s *bootenv20Suite) TestMarkBootSuccessful20BootAssetsStableStateHappy(c *C) { + // checked by resealKeyToModeenv + s.stampSealedKeys(c, dirs.GlobalRootDir) + + tab := s.bootloaderWithTrustedAssets(c, map[string]string{ + "nested/asset": "asset", + "shim": "shim", + }) + + data := []byte("foobar") + // SHA3-384 + dataHash := "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8" + shim := []byte("shim") + shimHash := "dac0063e831d4b2e7a330426720512fc50fa315042f0bb30f9d1db73e4898dcb89119cac41fdfa62137c8931a50f9d7b" + + c.Assert(os.MkdirAll(filepath.Join(boot.InitramfsUbuntuBootDir, "nested"), 0755), IsNil) + c.Assert(os.MkdirAll(filepath.Join(boot.InitramfsUbuntuSeedDir, "nested"), 0755), IsNil) + // only asset for ubuntu-boot + c.Assert(os.WriteFile(filepath.Join(boot.InitramfsUbuntuBootDir, "nested/asset"), data, 0644), IsNil) + // shim and asset for ubuntu-seed + c.Assert(os.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "nested/asset"), data, 0644), IsNil) + c.Assert(os.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "shim"), shim, 0644), IsNil) + + // mock the files in cache + mockAssetsCache(c, dirs.GlobalRootDir, "trusted", []string{ + "shim-" + shimHash, + "asset-" + dataHash, + }) + + runKernelBf := bootloader.NewBootFile(filepath.Join(s.kern1.Filename()), "kernel.efi", bootloader.RoleRunMode) + recoveryKernelBf := bootloader.NewBootFile("/var/lib/snapd/seed/snaps/pc-kernel_1.snap", "kernel.efi", bootloader.RoleRecovery) + + tab.BootChainList = []bootloader.BootFile{ + bootloader.NewBootFile("", "shim", bootloader.RoleRecovery), + bootloader.NewBootFile("", "nested/asset", bootloader.RoleRecovery), + runKernelBf, + } + tab.RecoveryBootChainList = []bootloader.BootFile{ + bootloader.NewBootFile("", "shim", bootloader.RoleRecovery), + bootloader.NewBootFile("", "nested/asset", bootloader.RoleRecovery), + recoveryKernelBf, + } + + uc20Model := boottest.MakeMockUC20Model() + + restore := boot.MockSeedReadSystemEssential(func(seedDir, label string, essentialTypes []snap.Type, tm timings.Measurer) (*asserts.Model, []*seed.Snap, error) { + return uc20Model, []*seed.Snap{mockNamedKernelSeedSnap(snap.R(1), "pc-kernel-recovery"), mockGadgetSeedSnap(c, nil)}, nil + }) + defer restore() + + // we were trying an update of boot assets + m := &boot.Modeenv{ + Mode: "run", + Base: s.base1.Filename(), + CurrentKernels: []string{s.kern1.Filename()}, + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": []string{dataHash}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": []string{dataHash}, + "shim": []string{shimHash}, + }, + CurrentRecoverySystems: []string{"system"}, + CurrentKernelCommandLines: boot.BootCommandLines{"snapd_recovery_mode=run"}, + + Model: uc20Model.Model(), + BrandID: uc20Model.BrandID(), + Grade: string(uc20Model.Grade()), + ModelSignKeyID: uc20Model.SignKeyID(), + } + r := setupUC20Bootenv( + c, + tab.MockBootloader, + &bootenv20Setup{ + modeenv: m, + kern: s.kern1, + kernStatus: boot.DefaultStatus, + }, + ) + defer r() + + coreDev := boottest.MockUC20Device("", uc20Model) + c.Assert(coreDev.HasModeenv(), Equals, true) + + resealCalls := 0 + restore = boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + resealCalls++ + return nil + }) + defer restore() + + // write boot-chains for current state that will stay unchanged + bootChains := []boot.BootChain{{ + BrandID: "my-brand", + Model: "my-model-uc20", + Grade: "dangerous", + ModelSignKeyID: "Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij", + AssetChain: []boot.BootAsset{{ + Role: bootloader.RoleRecovery, Name: "shim", + Hashes: []string{ + "dac0063e831d4b2e7a330426720512fc50fa315042f0bb30f9d1db73e4898dcb89119cac41fdfa62137c8931a50f9d7b", + }, + }, { + Role: bootloader.RoleRecovery, Name: "asset", Hashes: []string{ + "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8", + }, + }}, + Kernel: "pc-kernel", + KernelRevision: "1", + KernelCmdlines: []string{"snapd_recovery_mode=run"}, + }, { + BrandID: "my-brand", + Model: "my-model-uc20", + Grade: "dangerous", + ModelSignKeyID: "Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij", + AssetChain: []boot.BootAsset{{ + Role: bootloader.RoleRecovery, Name: "shim", + Hashes: []string{ + "dac0063e831d4b2e7a330426720512fc50fa315042f0bb30f9d1db73e4898dcb89119cac41fdfa62137c8931a50f9d7b", + }, + }, { + Role: bootloader.RoleRecovery, Name: "asset", Hashes: []string{ + "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8", + }, + }}, + Kernel: "pc-kernel-recovery", + KernelRevision: "1", + KernelCmdlines: []string{ + "snapd_recovery_mode=factory-reset snapd_recovery_system=system", + "snapd_recovery_mode=recover snapd_recovery_system=system", + }, + }} + + recoveryBootChains := []boot.BootChain{bootChains[1]} + + err := boot.WriteBootChains(boot.ToPredictableBootChains(bootChains), filepath.Join(dirs.SnapFDEDir, "boot-chains"), 0) + c.Assert(err, IsNil) + + err = boot.WriteBootChains(boot.ToPredictableBootChains(recoveryBootChains), filepath.Join(dirs.SnapFDEDir, "recovery-boot-chains"), 0) + c.Assert(err, IsNil) + + // mark successful + err = boot.MarkBootSuccessful(coreDev) + c.Assert(err, IsNil) + + // modeenv is unchanged + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Check(m2.CurrentTrustedBootAssets, DeepEquals, m.CurrentTrustedBootAssets) + c.Check(m2.CurrentTrustedRecoveryBootAssets, DeepEquals, m.CurrentTrustedRecoveryBootAssets) + // files are still in cache + checkContentGlob(c, filepath.Join(dirs.SnapBootAssetsDir, "trusted", "*"), []string{ + filepath.Join(dirs.SnapBootAssetsDir, "trusted", "asset-"+dataHash), + filepath.Join(dirs.SnapBootAssetsDir, "trusted", "shim-"+shimHash), + }) + + // boot chains were built + c.Check(tab.BootChainKernelPath, DeepEquals, []string{ + s.kern1.MountFile(), + }) + // no actual reseal + c.Check(resealCalls, Equals, 0) +} + +func (s *bootenv20Suite) TestMarkBootSuccessful20BootUnassertedKernelAssetsStableStateHappy(c *C) { + // checked by resealKeyToModeenv + s.stampSealedKeys(c, dirs.GlobalRootDir) + + tab := s.bootloaderWithTrustedAssets(c, map[string]string{ + "nested/asset": "asset", + "shim": "shim", + }) + + data := []byte("foobar") + // SHA3-384 + dataHash := "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8" + shim := []byte("shim") + shimHash := "dac0063e831d4b2e7a330426720512fc50fa315042f0bb30f9d1db73e4898dcb89119cac41fdfa62137c8931a50f9d7b" + + c.Assert(os.MkdirAll(filepath.Join(boot.InitramfsUbuntuBootDir, "nested"), 0755), IsNil) + c.Assert(os.MkdirAll(filepath.Join(boot.InitramfsUbuntuSeedDir, "nested"), 0755), IsNil) + // only asset for ubuntu-boot + c.Assert(os.WriteFile(filepath.Join(boot.InitramfsUbuntuBootDir, "nested/asset"), data, 0644), IsNil) + // shim and asset for ubuntu-seed + c.Assert(os.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "nested/asset"), data, 0644), IsNil) + c.Assert(os.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "shim"), shim, 0644), IsNil) + + // mock the files in cache + mockAssetsCache(c, dirs.GlobalRootDir, "trusted", []string{ + "shim-" + shimHash, + "asset-" + dataHash, + }) + + runKernelBf := bootloader.NewBootFile(filepath.Join(s.ukern1.Filename()), "kernel.efi", bootloader.RoleRunMode) + recoveryKernelBf := bootloader.NewBootFile("/var/lib/snapd/seed/snaps/pc-kernel_1.snap", "kernel.efi", bootloader.RoleRecovery) + + tab.BootChainList = []bootloader.BootFile{ + bootloader.NewBootFile("", "shim", bootloader.RoleRecovery), + bootloader.NewBootFile("", "nested/asset", bootloader.RoleRecovery), + runKernelBf, + } + tab.RecoveryBootChainList = []bootloader.BootFile{ + bootloader.NewBootFile("", "shim", bootloader.RoleRecovery), + bootloader.NewBootFile("", "nested/asset", bootloader.RoleRecovery), + recoveryKernelBf, + } + + uc20Model := boottest.MakeMockUC20Model() + + restore := boot.MockSeedReadSystemEssential(func(seedDir, label string, essentialTypes []snap.Type, tm timings.Measurer) (*asserts.Model, []*seed.Snap, error) { + return uc20Model, []*seed.Snap{mockNamedKernelSeedSnap(snap.R(1), "pc-kernel-recovery"), mockGadgetSeedSnap(c, nil)}, nil + }) + defer restore() + + // we were trying an update of boot assets + m := &boot.Modeenv{ + Mode: "run", + Base: s.base1.Filename(), + CurrentKernels: []string{s.ukern1.Filename()}, + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": []string{dataHash}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": []string{dataHash}, + "shim": []string{shimHash}, + }, + CurrentRecoverySystems: []string{"system"}, + GoodRecoverySystems: []string{"system"}, + CurrentKernelCommandLines: boot.BootCommandLines{"snapd_recovery_mode=run"}, + // leave this comment to keep old gofmt happy + Model: "my-model-uc20", + BrandID: "my-brand", + Grade: "dangerous", + ModelSignKeyID: "Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij", + } + r := setupUC20Bootenv( + c, + tab.MockBootloader, + &bootenv20Setup{ + modeenv: m, + kern: s.ukern1, + kernStatus: boot.DefaultStatus, + }, + ) + defer r() + + coreDev := boottest.MockUC20Device("", uc20Model) + c.Assert(coreDev.HasModeenv(), Equals, true) + + resealCalls := 0 + restore = boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + resealCalls++ + return nil + }) + defer restore() + + // write boot-chains for current state that will stay unchanged + bootChains := []boot.BootChain{{ + BrandID: "my-brand", + Model: "my-model-uc20", + Grade: "dangerous", + ModelSignKeyID: "Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij", + AssetChain: []boot.BootAsset{{ + Role: bootloader.RoleRecovery, Name: "shim", + Hashes: []string{ + "dac0063e831d4b2e7a330426720512fc50fa315042f0bb30f9d1db73e4898dcb89119cac41fdfa62137c8931a50f9d7b", + }, + }, { + Role: bootloader.RoleRecovery, Name: "asset", Hashes: []string{ + "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8", + }, + }}, + Kernel: "pc-kernel", + // unasserted kernel snap + KernelRevision: "", + KernelCmdlines: []string{"snapd_recovery_mode=run"}, + }, { + BrandID: "my-brand", + Model: "my-model-uc20", + Grade: "dangerous", + ModelSignKeyID: "Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij", + AssetChain: []boot.BootAsset{{ + Role: bootloader.RoleRecovery, Name: "shim", + Hashes: []string{ + "dac0063e831d4b2e7a330426720512fc50fa315042f0bb30f9d1db73e4898dcb89119cac41fdfa62137c8931a50f9d7b", + }, + }, { + Role: bootloader.RoleRecovery, Name: "asset", Hashes: []string{ + "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8", + }, + }}, + Kernel: "pc-kernel-recovery", + KernelRevision: "1", + KernelCmdlines: []string{ + "snapd_recovery_mode=factory-reset snapd_recovery_system=system", + "snapd_recovery_mode=recover snapd_recovery_system=system", + }, + }} + + recoveryBootChains := []boot.BootChain{bootChains[1]} + + err := boot.WriteBootChains(boot.ToPredictableBootChains(bootChains), filepath.Join(dirs.SnapFDEDir, "boot-chains"), 0) + c.Assert(err, IsNil) + + err = boot.WriteBootChains(boot.ToPredictableBootChains(recoveryBootChains), filepath.Join(dirs.SnapFDEDir, "recovery-boot-chains"), 0) + c.Assert(err, IsNil) + + // mark successful + err = boot.MarkBootSuccessful(coreDev) + c.Assert(err, IsNil) + + // modeenv is unchanged + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Check(m2.CurrentTrustedBootAssets, DeepEquals, m.CurrentTrustedBootAssets) + c.Check(m2.CurrentTrustedRecoveryBootAssets, DeepEquals, m.CurrentTrustedRecoveryBootAssets) + // files are still in cache + checkContentGlob(c, filepath.Join(dirs.SnapBootAssetsDir, "trusted", "*"), []string{ + filepath.Join(dirs.SnapBootAssetsDir, "trusted", "asset-"+dataHash), + filepath.Join(dirs.SnapBootAssetsDir, "trusted", "shim-"+shimHash), + }) + + // boot chains were built + c.Check(tab.BootChainKernelPath, DeepEquals, []string{ + s.ukern1.MountFile(), + }) + // no actual reseal + c.Check(resealCalls, Equals, 0) +} + +func (s *bootenv20Suite) TestMarkBootSuccessful20BootAssetsUpdateUnexpectedAsset(c *C) { + tab := s.bootloaderWithTrustedAssets(c, map[string]string{ + "EFI/asset": "efi:asset", + }) + + data := []byte("foobar") + // SHA3-384 + dataHash := "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8" + + c.Assert(os.MkdirAll(filepath.Join(boot.InitramfsUbuntuBootDir, "EFI"), 0755), IsNil) + c.Assert(os.MkdirAll(filepath.Join(boot.InitramfsUbuntuSeedDir, "EFI"), 0755), IsNil) + c.Assert(os.WriteFile(filepath.Join(boot.InitramfsUbuntuBootDir, "EFI/asset"), data, 0644), IsNil) + c.Assert(os.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "EFI/asset"), data, 0644), IsNil) + // mock some state in the cache + mockAssetsCache(c, dirs.GlobalRootDir, "trusted", []string{ + "asset-one", + "asset-two", + }) + + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + model := coreDev.Model() + + // we were trying an update of boot assets + m := &boot.Modeenv{ + Mode: "run", + Base: s.base1.Filename(), + CurrentKernels: []string{s.kern1.Filename()}, + CurrentTrustedBootAssets: boot.BootAssetsMap{ + // hash will not match + "asset": []string{"one", "two"}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": []string{"one", "two"}, + }, + Model: model.Model(), + BrandID: model.BrandID(), + Grade: string(model.Grade()), + ModelSignKeyID: model.SignKeyID(), + } + r := setupUC20Bootenv( + c, + tab.MockBootloader, + &bootenv20Setup{ + modeenv: m, + kern: s.kern1, + kernStatus: boot.DefaultStatus, + }, + ) + defer r() + + // mark successful + err := boot.MarkBootSuccessful(coreDev) + c.Assert(err, ErrorMatches, fmt.Sprintf(`cannot mark boot successful: cannot mark successful boot assets: system booted with unexpected run mode bootloader asset "EFI/asset" hash %s`, dataHash)) + + // check the modeenv + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + // modeenv is unchaged + c.Check(m2.CurrentTrustedBootAssets, DeepEquals, m.CurrentTrustedBootAssets) + c.Check(m2.CurrentTrustedRecoveryBootAssets, DeepEquals, m.CurrentTrustedRecoveryBootAssets) + // nothing was removed from cache + checkContentGlob(c, filepath.Join(dirs.SnapBootAssetsDir, "trusted", "*"), []string{ + filepath.Join(dirs.SnapBootAssetsDir, "trusted", "asset-one"), + filepath.Join(dirs.SnapBootAssetsDir, "trusted", "asset-two"), + }) +} + +func (s *bootenv20Suite) setupMarkBootSuccessful20CommandLine(c *C, model *asserts.Model, mode string, cmdlines boot.BootCommandLines) *boot.Modeenv { + // mock some state in the cache + mockAssetsCache(c, dirs.GlobalRootDir, "trusted", []string{ + "asset-one", + }) + // a pending kernel command line change + m := &boot.Modeenv{ + Mode: mode, + Base: s.base1.Filename(), + CurrentKernels: []string{s.kern1.Filename()}, + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": []string{"one"}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": []string{"one"}, + }, + CurrentKernelCommandLines: cmdlines, + + Model: model.Model(), + BrandID: model.BrandID(), + Grade: string(model.Grade()), + ModelSignKeyID: model.SignKeyID(), + } + return m +} + +func (s *bootenv20Suite) TestMarkBootSuccessful20CommandLineUpdatedHappy(c *C) { + s.mockCmdline(c, "snapd_recovery_mode=run candidate panic=-1") + tab := s.bootloaderWithTrustedAssets(c, map[string]string{ + "asset": "asset", + }) + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + m := s.setupMarkBootSuccessful20CommandLine(c, coreDev.Model(), "run", boot.BootCommandLines{ + "snapd_recovery_mode=run panic=-1", + "snapd_recovery_mode=run candidate panic=-1", + }) + + r := setupUC20Bootenv( + c, + tab.MockBootloader, + &bootenv20Setup{ + modeenv: m, + kern: s.kern1, + kernStatus: boot.DefaultStatus, + }, + ) + defer r() + // mark successful + err := boot.MarkBootSuccessful(coreDev) + c.Assert(err, IsNil) + + // check the modeenv + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + // modeenv is unchaged + c.Check(m2.CurrentKernelCommandLines, DeepEquals, boot.BootCommandLines{ + "snapd_recovery_mode=run candidate panic=-1", + }) +} + +func (s *bootenv20Suite) TestMarkBootSuccessful20CommandLineUpdatedOld(c *C) { + s.mockCmdline(c, "snapd_recovery_mode=run panic=-1") + tab := s.bootloaderWithTrustedAssets(c, map[string]string{ + "asset": "asset", + }) + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + m := s.setupMarkBootSuccessful20CommandLine(c, coreDev.Model(), "run", boot.BootCommandLines{ + "snapd_recovery_mode=run panic=-1", + "snapd_recovery_mode=run candidate panic=-1", + }) + r := setupUC20Bootenv( + c, + tab.MockBootloader, + &bootenv20Setup{ + modeenv: m, + kern: s.kern1, + kernStatus: boot.DefaultStatus, + }, + ) + defer r() + + // mark successful + err := boot.MarkBootSuccessful(coreDev) + c.Assert(err, IsNil) + + // check the modeenv + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + // modeenv is unchaged + c.Check(m2.CurrentKernelCommandLines, DeepEquals, boot.BootCommandLines{ + "snapd_recovery_mode=run panic=-1", + }) +} + +func (s *bootenv20Suite) TestMarkBootSuccessful20CommandLineUpdatedMismatch(c *C) { + s.mockCmdline(c, "snapd_recovery_mode=run different") + tab := s.bootloaderWithTrustedAssets(c, map[string]string{ + "asset": "asset", + }) + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + m := s.setupMarkBootSuccessful20CommandLine(c, coreDev.Model(), "run", boot.BootCommandLines{ + "snapd_recovery_mode=run", + "snapd_recovery_mode=run candidate", + }) + r := setupUC20Bootenv( + c, + tab.MockBootloader, + &bootenv20Setup{ + modeenv: m, + kern: s.kern1, + kernStatus: boot.DefaultStatus, + }, + ) + defer r() + + // mark successful + err := boot.MarkBootSuccessful(coreDev) + c.Assert(err, ErrorMatches, `cannot mark boot successful: cannot mark successful boot command line: current command line content "snapd_recovery_mode=run different" not matching any expected entry`) +} + +func (s *bootenv20Suite) TestMarkBootSuccessful20CommandLineUpdatedFallbackOnBootSuccessful(c *C) { + s.mockCmdline(c, "snapd_recovery_mode=run panic=-1") + tab := s.bootloaderWithTrustedAssets(c, map[string]string{ + "asset": "asset", + }) + tab.StaticCommandLine = "panic=-1" + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + m := s.setupMarkBootSuccessful20CommandLine(c, coreDev.Model(), "run", nil) + r := setupUC20Bootenv( + c, + tab.MockBootloader, + &bootenv20Setup{ + modeenv: m, + kern: s.kern1, + kernStatus: boot.DefaultStatus, + }, + ) + defer r() + + // mark successful + err := boot.MarkBootSuccessful(coreDev) + c.Assert(err, IsNil) + + // check the modeenv + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + // modeenv is unchaged + c.Check(m2.CurrentKernelCommandLines, DeepEquals, boot.BootCommandLines{ + "snapd_recovery_mode=run panic=-1", + }) +} + +func (s *bootenv20Suite) TestMarkBootSuccessful20CommandLineUpdatedFallbackOnBootMismatch(c *C) { + s.mockCmdline(c, "snapd_recovery_mode=run panic=-1 unexpected") + tab := s.bootloaderWithTrustedAssets(c, map[string]string{ + "asset": "asset", + }) + tab.StaticCommandLine = "panic=-1" + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + m := s.setupMarkBootSuccessful20CommandLine(c, coreDev.Model(), "run", nil) + r := setupUC20Bootenv( + c, + tab.MockBootloader, + &bootenv20Setup{ + modeenv: m, + kern: s.kern1, + kernStatus: boot.DefaultStatus, + }, + ) + defer r() + + // mark successful + err := boot.MarkBootSuccessful(coreDev) + c.Assert(err, ErrorMatches, `cannot mark boot successful: cannot mark successful boot command line: unexpected current command line: "snapd_recovery_mode=run panic=-1 unexpected"`) +} + +func (s *bootenv20Suite) TestMarkBootSuccessful20CommandLineNonRunMode(c *C) { + // recover mode + s.mockCmdline(c, "snapd_recovery_mode=recover snapd_recovery_system=1234 panic=-1") + tab := s.bootloaderWithTrustedAssets(c, map[string]string{ + "asset": "asset", + }) + tab.StaticCommandLine = "panic=-1" + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + // current command line does not match any of the run mode command lines + m := s.setupMarkBootSuccessful20CommandLine(c, coreDev.Model(), "recover", boot.BootCommandLines{ + "snapd_recovery_mode=run panic=-1", + "snapd_recovery_mode=run candidate panic=-1", + }) + r := setupUC20Bootenv( + c, + tab.MockBootloader, + &bootenv20Setup{ + modeenv: m, + kern: s.kern1, + kernStatus: boot.DefaultStatus, + }, + ) + defer r() + + // mark successful + err := boot.MarkBootSuccessful(coreDev) + c.Assert(err, IsNil) + + // check the modeenv + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + // modeenv is unchaged + c.Check(m2.CurrentKernelCommandLines, DeepEquals, boot.BootCommandLines{ + "snapd_recovery_mode=run panic=-1", + "snapd_recovery_mode=run candidate panic=-1", + }) +} + +func (s *bootenv20Suite) TestMarkBootSuccessful20CommandLineUpdatedNoFDEManagedBootloader(c *C) { + s.mockCmdline(c, "snapd_recovery_mode=run candidate panic=-1") + tab := s.bootloaderWithTrustedAssets(c, nil) + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + m := s.setupMarkBootSuccessful20CommandLine(c, coreDev.Model(), "run", boot.BootCommandLines{ + "snapd_recovery_mode=run panic=-1", + "snapd_recovery_mode=run candidate panic=-1", + }) + // without encryption, the trusted assets are not tracked in the modeenv, + // but we still may want to track command lines so that the gadget can + // contribute to the system command line + m.CurrentTrustedBootAssets = nil + m.CurrentTrustedRecoveryBootAssets = nil + + r := setupUC20Bootenv( + c, + tab.MockBootloader, + &bootenv20Setup{ + modeenv: m, + kern: s.kern1, + kernStatus: boot.DefaultStatus, + }, + ) + defer r() + + // mark successful + err := boot.MarkBootSuccessful(coreDev) + c.Assert(err, IsNil) + + // check the modeenv + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + // modeenv is unchaged + c.Check(m2.CurrentKernelCommandLines, DeepEquals, boot.BootCommandLines{ + "snapd_recovery_mode=run candidate panic=-1", + }) +} + +func (s *bootenv20Suite) TestMarkBootSuccessful20CommandLineCompatNonTrustedBootloader(c *C) { + s.mockCmdline(c, "snapd_recovery_mode=run candidate panic=-1") + // bootloader has no trusted assets + bl := bootloadertest.Mock("not-trusted", "") + bootloader.Force(bl) + s.AddCleanup(func() { bootloader.Force(nil) }) + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + m := s.setupMarkBootSuccessful20CommandLine(c, coreDev.Model(), "run", nil) + // no trusted assets + m.CurrentTrustedBootAssets = nil + m.CurrentTrustedRecoveryBootAssets = nil + // no kernel command lines tracked + m.CurrentKernelCommandLines = nil + + r := setupUC20Bootenv( + c, + bl, + &bootenv20Setup{ + modeenv: m, + kern: s.kern1, + kernStatus: boot.DefaultStatus, + }, + ) + defer r() + + // mark successful + err := boot.MarkBootSuccessful(coreDev) + c.Assert(err, IsNil) + + // check the modeenv + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + // modeenv isn't changed + c.Check(m2.CurrentKernelCommandLines, HasLen, 0) +} + +func (s *bootenv20Suite) TestMarkBootSuccessful20SystemsCompat(c *C) { + b := bootloadertest.Mock("mock", s.bootdir) + s.forceBootloader(b) + + m := &boot.Modeenv{ + Mode: "run", + Base: s.base1.Filename(), + CurrentKernels: []string{s.kern1.Filename()}, + CurrentRecoverySystems: []string{"1234"}, + } + + r := setupUC20Bootenv( + c, + b, + &bootenv20Setup{ + modeenv: m, + kern: s.kern1, + kernStatus: boot.DefaultStatus, + }, + ) + defer r() + + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + // mark successful + err := boot.MarkBootSuccessful(coreDev) + c.Assert(err, IsNil) + + // check the modeenv + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + // the list of good recovery systems has not been modified + c.Check(m2.GoodRecoverySystems, DeepEquals, []string{"1234"}) + c.Check(m2.CurrentRecoverySystems, DeepEquals, []string{"1234"}) +} + +func (s *bootenv20Suite) TestMarkBootSuccessful20SystemsPopulated(c *C) { + b := bootloadertest.Mock("mock", s.bootdir) + s.forceBootloader(b) + + m := &boot.Modeenv{ + Mode: "run", + Base: s.base1.Filename(), + CurrentKernels: []string{s.kern1.Filename()}, + CurrentRecoverySystems: []string{"1234", "9999"}, + GoodRecoverySystems: []string{"1234"}, + } + + r := setupUC20Bootenv( + c, + b, + &bootenv20Setup{ + modeenv: m, + kern: s.kern1, + kernStatus: boot.DefaultStatus, + }, + ) + defer r() + + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + // mark successful + err := boot.MarkBootSuccessful(coreDev) + c.Assert(err, IsNil) + + // check the modeenv + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + // good recovery systems has been populated + c.Check(m2.GoodRecoverySystems, DeepEquals, []string{"1234"}) + c.Check(m2.CurrentRecoverySystems, DeepEquals, []string{"1234", "9999"}) +} + +func (s *bootenv20Suite) TestMarkBootSuccessful20ModelSignKeyIDPopulated(c *C) { + b := bootloadertest.Mock("mock", s.bootdir) + s.forceBootloader(b) + + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + + m := &boot.Modeenv{ + Mode: "run", + Base: s.base1.Filename(), + CurrentKernels: []string{s.kern1.Filename()}, + Model: "my-model-uc20", + BrandID: "my-brand", + Grade: "dangerous", + // sign key ID is unset + } + + r := setupUC20Bootenv( + c, + b, + &bootenv20Setup{ + modeenv: m, + kern: s.kern1, + kernStatus: boot.DefaultStatus, + }, + ) + defer r() + + // mark successful + err := boot.MarkBootSuccessful(coreDev) + c.Assert(err, IsNil) + + // check the modeenv + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + // model's sign key ID has been set + c.Check(m2.ModelSignKeyID, Equals, "Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij") + c.Check(m2.Model, Equals, "my-model-uc20") + c.Check(m2.BrandID, Equals, "my-brand") + c.Check(m2.Grade, Equals, "dangerous") +} + +type recoveryBootenv20Suite struct { + baseBootenvSuite + + bootloader *bootloadertest.MockBootloader + + dev snap.Device +} + +var _ = Suite(&recoveryBootenv20Suite{}) + +func (s *recoveryBootenv20Suite) SetUpTest(c *C) { + s.baseBootenvSuite.SetUpTest(c) + + s.bootloader = bootloadertest.Mock("mock", c.MkDir()) + s.forceBootloader(s.bootloader) + + s.dev = boottest.MockUC20Device("", nil) +} + +func (s *recoveryBootenv20Suite) TestSetRecoveryBootSystemAndModeHappy(c *C) { + err := boot.SetRecoveryBootSystemAndMode(s.dev, "1234", "install") + c.Assert(err, IsNil) + c.Check(s.bootloader.BootVars, DeepEquals, map[string]string{ + "snapd_recovery_system": "1234", + "snapd_recovery_mode": "install", + }) +} + +func (s *recoveryBootenv20Suite) TestSetRecoveryBootSystemAndModeSetErr(c *C) { + s.bootloader.SetErr = errors.New("no can do") + err := boot.SetRecoveryBootSystemAndMode(s.dev, "1234", "install") + c.Assert(err, ErrorMatches, `no can do`) +} + +func (s *recoveryBootenv20Suite) TestSetRecoveryBootSystemAndModeNonUC20(c *C) { + non20Dev := boottest.MockDevice("some-snap") + err := boot.SetRecoveryBootSystemAndMode(non20Dev, "1234", "install") + c.Assert(err, Equals, boot.ErrUnsupportedSystemMode) +} + +func (s *recoveryBootenv20Suite) TestSetRecoveryBootSystemAndModeErrClumsy(c *C) { + err := boot.SetRecoveryBootSystemAndMode(s.dev, "", "install") + c.Assert(err, ErrorMatches, "internal error: system label is unset") + err = boot.SetRecoveryBootSystemAndMode(s.dev, "1234", "") + c.Assert(err, ErrorMatches, "internal error: system mode is unset") +} + +func (s *recoveryBootenv20Suite) TestSetRecoveryBootSystemAndModeRealHappy(c *C) { + bootloader.Force(nil) + + mockSeedGrubDir := filepath.Join(boot.InitramfsUbuntuSeedDir, "EFI", "ubuntu") + err := os.MkdirAll(mockSeedGrubDir, 0755) + c.Assert(err, IsNil) + err = os.WriteFile(filepath.Join(mockSeedGrubDir, "grub.cfg"), nil, 0644) + c.Assert(err, IsNil) + + err = boot.SetRecoveryBootSystemAndMode(s.dev, "1234", "install") + c.Assert(err, IsNil) + + bl, err := bootloader.Find(boot.InitramfsUbuntuSeedDir, &bootloader.Options{Role: bootloader.RoleRecovery}) + c.Assert(err, IsNil) + + blvars, err := bl.GetBootVars("snapd_recovery_mode", "snapd_recovery_system") + c.Assert(err, IsNil) + c.Check(blvars, DeepEquals, map[string]string{ + "snapd_recovery_system": "1234", + "snapd_recovery_mode": "install", + }) +} + +type bootConfigSuite struct { + baseBootenvSuite + + bootloader *bootloadertest.MockTrustedAssetsBootloader + gadgetSnap string +} + +var _ = Suite(&bootConfigSuite{}) + +func (s *bootConfigSuite) SetUpTest(c *C) { + s.baseBootenvSuite.SetUpTest(c) + + s.bootloader = bootloadertest.Mock("trusted", c.MkDir()).WithTrustedAssets() + s.bootloader.StaticCommandLine = "this is mocked panic=-1" + s.bootloader.CandidateStaticCommandLine = "mocked candidate panic=-1" + s.forceBootloader(s.bootloader) + + mockGadgetYaml := ` +volumes: + volumename: + bootloader: grub +` + + s.mockCmdline(c, "snapd_recovery_mode=run this is mocked panic=-1") + s.gadgetSnap = snaptest.MakeTestSnapWithFiles(c, gadgetSnapYaml, [][]string{{"meta/gadget.yaml", mockGadgetYaml}}) +} + +func (s *bootConfigSuite) mockCmdline(c *C, cmdline string) { + c.Assert(os.WriteFile(s.cmdlineFile, []byte(cmdline), 0644), IsNil) +} + +func (s *bootConfigSuite) TestBootConfigUpdateHappyNoKeysNoReseal(c *C) { + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + + m := &boot.Modeenv{ + Mode: "run", + CurrentKernelCommandLines: boot.BootCommandLines{ + "snapd_recovery_mode=run this is mocked panic=-1", + }, + } + c.Assert(m.WriteTo(""), IsNil) + + resealCalls := 0 + restore := boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + resealCalls++ + return nil + }) + defer restore() + + updated, err := boot.UpdateManagedBootConfigs(coreDev, s.gadgetSnap, "") + c.Assert(err, IsNil) + c.Check(updated, Equals, true) + c.Check(s.bootloader.UpdateCalls, Equals, 1) + c.Check(resealCalls, Equals, 0) +} + +func (s *bootConfigSuite) testBootConfigUpdateHappyWithReseal(c *C, cmdlineAppend string) { + s.stampSealedKeys(c, dirs.GlobalRootDir) + + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + + runKernelBf := bootloader.NewBootFile("/var/lib/snapd/snap/pc-kernel_600.snap", "kernel.efi", bootloader.RoleRunMode) + recoveryKernelBf := bootloader.NewBootFile("/var/lib/snapd/seed/snaps/pc-kernel_1.snap", "kernel.efi", bootloader.RoleRecovery) + mockAssetsCache(c, dirs.GlobalRootDir, "trusted", []string{ + "asset-hash-1", + }) + + s.bootloader.TrustedAssetsMap = map[string]string{"asset": "asset"} + s.bootloader.BootChainList = []bootloader.BootFile{ + bootloader.NewBootFile("", "asset", bootloader.RoleRunMode), + runKernelBf, + } + s.bootloader.RecoveryBootChainList = []bootloader.BootFile{ + bootloader.NewBootFile("", "asset", bootloader.RoleRecovery), + recoveryKernelBf, + } + m := &boot.Modeenv{ + Mode: "run", + CurrentKernels: []string{"pc-kernel_500.snap"}, + CurrentKernelCommandLines: boot.BootCommandLines{ + "snapd_recovery_mode=run this is mocked panic=-1", + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": []string{"hash-1"}, + }, + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": []string{"hash-1"}, + }, + } + c.Assert(m.WriteTo(""), IsNil) + + newCmdline := strutil.JoinNonEmpty([]string{ + "snapd_recovery_mode=run mocked candidate panic=-1", cmdlineAppend}, " ") + resealCalls := 0 + restore := boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + resealCalls++ + c.Assert(params, NotNil) + c.Assert(params.ModelParams, HasLen, 1) + c.Check(params.ModelParams[0].KernelCmdlines, DeepEquals, []string{ + newCmdline, + "snapd_recovery_mode=run this is mocked panic=-1", + }) + return nil + }) + defer restore() + + updated, err := boot.UpdateManagedBootConfigs(coreDev, s.gadgetSnap, cmdlineAppend) + c.Assert(err, IsNil) + c.Check(updated, Equals, true) + c.Check(s.bootloader.UpdateCalls, Equals, 1) + c.Check(resealCalls, Equals, 1) + + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Assert(m2.CurrentKernelCommandLines, DeepEquals, boot.BootCommandLines{ + "snapd_recovery_mode=run this is mocked panic=-1", + newCmdline, + }) +} + +func (s *bootConfigSuite) TestBootConfigUpdateHappyWithReseal(c *C) { + s.testBootConfigUpdateHappyWithReseal(c, "") +} + +func (s *bootConfigSuite) TestBootConfigUpdateHappyCmdlineAppendWithReseal(c *C) { + s.testBootConfigUpdateHappyWithReseal(c, "foo bar") +} + +func (s *bootConfigSuite) testBootConfigUpdateHappyNoChange(c *C, cmdlineAppend string) { + s.stampSealedKeys(c, dirs.GlobalRootDir) + + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + + s.bootloader.StaticCommandLine = "mocked unchanged panic=-1" + s.bootloader.CandidateStaticCommandLine = "mocked unchanged panic=-1" + + m := &boot.Modeenv{ + Mode: "run", + CurrentKernelCommandLines: boot.BootCommandLines{ + strutil.JoinNonEmpty([]string{ + "snapd_recovery_mode=run mocked unchanged panic=-1", cmdlineAppend}, " "), + }, + } + c.Assert(m.WriteTo(""), IsNil) + + resealCalls := 0 + restore := boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + resealCalls++ + return nil + }) + defer restore() + + updated, err := boot.UpdateManagedBootConfigs(coreDev, s.gadgetSnap, cmdlineAppend) + c.Assert(err, IsNil) + c.Check(updated, Equals, false) + c.Check(s.bootloader.UpdateCalls, Equals, 1) + c.Check(resealCalls, Equals, 0) + + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Assert(m2.CurrentKernelCommandLines, HasLen, 1) +} + +func (s *bootConfigSuite) TestBootConfigUpdateHappyNoChange(c *C) { + s.testBootConfigUpdateHappyNoChange(c, "") +} + +func (s *bootConfigSuite) TestBootConfigUpdateHappyCmdlineAppendNoChange(c *C) { + s.testBootConfigUpdateHappyNoChange(c, "foo bar") +} + +func (s *bootConfigSuite) TestBootConfigUpdateNonUC20DoesNothing(c *C) { + nonUC20coreDev := boottest.MockDevice("pc-kernel") + c.Assert(nonUC20coreDev.HasModeenv(), Equals, false) + updated, err := boot.UpdateManagedBootConfigs(nonUC20coreDev, s.gadgetSnap, "") + c.Assert(err, IsNil) + c.Check(updated, Equals, false) + c.Check(s.bootloader.UpdateCalls, Equals, 0) +} + +func (s *bootConfigSuite) TestBootConfigUpdateBadModeErr(c *C) { + uc20Dev := boottest.MockUC20Device("recover", nil) + c.Assert(uc20Dev.HasModeenv(), Equals, true) + updated, err := boot.UpdateManagedBootConfigs(uc20Dev, s.gadgetSnap, "") + c.Assert(err, ErrorMatches, "internal error: boot config can only be updated in run mode") + c.Check(updated, Equals, false) + c.Check(s.bootloader.UpdateCalls, Equals, 0) +} + +func (s *bootConfigSuite) TestBootConfigUpdateFailErr(c *C) { + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + + m := &boot.Modeenv{ + Mode: "run", + CurrentKernelCommandLines: boot.BootCommandLines{ + "snapd_recovery_mode=run this is mocked panic=-1", + }, + } + c.Assert(m.WriteTo(""), IsNil) + + s.bootloader.UpdateErr = errors.New("update fail") + + updated, err := boot.UpdateManagedBootConfigs(coreDev, s.gadgetSnap, "") + c.Assert(err, ErrorMatches, "update fail") + c.Check(updated, Equals, false) + c.Check(s.bootloader.UpdateCalls, Equals, 1) +} + +func (s *bootConfigSuite) TestBootConfigUpdateCmdlineMismatchErr(c *C) { + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + + m := &boot.Modeenv{ + Mode: "run", + } + c.Assert(m.WriteTo(""), IsNil) + + s.mockCmdline(c, "snapd_recovery_mode=run unexpected cmdline") + + updated, err := boot.UpdateManagedBootConfigs(coreDev, s.gadgetSnap, "") + c.Assert(err, ErrorMatches, `internal error: current kernel command lines is unset`) + c.Check(updated, Equals, false) + c.Check(s.bootloader.UpdateCalls, Equals, 0) +} + +func (s *bootConfigSuite) TestBootConfigUpdateNotManagedErr(c *C) { + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + + bl := bootloadertest.Mock("not-managed", c.MkDir()) + bootloader.Force(bl) + defer bootloader.Force(nil) + + m := &boot.Modeenv{ + Mode: "run", + } + c.Assert(m.WriteTo(""), IsNil) + + updated, err := boot.UpdateManagedBootConfigs(coreDev, s.gadgetSnap, "") + c.Assert(err, IsNil) + c.Check(updated, Equals, false) + c.Check(s.bootloader.UpdateCalls, Equals, 0) +} + +func (s *bootConfigSuite) TestBootConfigUpdateBootloaderFindErr(c *C) { + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + + bootloader.ForceError(errors.New("mocked find error")) + defer bootloader.ForceError(nil) + + m := &boot.Modeenv{ + Mode: "run", + } + c.Assert(m.WriteTo(""), IsNil) + + updated, err := boot.UpdateManagedBootConfigs(coreDev, s.gadgetSnap, "") + c.Assert(err, ErrorMatches, "internal error: cannot find trusted assets bootloader under .*: mocked find error") + c.Check(updated, Equals, false) + c.Check(s.bootloader.UpdateCalls, Equals, 0) +} + +func (s *bootConfigSuite) TestBootConfigUpdateWithGadgetAndReseal(c *C) { + s.stampSealedKeys(c, dirs.GlobalRootDir) + + mockGadgetYaml := ` +volumes: + volumename: + bootloader: grub +` + gadgetSnap := snaptest.MakeTestSnapWithFiles(c, gadgetSnapYaml, [][]string{ + {"cmdline.extra", "foo bar baz"}, + {"meta/gadget.yaml", mockGadgetYaml}, + }) + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + + runKernelBf := bootloader.NewBootFile("/var/lib/snapd/snap/pc-kernel_600.snap", "kernel.efi", bootloader.RoleRunMode) + recoveryKernelBf := bootloader.NewBootFile("/var/lib/snapd/seed/snaps/pc-kernel_1.snap", "kernel.efi", bootloader.RoleRecovery) + mockAssetsCache(c, dirs.GlobalRootDir, "trusted", []string{ + "asset-hash-1", + }) + + s.bootloader.TrustedAssetsMap = map[string]string{"asset": "asset"} + s.bootloader.BootChainList = []bootloader.BootFile{ + bootloader.NewBootFile("", "asset", bootloader.RoleRunMode), + runKernelBf, + } + s.bootloader.RecoveryBootChainList = []bootloader.BootFile{ + bootloader.NewBootFile("", "asset", bootloader.RoleRecovery), + recoveryKernelBf, + } + m := &boot.Modeenv{ + Mode: "run", + CurrentKernels: []string{"pc-kernel_500.snap"}, + CurrentKernelCommandLines: boot.BootCommandLines{ + // the extra arguments would be included in the current + // command line already + "snapd_recovery_mode=run this is mocked panic=-1 foo bar baz", + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": []string{"hash-1"}, + }, + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": []string{"hash-1"}, + }, + } + c.Assert(m.WriteTo(""), IsNil) + + resealCalls := 0 + restore := boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + resealCalls++ + c.Assert(params, NotNil) + c.Assert(params.ModelParams, HasLen, 1) + c.Check(params.ModelParams[0].KernelCmdlines, DeepEquals, []string{ + "snapd_recovery_mode=run mocked candidate panic=-1 foo bar baz", + "snapd_recovery_mode=run this is mocked panic=-1 foo bar baz", + }) + return nil + }) + defer restore() + + updated, err := boot.UpdateManagedBootConfigs(coreDev, gadgetSnap, "") + c.Assert(err, IsNil) + c.Check(updated, Equals, true) + c.Check(s.bootloader.UpdateCalls, Equals, 1) + c.Check(resealCalls, Equals, 1) + + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Assert(m2.CurrentKernelCommandLines, DeepEquals, boot.BootCommandLines{ + "snapd_recovery_mode=run this is mocked panic=-1 foo bar baz", + "snapd_recovery_mode=run mocked candidate panic=-1 foo bar baz", + }) +} + +func (s *bootConfigSuite) TestBootConfigUpdateWithGadgetFullAndReseal(c *C) { + s.stampSealedKeys(c, dirs.GlobalRootDir) + + mockGadgetYaml := ` +volumes: + volumename: + bootloader: grub +` + gadgetSnap := snaptest.MakeTestSnapWithFiles(c, gadgetSnapYaml, [][]string{ + {"cmdline.full", "foo bar baz"}, + {"meta/gadget.yaml", mockGadgetYaml}, + }) + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + + // a minimal bootloader and modeenv setup that works because reseal is + // not executed + s.bootloader.TrustedAssetsMap = map[string]string{"asset": "asset"} + m := &boot.Modeenv{ + Mode: "run", + CurrentKernelCommandLines: boot.BootCommandLines{ + // the full arguments would be included in the current + // command line already + "snapd_recovery_mode=run foo bar baz", + }, + } + c.Assert(m.WriteTo(""), IsNil) + + s.bootloader.Updated = true + + resealCalls := 0 + // reseal does not happen, because the gadget overrides the static + // command line which is part of boot config, thus there's no resulting + // change in the command lines tracked in modeenv and no need to reseal + restore := boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + resealCalls++ + return fmt.Errorf("unexpected call") + }) + defer restore() + + updated, err := boot.UpdateManagedBootConfigs(coreDev, gadgetSnap, "") + c.Assert(err, IsNil) + c.Check(updated, Equals, true) + c.Check(s.bootloader.UpdateCalls, Equals, 1) + c.Check(resealCalls, Equals, 0) + + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Assert(m2.CurrentKernelCommandLines, DeepEquals, boot.BootCommandLines{ + "snapd_recovery_mode=run foo bar baz", + }) +} + +type bootKernelCommandLineSuite struct { + baseBootenvSuite + + bootloader *bootloadertest.MockTrustedAssetsBootloader + gadgetSnap string + uc20dev snap.Device + recoveryKernelBf bootloader.BootFile + runKernelBf bootloader.BootFile + modeenvWithEncryption *boot.Modeenv + resealCalls int + resealCommandLines [][]string +} + +var _ = Suite(&bootKernelCommandLineSuite{}) + +func (s *bootKernelCommandLineSuite) SetUpTest(c *C) { + s.baseBootenvSuite.SetUpTest(c) + + data := []byte("foobar") + // SHA3-384 + dataHash := "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8" + c.Assert(os.MkdirAll(filepath.Join(boot.InitramfsUbuntuBootDir), 0755), IsNil) + c.Assert(os.MkdirAll(filepath.Join(boot.InitramfsUbuntuSeedDir), 0755), IsNil) + c.Assert(os.WriteFile(filepath.Join(boot.InitramfsUbuntuBootDir, "asset"), data, 0644), IsNil) + c.Assert(os.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "asset"), data, 0644), IsNil) + + s.bootloader = bootloadertest.Mock("trusted", c.MkDir()).WithTrustedAssets() + s.bootloader.TrustedAssetsMap = map[string]string{"asset": "asset"} + s.bootloader.StaticCommandLine = "static mocked panic=-1" + s.bootloader.CandidateStaticCommandLine = "mocked candidate panic=-1" + s.forceBootloader(s.bootloader) + + s.mockCmdline(c, "snapd_recovery_mode=run this is mocked panic=-1") + s.gadgetSnap = snaptest.MakeTestSnapWithFiles(c, gadgetSnapYaml, nil) + s.uc20dev = boottest.MockUC20Device("", boottest.MakeMockUC20Model(nil)) + s.runKernelBf = bootloader.NewBootFile("/var/lib/snapd/snap/pc-kernel_600.snap", "kernel.efi", bootloader.RoleRunMode) + s.recoveryKernelBf = bootloader.NewBootFile("/var/lib/snapd/seed/snaps/pc-kernel_1.snap", "kernel.efi", bootloader.RoleRecovery) + mockAssetsCache(c, dirs.GlobalRootDir, "trusted", []string{ + "asset-" + dataHash, + }) + + s.bootloader.BootChainList = []bootloader.BootFile{ + bootloader.NewBootFile("", "asset", bootloader.RoleRunMode), + s.runKernelBf, + } + s.bootloader.RecoveryBootChainList = []bootloader.BootFile{ + bootloader.NewBootFile("", "asset", bootloader.RoleRecovery), + s.recoveryKernelBf, + } + s.modeenvWithEncryption = &boot.Modeenv{ + Mode: "run", + CurrentKernels: []string{"pc-kernel_500.snap"}, + Base: "core20_1.snap", + BaseStatus: boot.DefaultStatus, + CurrentKernelCommandLines: boot.BootCommandLines{ + // the extra arguments would be included in the current + // command line already + "snapd_recovery_mode=run static mocked panic=-1", + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": []string{dataHash}, + }, + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": []string{dataHash}, + }, + } + s.bootloader.SetBootVars(map[string]string{ + "snap_kernel": "pc-kernel_500.snap", + }) + s.bootloader.SetBootVarsCalls = 0 + + s.resealCommandLines = nil + s.resealCalls = 0 + restore := boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + s.resealCalls++ + c.Assert(params, NotNil) + c.Assert(params.ModelParams, HasLen, 1) + s.resealCommandLines = append(s.resealCommandLines, params.ModelParams[0].KernelCmdlines) + return nil + }) + s.AddCleanup(restore) +} + +func (s *bootKernelCommandLineSuite) TestCommandLineUpdateNonUC20(c *C) { + nonUC20dev := boottest.MockDevice("") + + // gadget which would otherwise trigger an update + sf := snaptest.MakeTestSnapWithFiles(c, gadgetSnapYaml, [][]string{ + {"cmdline.extra", "foo"}, + }) + + reboot, err := boot.UpdateCommandLineForGadgetComponent(nonUC20dev, sf, "") + c.Assert(err, ErrorMatches, `internal error: command line component cannot be updated on pre-UC20 devices`) + c.Assert(reboot, Equals, false) +} + +func (s *bootKernelCommandLineSuite) TestCommandLineUpdateUC20NotManagedBootloader(c *C) { + // gadget which would otherwise trigger an update + sf := snaptest.MakeTestSnapWithFiles(c, gadgetSnapYaml, [][]string{ + {"cmdline.extra", "foo"}, + }) + + // but the bootloader is not managed by snapd + bl := bootloadertest.Mock("not-managed", c.MkDir()) + bl.SetErr = fmt.Errorf("unexpected call") + s.forceBootloader(bl) + + reboot, err := boot.UpdateCommandLineForGadgetComponent(s.uc20dev, sf, "") + c.Assert(err, IsNil) + c.Assert(reboot, Equals, false) + c.Check(bl.SetBootVarsCalls, Equals, 0) +} + +func (s *bootKernelCommandLineSuite) TestCommandLineUpdateUC20ArgsAdded(c *C) { + s.stampSealedKeys(c, dirs.GlobalRootDir) + + mockGadgetYaml := ` +volumes: + volumename: + bootloader: grub +` + sf := snaptest.MakeTestSnapWithFiles(c, gadgetSnapYaml, [][]string{ + {"cmdline.extra", "args from gadget"}, + {"meta/gadget.yaml", mockGadgetYaml}, + }) + + s.modeenvWithEncryption.CurrentKernelCommandLines = []string{"snapd_recovery_mode=run static mocked panic=-1"} + c.Assert(s.modeenvWithEncryption.WriteTo(""), IsNil) + + reboot, err := boot.UpdateCommandLineForGadgetComponent(s.uc20dev, sf, "") + c.Assert(err, IsNil) + c.Assert(reboot, Equals, true) + + // reseal happened + c.Check(s.resealCalls, Equals, 1) + c.Check(s.resealCommandLines, DeepEquals, [][]string{{ + "snapd_recovery_mode=run static mocked panic=-1", + "snapd_recovery_mode=run static mocked panic=-1 args from gadget", + }}) + + // modeenv has been updated + newM, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Check(newM.CurrentKernelCommandLines, DeepEquals, boot.BootCommandLines{ + "snapd_recovery_mode=run static mocked panic=-1", + "snapd_recovery_mode=run static mocked panic=-1 args from gadget", + }) + + // bootloader variables too + c.Check(s.bootloader.SetBootVarsCalls, Equals, 1) + args, err := s.bootloader.GetBootVars("snapd_extra_cmdline_args", "snapd_full_cmdline_args") + c.Assert(err, IsNil) + c.Check(args, DeepEquals, map[string]string{ + "snapd_extra_cmdline_args": "", + "snapd_full_cmdline_args": "static mocked panic=-1 args from gadget", + }) +} + +func (s *bootKernelCommandLineSuite) TestCommandLineUpdateUC20ArgsSwitch(c *C) { + s.stampSealedKeys(c, dirs.GlobalRootDir) + + mockGadgetYaml := ` +volumes: + volumename: + bootloader: grub +` + sf := snaptest.MakeTestSnapWithFiles(c, gadgetSnapYaml, [][]string{ + {"cmdline.extra", "no change"}, + {"meta/gadget.yaml", mockGadgetYaml}, + }) + + s.modeenvWithEncryption.CurrentKernelCommandLines = []string{"snapd_recovery_mode=run static mocked panic=-1 no change"} + c.Assert(s.modeenvWithEncryption.WriteTo(""), IsNil) + err := s.bootloader.SetBootVars(map[string]string{ + "snapd_extra_cmdline_args": "no change", + // this is intentionally filled and will be cleared + "snapd_full_cmdline_args": "canary", + }) + c.Assert(err, IsNil) + s.bootloader.SetBootVarsCalls = 0 + + reboot, err := boot.UpdateCommandLineForGadgetComponent(s.uc20dev, sf, "") + c.Assert(err, IsNil) + c.Assert(reboot, Equals, false) + + // no reseal needed + c.Check(s.resealCalls, Equals, 0) + + newM, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Check(newM.CurrentKernelCommandLines, DeepEquals, boot.BootCommandLines{ + "snapd_recovery_mode=run static mocked panic=-1 no change", + }) + c.Check(s.bootloader.SetBootVarsCalls, Equals, 0) + args, err := s.bootloader.GetBootVars("snapd_extra_cmdline_args", "snapd_full_cmdline_args") + c.Assert(err, IsNil) + c.Check(args, DeepEquals, map[string]string{ + "snapd_extra_cmdline_args": "no change", + // canary is still present, as nothing was modified + "snapd_full_cmdline_args": "canary", + }) + + // let's change them now + sfChanged := snaptest.MakeTestSnapWithFiles(c, gadgetSnapYaml, [][]string{ + {"cmdline.extra", "changed"}, + {"meta/gadget.yaml", mockGadgetYaml}, + }) + + reboot, err = boot.UpdateCommandLineForGadgetComponent(s.uc20dev, sfChanged, "") + c.Assert(err, IsNil) + c.Assert(reboot, Equals, true) + + // reseal was applied + c.Check(s.resealCalls, Equals, 1) + c.Check(s.resealCommandLines, DeepEquals, [][]string{{ + // those come from boot chains which use predictable sorting + "snapd_recovery_mode=run static mocked panic=-1 changed", + "snapd_recovery_mode=run static mocked panic=-1 no change", + }}) + + // modeenv has been updated + newM, err = boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Check(newM.CurrentKernelCommandLines, DeepEquals, boot.BootCommandLines{ + "snapd_recovery_mode=run static mocked panic=-1 no change", + // new ones are appended + "snapd_recovery_mode=run static mocked panic=-1 changed", + }) + // and bootloader env too + args, err = s.bootloader.GetBootVars("snapd_extra_cmdline_args", "snapd_full_cmdline_args") + c.Assert(err, IsNil) + c.Check(args, DeepEquals, map[string]string{ + "snapd_extra_cmdline_args": "", + "snapd_full_cmdline_args": "static mocked panic=-1 changed", + }) +} + +func (s *bootKernelCommandLineSuite) TestCommandLineUpdateUC20UnencryptedArgsRemoved(c *C) { + s.stampSealedKeys(c, dirs.GlobalRootDir) + + mockGadgetYaml := ` +volumes: + volumename: + bootloader: grub +` + // pretend we used to have additional arguments from the gadget, but + // those will be gone with new update + sf := snaptest.MakeTestSnapWithFiles(c, gadgetSnapYaml, [][]string{ + {"meta/gadget.yaml", mockGadgetYaml}, + }) + + s.modeenvWithEncryption.CurrentKernelCommandLines = []string{"snapd_recovery_mode=run static mocked panic=-1 from-gadget"} + c.Assert(s.modeenvWithEncryption.WriteTo(""), IsNil) + err := s.bootloader.SetBootVars(map[string]string{ + "snapd_extra_cmdline_args": "from-gadget", + // this is intentionally filled and will be cleared + "snapd_full_cmdline_args": "canary", + }) + c.Assert(err, IsNil) + s.bootloader.SetBootVarsCalls = 0 + + reboot, err := boot.UpdateCommandLineForGadgetComponent(s.uc20dev, sf, "") + c.Assert(err, IsNil) + c.Assert(reboot, Equals, true) + + c.Check(s.resealCalls, Equals, 1) + c.Check(s.resealCommandLines, DeepEquals, [][]string{{ + "snapd_recovery_mode=run static mocked panic=-1", + "snapd_recovery_mode=run static mocked panic=-1 from-gadget", + }}) + + newM, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Check(newM.CurrentKernelCommandLines, DeepEquals, boot.BootCommandLines{ + "snapd_recovery_mode=run static mocked panic=-1 from-gadget", + "snapd_recovery_mode=run static mocked panic=-1", + }) + // bootloader variables were explicitly cleared + c.Check(s.bootloader.SetBootVarsCalls, Equals, 1) + args, err := s.bootloader.GetBootVars("snapd_extra_cmdline_args", "snapd_full_cmdline_args") + c.Assert(err, IsNil) + c.Check(args, DeepEquals, map[string]string{ + "snapd_extra_cmdline_args": "", + "snapd_full_cmdline_args": "static mocked panic=-1", + }) +} + +func (s *bootKernelCommandLineSuite) TestCommandLineUpdateUC20SetError(c *C) { + s.stampSealedKeys(c, dirs.GlobalRootDir) + + mockGadgetYaml := ` +volumes: + volumename: + bootloader: grub +` + // pretend we used to have additional arguments from the gadget, but + // those will be gone with new update + sf := snaptest.MakeTestSnapWithFiles(c, gadgetSnapYaml, [][]string{ + {"cmdline.extra", "this-is-not-applied"}, + {"meta/gadget.yaml", mockGadgetYaml}, + }) + + s.modeenvWithEncryption.CurrentKernelCommandLines = []string{"snapd_recovery_mode=run static mocked panic=-1"} + c.Assert(s.modeenvWithEncryption.WriteTo(""), IsNil) + + s.bootloader.SetErr = fmt.Errorf("set fails") + + reboot, err := boot.UpdateCommandLineForGadgetComponent(s.uc20dev, sf, "") + c.Assert(err, ErrorMatches, "cannot set run system kernel command line arguments: set fails") + c.Assert(reboot, Equals, false) + // set boot vars was called and failed + c.Check(s.bootloader.SetBootVarsCalls, Equals, 1) + + // reseal with new parameters happened though + c.Check(s.resealCalls, Equals, 1) + c.Check(s.resealCommandLines, DeepEquals, [][]string{{ + "snapd_recovery_mode=run static mocked panic=-1", + "snapd_recovery_mode=run static mocked panic=-1 this-is-not-applied", + }}) + + newM, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Check(newM.CurrentKernelCommandLines, DeepEquals, boot.BootCommandLines{ + "snapd_recovery_mode=run static mocked panic=-1", + // this will be cleared on next reboot or will get overwritten + // by an update + "snapd_recovery_mode=run static mocked panic=-1 this-is-not-applied", + }) +} + +func (s *bootKernelCommandLineSuite) TestCommandLineUpdateWithResealError(c *C) { + mockGadgetYaml := ` +volumes: + volumename: + bootloader: grub +` + gadgetSnap := snaptest.MakeTestSnapWithFiles(c, gadgetSnapYaml, [][]string{ + {"cmdline.extra", "args from gadget"}, + {"meta/gadget.yaml", mockGadgetYaml}, + }) + + s.stampSealedKeys(c, dirs.GlobalRootDir) + c.Assert(s.modeenvWithEncryption.WriteTo(""), IsNil) + + resealCalls := 0 + restore := boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + resealCalls++ + return fmt.Errorf("reseal fails") + }) + defer restore() + + reboot, err := boot.UpdateCommandLineForGadgetComponent(s.uc20dev, gadgetSnap, "") + c.Assert(err, ErrorMatches, "cannot reseal the encryption key: reseal fails") + c.Check(reboot, Equals, false) + c.Check(s.bootloader.SetBootVarsCalls, Equals, 0) + c.Check(resealCalls, Equals, 1) + + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Assert(m2.CurrentKernelCommandLines, DeepEquals, boot.BootCommandLines{ + "snapd_recovery_mode=run static mocked panic=-1", + "snapd_recovery_mode=run static mocked panic=-1 args from gadget", + }) +} + +func (s *bootKernelCommandLineSuite) TestCommandLineUpdateUC20TransitionFullExtraAndBack(c *C) { + s.stampSealedKeys(c, dirs.GlobalRootDir) + + // no command line arguments from gadget + s.modeenvWithEncryption.CurrentKernelCommandLines = []string{"snapd_recovery_mode=run static mocked panic=-1"} + c.Assert(s.modeenvWithEncryption.WriteTo(""), IsNil) + err := s.bootloader.SetBootVars(map[string]string{ + // those are intentionally filled by the test + "snapd_extra_cmdline_args": "canary", + "snapd_full_cmdline_args": "canary", + }) + c.Assert(err, IsNil) + s.bootloader.SetBootVarsCalls = 0 + + mockGadgetYaml := ` +volumes: + volumename: + bootloader: grub +` + // transition to gadget with cmdline.extra + sf := snaptest.MakeTestSnapWithFiles(c, gadgetSnapYaml, [][]string{ + {"cmdline.extra", "extra args"}, + {"meta/gadget.yaml", mockGadgetYaml}, + }) + reboot, err := boot.UpdateCommandLineForGadgetComponent(s.uc20dev, sf, "") + c.Assert(err, IsNil) + c.Assert(reboot, Equals, true) + c.Check(s.resealCalls, Equals, 1) + c.Check(s.resealCommandLines, DeepEquals, [][]string{{ + // those come from boot chains which use predictable sorting + "snapd_recovery_mode=run static mocked panic=-1", + "snapd_recovery_mode=run static mocked panic=-1 extra args", + }}) + s.resealCommandLines = nil + + newM, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Check(newM.CurrentKernelCommandLines, DeepEquals, boot.BootCommandLines{ + "snapd_recovery_mode=run static mocked panic=-1", + "snapd_recovery_mode=run static mocked panic=-1 extra args", + }) + c.Check(s.bootloader.SetBootVarsCalls, Equals, 1) + args, err := s.bootloader.GetBootVars("snapd_extra_cmdline_args", "snapd_full_cmdline_args") + c.Assert(err, IsNil) + c.Check(args, DeepEquals, map[string]string{ + "snapd_extra_cmdline_args": "", + "snapd_full_cmdline_args": "static mocked panic=-1 extra args", + }) + // this normally happens after booting + s.modeenvWithEncryption.CurrentKernelCommandLines = []string{"snapd_recovery_mode=run static mocked panic=-1 extra args"} + c.Assert(s.modeenvWithEncryption.WriteTo(""), IsNil) + + // transition to full override from gadget + sfFull := snaptest.MakeTestSnapWithFiles(c, gadgetSnapYaml, [][]string{ + {"cmdline.full", "full args"}, + {"meta/gadget.yaml", mockGadgetYaml}, + }) + reboot, err = boot.UpdateCommandLineForGadgetComponent(s.uc20dev, sfFull, "") + c.Assert(err, IsNil) + c.Assert(reboot, Equals, true) + c.Check(s.resealCalls, Equals, 2) + c.Check(s.resealCommandLines, DeepEquals, [][]string{{ + // those come from boot chains which use predictable sorting + "snapd_recovery_mode=run full args", + "snapd_recovery_mode=run static mocked panic=-1 extra args", + }}) + s.resealCommandLines = nil + // modeenv has been updated + newM, err = boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Check(newM.CurrentKernelCommandLines, DeepEquals, boot.BootCommandLines{ + "snapd_recovery_mode=run static mocked panic=-1 extra args", + // new ones are appended + "snapd_recovery_mode=run full args", + }) + // and bootloader env too + args, err = s.bootloader.GetBootVars("snapd_extra_cmdline_args", "snapd_full_cmdline_args") + c.Assert(err, IsNil) + c.Check(args, DeepEquals, map[string]string{ + // cleared + "snapd_extra_cmdline_args": "", + // and full arguments were set + "snapd_full_cmdline_args": "full args", + }) + // this normally happens after booting + s.modeenvWithEncryption.CurrentKernelCommandLines = []string{"snapd_recovery_mode=run full args"} + c.Assert(s.modeenvWithEncryption.WriteTo(""), IsNil) + + // transition back to no arguments from the gadget + sfNone := snaptest.MakeTestSnapWithFiles(c, gadgetSnapYaml, [][]string{ + {"meta/gadget.yaml", mockGadgetYaml}, + }) + reboot, err = boot.UpdateCommandLineForGadgetComponent(s.uc20dev, sfNone, "") + + c.Assert(err, IsNil) + c.Assert(reboot, Equals, true) + c.Check(s.resealCalls, Equals, 3) + c.Check(s.resealCommandLines, DeepEquals, [][]string{{ + // those come from boot chains which use predictable sorting + "snapd_recovery_mode=run full args", + "snapd_recovery_mode=run static mocked panic=-1", + }}) + // modeenv has been updated again + newM, err = boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Check(newM.CurrentKernelCommandLines, DeepEquals, boot.BootCommandLines{ + "snapd_recovery_mode=run full args", + // new ones are appended + "snapd_recovery_mode=run static mocked panic=-1", + }) + // and bootloader env too + args, err = s.bootloader.GetBootVars("snapd_extra_cmdline_args", "snapd_full_cmdline_args") + c.Assert(err, IsNil) + c.Check(args, DeepEquals, map[string]string{ + "snapd_extra_cmdline_args": "", + "snapd_full_cmdline_args": "static mocked panic=-1", + }) +} + +func (s *bootKernelCommandLineSuite) TestCommandLineUpdateUC20OverSpuriousRebootsBeforeBootVarsSet(c *C) { + // simulate spurious reboots + s.stampSealedKeys(c, dirs.GlobalRootDir) + + resealPanic := false + restore := boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + s.resealCalls++ + c.Logf("reseal call %v", s.resealCalls) + c.Assert(params, NotNil) + c.Assert(params.ModelParams, HasLen, 1) + s.resealCommandLines = append(s.resealCommandLines, params.ModelParams[0].KernelCmdlines) + if resealPanic { + panic("reseal panic") + } + return nil + }) + defer restore() + + // no command line arguments from gadget + s.modeenvWithEncryption.CurrentKernelCommandLines = []string{"snapd_recovery_mode=run static mocked panic=-1"} + c.Assert(s.modeenvWithEncryption.WriteTo(""), IsNil) + + cmdlineFile := filepath.Join(c.MkDir(), "cmdline") + err := os.WriteFile(cmdlineFile, []byte("snapd_recovery_mode=run static mocked panic=-1"), 0644) + c.Assert(err, IsNil) + restore = kcmdline.MockProcCmdline(cmdlineFile) + s.AddCleanup(restore) + + err = s.bootloader.SetBootVars(map[string]string{ + // those are intentionally filled by the test + "snapd_extra_cmdline_args": "canary", + "snapd_full_cmdline_args": "canary", + }) + c.Assert(err, IsNil) + s.bootloader.SetBootVarsCalls = 0 + + restoreBootloaderNoPanic := s.bootloader.SetMockToPanic("SetBootVars") + defer restoreBootloaderNoPanic() + + mockGadgetYaml := ` +volumes: + volumename: + bootloader: grub +` + // transition to gadget with cmdline.extra + sf := snaptest.MakeTestSnapWithFiles(c, gadgetSnapYaml, [][]string{ + {"cmdline.extra", "extra args"}, + {"meta/gadget.yaml", mockGadgetYaml}, + }) + + // let's panic on reseal first + resealPanic = true + c.Assert(func() { + boot.UpdateCommandLineForGadgetComponent(s.uc20dev, sf, "") + }, PanicMatches, "reseal panic") + c.Check(s.resealCalls, Equals, 1) + c.Check(s.resealCommandLines, DeepEquals, [][]string{{ + // those come from boot chains which use predictable sorting + "snapd_recovery_mode=run static mocked panic=-1", + "snapd_recovery_mode=run static mocked panic=-1 extra args", + }}) + // bootenv hasn't been updated yet + c.Check(s.bootloader.SetBootVarsCalls, Equals, 0) + // but modeenv has already been updated + m, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Check(m.CurrentKernelCommandLines, DeepEquals, boot.BootCommandLines{ + "snapd_recovery_mode=run static mocked panic=-1", + "snapd_recovery_mode=run static mocked panic=-1 extra args", + }) + + // REBOOT + resealPanic = false + err = boot.MarkBootSuccessful(s.uc20dev) + c.Assert(err, IsNil) + // we resealed after reboot, since modeenv was updated and carries the + // current command line only + c.Check(s.resealCalls, Equals, 2) + m, err = boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Check(m.CurrentKernelCommandLines, DeepEquals, boot.BootCommandLines{ + "snapd_recovery_mode=run static mocked panic=-1", + }) + + // try the update again, but no panic in reseal this time + s.resealCalls = 0 + s.resealCommandLines = nil + resealPanic = false + // but panic in set + c.Assert(func() { + boot.UpdateCommandLineForGadgetComponent(s.uc20dev, sf, "") + }, PanicMatches, "mocked reboot panic in SetBootVars") + c.Check(s.resealCalls, Equals, 1) + c.Check(s.resealCommandLines, DeepEquals, [][]string{{ + // those come from boot chains which use predictable sorting + "snapd_recovery_mode=run static mocked panic=-1", + "snapd_recovery_mode=run static mocked panic=-1 extra args", + }}) + // the call to bootloader wasn't counted, because it called panic + c.Check(s.bootloader.SetBootVarsCalls, Equals, 0) + m, err = boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Check(m.CurrentKernelCommandLines, DeepEquals, boot.BootCommandLines{ + "snapd_recovery_mode=run static mocked panic=-1", + "snapd_recovery_mode=run static mocked panic=-1 extra args", + }) + + // REBOOT + err = boot.MarkBootSuccessful(s.uc20dev) + c.Assert(err, IsNil) + // we resealed after reboot again + c.Check(s.resealCalls, Equals, 2) + m, err = boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Check(m.CurrentKernelCommandLines, DeepEquals, boot.BootCommandLines{ + "snapd_recovery_mode=run static mocked panic=-1", + }) + + // try again, for the last time, things should go smoothly + s.resealCalls = 0 + s.resealCommandLines = nil + restoreBootloaderNoPanic() + reboot, err := boot.UpdateCommandLineForGadgetComponent(s.uc20dev, sf, "") + c.Assert(err, IsNil) + c.Check(reboot, Equals, true) + c.Check(s.resealCalls, Equals, 1) + c.Check(s.bootloader.SetBootVarsCalls, Equals, 1) + // all done, modeenv + m, err = boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Check(m.CurrentKernelCommandLines, DeepEquals, boot.BootCommandLines{ + "snapd_recovery_mode=run static mocked panic=-1", + "snapd_recovery_mode=run static mocked panic=-1 extra args", + }) + args, err := s.bootloader.GetBootVars("snapd_extra_cmdline_args", "snapd_full_cmdline_args") + c.Assert(err, IsNil) + c.Check(args, DeepEquals, map[string]string{ + "snapd_extra_cmdline_args": "", + "snapd_full_cmdline_args": "static mocked panic=-1 extra args", + }) +} + +func (s *bootKernelCommandLineSuite) TestCommandLineUpdateUC20OverSpuriousRebootsAfterBootVars(c *C) { + // simulate spurious reboots + s.stampSealedKeys(c, dirs.GlobalRootDir) + + // no command line arguments from gadget + s.modeenvWithEncryption.CurrentKernelCommandLines = []string{"snapd_recovery_mode=run static mocked panic=-1"} + c.Assert(s.modeenvWithEncryption.WriteTo(""), IsNil) + + cmdlineFile := filepath.Join(c.MkDir(), "cmdline") + restore := kcmdline.MockProcCmdline(cmdlineFile) + s.AddCleanup(restore) + + err := s.bootloader.SetBootVars(map[string]string{ + // those are intentionally filled by the test + "snapd_extra_cmdline_args": "canary", + "snapd_full_cmdline_args": "canary", + }) + c.Assert(err, IsNil) + s.bootloader.SetBootVarsCalls = 0 + + mockGadgetYaml := ` +volumes: + volumename: + bootloader: grub +` + // transition to gadget with cmdline.extra + sf := snaptest.MakeTestSnapWithFiles(c, gadgetSnapYaml, [][]string{ + {"cmdline.extra", "extra args"}, + {"meta/gadget.yaml", mockGadgetYaml}, + }) + + // let's panic after setting bootenv, but before returning, such that if + // executed by a task handler, the task's status would not get updated + s.bootloader.SetErrFunc = func() error { + panic("mocked reboot panic after SetBootVars") + } + c.Assert(func() { + boot.UpdateCommandLineForGadgetComponent(s.uc20dev, sf, "") + }, PanicMatches, "mocked reboot panic after SetBootVars") + c.Check(s.resealCalls, Equals, 1) + c.Check(s.resealCommandLines, DeepEquals, [][]string{{ + // those come from boot chains which use predictable sorting + "snapd_recovery_mode=run static mocked panic=-1", + "snapd_recovery_mode=run static mocked panic=-1 extra args", + }}) + // the call to bootloader was executed + c.Check(s.bootloader.SetBootVarsCalls, Equals, 1) + m, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Check(m.CurrentKernelCommandLines, DeepEquals, boot.BootCommandLines{ + "snapd_recovery_mode=run static mocked panic=-1", + "snapd_recovery_mode=run static mocked panic=-1 extra args", + }) + args, err := s.bootloader.GetBootVars("snapd_extra_cmdline_args", "snapd_full_cmdline_args") + c.Assert(err, IsNil) + c.Check(args, DeepEquals, map[string]string{ + "snapd_extra_cmdline_args": "", + "snapd_full_cmdline_args": "static mocked panic=-1 extra args", + }) + + // REBOOT; since we rebooted after updating the bootenv, the kernel + // command line will include arguments that came from gadget snap + s.bootloader.SetBootVarsCalls = 0 + s.resealCalls = 0 + err = os.WriteFile(cmdlineFile, []byte("snapd_recovery_mode=run static mocked panic=-1 extra args"), 0644) + c.Assert(err, IsNil) + err = boot.MarkBootSuccessful(s.uc20dev) + c.Assert(err, IsNil) + // we resealed after reboot again + c.Check(s.resealCalls, Equals, 1) + // bootenv wasn't touched + c.Check(s.bootloader.SetBootVarsCalls, Equals, 0) + m, err = boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Check(m.CurrentKernelCommandLines, DeepEquals, boot.BootCommandLines{ + "snapd_recovery_mode=run static mocked panic=-1 extra args", + }) + + // try again, as if the task handler gets to run again + s.resealCalls = 0 + reboot, err := boot.UpdateCommandLineForGadgetComponent(s.uc20dev, sf, "") + c.Assert(err, IsNil) + // nothing changed now, we already booted with the new command line + c.Check(reboot, Equals, false) + // not reseal since nothing changed + c.Check(s.resealCalls, Equals, 0) + // no changes to the bootenv either + c.Check(s.bootloader.SetBootVarsCalls, Equals, 0) + // all done, modeenv + m, err = boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Check(m.CurrentKernelCommandLines, DeepEquals, boot.BootCommandLines{ + "snapd_recovery_mode=run static mocked panic=-1 extra args", + }) + args, err = s.bootloader.GetBootVars("snapd_extra_cmdline_args", "snapd_full_cmdline_args") + c.Assert(err, IsNil) + c.Check(args, DeepEquals, map[string]string{ + "snapd_extra_cmdline_args": "", + "snapd_full_cmdline_args": "static mocked panic=-1 extra args", + }) +} + +func (s *bootenv20RebootBootloaderSuite) TestCoreParticipant20WithRebootBootloader(c *C) { + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + + r := setupUC20Bootenv( + c, + s.bootloader, + s.normalDefaultState, + ) + defer r() + + // get the boot kernel participant from our new kernel snap + bootKern := boot.Participant(s.kern2, snap.TypeKernel, coreDev) + // make sure it's not a trivial boot participant + c.Assert(bootKern.IsTrivial(), Equals, false) + + // make the kernel used on next boot + rebootInfo, err := bootKern.SetNextBoot(boot.NextBootContext{BootWithoutTry: false}) + c.Assert(err, IsNil) + c.Assert(rebootInfo.RebootRequired, Equals, true) + // Test that we get the bootloader options + c.Assert(rebootInfo.BootloaderOptions, DeepEquals, + &bootloader.Options{ + Role: bootloader.RoleRunMode, + }) + + // make sure the env was updated + m := s.bootloader.BootVars + c.Assert(m, DeepEquals, map[string]string{ + "kernel_status": boot.TryStatus, + "snap_kernel": s.kern1.Filename(), + "snap_try_kernel": s.kern2.Filename(), + }) + + // and that the modeenv now has this kernel listed + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Assert(m2.CurrentKernels, DeepEquals, []string{s.kern1.Filename(), s.kern2.Filename()}) +} + +func (s *bootenv20Suite) TestCoreParticipant20SetNextSameGadgetSnap(c *C) { + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + + r := setupUC20Bootenv( + c, + s.bootloader, + s.normalDefaultState, + ) + defer r() + r = boot.MockResealKeyToModeenv(func(_ string, _ *boot.Modeenv, expectReseal bool, _ boot.Unlocker) error { + c.Assert(expectReseal, Equals, false) + return nil + }) + defer r() + + // get the gadget participant + bootGadget := boot.Participant(s.gadget1, snap.TypeGadget, coreDev) + // make sure it's not a trivial boot participant + c.Assert(bootGadget.IsTrivial(), Equals, false) + + // make the gadget used on next boot + rebootRequired, err := bootGadget.SetNextBoot(boot.NextBootContext{}) + c.Assert(err, IsNil) + c.Assert(rebootRequired, Equals, boot.RebootInfo{RebootRequired: false}) + + // the modeenv is still the same + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Assert(m2.Gadget, Equals, s.gadget1.Filename()) + + // we didn't call SetBootVars on the bootloader (unneeded for gadget) + c.Assert(s.bootloader.SetBootVarsCalls, Equals, 0) +} + +func (s *bootenv20Suite) TestCoreParticipant20SetNextNewGadgetSnap(c *C) { + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + + r := setupUC20Bootenv( + c, + s.bootloader, + s.normalDefaultState, + ) + defer r() + r = boot.MockResealKeyToModeenv(func(_ string, _ *boot.Modeenv, expectReseal bool, _ boot.Unlocker) error { + c.Assert(expectReseal, Equals, false) + return nil + }) + defer r() + + // get the gadget participant + bootGadget := boot.Participant(s.gadget2, snap.TypeGadget, coreDev) + // make sure it's not a trivial boot participant + c.Assert(bootGadget.IsTrivial(), Equals, false) + + // make the gadget used on next boot + rebootRequired, err := bootGadget.SetNextBoot(boot.NextBootContext{}) + c.Assert(err, IsNil) + c.Assert(rebootRequired, Equals, boot.RebootInfo{RebootRequired: false}) + + // and that the modeenv now contains gadget2 + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Assert(m2.Gadget, Equals, s.gadget2.Filename()) + + // we didn't call SetBootVars on the bootloader (unneeded for gadget) + c.Assert(s.bootloader.SetBootVarsCalls, Equals, 0) +} + +func (s *bootenv20Suite) TestCoreParticipant20UndoKernelSnapInstallSame(c *C) { + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + + r := setupUC20Bootenv( + c, + s.bootloader, + s.normalDefaultState, + ) + defer r() + + // get the boot kernel participant from our kernel snap + bootKern := boot.Participant(s.kern1, snap.TypeKernel, coreDev) + + // make sure it's not a trivial boot participant + c.Assert(bootKern.IsTrivial(), Equals, false) + + // make the kernel used on next boot + rebootRequired, err := bootKern.SetNextBoot(boot.NextBootContext{BootWithoutTry: true}) + c.Assert(err, IsNil) + c.Assert(rebootRequired, Equals, boot.RebootInfo{RebootRequired: false}) + + // make sure that the bootloader was asked for the current kernel + _, nKernelCalls := s.bootloader.GetRunKernelImageFunctionSnapCalls("Kernel") + c.Assert(nKernelCalls, Equals, 1) + + // ensure that kernel_status is still empty + c.Assert(s.bootloader.BootVars["kernel_status"], Equals, boot.DefaultStatus) + + // there was no attempt to try a kernel + _, enableKernelCalls := s.bootloader.GetRunKernelImageFunctionSnapCalls("EnableTryKernel") + c.Assert(enableKernelCalls, Equals, 0) + + // the modeenv is still the same as well + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Assert(m2.CurrentKernels, DeepEquals, []string{s.kern1.Filename()}) + + // finally we didn't call SetBootVars on the bootloader because nothing + // changed + c.Assert(s.bootloader.SetBootVarsCalls, Equals, 0) +} + +func (s *bootenv20EnvRefKernelSuite) TestCoreParticipant20UndoKernelSnapInstallSame(c *C) { + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + + r := setupUC20Bootenv( + c, + s.bootloader, + s.normalDefaultState, + ) + defer r() + + // get the boot kernel participant from our kernel snap + bootKern := boot.Participant(s.kern1, snap.TypeKernel, coreDev) + + // make sure it's not a trivial boot participant + c.Assert(bootKern.IsTrivial(), Equals, false) + + // make the kernel used on next boot + rebootRequired, err := bootKern.SetNextBoot(boot.NextBootContext{BootWithoutTry: true}) + c.Assert(err, IsNil) + c.Assert(rebootRequired, Equals, boot.RebootInfo{RebootRequired: false}) + + // ensure that bootenv is unchanged + m, err := s.bootloader.GetBootVars("kernel_status", "snap_kernel", "snap_try_kernel") + c.Assert(err, IsNil) + c.Assert(m, DeepEquals, map[string]string{ + "kernel_status": boot.DefaultStatus, + "snap_kernel": s.kern1.Filename(), + "snap_try_kernel": "", + }) + + // the modeenv is still the same as well + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Assert(m2.CurrentKernels, DeepEquals, []string{s.kern1.Filename()}) + + // finally we didn't call SetBootVars on the bootloader because nothing + // changed + c.Assert(s.bootloader.SetBootVarsCalls, Equals, 0) +} + +func (s *bootenv20Suite) TestCoreParticipant20UndoKernelSnapInstallNew(c *C) { + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + + r := setupUC20Bootenv( + c, + s.bootloader, + s.normalDefaultState, + ) + defer r() + + // get the boot kernel participant from our new kernel snap + bootKern := boot.Participant(s.kern2, snap.TypeKernel, coreDev) + // make sure it's not a trivial boot participant + c.Assert(bootKern.IsTrivial(), Equals, false) + + // make the kernel used on next boot, reverting the installation + rebootRequired, err := bootKern.SetNextBoot(boot.NextBootContext{BootWithoutTry: true}) + c.Assert(err, IsNil) + c.Assert(rebootRequired, DeepEquals, boot.RebootInfo{ + RebootRequired: true, + BootloaderOptions: &bootloader.Options{ + Role: bootloader.RoleRunMode, + }, + }) + + // make sure that the bootloader was asked for the current kernel + _, nKernelCalls := s.bootloader.GetRunKernelImageFunctionSnapCalls("Kernel") + c.Assert(nKernelCalls, Equals, 1) + + // ensure that kernel_status is the default + c.Assert(s.bootloader.BootVars["kernel_status"], Equals, boot.DefaultStatus) + + // and we were asked to enable kernel2 as kernel, not as try kernel + _, numTry := s.bootloader.GetRunKernelImageFunctionSnapCalls("EnableTryKernel") + c.Assert(numTry, Equals, 0) + actual, _ := s.bootloader.GetRunKernelImageFunctionSnapCalls("EnableKernel") + c.Assert(actual, DeepEquals, []snap.PlaceInfo{s.kern2}) + + // and that the modeenv now has only this kernel listed + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Assert(m2.CurrentKernels, DeepEquals, []string{s.kern2.Filename()}) +} + +func (s *bootenv20EnvRefKernelSuite) TestCoreParticipant20UndoKernelSnapInstallNew(c *C) { + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + + r := setupUC20Bootenv( + c, + s.bootloader, + s.normalDefaultState, + ) + defer r() + + // get the boot kernel participant from our new kernel snap + bootKern := boot.Participant(s.kern2, snap.TypeKernel, coreDev) + // make sure it's not a trivial boot participant + c.Assert(bootKern.IsTrivial(), Equals, false) + + // make the kernel used on next boot + rebootRequired, err := bootKern.SetNextBoot(boot.NextBootContext{BootWithoutTry: true}) + c.Assert(err, IsNil) + c.Assert(rebootRequired, DeepEquals, boot.RebootInfo{ + RebootRequired: true, + BootloaderOptions: &bootloader.Options{ + Role: bootloader.RoleRunMode, + }, + }) + + // make sure the env was updated + m := s.bootloader.BootVars + c.Assert(m, DeepEquals, map[string]string{ + "kernel_status": boot.DefaultStatus, + "snap_kernel": s.kern2.Filename(), + "snap_try_kernel": "", + }) + + // and that the modeenv now has this kernel listed + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Assert(m2.CurrentKernels, DeepEquals, []string{s.kern2.Filename()}) +} + +func (s *bootenv20Suite) TestCoreParticipant20UndoKernelSnapInstallNewWithReseal(c *C) { + // checked by resealKeyToModeenv + s.stampSealedKeys(c, dirs.GlobalRootDir) + + tab := s.bootloaderWithTrustedAssets(c, map[string]string{ + "asset": "asset", + }) + + data := []byte("foobar") + // SHA3-384 + dataHash := "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8" + + c.Assert(os.MkdirAll(filepath.Join(boot.InitramfsUbuntuBootDir), 0755), IsNil) + c.Assert(os.MkdirAll(filepath.Join(boot.InitramfsUbuntuSeedDir), 0755), IsNil) + c.Assert(os.WriteFile(filepath.Join(boot.InitramfsUbuntuBootDir, "asset"), data, 0644), IsNil) + c.Assert(os.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "asset"), data, 0644), IsNil) + + // mock the files in cache + mockAssetsCache(c, dirs.GlobalRootDir, "trusted", []string{ + "asset-" + dataHash, + }) + + assetBf := bootloader.NewBootFile("", filepath.Join(dirs.SnapBootAssetsDir, + "trusted", fmt.Sprintf("asset-%s", dataHash)), bootloader.RoleRunMode) + runKernelBf := bootloader.NewBootFile(filepath.Join(s.kern2.Filename()), + "kernel.efi", bootloader.RoleRunMode) + + tab.BootChainList = []bootloader.BootFile{ + bootloader.NewBootFile("", "asset", bootloader.RoleRunMode), + // TODO:UC20: fix mocked trusted assets bootloader to actually + // geenerate kernel boot files + runKernelBf, + } + + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + model := coreDev.Model() + + m := &boot.Modeenv{ + Mode: "run", + Base: s.base1.Filename(), + CurrentKernels: []string{s.kern1.Filename()}, + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": []string{dataHash}, + }, + Model: model.Model(), + BrandID: model.BrandID(), + Grade: string(model.Grade()), + ModelSignKeyID: model.SignKeyID(), + } + + r := setupUC20Bootenv( + c, + tab.MockBootloader, + &bootenv20Setup{ + modeenv: m, + kern: s.kern1, + kernStatus: boot.DefaultStatus, + }, + ) + defer r() + + resealCalls := 0 + restore := boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + resealCalls++ + + c.Assert(params.ModelParams, HasLen, 1) + mp := params.ModelParams[0] + c.Check(mp.Model.Model(), Equals, model.Model()) + for _, ch := range mp.EFILoadChains { + printChain(c, ch, "-") + } + c.Check(mp.EFILoadChains, DeepEquals, []*secboot.LoadChain{ + secboot.NewLoadChain(assetBf, + secboot.NewLoadChain(runKernelBf)), + }) + // actual paths are seen only here + c.Check(tab.BootChainKernelPath, DeepEquals, []string{ + s.kern2.MountFile(), + }) + return nil + }) + defer restore() + + // get the boot kernel participant from our new kernel snap + bootKern := boot.Participant(s.kern2, snap.TypeKernel, coreDev) + // make sure it's not a trivial boot participant + c.Assert(bootKern.IsTrivial(), Equals, false) + + // make the kernel used on next boot + rebootRequired, err := bootKern.SetNextBoot(boot.NextBootContext{BootWithoutTry: true}) + c.Assert(err, IsNil) + c.Assert(rebootRequired, DeepEquals, boot.RebootInfo{ + RebootRequired: true, + BootloaderOptions: &bootloader.Options{ + Role: bootloader.RoleRunMode, + }, + }) + + // make sure the env was updated + bvars, err := tab.GetBootVars("kernel_status", "snap_kernel", "snap_try_kernel") + c.Assert(err, IsNil) + c.Assert(bvars, DeepEquals, map[string]string{ + "kernel_status": boot.DefaultStatus, + "snap_kernel": s.kern2.Filename(), + "snap_try_kernel": "", + }) + + // and that the modeenv now has this kernel listed + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Assert(m2.CurrentKernels, DeepEquals, []string{s.kern2.Filename()}) + + c.Check(resealCalls, Equals, 1) +} + +func (s *bootenv20Suite) TestCoreParticipant20UndoUnassertedKernelSnapInstallNewWithReseal(c *C) { + // checked by resealKeyToModeenv + s.stampSealedKeys(c, dirs.GlobalRootDir) + + tab := s.bootloaderWithTrustedAssets(c, map[string]string{ + "asset": "asset", + }) + + data := []byte("foobar") + // SHA3-384 + dataHash := "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8" + + c.Assert(os.MkdirAll(filepath.Join(boot.InitramfsUbuntuBootDir), 0755), IsNil) + c.Assert(os.MkdirAll(filepath.Join(boot.InitramfsUbuntuSeedDir), 0755), IsNil) + c.Assert(os.WriteFile(filepath.Join(boot.InitramfsUbuntuBootDir, "asset"), data, 0644), IsNil) + c.Assert(os.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "asset"), data, 0644), IsNil) + + // mock the files in cache + mockAssetsCache(c, dirs.GlobalRootDir, "trusted", []string{ + "asset-" + dataHash, + }) + + assetBf := bootloader.NewBootFile("", filepath.Join(dirs.SnapBootAssetsDir, "trusted", fmt.Sprintf("asset-%s", dataHash)), bootloader.RoleRunMode) + runKernelBf := bootloader.NewBootFile(filepath.Join(s.ukern2.Filename()), "kernel.efi", bootloader.RoleRunMode) + + tab.BootChainList = []bootloader.BootFile{ + bootloader.NewBootFile("", "asset", bootloader.RoleRunMode), + // TODO:UC20: fix mocked trusted assets bootloader to actually + // geenerate kernel boot files + runKernelBf, + } + + uc20Model := boottest.MakeMockUC20Model() + coreDev := boottest.MockUC20Device("", uc20Model) + c.Assert(coreDev.HasModeenv(), Equals, true) + + m := &boot.Modeenv{ + Mode: "run", + Base: s.base1.Filename(), + CurrentKernels: []string{s.ukern1.Filename()}, + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": []string{dataHash}, + }, + Model: uc20Model.Model(), + BrandID: uc20Model.BrandID(), + Grade: string(uc20Model.Grade()), + ModelSignKeyID: uc20Model.SignKeyID(), + } + + r := setupUC20Bootenv( + c, + tab.MockBootloader, + &bootenv20Setup{ + modeenv: m, + kern: s.ukern1, + kernStatus: boot.DefaultStatus, + }, + ) + defer r() + + resealCalls := 0 + restore := boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + resealCalls++ + + c.Assert(params.ModelParams, HasLen, 1) + mp := params.ModelParams[0] + c.Check(mp.Model.Model(), Equals, uc20Model.Model()) + for _, ch := range mp.EFILoadChains { + printChain(c, ch, "-") + } + c.Check(mp.EFILoadChains, DeepEquals, []*secboot.LoadChain{ + secboot.NewLoadChain(assetBf, + secboot.NewLoadChain(runKernelBf)), + }) + // actual paths are seen only here + c.Check(tab.BootChainKernelPath, DeepEquals, []string{ + s.ukern2.MountFile(), + }) + return nil + }) + defer restore() + + // get the boot kernel participant from our new kernel snap + bootKern := boot.Participant(s.ukern2, snap.TypeKernel, coreDev) + // make sure it's not a trivial boot participant + c.Assert(bootKern.IsTrivial(), Equals, false) + + // make the kernel used on next boot + rebootRequired, err := bootKern.SetNextBoot(boot.NextBootContext{BootWithoutTry: true}) + c.Assert(err, IsNil) + c.Assert(rebootRequired, DeepEquals, boot.RebootInfo{ + RebootRequired: true, + BootloaderOptions: &bootloader.Options{ + Role: bootloader.RoleRunMode, + }, + }) + + bvars, err := tab.GetBootVars("kernel_status", "snap_kernel", "snap_try_kernel") + c.Assert(err, IsNil) + c.Assert(bvars, DeepEquals, map[string]string{ + "kernel_status": boot.DefaultStatus, + "snap_kernel": s.ukern2.Filename(), + "snap_try_kernel": "", + }) + + // and that the modeenv now has this kernel listed + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Assert(m2.CurrentKernels, DeepEquals, []string{s.ukern2.Filename()}) + + c.Check(resealCalls, Equals, 1) +} + +func (s *bootenv20Suite) TestCoreParticipant20UndoKernelSnapInstallSameNoReseal(c *C) { + // checked by resealKeyToModeenv + s.stampSealedKeys(c, dirs.GlobalRootDir) + + tab := s.bootloaderWithTrustedAssets(c, map[string]string{ + "asset": "asset", + }) + + data := []byte("foobar") + // SHA3-384 + dataHash := "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8" + + c.Assert(os.MkdirAll(filepath.Join(boot.InitramfsUbuntuBootDir), 0755), IsNil) + c.Assert(os.MkdirAll(filepath.Join(boot.InitramfsUbuntuSeedDir), 0755), IsNil) + c.Assert(os.WriteFile(filepath.Join(boot.InitramfsUbuntuBootDir, "asset"), data, 0644), IsNil) + c.Assert(os.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "asset"), data, 0644), IsNil) + + // mock the files in cache + mockAssetsCache(c, dirs.GlobalRootDir, "trusted", []string{ + "asset-" + dataHash, + }) + + runKernelBf := bootloader.NewBootFile(filepath.Join(s.kern1.Filename()), "kernel.efi", bootloader.RoleRunMode) + + tab.BootChainList = []bootloader.BootFile{ + bootloader.NewBootFile("", "asset", bootloader.RoleRunMode), + runKernelBf, + } + + uc20Model := boottest.MakeMockUC20Model() + coreDev := boottest.MockUC20Device("", uc20Model) + c.Assert(coreDev.HasModeenv(), Equals, true) + + m := &boot.Modeenv{ + Mode: "run", + Base: s.base1.Filename(), + CurrentKernels: []string{s.kern1.Filename()}, + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": []string{dataHash}, + }, + CurrentKernelCommandLines: boot.BootCommandLines{"snapd_recovery_mode=run"}, + + Model: uc20Model.Model(), + BrandID: uc20Model.BrandID(), + Grade: string(uc20Model.Grade()), + ModelSignKeyID: uc20Model.SignKeyID(), + } + + r := setupUC20Bootenv( + c, + tab.MockBootloader, + &bootenv20Setup{ + modeenv: m, + kern: s.kern1, + kernStatus: boot.DefaultStatus, + }, + ) + defer r() + + resealCalls := 0 + restore := boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + resealCalls++ + return fmt.Errorf("unexpected call to mocked secbootResealKeys") + }) + defer restore() + + // get the boot kernel participant from our kernel snap + bootKern := boot.Participant(s.kern1, snap.TypeKernel, coreDev) + // make sure it's not a trivial boot participant + c.Assert(bootKern.IsTrivial(), Equals, false) + + // write boot-chains for current state that will stay unchanged + bootChains := []boot.BootChain{{ + BrandID: "my-brand", + Model: "my-model-uc20", + Grade: "dangerous", + ModelSignKeyID: "Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij", + AssetChain: []boot.BootAsset{ + { + Role: bootloader.RoleRunMode, + Name: "asset", + Hashes: []string{ + "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8", + }, + }, + }, + Kernel: "pc-kernel", + KernelRevision: "1", + KernelCmdlines: []string{"snapd_recovery_mode=run"}, + }} + err := boot.WriteBootChains(bootChains, filepath.Join(dirs.SnapFDEDir, "boot-chains"), 0) + c.Assert(err, IsNil) + + // make the kernel used on next boot + rebootRequired, err := bootKern.SetNextBoot(boot.NextBootContext{BootWithoutTry: true}) + c.Assert(err, IsNil) + c.Assert(rebootRequired, Equals, boot.RebootInfo{RebootRequired: false}) + + // make sure the env is as expected + bvars, err := tab.GetBootVars("kernel_status", "snap_kernel", "snap_try_kernel") + c.Assert(err, IsNil) + c.Assert(bvars, DeepEquals, map[string]string{ + "kernel_status": boot.DefaultStatus, + "snap_kernel": s.kern1.Filename(), + "snap_try_kernel": "", + }) + + // and that the modeenv now has the one kernel listed + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Assert(m2.CurrentKernels, DeepEquals, []string{s.kern1.Filename()}) + + // boot chains were built + c.Check(tab.BootChainKernelPath, DeepEquals, []string{ + s.kern1.MountFile(), + }) + // no actual reseal + c.Check(resealCalls, Equals, 0) +} + +func (s *bootenv20Suite) TestCoreParticipant20UndoUnassertedKernelSnapInstallSameNoReseal(c *C) { + // checked by resealKeyToModeenv + s.stampSealedKeys(c, dirs.GlobalRootDir) + + tab := s.bootloaderWithTrustedAssets(c, map[string]string{ + "asset": "asset", + }) + + data := []byte("foobar") + // SHA3-384 + dataHash := "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8" + + c.Assert(os.MkdirAll(filepath.Join(boot.InitramfsUbuntuBootDir), 0755), IsNil) + c.Assert(os.MkdirAll(filepath.Join(boot.InitramfsUbuntuSeedDir), 0755), IsNil) + c.Assert(os.WriteFile(filepath.Join(boot.InitramfsUbuntuBootDir, "asset"), data, 0644), IsNil) + c.Assert(os.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "asset"), data, 0644), IsNil) + + // mock the files in cache + mockAssetsCache(c, dirs.GlobalRootDir, "trusted", []string{ + "asset-" + dataHash, + }) + + runKernelBf := bootloader.NewBootFile(filepath.Join(s.ukern1.Filename()), "kernel.efi", bootloader.RoleRunMode) + + tab.BootChainList = []bootloader.BootFile{ + bootloader.NewBootFile("", "asset", bootloader.RoleRunMode), + runKernelBf, + } + + uc20Model := boottest.MakeMockUC20Model() + coreDev := boottest.MockUC20Device("", uc20Model) + c.Assert(coreDev.HasModeenv(), Equals, true) + + m := &boot.Modeenv{ + Mode: "run", + Base: s.base1.Filename(), + CurrentKernels: []string{s.ukern1.Filename()}, + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": []string{dataHash}, + }, + CurrentKernelCommandLines: boot.BootCommandLines{"snapd_recovery_mode=run"}, + + Model: uc20Model.Model(), + BrandID: uc20Model.BrandID(), + Grade: string(uc20Model.Grade()), + ModelSignKeyID: uc20Model.SignKeyID(), + } + + r := setupUC20Bootenv( + c, + tab.MockBootloader, + &bootenv20Setup{ + modeenv: m, + kern: s.ukern1, + kernStatus: boot.DefaultStatus, + }, + ) + defer r() + + resealCalls := 0 + restore := boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + resealCalls++ + return fmt.Errorf("unexpected call") + }) + defer restore() + + // get the boot kernel participant from our kernel snap + bootKern := boot.Participant(s.ukern1, snap.TypeKernel, coreDev) + // make sure it's not a trivial boot participant + c.Assert(bootKern.IsTrivial(), Equals, false) + + // write boot-chains for current state that will stay unchanged + bootChains := []boot.BootChain{{ + BrandID: "my-brand", + Model: "my-model-uc20", + Grade: "dangerous", + ModelSignKeyID: "Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij", + AssetChain: []boot.BootAsset{ + { + Role: bootloader.RoleRunMode, + Name: "asset", + Hashes: []string{ + "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8", + }, + }, + }, + Kernel: "pc-kernel", + KernelRevision: "", + KernelCmdlines: []string{"snapd_recovery_mode=run"}, + }} + err := boot.WriteBootChains(bootChains, filepath.Join(dirs.SnapFDEDir, "boot-chains"), 0) + c.Assert(err, IsNil) + + // make the kernel used on next boot + rebootRequired, err := bootKern.SetNextBoot(boot.NextBootContext{BootWithoutTry: true}) + c.Assert(err, IsNil) + c.Assert(rebootRequired, Equals, boot.RebootInfo{RebootRequired: false}) + + // make sure the env is as expected + bvars, err := tab.GetBootVars("kernel_status", "snap_kernel", "snap_try_kernel") + c.Assert(err, IsNil) + c.Assert(bvars, DeepEquals, map[string]string{ + "kernel_status": boot.DefaultStatus, + "snap_kernel": s.ukern1.Filename(), + "snap_try_kernel": "", + }) + + // and that the modeenv now has the one kernel listed + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Assert(m2.CurrentKernels, DeepEquals, []string{s.ukern1.Filename()}) + + // boot chains were built + c.Check(tab.BootChainKernelPath, DeepEquals, []string{ + s.ukern1.MountFile(), + }) + // no actual reseal + c.Check(resealCalls, Equals, 0) +} + +func (s *bootenv20Suite) TestCoreParticipant20UndoBaseSnapInstallSame(c *C) { + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + + m := &boot.Modeenv{ + Mode: "run", + Base: s.base1.Filename(), + } + r := setupUC20Bootenv( + c, + s.bootloader, + &bootenv20Setup{ + modeenv: m, + // no kernel setup necessary + }, + ) + defer r() + + // get the boot base participant from our base snap + bootBase := boot.Participant(s.base1, snap.TypeBase, coreDev) + // make sure it's not a trivial boot participant + c.Assert(bootBase.IsTrivial(), Equals, false) + + // make the base used on next boot + rebootRequired, err := bootBase.SetNextBoot(boot.NextBootContext{BootWithoutTry: true}) + c.Assert(err, IsNil) + + // we don't need to reboot because it's the same base snap + c.Assert(rebootRequired, Equals, boot.RebootInfo{RebootRequired: false}) + + // make sure the modeenv wasn't changed + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Assert(m2.Base, Equals, m.Base) + c.Assert(m2.BaseStatus, Equals, m.BaseStatus) + c.Assert(m2.TryBase, Equals, m.TryBase) +} + +func (s *bootenv20Suite) TestCoreParticipant20UndoBaseSnapInstallNew(c *C) { + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + + // default state + m := &boot.Modeenv{ + Mode: "run", + Base: s.base1.Filename(), + } + r := setupUC20Bootenv( + c, + s.bootloader, + &bootenv20Setup{ + modeenv: m, + // no kernel setup necessary + }, + ) + defer r() + + // get the boot base participant from our new base snap + bootBase := boot.Participant(s.base2, snap.TypeBase, coreDev) + // make sure it's not a trivial boot participant + c.Assert(bootBase.IsTrivial(), Equals, false) + + // make the base used on next boot, reverting the current one installed + rebootRequired, err := bootBase.SetNextBoot(boot.NextBootContext{BootWithoutTry: true}) + c.Assert(err, IsNil) + c.Assert(rebootRequired, Equals, boot.RebootInfo{RebootRequired: true}) + + // make sure the modeenv was updated + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Assert(m2.Base, Equals, s.base2.Filename()) + c.Assert(m2.BaseStatus, Equals, "") + c.Assert(m2.TryBase, Equals, "") +} + +func (s *bootenv20Suite) TestCoreParticipant20UndoBaseSnapInstallNewNoReseal(c *C) { + // checked by resealKeyToModeenv + s.stampSealedKeys(c, dirs.GlobalRootDir) + + // set up all the bits required for an encrypted system + tab := s.bootloaderWithTrustedAssets(c, map[string]string{ + "asset": "asset", + }) + data := []byte("foobar") + // SHA3-384 + dataHash := "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8" + c.Assert(os.MkdirAll(filepath.Join(boot.InitramfsUbuntuBootDir), 0755), IsNil) + c.Assert(os.WriteFile(filepath.Join(boot.InitramfsUbuntuBootDir, "asset"), data, 0644), IsNil) + // mock the files in cache + mockAssetsCache(c, dirs.GlobalRootDir, "trusted", []string{ + "asset-" + dataHash, + }) + runKernelBf := bootloader.NewBootFile(filepath.Join(s.kern1.Filename()), "kernel.efi", bootloader.RoleRunMode) + // write boot-chains for current state that will stay unchanged even + // though base is changed + bootChains := []boot.BootChain{{ + BrandID: "my-brand", + Model: "my-model-uc20", + Grade: "dangerous", + ModelSignKeyID: "Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij", + AssetChain: []boot.BootAsset{{ + Role: bootloader.RoleRunMode, Name: "asset", Hashes: []string{ + "0fa8abfbdaf924ad307b74dd2ed183b9a4a398891a2f6bac8fd2db7041b77f068580f9c6c66f699b496c2da1cbcc7ed8", + }, + }}, + Kernel: "pc-kernel", + KernelRevision: "1", + KernelCmdlines: []string{"snapd_recovery_mode=run"}, + }} + + err := boot.WriteBootChains(boot.ToPredictableBootChains(bootChains), filepath.Join(dirs.SnapFDEDir, "boot-chains"), 0) + c.Assert(err, IsNil) + + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + model := coreDev.Model() + + resealCalls := 0 + restore := boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + resealCalls++ + return nil + }) + defer restore() + + tab.BootChainList = []bootloader.BootFile{ + bootloader.NewBootFile("", "asset", bootloader.RoleRunMode), + runKernelBf, + } + + // default state + m := &boot.Modeenv{ + Mode: "run", + Base: s.base1.Filename(), + CurrentKernels: []string{s.kern1.Filename()}, + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": []string{dataHash}, + }, + + Model: model.Model(), + BrandID: model.BrandID(), + Grade: string(model.Grade()), + ModelSignKeyID: model.SignKeyID(), + } + r := setupUC20Bootenv( + c, + tab.MockBootloader, + &bootenv20Setup{ + modeenv: m, + // no kernel setup necessary + }, + ) + defer r() + + // get the boot base participant from our new base snap + bootBase := boot.Participant(s.base2, snap.TypeBase, coreDev) + // make sure it's not a trivial boot participant + c.Assert(bootBase.IsTrivial(), Equals, false) + + // make the base used on next boot + rebootRequired, err := bootBase.SetNextBoot(boot.NextBootContext{BootWithoutTry: true}) + c.Assert(err, IsNil) + c.Assert(rebootRequired, Equals, boot.RebootInfo{RebootRequired: true}) + + // make sure the modeenv was updated + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Assert(m2.Base, Equals, s.base2.Filename()) + c.Assert(m2.BaseStatus, Equals, boot.DefaultStatus) + c.Assert(m2.TryBase, Equals, "") + + // no reseal + c.Check(resealCalls, Equals, 0) +} + +func (s *bootenv20Suite) TestInUseClassicWithModes(c *C) { + classicWithModesDev := boottest.MockClassicWithModesDevice("", nil) + c.Assert(classicWithModesDev.IsCoreBoot(), Equals, true) + + r := setupUC20Bootenv( + c, + s.bootloader, + &bootenv20Setup{ + modeenv: &boot.Modeenv{ + // gadget is gadget1 + Gadget: s.gadget1.Filename(), + // current kernels is just kern1 + CurrentKernels: []string{s.kern1.Filename()}, + // operating mode is run + Mode: "run", + // RecoverySystem is unset, as it should be during run mode + RecoverySystem: "", + }, + // enabled kernel is kern1 + kern: s.kern1, + // no try kernel enabled + tryKern: nil, + // kernel status is default + kernStatus: boot.DefaultStatus, + }) + defer r() + + inUse, err := boot.InUse(snap.TypeKernel, classicWithModesDev) + c.Check(err, IsNil) + c.Check(inUse(s.kern1.SnapName(), s.kern1.SnapRevision()), Equals, true) + c.Check(inUse(s.kern2.SnapName(), s.kern2.SnapRevision()), Equals, false) + + _, err = boot.InUse(snap.TypeBase, classicWithModesDev) + c.Check(err, IsNil) +} + +func (s *bootenv20Suite) TestCoreParticipant20SetNextCurrentKernelSnap(c *C) { + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + + r := setupUC20Bootenv( + c, + s.bootloader, + s.normalDefaultState, + ) + defer r() + + // get the boot kernel participant from our current kernel snap + bootKern := boot.Participant(s.kern1, snap.TypeKernel, coreDev) + // make sure it's not a trivial boot participant + c.Assert(bootKern.IsTrivial(), Equals, false) + + // Make it the kernel used on next boot. This sort of situation (same + // current and next kernel) can happen when an installation of a new + // kernel is aborted before we reboot: in that case we need to clean up + // some things although the current kernel did not really change. + rebootRequired, err := bootKern.SetNextBoot(boot.NextBootContext{BootWithoutTry: true}) + c.Assert(err, IsNil) + c.Assert(rebootRequired, Equals, boot.RebootInfo{RebootRequired: false}) + + // make sure that the bootloader was asked for the current kernel + _, nKernelCalls := s.bootloader.GetRunKernelImageFunctionSnapCalls("Kernel") + c.Assert(nKernelCalls, Equals, 1) + + // ensure that kernel_status is not set + c.Assert(s.bootloader.BootVars["kernel_status"], Equals, "") + + // we were not asked to enable a try kernel + actual, _ := s.bootloader.GetRunKernelImageFunctionSnapCalls("EnableTryKernel") + c.Assert(len(actual), Equals, 0) + + // and we were asked to disable the try kernel + _, nDisableTryCalls := s.bootloader.GetRunKernelImageFunctionSnapCalls("DisableTryKernel") + c.Assert(nDisableTryCalls, Equals, 1) + + // and that the modeenv has this kernel listed only once + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Assert(m2.CurrentKernels, DeepEquals, []string{s.kern1.Filename()}) +} + +func (s *bootenv20Suite) TestMarkBootSuccessfulClassModes(c *C) { + // MarkBootSuccessful on classic+modes will not have a "base" + // in the modeenv + m := &boot.Modeenv{ + Mode: "run", + CurrentKernels: []string{s.kern1.Filename()}, + } + r := setupUC20Bootenv( + c, + s.bootloader, + &bootenv20Setup{ + modeenv: m, + kern: s.kern1, + kernStatus: boot.DefaultStatus, + }, + ) + defer r() + + classicWithModesDev := boottest.MockClassicWithModesDevice("", nil) + c.Assert(classicWithModesDev.HasModeenv(), Equals, true) + + // mark successful + err := boot.MarkBootSuccessful(classicWithModesDev) + c.Assert(err, IsNil) + + // no error, modeenv is unchanged + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Check(m2.Base, Equals, "") + c.Check(m2.TryBase, Equals, "") +} diff --git a/boot/bootchain.go b/boot/bootchain.go new file mode 100644 index 00000000..4189dc94 --- /dev/null +++ b/boot/bootchain.go @@ -0,0 +1,349 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020-2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package boot + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/bootloader" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/secboot" +) + +// TODO:UC20 add a doc comment when this is stabilized +type bootChain struct { + BrandID string `json:"brand-id"` + Model string `json:"model"` + Classic bool `json:"classic,omitempty"` + Grade asserts.ModelGrade `json:"grade"` + ModelSignKeyID string `json:"model-sign-key-id"` + AssetChain []bootAsset `json:"asset-chain"` + Kernel string `json:"kernel"` + // KernelRevision is the revision of the kernel snap. It is empty if + // kernel is unasserted, in which case always reseal. + KernelRevision string `json:"kernel-revision"` + KernelCmdlines []string `json:"kernel-cmdlines"` + + kernelBootFile bootloader.BootFile +} + +func (b *bootChain) modelForSealing() *modelForSealing { + return &modelForSealing{ + brandID: b.BrandID, + model: b.Model, + classic: b.Classic, + grade: b.Grade, + modelSignKeyID: b.ModelSignKeyID, + } +} + +// TODO:UC20 add a doc comment when this is stabilized +type bootAsset struct { + Role bootloader.Role `json:"role"` + Name string `json:"name"` + Hashes []string `json:"hashes"` +} + +func bootAssetLess(b, other *bootAsset) bool { + byRole := b.Role < other.Role + byName := b.Name < other.Name + // sort order: role -> name -> hash list (len -> lexical) + if b.Role != other.Role { + return byRole + } + if b.Name != other.Name { + return byName + } + return stringListsLess(b.Hashes, other.Hashes) +} + +func stringListsEqual(sl1, sl2 []string) bool { + if len(sl1) != len(sl2) { + return false + } + for i := range sl1 { + if sl1[i] != sl2[i] { + return false + } + } + return true +} + +func stringListsLess(sl1, sl2 []string) bool { + if len(sl1) != len(sl2) { + return len(sl1) < len(sl2) + } + for idx := range sl1 { + if sl1[idx] < sl2[idx] { + return true + } + } + return false +} + +func toPredictableBootChain(b *bootChain) *bootChain { + if b == nil { + return nil + } + newB := *b + // AssetChain is sorted list (by boot order) of sorted list (old to new asset). + // So it is already predictable and we can keep it the way it is. + + // However we still need to sort kernel KernelCmdlines + if b.KernelCmdlines != nil { + newB.KernelCmdlines = make([]string, len(b.KernelCmdlines)) + copy(newB.KernelCmdlines, b.KernelCmdlines) + sort.Strings(newB.KernelCmdlines) + } + return &newB +} + +func predictableBootAssetsEqual(b1, b2 []bootAsset) bool { + b1JSON, err := json.Marshal(b1) + if err != nil { + return false + } + b2JSON, err := json.Marshal(b2) + if err != nil { + return false + } + return bytes.Equal(b1JSON, b2JSON) +} + +func predictableBootAssetsLess(b1, b2 []bootAsset) bool { + if len(b1) != len(b2) { + return len(b1) < len(b2) + } + for i := range b1 { + if bootAssetLess(&b1[i], &b2[i]) { + return true + } + } + return false +} + +type byBootChainOrder []bootChain + +func (b byBootChainOrder) Len() int { return len(b) } +func (b byBootChainOrder) Swap(i, j int) { b[i], b[j] = b[j], b[i] } +func (b byBootChainOrder) Less(i, j int) bool { + // sort by model info + if b[i].BrandID != b[j].BrandID { + return b[i].BrandID < b[j].BrandID + } + if b[i].Model != b[j].Model { + return b[i].Model < b[j].Model + } + if b[i].Grade != b[j].Grade { + return b[i].Grade < b[j].Grade + } + if b[i].ModelSignKeyID != b[j].ModelSignKeyID { + return b[i].ModelSignKeyID < b[j].ModelSignKeyID + } + // then boot assets + if !predictableBootAssetsEqual(b[i].AssetChain, b[j].AssetChain) { + return predictableBootAssetsLess(b[i].AssetChain, b[j].AssetChain) + } + // then kernel + if b[i].Kernel != b[j].Kernel { + return b[i].Kernel < b[j].Kernel + } + if b[i].KernelRevision != b[j].KernelRevision { + return b[i].KernelRevision < b[j].KernelRevision + } + // and last kernel command lines + if !stringListsEqual(b[i].KernelCmdlines, b[j].KernelCmdlines) { + return stringListsLess(b[i].KernelCmdlines, b[j].KernelCmdlines) + } + return false +} + +type predictableBootChains []bootChain + +// hasUnrevisionedKernels returns true if any of the chains have an +// unrevisioned kernel. Revisions will not be set for unasserted +// kernels. +func (pbc predictableBootChains) hasUnrevisionedKernels() bool { + for i := range pbc { + if pbc[i].KernelRevision == "" { + return true + } + } + return false +} + +func toPredictableBootChains(chains []bootChain) predictableBootChains { + if chains == nil { + return nil + } + predictableChains := make([]bootChain, len(chains)) + for i := range chains { + predictableChains[i] = *toPredictableBootChain(&chains[i]) + } + sort.Sort(byBootChainOrder(predictableChains)) + return predictableChains +} + +type bootChainEquivalence int + +const ( + bootChainEquivalent bootChainEquivalence = 0 + bootChainDifferent bootChainEquivalence = 1 + bootChainUnrevisioned bootChainEquivalence = -1 +) + +// predictableBootChainsEqualForReseal returns bootChainEquivalent +// when boot chains are equivalent for reseal. If the boot chains +// are clearly different it returns bootChainDifferent. +// If it would return bootChainEquivalent but the chains contain +// unrevisioned kernels it will return bootChainUnrevisioned. +func predictableBootChainsEqualForReseal(pb1, pb2 predictableBootChains) bootChainEquivalence { + pb1JSON, err := json.Marshal(pb1) + if err != nil { + return bootChainDifferent + } + pb2JSON, err := json.Marshal(pb2) + if err != nil { + return bootChainDifferent + } + if bytes.Equal(pb1JSON, pb2JSON) { + if pb1.hasUnrevisionedKernels() { + return bootChainUnrevisioned + } + return bootChainEquivalent + } + return bootChainDifferent +} + +// bootAssetsToLoadChains generates a list of load chains covering given boot +// assets sequence. At the end of each chain, adds an entry for the kernel boot +// file. +// We do not calculate some boot chains because they are impossible as +// when we update assets we write first the binaries that are used +// later, that is, if updating both shim and grub, the new grub is +// copied first to the disk, so booting from the new shim to the old +// grub is not possible. This is controlled by expectNew, that tells +// us that the previous step in the chain is from a new asset. +func bootAssetsToLoadChains(assets []bootAsset, kernelBootFile bootloader.BootFile, roleToBlName map[bootloader.Role]string, expectNew bool) ([]*secboot.LoadChain, error) { + // kernel is added after all the assets + addKernelBootFile := len(assets) == 0 + if addKernelBootFile { + return []*secboot.LoadChain{secboot.NewLoadChain(kernelBootFile)}, nil + } + + thisAsset := assets[0] + blName := roleToBlName[thisAsset.Role] + if blName == "" { + return nil, fmt.Errorf("internal error: no bootloader name for boot asset role %q", thisAsset.Role) + } + var chains []*secboot.LoadChain + + for i, hash := range thisAsset.Hashes { + // There should be 1 or 2 assets, and their position has a meaning. + // See TrustedAssetsUpdateObserver.observeUpdate + if i == 0 { + // i == 0 means currently installed asset. + // We do not expect this asset to be used as + // we have new assets earlier in the chain + if len(thisAsset.Hashes) == 2 && expectNew { + continue + } + } else if i == 1 { + // i == 1 means new asset + } else { + // If there is a second asset, it is the next asset to be installed + return nil, fmt.Errorf("internal error: did not expect more than 2 hashes for %s", thisAsset.Name) + } + + var bf bootloader.BootFile + var next []*secboot.LoadChain + var err error + + p := filepath.Join( + dirs.SnapBootAssetsDir, + trustedAssetCacheRelPath(blName, thisAsset.Name, hash)) + if !osutil.FileExists(p) { + return nil, fmt.Errorf("file %s not found in boot assets cache", p) + } + bf = bootloader.NewBootFile( + "", // asset comes from the filesystem, not a snap + p, + thisAsset.Role, + ) + next, err = bootAssetsToLoadChains(assets[1:], kernelBootFile, roleToBlName, expectNew || i == 1) + if err != nil { + return nil, err + } + chains = append(chains, secboot.NewLoadChain(bf, next...)) + } + return chains, nil +} + +// predictableBootChainsWrapperForStorage wraps the boot chains so +// that we do not store the arrays directly as JSON and we can add +// other information +type predictableBootChainsWrapperForStorage struct { + ResealCount int `json:"reseal-count"` + BootChains predictableBootChains `json:"boot-chains"` +} + +func readBootChains(path string) (pbc predictableBootChains, resealCount int, err error) { + inf, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + return nil, 0, nil + } + return nil, 0, fmt.Errorf("cannot open existing boot chains data file: %v", err) + } + defer inf.Close() + var wrapped predictableBootChainsWrapperForStorage + if err := json.NewDecoder(inf).Decode(&wrapped); err != nil { + return nil, 0, fmt.Errorf("cannot read boot chains data: %v", err) + } + return wrapped.BootChains, wrapped.ResealCount, nil +} + +func writeBootChains(pbc predictableBootChains, path string, resealCount int) error { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("cannot create device fde state directory: %v", err) + } + outf, err := osutil.NewAtomicFile(path, 0600, 0, osutil.NoChown, osutil.NoChown) + if err != nil { + return fmt.Errorf("cannot create a temporary boot chains file: %v", err) + } + // becomes noop when the file is committed + defer outf.Cancel() + + wrapped := predictableBootChainsWrapperForStorage{ + ResealCount: resealCount, + BootChains: pbc, + } + if err := json.NewEncoder(outf).Encode(wrapped); err != nil { + return fmt.Errorf("cannot write boot chains data: %v", err) + } + return outf.Commit() +} diff --git a/boot/bootchain_test.go b/boot/bootchain_test.go new file mode 100644 index 00000000..66a9becf --- /dev/null +++ b/boot/bootchain_test.go @@ -0,0 +1,1234 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020-2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package boot_test + +import ( + "encoding/json" + "os" + "path/filepath" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/boot" + "github.com/snapcore/snapd/bootloader" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/secboot" + "github.com/snapcore/snapd/testutil" +) + +type bootchainSuite struct { + testutil.BaseTest + + rootDir string +} + +var _ = Suite(&bootchainSuite{}) + +func (s *bootchainSuite) SetUpTest(c *C) { + s.BaseTest.SetUpTest(c) + s.rootDir = c.MkDir() + s.AddCleanup(func() { dirs.SetRootDir("/") }) + dirs.SetRootDir(s.rootDir) + + c.Assert(os.MkdirAll(filepath.Join(dirs.SnapBootAssetsDir), 0755), IsNil) +} + +func (s *bootchainSuite) TestBootAssetLess(c *C) { + for _, tc := range []struct { + l, r *boot.BootAsset + exp bool + }{ + {&boot.BootAsset{Role: "recovery"}, &boot.BootAsset{Role: "run"}, true}, + {&boot.BootAsset{Role: "run"}, &boot.BootAsset{Role: "recovery"}, false}, + {&boot.BootAsset{Name: "1"}, &boot.BootAsset{Name: "11"}, true}, + {&boot.BootAsset{Name: "11"}, &boot.BootAsset{Name: "1"}, false}, + {&boot.BootAsset{Hashes: []string{"11"}}, &boot.BootAsset{Hashes: []string{"11", "11"}}, true}, + {&boot.BootAsset{Hashes: []string{"11"}}, &boot.BootAsset{Hashes: []string{"12"}}, true}, + } { + less := boot.BootAssetLess(tc.l, tc.r) + c.Check(less, Equals, tc.exp, Commentf("expected %v got %v for:\nl:%v\nr:%v", tc.exp, less, tc.l, tc.r)) + } +} + +func (s *bootchainSuite) TestBootChainMarshalOnlyAssets(c *C) { + pbNil := boot.ToPredictableBootChain(nil) + c.Check(pbNil, IsNil) + + bc := &boot.BootChain{ + AssetChain: []boot.BootAsset{ + {Role: bootloader.RoleRecovery, Name: "shim", Hashes: []string{"b"}}, + {Role: bootloader.RoleRecovery, Name: "loader", Hashes: []string{"e", "d"}}, + {Role: bootloader.RoleRunMode, Name: "loader", Hashes: []string{"d", "c"}}, + {Role: bootloader.RoleRunMode, Name: "1oader", Hashes: []string{"e", "d"}}, + {Role: bootloader.RoleRunMode, Name: "0oader", Hashes: []string{"z", "x"}}, + }, + } + + predictableBc := boot.ToPredictableBootChain(bc) + + c.Check(predictableBc, DeepEquals, &boot.BootChain{ + // assets not reordered + AssetChain: []boot.BootAsset{ + // hash lists are sorted + {Role: bootloader.RoleRecovery, Name: "shim", Hashes: []string{"b"}}, + {Role: bootloader.RoleRecovery, Name: "loader", Hashes: []string{"e", "d"}}, + {Role: bootloader.RoleRunMode, Name: "loader", Hashes: []string{"d", "c"}}, + {Role: bootloader.RoleRunMode, Name: "1oader", Hashes: []string{"e", "d"}}, + {Role: bootloader.RoleRunMode, Name: "0oader", Hashes: []string{"z", "x"}}, + }, + }) + + // already predictable, but try again + alreadySortedBc := boot.ToPredictableBootChain(predictableBc) + c.Check(alreadySortedBc, DeepEquals, predictableBc) + + // boot chain with 2 identical assets + bcIdenticalAssets := &boot.BootChain{ + AssetChain: []boot.BootAsset{ + {Role: bootloader.RoleRunMode, Name: "loader", Hashes: []string{"z"}}, + {Role: bootloader.RoleRunMode, Name: "loader", Hashes: []string{"z"}}, + }, + } + sortedBcIdentical := boot.ToPredictableBootChain(bcIdenticalAssets) + c.Check(sortedBcIdentical, DeepEquals, bcIdenticalAssets) +} + +func (s *bootchainSuite) TestBootChainMarshalFull(c *C) { + bc := &boot.BootChain{ + BrandID: "mybrand", + Model: "foo", + Grade: "dangerous", + ModelSignKeyID: "my-key-id", + // asset chain does not get sorted when marshaling + AssetChain: []boot.BootAsset{ + // hash list will get sorted + {Role: bootloader.RoleRecovery, Name: "shim", Hashes: []string{"b", "a"}}, + {Role: bootloader.RoleRecovery, Name: "loader", Hashes: []string{"d"}}, + {Role: bootloader.RoleRunMode, Name: "loader", Hashes: []string{"c", "d"}}, + }, + Kernel: "pc-kernel", + KernelRevision: "1234", + KernelCmdlines: []string{`foo=bar baz=0x123`, `a=1`}, + } + + kernelBootFile := bootloader.NewBootFile("pc-kernel", "/foo", bootloader.RoleRecovery) + bc.SetKernelBootFile(kernelBootFile) + + expectedPredictableBc := &boot.BootChain{ + BrandID: "mybrand", + Model: "foo", + Grade: "dangerous", + ModelSignKeyID: "my-key-id", + // assets are not reordered + AssetChain: []boot.BootAsset{ + // hash lists are sorted + {Role: bootloader.RoleRecovery, Name: "shim", Hashes: []string{"b", "a"}}, + {Role: bootloader.RoleRecovery, Name: "loader", Hashes: []string{"d"}}, + {Role: bootloader.RoleRunMode, Name: "loader", Hashes: []string{"c", "d"}}, + }, + Kernel: "pc-kernel", + KernelRevision: "1234", + KernelCmdlines: []string{`a=1`, `foo=bar baz=0x123`}, + } + // those can't be set directly, but are copied as well + expectedPredictableBc.SetKernelBootFile(kernelBootFile) + + predictableBc := boot.ToPredictableBootChain(bc) + c.Check(predictableBc, DeepEquals, expectedPredictableBc) + + d, err := json.Marshal(predictableBc) + c.Assert(err, IsNil) + c.Check(string(d), Equals, `{"brand-id":"mybrand","model":"foo","grade":"dangerous","model-sign-key-id":"my-key-id","asset-chain":[{"role":"recovery","name":"shim","hashes":["b","a"]},{"role":"recovery","name":"loader","hashes":["d"]},{"role":"run-mode","name":"loader","hashes":["c","d"]}],"kernel":"pc-kernel","kernel-revision":"1234","kernel-cmdlines":["a=1","foo=bar baz=0x123"]}`) + expectedOriginal := &boot.BootChain{ + BrandID: "mybrand", + Model: "foo", + Grade: "dangerous", + ModelSignKeyID: "my-key-id", + AssetChain: []boot.BootAsset{ + {Role: bootloader.RoleRecovery, Name: "shim", Hashes: []string{"b", "a"}}, + {Role: bootloader.RoleRecovery, Name: "loader", Hashes: []string{"d"}}, + {Role: bootloader.RoleRunMode, Name: "loader", Hashes: []string{"c", "d"}}, + }, + Kernel: "pc-kernel", + KernelRevision: "1234", + KernelCmdlines: []string{`foo=bar baz=0x123`, `a=1`}, + } + expectedOriginal.SetKernelBootFile(kernelBootFile) + // original structure has not been modified + c.Check(bc, DeepEquals, expectedOriginal) +} + +func (s *bootchainSuite) TestPredictableBootChainsEqualForReseal(c *C) { + var pbNil boot.PredictableBootChains + + c.Check(boot.PredictableBootChainsEqualForReseal(pbNil, pbNil), Equals, boot.BootChainEquivalent) + + bcJustOne := []boot.BootChain{ + { + BrandID: "mybrand", + Model: "foo", + Grade: "dangerous", + ModelSignKeyID: "my-key-id", + AssetChain: []boot.BootAsset{ + {Role: bootloader.RoleRecovery, Name: "shim", Hashes: []string{"b", "a"}}, + {Role: bootloader.RoleRecovery, Name: "loader", Hashes: []string{"d"}}, + {Role: bootloader.RoleRunMode, Name: "loader", Hashes: []string{"c", "d"}}, + }, + Kernel: "pc-kernel-other", + KernelRevision: "1234", + KernelCmdlines: []string{`foo`}, + }, + } + pbJustOne := boot.ToPredictableBootChains(bcJustOne) + // equal with self + c.Check(boot.PredictableBootChainsEqualForReseal(pbJustOne, pbJustOne), Equals, boot.BootChainEquivalent) + + // equal with nil? + c.Check(boot.PredictableBootChainsEqualForReseal(pbJustOne, pbNil), Equals, boot.BootChainDifferent) + + bcMoreAssets := []boot.BootChain{ + { + BrandID: "mybrand", + Model: "foo", + Grade: "dangerous", + ModelSignKeyID: "my-key-id", + AssetChain: []boot.BootAsset{ + {Role: bootloader.RoleRecovery, Name: "shim", Hashes: []string{"a", "b"}}, + {Role: bootloader.RoleRecovery, Name: "loader", Hashes: []string{"d"}}, + }, + Kernel: "pc-kernel-recovery", + KernelRevision: "1234", + KernelCmdlines: []string{`foo`}, + }, { + BrandID: "mybrand", + Model: "foo", + Grade: "dangerous", + ModelSignKeyID: "my-key-id", + AssetChain: []boot.BootAsset{ + {Role: bootloader.RoleRecovery, Name: "shim", Hashes: []string{"a", "b"}}, + {Role: bootloader.RoleRecovery, Name: "loader", Hashes: []string{"d"}}, + {Role: bootloader.RoleRunMode, Name: "loader", Hashes: []string{"c", "d"}}, + }, + Kernel: "pc-kernel-other", + KernelRevision: "1234", + KernelCmdlines: []string{`foo`}, + }, + } + + pbMoreAssets := boot.ToPredictableBootChains(bcMoreAssets) + + c.Check(boot.PredictableBootChainsEqualForReseal(pbMoreAssets, pbJustOne), Equals, boot.BootChainDifferent) + // with self + c.Check(boot.PredictableBootChainsEqualForReseal(pbMoreAssets, pbMoreAssets), Equals, boot.BootChainEquivalent) + // chains composed of respective elements are not equal + c.Check(boot.PredictableBootChainsEqualForReseal( + []boot.BootChain{pbMoreAssets[0]}, + []boot.BootChain{pbMoreAssets[1]}), + Equals, boot.BootChainDifferent) + + // unrevisioned/unasserted kernels + bcUnrevOne := []boot.BootChain{pbJustOne[0]} + bcUnrevOne[0].KernelRevision = "" + pbUnrevOne := boot.ToPredictableBootChains(bcUnrevOne) + // soundness + c.Check(boot.PredictableBootChainsEqualForReseal(pbJustOne, pbJustOne), Equals, boot.BootChainEquivalent) + // never equal even with self because of unrevisioned + c.Check(boot.PredictableBootChainsEqualForReseal(pbJustOne, pbUnrevOne), Equals, boot.BootChainDifferent) + c.Check(boot.PredictableBootChainsEqualForReseal(pbUnrevOne, pbUnrevOne), Equals, boot.BootChainUnrevisioned) + + bcUnrevMoreAssets := []boot.BootChain{pbMoreAssets[0], pbMoreAssets[1]} + bcUnrevMoreAssets[1].KernelRevision = "" + pbUnrevMoreAssets := boot.ToPredictableBootChains(bcUnrevMoreAssets) + // never equal even with self because of unrevisioned + c.Check(boot.PredictableBootChainsEqualForReseal(pbUnrevMoreAssets, pbMoreAssets), Equals, boot.BootChainDifferent) + c.Check(boot.PredictableBootChainsEqualForReseal(pbUnrevMoreAssets, pbUnrevMoreAssets), Equals, boot.BootChainUnrevisioned) +} + +func (s *bootchainSuite) TestPredictableBootChainsFullMarshal(c *C) { + // chains will be sorted + chains := []boot.BootChain{ + { + BrandID: "mybrand", + Model: "foo", + Grade: "signed", + ModelSignKeyID: "my-key-id", + AssetChain: []boot.BootAsset{ + // hashes will be sorted + {Role: bootloader.RoleRecovery, Name: "shim", Hashes: []string{"x", "y"}}, + {Role: bootloader.RoleRecovery, Name: "loader", Hashes: []string{"c", "d"}}, + {Role: bootloader.RoleRunMode, Name: "loader", Hashes: []string{"z", "x"}}, + }, + Kernel: "pc-kernel-other", + KernelRevision: "2345", + KernelCmdlines: []string{`snapd_recovery_mode=run foo`}, + }, { + BrandID: "mybrand", + Model: "foo", + Grade: "dangerous", + ModelSignKeyID: "my-key-id", + AssetChain: []boot.BootAsset{ + // hashes will be sorted + {Role: bootloader.RoleRecovery, Name: "shim", Hashes: []string{"y", "x"}}, + {Role: bootloader.RoleRecovery, Name: "loader", Hashes: []string{"c", "d"}}, + {Role: bootloader.RoleRunMode, Name: "loader", Hashes: []string{"b", "a"}}, + }, + Kernel: "pc-kernel-other", + KernelRevision: "1234", + KernelCmdlines: []string{`snapd_recovery_mode=run foo`}, + }, { + // recovery system + BrandID: "mybrand", + Model: "foo", + Grade: "dangerous", + ModelSignKeyID: "my-key-id", + AssetChain: []boot.BootAsset{ + // hashes will be sorted + {Role: bootloader.RoleRecovery, Name: "shim", Hashes: []string{"y", "x"}}, + {Role: bootloader.RoleRecovery, Name: "loader", Hashes: []string{"c", "d"}}, + }, + Kernel: "pc-kernel-other", + KernelRevision: "12", + KernelCmdlines: []string{ + // will be sorted + `snapd_recovery_mode=recover snapd_recovery_system=23 foo`, + `snapd_recovery_mode=recover snapd_recovery_system=12 foo`, + }, + }, + } + + predictableChains := boot.ToPredictableBootChains(chains) + d, err := json.Marshal(predictableChains) + c.Assert(err, IsNil) + + var data []map[string]interface{} + err = json.Unmarshal(d, &data) + c.Assert(err, IsNil) + c.Check(data, DeepEquals, []map[string]interface{}{ + { + "model": "foo", + "brand-id": "mybrand", + "grade": "dangerous", + "model-sign-key-id": "my-key-id", + "kernel": "pc-kernel-other", + "kernel-revision": "12", + "kernel-cmdlines": []interface{}{ + `snapd_recovery_mode=recover snapd_recovery_system=12 foo`, + `snapd_recovery_mode=recover snapd_recovery_system=23 foo`, + }, + "asset-chain": []interface{}{ + map[string]interface{}{"role": "recovery", "name": "shim", "hashes": []interface{}{"y", "x"}}, + map[string]interface{}{"role": "recovery", "name": "loader", "hashes": []interface{}{"c", "d"}}, + }, + }, { + "model": "foo", + "brand-id": "mybrand", + "grade": "dangerous", + "model-sign-key-id": "my-key-id", + "kernel": "pc-kernel-other", + "kernel-revision": "1234", + "kernel-cmdlines": []interface{}{"snapd_recovery_mode=run foo"}, + "asset-chain": []interface{}{ + map[string]interface{}{"role": "recovery", "name": "shim", "hashes": []interface{}{"y", "x"}}, + map[string]interface{}{"role": "recovery", "name": "loader", "hashes": []interface{}{"c", "d"}}, + map[string]interface{}{"role": "run-mode", "name": "loader", "hashes": []interface{}{"b", "a"}}, + }, + }, { + "model": "foo", + "brand-id": "mybrand", + "grade": "signed", + "model-sign-key-id": "my-key-id", + "kernel": "pc-kernel-other", + "kernel-revision": "2345", + "kernel-cmdlines": []interface{}{"snapd_recovery_mode=run foo"}, + "asset-chain": []interface{}{ + map[string]interface{}{"role": "recovery", "name": "shim", "hashes": []interface{}{"x", "y"}}, + map[string]interface{}{"role": "recovery", "name": "loader", "hashes": []interface{}{"c", "d"}}, + map[string]interface{}{"role": "run-mode", "name": "loader", "hashes": []interface{}{"z", "x"}}, + }, + }, + }) +} + +func (s *bootchainSuite) TestPredictableBootChainsFields(c *C) { + chainsNil := boot.ToPredictableBootChains(nil) + c.Check(chainsNil, IsNil) + + justOne := []boot.BootChain{ + { + BrandID: "mybrand", + Model: "foo", + Grade: "signed", + ModelSignKeyID: "my-key-id", + Kernel: "pc-kernel-other", + KernelRevision: "2345", + KernelCmdlines: []string{`foo`}, + }, + } + predictableJustOne := boot.ToPredictableBootChains(justOne) + c.Check(predictableJustOne, DeepEquals, boot.PredictableBootChains(justOne)) + + chainsGrade := []boot.BootChain{ + { + Grade: "signed", + }, { + Grade: "dangerous", + }, + } + c.Check(boot.ToPredictableBootChains(chainsGrade), DeepEquals, boot.PredictableBootChains{ + { + Grade: "dangerous", + }, { + Grade: "signed", + }, + }) + + chainsKernel := []boot.BootChain{ + { + Grade: "dangerous", + Kernel: "foo", + }, { + Grade: "dangerous", + Kernel: "bar", + }, + } + c.Check(boot.ToPredictableBootChains(chainsKernel), DeepEquals, boot.PredictableBootChains{ + { + Grade: "dangerous", + Kernel: "bar", + }, { + Grade: "dangerous", + Kernel: "foo", + }, + }) + + chainsKernelRevision := []boot.BootChain{ + { + Kernel: "foo", + KernelRevision: "9", + }, { + Kernel: "foo", + KernelRevision: "21", + }, + } + c.Check(boot.ToPredictableBootChains(chainsKernelRevision), DeepEquals, boot.PredictableBootChains{ + { + Kernel: "foo", + KernelRevision: "21", + }, { + Kernel: "foo", + KernelRevision: "9", + }, + }) + + chainsCmdline := []boot.BootChain{ + { + Grade: "dangerous", + Kernel: "foo", + KernelCmdlines: []string{`panic=1`}, + }, { + Grade: "dangerous", + Kernel: "foo", + KernelCmdlines: []string{`a`}, + }, + } + c.Check(boot.ToPredictableBootChains(chainsCmdline), DeepEquals, boot.PredictableBootChains{ + { + Grade: "dangerous", + Kernel: "foo", + KernelCmdlines: []string{`a`}, + }, { + Grade: "dangerous", + Kernel: "foo", + KernelCmdlines: []string{`panic=1`}, + }, + }) + + chainsModel := []boot.BootChain{ + { + Model: "fridge", + Grade: "dangerous", + Kernel: "foo", + KernelCmdlines: []string{`panic=1`}, + }, { + Model: "box", + Grade: "dangerous", + Kernel: "foo", + KernelCmdlines: []string{`panic=1`}, + }, + } + c.Check(boot.ToPredictableBootChains(chainsModel), DeepEquals, boot.PredictableBootChains{ + { + Model: "box", + Grade: "dangerous", + Kernel: "foo", + KernelCmdlines: []string{`panic=1`}, + }, { + Model: "fridge", + Grade: "dangerous", + Kernel: "foo", + KernelCmdlines: []string{`panic=1`}, + }, + }) + + chainsBrand := []boot.BootChain{ + { + BrandID: "foo", + Model: "box", + Grade: "dangerous", + Kernel: "foo", + KernelCmdlines: []string{`panic=1`}, + }, { + BrandID: "acme", + Model: "box", + Grade: "dangerous", + Kernel: "foo", + KernelCmdlines: []string{`panic=1`}, + }, + } + c.Check(boot.ToPredictableBootChains(chainsBrand), DeepEquals, boot.PredictableBootChains{ + { + BrandID: "acme", + Model: "box", + Grade: "dangerous", + Kernel: "foo", + KernelCmdlines: []string{`panic=1`}, + }, { + BrandID: "foo", + Model: "box", + Grade: "dangerous", + Kernel: "foo", + KernelCmdlines: []string{`panic=1`}, + }, + }) + + chainsKeyID := []boot.BootChain{ + { + BrandID: "foo", + Model: "box", + Grade: "dangerous", + Kernel: "foo", + KernelCmdlines: []string{`panic=1`}, + ModelSignKeyID: "key-2", + }, { + BrandID: "foo", + Model: "box", + Grade: "dangerous", + Kernel: "foo", + KernelCmdlines: []string{`panic=1`}, + ModelSignKeyID: "key-1", + }, + } + c.Check(boot.ToPredictableBootChains(chainsKeyID), DeepEquals, boot.PredictableBootChains{ + { + BrandID: "foo", + Model: "box", + Grade: "dangerous", + Kernel: "foo", + KernelCmdlines: []string{`panic=1`}, + ModelSignKeyID: "key-1", + }, { + BrandID: "foo", + Model: "box", + Grade: "dangerous", + Kernel: "foo", + KernelCmdlines: []string{`panic=1`}, + ModelSignKeyID: "key-2", + }, + }) + + chainsAssets := []boot.BootChain{ + { + BrandID: "foo", + Model: "box", + Grade: "dangerous", + ModelSignKeyID: "key-1", + AssetChain: []boot.BootAsset{ + // will be sorted + {Hashes: []string{"b", "a"}}, + }, + Kernel: "foo", + KernelCmdlines: []string{`panic=1`}, + }, { + BrandID: "foo", + Model: "box", + Grade: "dangerous", + ModelSignKeyID: "key-1", + AssetChain: []boot.BootAsset{ + {Hashes: []string{"b"}}, + }, + Kernel: "foo", + KernelCmdlines: []string{`panic=1`}, + }, + } + c.Check(boot.ToPredictableBootChains(chainsAssets), DeepEquals, boot.PredictableBootChains{ + { + BrandID: "foo", + Model: "box", + Grade: "dangerous", + ModelSignKeyID: "key-1", + AssetChain: []boot.BootAsset{ + {Hashes: []string{"b"}}, + }, + Kernel: "foo", + KernelCmdlines: []string{`panic=1`}, + }, { + BrandID: "foo", + Model: "box", + Grade: "dangerous", + ModelSignKeyID: "key-1", + AssetChain: []boot.BootAsset{ + {Hashes: []string{"b", "a"}}, + }, + Kernel: "foo", + KernelCmdlines: []string{`panic=1`}, + }, + }) + + chainsFewerAssets := []boot.BootChain{ + { + AssetChain: []boot.BootAsset{ + {Hashes: []string{"b", "a"}}, + {Hashes: []string{"c", "d"}}, + }, + }, { + AssetChain: []boot.BootAsset{ + {Hashes: []string{"b"}}, + }, + }, + } + c.Check(boot.ToPredictableBootChains(chainsFewerAssets), DeepEquals, boot.PredictableBootChains{ + { + AssetChain: []boot.BootAsset{ + {Hashes: []string{"b"}}, + }, + }, { + AssetChain: []boot.BootAsset{ + {Hashes: []string{"b", "a"}}, + {Hashes: []string{"c", "d"}}, + }, + }, + }) + + // not confused if 2 chains are identical + chainsIdenticalAssets := []boot.BootChain{ + { + BrandID: "foo", + Model: "box", + ModelSignKeyID: "key-1", + AssetChain: []boot.BootAsset{ + {Name: "asset", Hashes: []string{"a", "b"}}, + {Name: "asset", Hashes: []string{"a", "b"}}, + }, + Grade: "dangerous", + Kernel: "foo", + KernelCmdlines: []string{`panic=1`}, + }, { + BrandID: "foo", + Model: "box", + Grade: "dangerous", + ModelSignKeyID: "key-1", + AssetChain: []boot.BootAsset{ + {Name: "asset", Hashes: []string{"a", "b"}}, + {Name: "asset", Hashes: []string{"a", "b"}}, + }, + Kernel: "foo", + KernelCmdlines: []string{`panic=1`}, + }, + } + c.Check(boot.ToPredictableBootChains(chainsIdenticalAssets), DeepEquals, boot.PredictableBootChains(chainsIdenticalAssets)) +} + +func (s *bootchainSuite) TestPredictableBootChainsSortOrder(c *C) { + // check that sort order is model info, assets, kernel, kernel cmdline + + chains := []boot.BootChain{ + { + Model: "b", + AssetChain: []boot.BootAsset{ + {Name: "asset", Hashes: []string{"y"}}, + }, + Kernel: "k1", + KernelCmdlines: []string{"cm=1"}, + }, + { + Model: "b", + AssetChain: []boot.BootAsset{ + {Name: "asset", Hashes: []string{"y"}}, + }, + Kernel: "k2", + KernelCmdlines: []string{"cm=1"}, + }, + { + Model: "a", + AssetChain: []boot.BootAsset{ + {Name: "asset", Hashes: []string{"y"}}, + }, + Kernel: "k1", + KernelCmdlines: []string{"cm=1"}, + }, + { + Model: "a", + AssetChain: []boot.BootAsset{ + {Name: "asset", Hashes: []string{"y"}}, + }, + Kernel: "k2", + KernelCmdlines: []string{"cm=1"}, + }, + { + Model: "b", + AssetChain: []boot.BootAsset{ + {Name: "asset", Hashes: []string{"y"}}, + }, + Kernel: "k1", + KernelCmdlines: []string{"cm=2"}, + }, + { + Model: "b", + AssetChain: []boot.BootAsset{ + {Name: "asset", Hashes: []string{"y"}}, + }, + Kernel: "k2", + KernelCmdlines: []string{"cm=2"}, + }, + { + Model: "a", + AssetChain: []boot.BootAsset{ + {Name: "asset", Hashes: []string{"y"}}, + }, + Kernel: "k1", + KernelCmdlines: []string{"cm=2"}, + }, + { + Model: "a", + AssetChain: []boot.BootAsset{ + {Name: "asset", Hashes: []string{"y"}}, + }, + Kernel: "k2", + KernelCmdlines: []string{"cm=2"}, + }, + { + Model: "b", + AssetChain: []boot.BootAsset{ + {Name: "asset", Hashes: []string{"x"}}, + }, + Kernel: "k1", + KernelCmdlines: []string{"cm=1"}, + }, + { + Model: "b", + AssetChain: []boot.BootAsset{ + {Name: "asset", Hashes: []string{"x"}}, + }, + Kernel: "k2", + KernelCmdlines: []string{"cm=1"}, + }, + { + Model: "a", + AssetChain: []boot.BootAsset{ + {Name: "asset", Hashes: []string{"x"}}, + }, + Kernel: "k1", + KernelCmdlines: []string{"cm=1"}, + }, + { + Model: "a", + AssetChain: []boot.BootAsset{ + {Name: "asset", Hashes: []string{"x"}}, + }, + Kernel: "k2", + KernelCmdlines: []string{"cm=1"}, + }, + { + Model: "b", + AssetChain: []boot.BootAsset{ + {Name: "asset", Hashes: []string{"x"}}, + }, + Kernel: "k1", + KernelCmdlines: []string{"cm=2"}, + }, + { + Model: "b", + AssetChain: []boot.BootAsset{ + {Name: "asset", Hashes: []string{"x"}}, + }, + Kernel: "k2", + KernelCmdlines: []string{"cm=2"}, + }, + { + Model: "a", + AssetChain: []boot.BootAsset{ + {Name: "asset", Hashes: []string{"x"}}, + }, + Kernel: "k1", + KernelCmdlines: []string{"cm=2"}, + }, + { + Model: "a", + AssetChain: []boot.BootAsset{ + {Name: "asset", Hashes: []string{"x"}}, + }, + Kernel: "k2", + KernelCmdlines: []string{"cm=2"}, + }, + { + Model: "a", + AssetChain: []boot.BootAsset{ + {Name: "asset", Hashes: []string{"y"}}, + }, + Kernel: "k2", + KernelCmdlines: []string{"cm=1", "cm=2"}, + }, + { + Model: "a", + AssetChain: []boot.BootAsset{ + {Name: "asset", Hashes: []string{"y"}}, + }, + Kernel: "k1", + KernelCmdlines: []string{"cm=1", "cm=2"}, + }, + } + predictable := boot.ToPredictableBootChains(chains) + c.Check(predictable, DeepEquals, boot.PredictableBootChains{ + { + Model: "a", + AssetChain: []boot.BootAsset{ + {Name: "asset", Hashes: []string{"x"}}, + }, + Kernel: "k1", + KernelCmdlines: []string{"cm=1"}, + }, + { + Model: "a", + AssetChain: []boot.BootAsset{ + {Name: "asset", Hashes: []string{"x"}}, + }, + Kernel: "k1", + KernelCmdlines: []string{"cm=2"}, + }, + { + Model: "a", + AssetChain: []boot.BootAsset{ + {Name: "asset", Hashes: []string{"x"}}, + }, + Kernel: "k2", + KernelCmdlines: []string{"cm=1"}, + }, + { + Model: "a", + AssetChain: []boot.BootAsset{ + {Name: "asset", Hashes: []string{"x"}}, + }, + Kernel: "k2", + KernelCmdlines: []string{"cm=2"}, + }, + { + Model: "a", + AssetChain: []boot.BootAsset{ + {Name: "asset", Hashes: []string{"y"}}, + }, + Kernel: "k1", + KernelCmdlines: []string{"cm=1"}, + }, + { + Model: "a", + AssetChain: []boot.BootAsset{ + {Name: "asset", Hashes: []string{"y"}}, + }, + Kernel: "k1", + KernelCmdlines: []string{"cm=2"}, + }, + { + Model: "a", + AssetChain: []boot.BootAsset{ + {Name: "asset", Hashes: []string{"y"}}, + }, + Kernel: "k1", + KernelCmdlines: []string{"cm=1", "cm=2"}, + }, + { + Model: "a", + AssetChain: []boot.BootAsset{ + {Name: "asset", Hashes: []string{"y"}}, + }, + Kernel: "k2", + KernelCmdlines: []string{"cm=1"}, + }, + { + Model: "a", + AssetChain: []boot.BootAsset{ + {Name: "asset", Hashes: []string{"y"}}, + }, + Kernel: "k2", + KernelCmdlines: []string{"cm=2"}, + }, + { + Model: "a", + AssetChain: []boot.BootAsset{ + {Name: "asset", Hashes: []string{"y"}}, + }, + Kernel: "k2", + KernelCmdlines: []string{"cm=1", "cm=2"}, + }, + { + Model: "b", + AssetChain: []boot.BootAsset{ + {Name: "asset", Hashes: []string{"x"}}, + }, + Kernel: "k1", + KernelCmdlines: []string{"cm=1"}, + }, + { + Model: "b", + AssetChain: []boot.BootAsset{ + {Name: "asset", Hashes: []string{"x"}}, + }, + Kernel: "k1", + KernelCmdlines: []string{"cm=2"}, + }, + { + Model: "b", + AssetChain: []boot.BootAsset{ + {Name: "asset", Hashes: []string{"x"}}, + }, + Kernel: "k2", + KernelCmdlines: []string{"cm=1"}, + }, + { + Model: "b", + AssetChain: []boot.BootAsset{ + {Name: "asset", Hashes: []string{"x"}}, + }, + Kernel: "k2", + KernelCmdlines: []string{"cm=2"}, + }, + { + Model: "b", + AssetChain: []boot.BootAsset{ + {Name: "asset", Hashes: []string{"y"}}, + }, + Kernel: "k1", + KernelCmdlines: []string{"cm=1"}, + }, + { + Model: "b", + AssetChain: []boot.BootAsset{ + {Name: "asset", Hashes: []string{"y"}}, + }, + Kernel: "k1", + KernelCmdlines: []string{"cm=2"}, + }, + { + Model: "b", + AssetChain: []boot.BootAsset{ + {Name: "asset", Hashes: []string{"y"}}, + }, + Kernel: "k2", + KernelCmdlines: []string{"cm=1"}, + }, + { + Model: "b", + AssetChain: []boot.BootAsset{ + {Name: "asset", Hashes: []string{"y"}}, + }, + Kernel: "k2", + KernelCmdlines: []string{"cm=2"}, + }, + }) +} + +func printChain(c *C, chain *secboot.LoadChain, prefix string) { + c.Logf("%v %v", prefix, chain.BootFile) + for _, n := range chain.Next { + printChain(c, n, prefix+"-") + } +} + +// cPath returns a path under boot assets cache directory +func cPath(p string) string { + return filepath.Join(dirs.SnapBootAssetsDir, p) +} + +// nbf is bootloader.NewBootFile but shorter +var nbf = bootloader.NewBootFile + +func (s *bootchainSuite) TestBootAssetsToLoadChainTrivialKernel(c *C) { + kbl := bootloader.NewBootFile("pc-kernel", "kernel.efi", bootloader.RoleRunMode) + + chains, err := boot.BootAssetsToLoadChains(nil, kbl, nil, false) + c.Assert(err, IsNil) + + c.Check(chains, DeepEquals, []*secboot.LoadChain{ + secboot.NewLoadChain(nbf("pc-kernel", "kernel.efi", bootloader.RoleRunMode)), + }) +} + +func (s *bootchainSuite) TestBootAssetsToLoadChainErr(c *C) { + kbl := bootloader.NewBootFile("pc-kernel", "kernel.efi", bootloader.RoleRunMode) + + assets := []boot.BootAsset{ + {Name: "shim", Hashes: []string{"hash0"}, Role: bootloader.RoleRecovery}, + {Name: "loader-recovery", Hashes: []string{"hash0"}, Role: bootloader.RoleRecovery}, + {Name: "loader-run", Hashes: []string{"hash0"}, Role: bootloader.RoleRunMode}, + } + + blNames := map[bootloader.Role]string{ + bootloader.RoleRecovery: "recovery-bl", + // missing bootloader name for role "run-mode" + } + // fails when probing the shim asset in the cache + chains, err := boot.BootAssetsToLoadChains(assets, kbl, blNames, false) + c.Assert(err, ErrorMatches, "file .*/recovery-bl/shim-hash0 not found in boot assets cache") + c.Check(chains, IsNil) + // make it work now + c.Assert(os.MkdirAll(filepath.Dir(cPath("recovery-bl/shim-hash0")), 0755), IsNil) + c.Assert(os.WriteFile(cPath("recovery-bl/shim-hash0"), nil, 0644), IsNil) + + // nested error bubbled up + chains, err = boot.BootAssetsToLoadChains(assets, kbl, blNames, false) + c.Assert(err, ErrorMatches, "file .*/recovery-bl/loader-recovery-hash0 not found in boot assets cache") + c.Check(chains, IsNil) + // again, make it work + c.Assert(os.MkdirAll(filepath.Dir(cPath("recovery-bl/loader-recovery-hash0")), 0755), IsNil) + c.Assert(os.WriteFile(cPath("recovery-bl/loader-recovery-hash0"), nil, 0644), IsNil) + + // fails on missing bootloader name for role "run-mode" + chains, err = boot.BootAssetsToLoadChains(assets, kbl, blNames, false) + c.Assert(err, ErrorMatches, `internal error: no bootloader name for boot asset role "run-mode"`) + c.Check(chains, IsNil) +} + +func (s *bootchainSuite) TestBootAssetsToLoadChainSimpleChain(c *C) { + kbl := bootloader.NewBootFile("pc-kernel", "kernel.efi", bootloader.RoleRunMode) + + assets := []boot.BootAsset{ + {Name: "shim", Hashes: []string{"hash0"}, Role: bootloader.RoleRecovery}, + {Name: "loader-recovery", Hashes: []string{"hash0"}, Role: bootloader.RoleRecovery}, + {Name: "loader-run", Hashes: []string{"hash0"}, Role: bootloader.RoleRunMode}, + } + + // mock relevant files in cache + for _, name := range []string{ + "recovery-bl/shim-hash0", + "recovery-bl/loader-recovery-hash0", + "run-bl/loader-run-hash0", + } { + p := filepath.Join(dirs.SnapBootAssetsDir, name) + c.Assert(os.MkdirAll(filepath.Dir(p), 0755), IsNil) + c.Assert(os.WriteFile(p, nil, 0644), IsNil) + } + + blNames := map[bootloader.Role]string{ + bootloader.RoleRecovery: "recovery-bl", + bootloader.RoleRunMode: "run-bl", + } + + chains, err := boot.BootAssetsToLoadChains(assets, kbl, blNames, false) + c.Assert(err, IsNil) + + c.Logf("got:") + for _, ch := range chains { + printChain(c, ch, "-") + } + + expected := []*secboot.LoadChain{ + secboot.NewLoadChain(nbf("", cPath("recovery-bl/shim-hash0"), bootloader.RoleRecovery), + secboot.NewLoadChain(nbf("", cPath("recovery-bl/loader-recovery-hash0"), bootloader.RoleRecovery), + secboot.NewLoadChain(nbf("", cPath("run-bl/loader-run-hash0"), bootloader.RoleRunMode), + secboot.NewLoadChain(nbf("pc-kernel", "kernel.efi", bootloader.RoleRunMode))))), + } + c.Check(chains, DeepEquals, expected) +} + +func (s *bootchainSuite) TestBootAssetsToLoadChainWithAlternativeChains(c *C) { + kbl := bootloader.NewBootFile("pc-kernel", "kernel.efi", bootloader.RoleRunMode) + + assets := []boot.BootAsset{ + {Name: "shim", Hashes: []string{"hash0", "hash1"}, Role: bootloader.RoleRecovery}, + {Name: "loader-recovery", Hashes: []string{"hash0", "hash1"}, Role: bootloader.RoleRecovery}, + {Name: "loader-run", Hashes: []string{"hash0", "hash1"}, Role: bootloader.RoleRunMode}, + } + + // mock relevant files in cache + mockAssetsCache(c, s.rootDir, "recovery-bl", []string{ + "shim-hash0", + "shim-hash1", + "loader-recovery-hash0", + "loader-recovery-hash1", + }) + mockAssetsCache(c, s.rootDir, "run-bl", []string{ + "loader-run-hash0", + "loader-run-hash1", + }) + + blNames := map[bootloader.Role]string{ + bootloader.RoleRecovery: "recovery-bl", + bootloader.RoleRunMode: "run-bl", + } + chains, err := boot.BootAssetsToLoadChains(assets, kbl, blNames, false) + c.Assert(err, IsNil) + + c.Logf("got:") + for _, ch := range chains { + printChain(c, ch, "-") + } + + expected := []*secboot.LoadChain{ + secboot.NewLoadChain(nbf("", cPath("recovery-bl/shim-hash0"), bootloader.RoleRecovery), + secboot.NewLoadChain(nbf("", cPath("recovery-bl/loader-recovery-hash0"), bootloader.RoleRecovery), + secboot.NewLoadChain(nbf("", cPath("run-bl/loader-run-hash0"), bootloader.RoleRunMode), + secboot.NewLoadChain(nbf("pc-kernel", "kernel.efi", bootloader.RoleRunMode))), + secboot.NewLoadChain(nbf("", cPath("run-bl/loader-run-hash1"), bootloader.RoleRunMode), + secboot.NewLoadChain(nbf("pc-kernel", "kernel.efi", bootloader.RoleRunMode)))), + secboot.NewLoadChain(nbf("", cPath("recovery-bl/loader-recovery-hash1"), bootloader.RoleRecovery), + secboot.NewLoadChain(nbf("", cPath("run-bl/loader-run-hash1"), bootloader.RoleRunMode), + secboot.NewLoadChain(nbf("pc-kernel", "kernel.efi", bootloader.RoleRunMode))))), + secboot.NewLoadChain(nbf("", cPath("recovery-bl/shim-hash1"), bootloader.RoleRecovery), + secboot.NewLoadChain(nbf("", cPath("recovery-bl/loader-recovery-hash1"), bootloader.RoleRecovery), + secboot.NewLoadChain(nbf("", cPath("run-bl/loader-run-hash1"), bootloader.RoleRunMode), + secboot.NewLoadChain(nbf("pc-kernel", "kernel.efi", bootloader.RoleRunMode))))), + } + c.Check(chains, DeepEquals, expected) +} + +func (s *sealSuite) TestReadWriteBootChains(c *C) { + if os.Geteuid() == 0 { + c.Skip("the test cannot be run by the root user") + } + + chains := []boot.BootChain{ + { + BrandID: "mybrand", + Model: "foo", + Grade: "signed", + ModelSignKeyID: "my-key-id", + AssetChain: []boot.BootAsset{ + // hashes will be sorted + {Role: bootloader.RoleRecovery, Name: "shim", Hashes: []string{"x", "y"}}, + {Role: bootloader.RoleRecovery, Name: "loader", Hashes: []string{"c", "d"}}, + {Role: bootloader.RoleRunMode, Name: "loader", Hashes: []string{"z", "x"}}, + }, + Kernel: "pc-kernel-other", + KernelRevision: "2345", + KernelCmdlines: []string{`snapd_recovery_mode=run foo`}, + }, { + BrandID: "mybrand", + Model: "foo", + Grade: "dangerous", + ModelSignKeyID: "my-key-id", + AssetChain: []boot.BootAsset{ + // hashes will be sorted + {Role: bootloader.RoleRecovery, Name: "shim", Hashes: []string{"y", "x"}}, + {Role: bootloader.RoleRecovery, Name: "loader", Hashes: []string{"c", "d"}}, + }, + Kernel: "pc-kernel-recovery", + KernelRevision: "1234", + KernelCmdlines: []string{`snapd_recovery_mode=recover foo`}, + }, + } + + pbc := boot.ToPredictableBootChains(chains) + + rootdir := c.MkDir() + + expected := `{"reseal-count":0,"boot-chains":[{"brand-id":"mybrand","model":"foo","grade":"dangerous","model-sign-key-id":"my-key-id","asset-chain":[{"role":"recovery","name":"shim","hashes":["y","x"]},{"role":"recovery","name":"loader","hashes":["c","d"]}],"kernel":"pc-kernel-recovery","kernel-revision":"1234","kernel-cmdlines":["snapd_recovery_mode=recover foo"]},{"brand-id":"mybrand","model":"foo","grade":"signed","model-sign-key-id":"my-key-id","asset-chain":[{"role":"recovery","name":"shim","hashes":["x","y"]},{"role":"recovery","name":"loader","hashes":["c","d"]},{"role":"run-mode","name":"loader","hashes":["z","x"]}],"kernel":"pc-kernel-other","kernel-revision":"2345","kernel-cmdlines":["snapd_recovery_mode=run foo"]}]} +` + // creates a complete tree and writes a file + err := boot.WriteBootChains(pbc, filepath.Join(dirs.SnapFDEDirUnder(rootdir), "boot-chains"), 0) + c.Assert(err, IsNil) + c.Check(filepath.Join(dirs.SnapFDEDirUnder(rootdir), "boot-chains"), testutil.FileEquals, expected) + + fi, err := os.Stat(filepath.Join(dirs.SnapFDEDirUnder(rootdir), "boot-chains")) + c.Assert(err, IsNil) + c.Check(fi.Mode().Perm(), Equals, os.FileMode(0600)) + + loaded, cnt, err := boot.ReadBootChains(filepath.Join(dirs.SnapFDEDirUnder(rootdir), "boot-chains")) + c.Assert(err, IsNil) + c.Check(loaded, DeepEquals, pbc) + c.Check(cnt, Equals, 0) + // boot chains should be same for reseal purpose + c.Check(boot.PredictableBootChainsEqualForReseal(pbc, loaded), Equals, boot.BootChainEquivalent) + + // write them again with count > 0 + err = boot.WriteBootChains(pbc, filepath.Join(dirs.SnapFDEDirUnder(rootdir), "boot-chains"), 99) + c.Assert(err, IsNil) + + _, cnt, err = boot.ReadBootChains(filepath.Join(dirs.SnapFDEDirUnder(rootdir), "boot-chains")) + c.Assert(err, IsNil) + c.Check(cnt, Equals, 99) + + // make device/fde directory read only so that writing fails + otherRootdir := c.MkDir() + c.Assert(os.MkdirAll(dirs.SnapFDEDirUnder(otherRootdir), 0755), IsNil) + c.Assert(os.Chmod(dirs.SnapFDEDirUnder(otherRootdir), 0000), IsNil) + defer os.Chmod(dirs.SnapFDEDirUnder(otherRootdir), 0755) + + err = boot.WriteBootChains(pbc, filepath.Join(dirs.SnapFDEDirUnder(otherRootdir), "boot-chains"), 0) + c.Assert(err, ErrorMatches, `cannot create a temporary boot chains file: open .*/boot-chains\.[a-zA-Z0-9]+~: permission denied`) + + // make the original file non readable + c.Assert(os.Chmod(filepath.Join(dirs.SnapFDEDirUnder(rootdir), "boot-chains"), 0000), IsNil) + defer os.Chmod(filepath.Join(dirs.SnapFDEDirUnder(rootdir), "boot-chains"), 0755) + loaded, _, err = boot.ReadBootChains(filepath.Join(dirs.SnapFDEDirUnder(rootdir), "boot-chains")) + c.Assert(err, ErrorMatches, "cannot open existing boot chains data file: open .*/boot-chains: permission denied") + c.Check(loaded, IsNil) + + // loading from a file that does not exist yields a nil boot chain + // and 0 count + loaded, cnt, err = boot.ReadBootChains("does-not-exist") + c.Assert(err, IsNil) + c.Check(loaded, IsNil) + c.Check(cnt, Equals, 0) +} + +func (s *bootchainSuite) TestModelForSealing(c *C) { + bc := boot.BootChain{ + BrandID: "my-brand", + Model: "my-model", + Grade: "signed", + ModelSignKeyID: "my-key-id", + } + + modelForSealing := bc.SecbootModelForSealing() + c.Check(modelForSealing.Model(), Equals, "my-model") + c.Check(modelForSealing.BrandID(), Equals, "my-brand") + c.Check(modelForSealing.Classic(), Equals, false) + c.Check(modelForSealing.Grade(), Equals, asserts.ModelGrade("signed")) + c.Check(modelForSealing.SignKeyID(), Equals, "my-key-id") + c.Check(modelForSealing.Series(), Equals, "16") + c.Check(boot.ModelUniqueID(modelForSealing), Equals, "my-brand/my-model,signed,my-key-id") + +} + +func (s *bootchainSuite) TestClassicModelForSealing(c *C) { + bc := boot.BootChain{ + BrandID: "my-brand", + Model: "my-model", + Classic: true, + Grade: "signed", + ModelSignKeyID: "my-key-id", + } + + modelForSealing := bc.SecbootModelForSealing() + c.Check(modelForSealing.Model(), Equals, "my-model") + c.Check(modelForSealing.BrandID(), Equals, "my-brand") + c.Check(modelForSealing.Classic(), Equals, true) + c.Check(boot.ModelUniqueID(modelForSealing), Equals, "my-brand/my-model,signed,my-key-id") +} diff --git a/boot/booted_kernel_partition_linux.go b/boot/booted_kernel_partition_linux.go new file mode 100644 index 00000000..51205cdc --- /dev/null +++ b/boot/booted_kernel_partition_linux.go @@ -0,0 +1,56 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package boot + +import ( + "fmt" + "strings" + + "github.com/snapcore/snapd/bootloader/efi" +) + +const ( + // note the vendor ID 4a67b082-0a4c-41cf-b6c7-440b29bb8c4f is systemd, this + // variable is populated by shim + loaderDevicePartUUID = "LoaderDevicePartUUID-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f" +) + +// FindPartitionUUIDForBootedKernelDisk returns the partition uuid for the +// partition that the booted kernel is located on. +func FindPartitionUUIDForBootedKernelDisk() (string, error) { + // try efi variables first + partuuid, _, err := efi.ReadVarString(loaderDevicePartUUID) + if err == nil { + // the LoaderDevicePartUUID is in all caps, but lsblk, + // etc. use lower case so for consistency just make it + // lower case here too + return strings.ToLower(partuuid), nil + } + if err == efi.ErrNoEFISystem { + return "", err + } + + // TODO:UC20: use the kernel command line parameter from the little kernel + // bootloader if we have a littlekernel bootloader + + // TODO:UC20: add more fallbacks here, even on amd64, when we don't have efi + // i.e. on bios? + return "", fmt.Errorf("could not find partition uuid for booted kernel: %v", err) +} diff --git a/boot/booted_kernel_partition_test.go b/boot/booted_kernel_partition_test.go new file mode 100644 index 00000000..7ef6f0b7 --- /dev/null +++ b/boot/booted_kernel_partition_test.go @@ -0,0 +1,60 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package boot_test + +import ( + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/boot" + "github.com/snapcore/snapd/bootloader/bootloadertest" + "github.com/snapcore/snapd/bootloader/efi" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/testutil" +) + +var _ = Suite(&bootedKernelPartitionSuite{}) + +type bootedKernelPartitionSuite struct { + testutil.BaseTest +} + +func (s *bootedKernelPartitionSuite) SetUpTest(c *C) { + dirs.SetRootDir(c.MkDir()) + s.AddCleanup(func() { dirs.SetRootDir("") }) +} + +func (s *bootedKernelPartitionSuite) TestFindPartitionUUIDForBootedKernelDisk(c *C) { + restore := efi.MockVars(map[string][]byte{ + "LoaderDevicePartUUID-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f": bootloadertest.UTF16Bytes("A9F5C949-AB89-5B47-A7BF-56DD28F96E65"), + }, nil) + defer restore() + + partuuid, err := boot.FindPartitionUUIDForBootedKernelDisk() + c.Assert(err, IsNil) + c.Assert(partuuid, Equals, "a9f5c949-ab89-5b47-a7bf-56dd28f96e65") +} + +func (s *bootedKernelPartitionSuite) TestFindPartitionUUIDForBootedKernelDiskNoEFISystem(c *C) { + restore := efi.MockVars(nil, nil) + defer restore() + + _, err := boot.FindPartitionUUIDForBootedKernelDisk() + c.Check(err, Equals, efi.ErrNoEFISystem) +} diff --git a/boot/bootstate16.go b/boot/bootstate16.go new file mode 100644 index 00000000..457180de --- /dev/null +++ b/boot/bootstate16.go @@ -0,0 +1,200 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package boot + +import ( + "fmt" + + "github.com/snapcore/snapd/bootloader" + "github.com/snapcore/snapd/snap" +) + +type bootState16 struct { + varSuffix string + errName string +} + +func newBootState16(typ snap.Type, dev snap.Device) bootState { + var varSuffix, errName string + switch typ { + case snap.TypeKernel: + varSuffix = "kernel" + errName = "kernel" + case snap.TypeBase: + varSuffix = "core" + errName = "boot base" + default: + panic(fmt.Sprintf("cannot make a bootState16 for snap type %q", typ)) + } + return &bootState16{varSuffix: varSuffix, errName: errName} +} + +func (s16 *bootState16) revisions() (s, tryS snap.PlaceInfo, status string, err error) { + bloader, err := bootloader.Find("", nil) + if err != nil { + return nil, nil, "", fmt.Errorf("cannot get boot settings: %s", err) + } + + snapVar := "snap_" + s16.varSuffix + trySnapVar := "snap_try_" + s16.varSuffix + vars := []string{"snap_mode", snapVar, trySnapVar} + snaps := make(map[string]snap.PlaceInfo, 2) + + m, err := bloader.GetBootVars(vars...) + if err != nil { + return nil, nil, "", fmt.Errorf("cannot get boot variables: %s", err) + } + + for _, vName := range vars { + v := m[vName] + if v == "" && vName != snapVar { + // snap_mode & snap_try_ can be empty + // snap_ cannot be! and will fail parsing + // below + continue + } + + if vName == "snap_mode" { + status = v + } else { + // TODO: use trySnapError here somehow? + if v == "" { + return nil, nil, "", fmt.Errorf("cannot get name and revision of %s (%s): boot variable unset", s16.errName, vName) + } + snap, err := snap.ParsePlaceInfoFromSnapFileName(v) + if err != nil { + return nil, nil, "", fmt.Errorf("cannot get name and revision of %s (%s): %v", s16.errName, vName, err) + } + snaps[vName] = snap + } + } + + return snaps[snapVar], snaps[trySnapVar], status, nil +} + +type bootStateUpdate16 struct { + bl bootloader.Bootloader + env map[string]string + toCommit map[string]string +} + +func newBootStateUpdate16(u bootStateUpdate, names ...string) (*bootStateUpdate16, error) { + if u != nil { + u16, ok := u.(*bootStateUpdate16) + if !ok { + return nil, fmt.Errorf("internal error: threading unexpected boot state update on UC16/18: %T", u) + } + return u16, nil + } + bl, err := bootloader.Find("", nil) + if err != nil { + return nil, err + } + m, err := bl.GetBootVars(names...) + if err != nil { + return nil, err + } + return &bootStateUpdate16{bl: bl, env: m, toCommit: make(map[string]string)}, nil +} + +func (u16 *bootStateUpdate16) commit() error { + if len(u16.toCommit) == 0 { + // nothing to do + return nil + } + env := u16.env + // TODO: we could just SetBootVars(toCommit) but it's not + // fully backward compatible with the preexisting behavior + for k, v := range u16.toCommit { + env[k] = v + } + return u16.bl.SetBootVars(env) +} + +func (s16 *bootState16) markSuccessful(update bootStateUpdate) (bootStateUpdate, error) { + u16, err := newBootStateUpdate16(update, "snap_mode", "snap_try_core", "snap_try_kernel") + if err != nil { + return nil, err + } + + env := u16.env + toCommit := u16.toCommit + + tryBootVar := fmt.Sprintf("snap_try_%s", s16.varSuffix) + bootVar := fmt.Sprintf("snap_%s", s16.varSuffix) + + // snap_mode goes from "" -> "try" -> "trying" -> "" + // so if we are not in "trying" mode, nothing to do here + if env["snap_mode"] != TryingStatus { + // clean the try var anyways in case it was leftover from a rollback, + // etc. + toCommit[tryBootVar] = "" + return u16, nil + } + + // update the boot vars + if env[tryBootVar] != "" { + toCommit[bootVar] = env[tryBootVar] + toCommit[tryBootVar] = "" + } + toCommit["snap_mode"] = DefaultStatus + + return u16, nil +} + +func (s16 *bootState16) setNext(s snap.PlaceInfo, bootCtx NextBootContext) (rbi RebootInfo, u bootStateUpdate, err error) { + nextBootVar := fmt.Sprintf("snap_try_%s", s16.varSuffix) + goodBootVar := fmt.Sprintf("snap_%s", s16.varSuffix) + + u16, err := newBootStateUpdate16(nil, "snap_mode", goodBootVar) + if err != nil { + return RebootInfo{RebootRequired: false}, nil, err + } + + env := u16.env + toCommit := u16.toCommit + + rbi.RebootRequired = true + snapMode := TryStatus + nextBoot := s.Filename() + if env[goodBootVar] == nextBoot { + // If we were in anything but default ("") mode before + // and switched to the good core/kernel again, make + // sure to clean the snap_mode here. This also + // mitigates https://forum.snapcraft.io/t/5253 + if env["snap_mode"] == DefaultStatus { + // already clean + return RebootInfo{RebootRequired: false}, nil, nil + } + // clean + snapMode = DefaultStatus + nextBoot = "" + rbi.RebootRequired = false + } else if bootCtx.BootWithoutTry { + toCommit[goodBootVar] = nextBoot + snapMode = DefaultStatus + nextBoot = "" + } + + toCommit["snap_mode"] = snapMode + toCommit[nextBootVar] = nextBoot + + return rbi, u16, nil +} diff --git a/boot/bootstate20.go b/boot/bootstate20.go new file mode 100644 index 00000000..1474918e --- /dev/null +++ b/boot/bootstate20.go @@ -0,0 +1,924 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2019-2023 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package boot + +import ( + "fmt" + "path/filepath" + "sync" + "sync/atomic" + + "github.com/snapcore/snapd/bootloader" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/logger" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/strutil" +) + +func newBootState20(typ snap.Type, dev snap.Device) bootState { + switch typ { + case snap.TypeBase: + return &bootState20Base{} + case snap.TypeKernel: + return &bootState20Kernel{ + dev: dev, + } + case snap.TypeGadget: + return &bootState20Gadget{} + default: + panic(fmt.Sprintf("cannot make a bootState20 for snap type %q", typ)) + } +} + +// modeenvMu is used to protect sections doing: +// - read moddeenv/modify it(/reseal from it) +// - write modeenv/seal from it +// +// while we might want to release the global state lock as seal/reseal are slow +// (see Unlocker for that) +var ( + modeenvMu sync.Mutex + modeenvLocked int32 +) + +func modeenvLock() { + modeenvMu.Lock() + atomic.AddInt32(&modeenvLocked, 1) +} + +func modeenvUnlock() { + atomic.AddInt32(&modeenvLocked, -1) + modeenvMu.Unlock() +} + +func isModeeenvLocked() bool { + return atomic.LoadInt32(&modeenvLocked) == 1 +} + +func loadModeenv() (*Modeenv, error) { + if !isModeeenvLocked() { + return nil, fmt.Errorf("internal error: cannot read modeenv without the lock") + } + modeenv, err := ReadModeenv("") + if err != nil { + return nil, fmt.Errorf("cannot read modeenv: %v", err) + } + return modeenv, nil +} + +// selectGadgetSnap finds the currently active gadget snap +func selectGadgetSnap(modeenv *Modeenv, rootfsDir string) (snap.PlaceInfo, error) { + gadgetInfo, err := snap.ParsePlaceInfoFromSnapFileName(modeenv.Gadget) + if err != nil { + return nil, fmt.Errorf("cannot get snap revision: modeenv gadget boot variable is invalid: %v", err) + } + + // check that the current snap actually exists + file := modeenv.Gadget + snapPath := filepath.Join(dirs.SnapBlobDirUnder(rootfsDir), file) + if !osutil.FileExists(snapPath) { + // somehow the gadget snap doesn't exist in ubuntu-data + // this could happen if the modeenv is manipulated + // out-of-band from snapd + return nil, fmt.Errorf("gadget snap %q does not exist on ubuntu-data", file) + } + + return gadgetInfo, nil +} + +// +// bootloaderKernelState20 methods +// + +type bootloaderKernelState20 interface { + // load will setup any state / actors needed to use other methods + load() error + // kernelStatus returns the current status of the kernel, i.e. the + // kernel_status bootenv + kernelStatus() string + // kernel returns the current non-try kernel + kernel() snap.PlaceInfo + // kernel returns the current try kernel if it exists on the bootloader + tryKernel() (snap.PlaceInfo, error) + + // setNextKernel marks the kernel as the next, if it's not the currently + // booted kernel, then the specified kernel is setup as a try-kernel + setNextKernel(sn snap.PlaceInfo, status string) error + // markSuccessfulKernel marks the specified kernel as having booted + // successfully, whether that kernel is the current kernel or the try-kernel + markSuccessfulKernel(sn snap.PlaceInfo) error + // setNextKernelNoTry changes boot configuration so the specified kernel will + // be the one used in next boot, without the "try" logic. This shall be + // used only when we have already booted to a new kernel but for some + // reason we need to revert to the previous kernel (for instance, in a + // transactional update when the failing snap is not the kernel). + setNextKernelNoTry(sn snap.PlaceInfo) error +} + +// +// bootStateUpdate for 20 methods +// + +type bootCommitTask func() error + +// bootStateUpdate20 implements the bootStateUpdate interface for both kernel +// and base snaps on UC20. +type bootStateUpdate20 struct { + // tasks to run before the modeenv has been written + preModeenvTasks []bootCommitTask + + // the modeenv that was read from disk + modeenv *Modeenv + + // the modeenv that will be written out in commit + writeModeenv *Modeenv + + // tasks to run after the modeenv has been written + postModeenvTasks []bootCommitTask +} + +func (u20 *bootStateUpdate20) preModeenv(task bootCommitTask) { + u20.preModeenvTasks = append(u20.preModeenvTasks, task) +} + +func (u20 *bootStateUpdate20) postModeenv(task bootCommitTask) { + u20.postModeenvTasks = append(u20.postModeenvTasks, task) +} + +func newBootStateUpdate20(m *Modeenv) (*bootStateUpdate20, error) { + u20 := &bootStateUpdate20{} + if m == nil { + var err error + m, err = loadModeenv() + if err != nil { + return nil, err + } + } + // copy the modeenv for the write object + u20.modeenv = m + var err error + u20.writeModeenv, err = m.Copy() + if err != nil { + return nil, err + } + return u20, nil +} + +// commit will write out boot state persistently to disk. +func (u20 *bootStateUpdate20) commit() error { + if !isModeeenvLocked() { + return fmt.Errorf("internal error: cannot commit modeenv without the lock") + } + + // The actual actions taken here will depend on what things were called + // before commit(), either setNextBoot for a single type of kernel snap, or + // markSuccessful for kernel and/or base snaps. + // It is expected that the caller code is carefully analyzed to avoid + // critical points where a hard system reset during that critical point + // would brick a device or otherwise severely fail an update. + // There are three things that callers can do before calling commit(), + // 1. modify writeModeenv to specify new values for things that will be + // written to disk in the modeenv. + // 2. Add tasks to run before writing the modeenv. + // 3. Add tasks to run after writing the modeenv. + + // first handle any pre-modeenv writing tasks + for _, t := range u20.preModeenvTasks { + if err := t(); err != nil { + return err + } + } + + expectReseal := false + // next write the modeenv if it changed + if !u20.writeModeenv.deepEqual(u20.modeenv) { + if err := u20.writeModeenv.Write(); err != nil { + return err + } + expectReseal = resealExpectedByModeenvChange(u20.writeModeenv, u20.modeenv) + } + + // next reseal using the modeenv values, we do this before any + // post-modeenv tasks so if we are rebooted at any point after + // the reseal even before the post tasks are completed, we + // still boot properly + + // if there is ambiguity whether the boot chains have + // changed because of unasserted kernels, then pass a + // flag as hint whether to reseal based on whether we + // wrote the modeenv + if err := resealKeyToModeenv(dirs.GlobalRootDir, u20.writeModeenv, expectReseal, nil); err != nil { + return err + } + + // finally handle any post-modeenv writing tasks + for _, t := range u20.postModeenvTasks { + if err := t(); err != nil { + return err + } + } + + return nil +} + +// +// kernel snap methods +// + +// bootState20Kernel implements the bootState interface for kernel snaps on +// UC20. It is used for both setNext() and markSuccessful(), with both of those +// methods returning bootStateUpdate20 to be used with bootStateUpdate. +type bootState20Kernel struct { + bks bootloaderKernelState20 + rbl bootloader.RebootBootloader + + // used to find the bootloader to manipulate the enabled kernel, etc. + blOpts *bootloader.Options + blDir string + + dev snap.Device +} + +func (ks20 *bootState20Kernel) bootloaderOptions() *bootloader.Options { + if ks20.blOpts != nil { + return ks20.blOpts + } + // find the run-mode bootloader + return &bootloader.Options{ + Role: bootloader.RoleRunMode, + } +} + +func (ks20 *bootState20Kernel) loadBootenv() error { + // don't setup multiple times + if ks20.bks != nil { + return nil + } + + bl, err := bootloader.Find(ks20.blDir, ks20.bootloaderOptions()) + if err != nil { + return err + } + ebl, ok := bl.(bootloader.ExtractedRunKernelImageBootloader) + if ok { + // use the new 20-style ExtractedRunKernelImage implementation + ks20.bks = &extractedRunKernelImageBootloaderKernelState{ebl: ebl} + } else { + // use fallback pure bootloader env implementation + ks20.bks = &envRefExtractedKernelBootloaderKernelState{bl: bl} + } + + rbl, ok := bl.(bootloader.RebootBootloader) + if ok { + ks20.rbl = rbl + } + + // setup the bootloaderKernelState20 + if err := ks20.bks.load(); err != nil { + return err + } + + return nil +} + +func (ks20 *bootState20Kernel) revisions() (curSnap, trySnap snap.PlaceInfo, tryingStatus string, err error) { + var tryBootSn snap.PlaceInfo + err = ks20.loadBootenv() + if err != nil { + return nil, nil, "", err + } + + status := ks20.bks.kernelStatus() + kern := ks20.bks.kernel() + + tryKernel, err := ks20.bks.tryKernel() + // if err is ErrNoTryKernelRef, then we will just return nil as the trySnap + if err != nil && err != bootloader.ErrNoTryKernelRef { + return kern, nil, status, newTrySnapErrorf("cannot identify try kernel snap: %v", err) + } + + if err == nil { + tryBootSn = tryKernel + } + + return kern, tryBootSn, status, nil +} + +func (ks20 *bootState20Kernel) revisionsFromModeenv(*Modeenv) (curSnap, trySnap snap.PlaceInfo, tryingStatus string, err error) { + // the kernel snap doesn't use modeenv at all for getting their revisions + return ks20.revisions() +} + +func (ks20 *bootState20Kernel) markSuccessful(update bootStateUpdate) (bootStateUpdate, error) { + // call the generic method with this object to do most of the legwork + u20, sn, err := selectSuccessfulBootSnap(ks20, update) + if err != nil { + return nil, err + } + + // XXX: this if arises because some unit tests rely on not setting up kernel + // details and just operating on the base snap but this situation would + // never happen in reality + if sn != nil { + // On commit, mark the kernel successful before rewriting the modeenv + // because if we first rewrote the modeenv then got rebooted before + // marking the kernel successful, the bootloader would see that the boot + // failed to mark it successful and then fall back to the original + // kernel, but that kernel would no longer be in the modeenv, so we + // would die in the initramfs + u20.preModeenv(func() error { return ks20.bks.markSuccessfulKernel(sn) }) + + // On commit, set CurrentKernels as just this kernel because that is the + // successful kernel we booted + u20.writeModeenv.CurrentKernels = []string{sn.Filename()} + } + + return u20, nil +} + +func (ks20 *bootState20Kernel) setNext(next snap.PlaceInfo, bootCtx NextBootContext) (rbi RebootInfo, u bootStateUpdate, err error) { + u20, rebootRequired, err := genericSetNext(ks20, next) + if err != nil { + return RebootInfo{RebootRequired: false}, nil, err + } + + nextStatus := DefaultStatus + rbi.RebootRequired = rebootRequired + if rbi.RebootRequired { + // if we need to reboot and we are not undoing, we set the try status + if !bootCtx.BootWithoutTry { + nextStatus = TryStatus + } + // Kernels are usually loaded directly by the bootloader, for + // which we may need to pass additional data to make 'try' + // operation more robust. Set the bootloader options so the + // reboot code can find the relevant bootloader and get those + // arguments. + rbi.BootloaderOptions = ks20.bootloaderOptions() + } + + currentKernel := ks20.bks.kernel() + if bootCtx.BootWithoutTry { + // When undoing, only next kernel will be available (which will + // be actually the old kernel). Depending on when the undo + // happens (before or after the reboot triggered by the update), + // current will be the same as next or different, so in both + // cases we need this. + u20.writeModeenv.CurrentKernels = []string{next.Filename()} + } else if next.Filename() != currentKernel.Filename() { + // We are trying a new kernel, add to the modeenv + u20.writeModeenv.CurrentKernels = append( + u20.writeModeenv.CurrentKernels, + next.Filename(), + ) + } + logger.Debugf("available kernels (BootWithoutTry: %t): %v", + bootCtx.BootWithoutTry, u20.writeModeenv.CurrentKernels) + + bootTask := func() error { return ks20.bks.setNextKernel(next, nextStatus) } + if bootCtx.BootWithoutTry { + // force revert to "next" kernel (actually it is the old one) + // and ignore the try status, that will be empty in this case. + bootTask = func() error { return ks20.bks.setNextKernelNoTry(next) } + } + + // On commit, if we are about to try an update, and need to set the next + // kernel before rebooting, we need to do that after updating the modeenv, + // because if we did it before and got rebooted in between setting the next + // kernel and updating the modeenv, the initramfs would fail the boot + // because the modeenv doesn't "trust" or expect the new kernel that booted. + // As such, set the next kernel as a post modeenv task. + u20.postModeenv(bootTask) + + return rbi, u20, nil +} + +// selectAndCommitSnapInitramfsMount chooses which snap should be mounted +// during the initramfs, and commits that choice if it needs state updated. +// Choosing to boot/mount the base snap needs to be committed to the +// modeenv, but no state needs to be committed when choosing to mount a +// kernel snap. +func (ks20 *bootState20Kernel) selectAndCommitSnapInitramfsMount(modeenv *Modeenv, rootfsDir string) (sn snap.PlaceInfo, err error) { + // first do the generic choice of which snap to use + first, second, err := genericInitramfsSelectSnap(ks20, modeenv, rootfsDir, TryingStatus, "kernel") + if err != nil && err != errTrySnapFallback { + return nil, err + } + + // If errTrySnapFallback it means that we are trying a new kernel + // but somewhat the status does not look correct of we cannot find + // the snap. Reboot so bootloader reverts to using the old kernel. + if err == errTrySnapFallback { + // this should not actually return, it should immediately reboot + return nil, initramfsReboot() + } + + // now validate the chosen kernel snap against the modeenv CurrentKernel's + // setting + if strutil.ListContains(modeenv.CurrentKernels, first.Filename()) { + return first, nil + } + + // if we didn't trust the first kernel in the modeenv, and second is set as + // a fallback, that means we booted a try kernel which is the first kernel, + // but we need to fallback to the second kernel, but we can't do that in the + // initramfs, we need to reboot so the bootloader boots the fallback kernel + // for us + if second != nil { + // this should not actually return, it should immediately reboot + return nil, initramfsReboot() + } + + // no fallback expected, so first snap _is_ the only kernel and isn't + // trusted! + // since we have nothing to fallback to, we don't issue a reboot and will + // instead just fail the systemd unit in the initramfs for an operator to + // debug/fix + return nil, fmt.Errorf("fallback kernel snap %q is not trusted in the modeenv", first.Filename()) +} + +// +// gadget snap methods +// + +// bootState20Gadget implements the bootState interface for gadget +// snaps on UC20+. It is used for both setNext() and markSuccessful(), +// with both of those methods returning bootStateUpdate20 to be used +// with bootStateUpdate. +type bootState20Gadget struct{} + +func (bs20 *bootState20Gadget) revisions() (curSnap, trySnap snap.PlaceInfo, tryingStatus string, err error) { + return nil, nil, "", fmt.Errorf("internal error, revisions not implemented for gadget") +} + +func (bs20 *bootState20Gadget) setNext(next snap.PlaceInfo, bootCtx NextBootContext) (rbi RebootInfo, u bootStateUpdate, err error) { + u20, err := newBootStateUpdate20(nil) + if err != nil { + return RebootInfo{RebootRequired: false}, nil, err + } + + u20.writeModeenv.Gadget = next.Filename() + + return RebootInfo{RebootRequired: false}, u20, err +} + +func (bs20 *bootState20Gadget) markSuccessful(bootStateUpdate) (bootStateUpdate, error) { + return nil, fmt.Errorf("internal error, markSuccessful not implemented for gadget") +} + +// +// base snap methods +// + +// bootState20Base implements the bootState interface for base snaps on UC20. +// It is used for both setNext() and markSuccessful(), with both of those +// methods returning bootStateUpdate20 to be used with bootStateUpdate. +type bootState20Base struct{} + +// revisions returns the current boot snap and optional try boot snap for the +// type specified in bsgeneric. +func (bs20 *bootState20Base) revisions() (curSnap, trySnap snap.PlaceInfo, tryingStatus string, err error) { + modeenv, err := loadModeenv() + if err != nil { + return nil, nil, "", fmt.Errorf("cannot get snap revision: %v", err) + } + return bs20.revisionsFromModeenv(modeenv) +} + +func (bs20 *bootState20Base) revisionsFromModeenv(modeenv *Modeenv) (curSnap, trySnap snap.PlaceInfo, tryingStatus string, err error) { + var bootSn, tryBootSn snap.PlaceInfo + + if modeenv.Base == "" { + return nil, nil, "", fmt.Errorf("cannot get snap revision: modeenv base boot variable is empty") + } + + bootSn, err = snap.ParsePlaceInfoFromSnapFileName(modeenv.Base) + if err != nil { + return nil, nil, "", fmt.Errorf("cannot get snap revision: modeenv base boot variable is invalid: %v", err) + } + + if modeenv.BaseStatus != DefaultStatus && modeenv.TryBase != "" { + tryBootSn, err = snap.ParsePlaceInfoFromSnapFileName(modeenv.TryBase) + if err != nil { + return bootSn, nil, "", newTrySnapErrorf("cannot get snap revision: modeenv try base boot variable is invalid: %v", err) + } + } + + return bootSn, tryBootSn, modeenv.BaseStatus, nil +} + +func (bs20 *bootState20Base) markSuccessful(update bootStateUpdate) (bootStateUpdate, error) { + // call the generic method with this object to do most of the legwork + u20, sn, err := selectSuccessfulBootSnap(bs20, update) + if err != nil { + return nil, err + } + + // on commit, always clear the base_status and try_base when marking + // successful, this has the useful side-effect of cleaning up if we have + // base_status=trying but no try_base set, or if we had an issue with + // try_base being invalid + u20.writeModeenv.BaseStatus = DefaultStatus + u20.writeModeenv.TryBase = "" + + // set the base + u20.writeModeenv.Base = sn.Filename() + + return u20, nil +} + +func (bs20 *bootState20Base) setNext(next snap.PlaceInfo, bootCtx NextBootContext) (rbi RebootInfo, u bootStateUpdate, err error) { + // bases are handled by snap-bootstrap, hence we are not interested in + // the bootloader's opinion (no need for rbi.RebootBootloader, so it is + // not filled anywhere in this method). + u20, rebootRequired, err := genericSetNext(bs20, next) + if err != nil { + return RebootInfo{RebootRequired: false}, nil, err + } + + nextStatus := DefaultStatus + rbi.RebootRequired = rebootRequired + if rbi.RebootRequired { + if bootCtx.BootWithoutTry { + // we must make sure we boot with the base we revert to + u20.writeModeenv.Base = next.Filename() + u20.writeModeenv.TryBase = "" + } else { + // if we need to reboot and we are not undoing, we set the try status + // and set appropriately the base we want to try + nextStatus = TryStatus + u20.writeModeenv.TryBase = next.Filename() + } + } + + // always update the base status + u20.writeModeenv.BaseStatus = nextStatus + + return rbi, u20, nil +} + +// selectAndCommitSnapInitramfsMount chooses which snap should be mounted +// during the early boot sequence, i.e. the initramfs, and commits that +// choice if it needs state updated. +// Choosing to boot/mount the base snap needs to be committed to the +// modeenv, but no state needs to be committed when choosing to mount a +// kernel snap. +func (bs20 *bootState20Base) selectAndCommitSnapInitramfsMount(modeenv *Modeenv, rootfsDir string) (sn snap.PlaceInfo, err error) { + // first do the generic choice of which snap to use + // the logic in that function is sufficient to pick the base snap entirely, + // so we don't ever need to look at the fallback snap, we just need to know + // whether the chosen snap is a try snap or not, if it is then we process + // the modeenv in the "try" -> "trying" case + first, second, err := + genericInitramfsSelectSnap(bs20, modeenv, rootfsDir, TryStatus, "base") + // errTrySnapFallback is handled manually by inspecting second below + if err != nil && err != errTrySnapFallback { + return nil, err + } + + modeenvChanged := false + + // apply the update logic to the choices modeenv + switch modeenv.BaseStatus { + case TryStatus: + // if we were in try status and we have a fallback, then we are in a + // normal try state and we change status to TryingStatus now + // all other cleanup of state is left to user space snapd + if second != nil { + modeenv.BaseStatus = TryingStatus + modeenvChanged = true + } + case TryingStatus: + // we tried to boot a try base snap and failed, so we need to reset + // BaseStatus + modeenv.BaseStatus = DefaultStatus + modeenvChanged = true + case DefaultStatus: + // nothing to do + default: + // log a message about invalid setting + logger.Noticef("invalid setting for \"base_status\" in modeenv : %q", modeenv.BaseStatus) + } + + if modeenvChanged { + err = modeenv.Write() + if err != nil { + return nil, err + } + } + + return first, nil +} + +// +// generic methods +// + +type bootState20 interface { + bootState + // revisionsFromModeenv implements bootState.revisions but starting + // from an already loaded Modeenv. + revisionsFromModeenv(*Modeenv) (curSnap, trySnap snap.PlaceInfo, tryingStatus string, err error) +} + +// genericSetNext implements the generic logic for setting up a snap to be tried +// for boot and works for both kernel and base snaps (though not +// simultaneously). +func genericSetNext(b bootState20, next snap.PlaceInfo) (u20 *bootStateUpdate20, rebootRequired bool, err error) { + u20, err = newBootStateUpdate20(nil) + if err != nil { + return nil, false, err + } + + // get the current snap + current, _, _, err := b.revisionsFromModeenv(u20.modeenv) + if err != nil { + return nil, false, err + } + + // check if the next snap is really the same as the current snap, in which + // case we either do nothing or just clear the status (and not reboot) + if current.SnapName() == next.SnapName() && next.SnapRevision() == current.SnapRevision() { + // if we are setting the next snap as the current snap, don't need to + // change any snaps, just reset the status to default + return u20, false, nil + } + + // next != current so we need to reboot + return u20, true, nil +} + +func toBootStateUpdate20(update bootStateUpdate) (u20 *bootStateUpdate20, err error) { + // try to extract bootStateUpdate20 out of update + if update != nil { + var ok bool + if u20, ok = update.(*bootStateUpdate20); !ok { + return nil, fmt.Errorf("internal error, cannot thread %T with update for UC20+", update) + } + } + if u20 == nil { + // make a new one, also loading modeenv + u20, err = newBootStateUpdate20(nil) + if err != nil { + return nil, err + } + } + return u20, nil +} + +// selectSuccessfulBootSnap inspects the specified boot state to pick what +// boot snap should be marked as successful and use as a valid rollback target. +// If the first return value is non-nil, the second return value will be the +// snap that was booted and should be marked as successful. +func selectSuccessfulBootSnap(b bootState20, update bootStateUpdate) ( + u20 *bootStateUpdate20, + bootedSnap snap.PlaceInfo, + err error, +) { + u20, err = toBootStateUpdate20(update) + if err != nil { + return nil, nil, err + } + + // get the try snap and the current status + sn, trySnap, status, err := b.revisionsFromModeenv(u20.modeenv) + if err != nil { + return nil, nil, err + } + + // kernel_status and base_status go from "" -> "try" (set by snapd), to + // "try" -> "trying" (set by the boot script) + // so if we are in "trying" mode, then we should choose the try snap + if status == TryingStatus && trySnap != nil { + return u20, trySnap, nil + } + + // if we are not in trying then choose the normal snap + return u20, sn, nil +} + +// genericInitramfsSelectSnap will run the logic to choose which snap should be +// mounted during the initramfs using the given bootState and the expected try +// status. The try status is needed because during the initramfs we will have +// different statuses for kernel vs base snaps, where base snap is expected to +// be in "try" mode, but kernel is expected to be in "trying" mode. It returns +// the first and second choice for what snaps to mount. If there is a second +// snap, then that snap is the fallback or non-trying snap and the first snap is +// the try snap. +func genericInitramfsSelectSnap(bs bootState20, modeenv *Modeenv, rootfsDir string, expectedTryStatus, typeString string) ( + firstChoice, secondChoice snap.PlaceInfo, + err error, +) { + curSnap, trySnap, snapTryStatus, err := bs.revisionsFromModeenv(modeenv) + if err != nil { + if isTrySnapError(err) { + // We just log the error, if we are here and this is a + // kernel is either because try-kernel.efi is a dangling + // link or because it points to a bad file. Most + // possibly the try kernel has not been used to start + // the system, so we just go on and wait to see what the + // try_status says - otherwise we could enter a boot + // loop. If it is a base, we have bad format in the + // modeenv for it, log and move on, we still want to + // boot. + logger.Noticef("unable to process try %s snap: %v", typeString, err) + } else { + // No current snap information found in modeenv for base, + // or cannot get information from bootloader for kernel. + return nil, nil, fmt.Errorf("no currently usable %s snaps: %v", typeString, err) + } + } + + // check that the current snap actually exists + file := curSnap.Filename() + snapPath := filepath.Join(dirs.SnapBlobDirUnder(rootfsDir), file) + if !osutil.FileExists(snapPath) { + // somehow the boot snap doesn't exist in ubuntu-data + // for a kernel, this could happen if we have some bug where ubuntu-boot + // isn't properly updated and never changes, but snapd thinks it was + // updated and eventually snapd garbage collects old revisions of + // the kernel snap as it is "refreshed" + // for a base, this could happen if the modeenv is manipulated + // out-of-band from snapd + return nil, nil, fmt.Errorf("%s snap %q does not exist on ubuntu-data", typeString, file) + } + + if snapTryStatus != expectedTryStatus { + // status does not match what we would have if we were trying a + // snap (which is the normal path when no update is happening), + // log if its value is invalid and continue with the normal snap + fallbackErr := errTrySnapFallback + switch snapTryStatus { + case DefaultStatus: + // all good, no update is happening in this boot + fallbackErr = nil + case TryStatus, TryingStatus: + default: + // something is wrong, status is neither the default nor + // what we would see from the initramfs if we were + // trying a snap + logger.Noticef("\"%s_status\" has an invalid setting: %q", typeString, snapTryStatus) + } + return curSnap, nil, fallbackErr + } + // then we are trying a snap update and there should be a try snap + if trySnap == nil { + // it is unexpected when there isn't one + logger.Noticef("try-%[1]s snap is empty, but \"%[1]s_status\" is \"trying\"", typeString) + return curSnap, nil, errTrySnapFallback + } + trySnapPath := filepath.Join(dirs.SnapBlobDirUnder(rootfsDir), trySnap.Filename()) + if !osutil.FileExists(trySnapPath) { + // or when the snap file does not exist + logger.Noticef("try-%s snap %q does not exist", typeString, trySnap.Filename()) + return curSnap, nil, errTrySnapFallback + } + + // we have a try snap and everything appears in order + return trySnap, curSnap, nil +} + +// +// non snap boot resources +// + +// bootState20BootAssets implements the successfulBootState interface for trusted +// boot assets UC20. +type bootState20BootAssets struct { + dev snap.Device +} + +func (ba20 *bootState20BootAssets) markSuccessful(update bootStateUpdate) (bootStateUpdate, error) { + u20, err := toBootStateUpdate20(update) + if err != nil { + return nil, err + } + + if len(u20.modeenv.CurrentTrustedBootAssets) == 0 && len(u20.modeenv.CurrentTrustedRecoveryBootAssets) == 0 { + // not using trusted boot assets, nothing more to do + return update, nil + } + + newM, dropAssets, err := observeSuccessfulBootAssets(u20.writeModeenv) + if err != nil { + return nil, fmt.Errorf("cannot mark successful boot assets: %v", err) + } + // update modeenv + u20.writeModeenv = newM + + if len(dropAssets) == 0 { + // nothing to drop, we're done + return u20, nil + } + + u20.postModeenv(func() error { + cache := newTrustedAssetsCache(dirs.SnapBootAssetsDir) + // drop listed assets from cache + for _, ta := range dropAssets { + err := cache.Remove(ta.blName, ta.name, ta.hash) + if err != nil { + // XXX: should this be a log instead? + return fmt.Errorf("cannot remove unused boot asset %v:%v: %v", ta.name, ta.hash, err) + } + } + return nil + }) + return u20, nil +} + +func trustedAssetsBootState(dev snap.Device) *bootState20BootAssets { + return &bootState20BootAssets{ + dev: dev, + } +} + +// bootState20CommandLine implements the successfulBootState interface for +// kernel command line +type bootState20CommandLine struct { + dev snap.Device +} + +func (bcl20 *bootState20CommandLine) markSuccessful(update bootStateUpdate) (bootStateUpdate, error) { + u20, err := toBootStateUpdate20(update) + if err != nil { + return nil, err + } + newM, err := observeSuccessfulCommandLine(bcl20.dev.Model(), u20.writeModeenv) + if err != nil { + return nil, fmt.Errorf("cannot mark successful boot command line: %v", err) + } + u20.writeModeenv = newM + return u20, nil +} + +func trustedCommandLineBootState(dev snap.Device) *bootState20CommandLine { + return &bootState20CommandLine{ + dev: dev, + } +} + +// bootState20RecoverySystem implements the successfulBootState interface for +// tried recovery systems +type bootState20RecoverySystem struct { + dev snap.Device +} + +func (brs20 *bootState20RecoverySystem) markSuccessful(update bootStateUpdate) (bootStateUpdate, error) { + u20, err := toBootStateUpdate20(update) + if err != nil { + return nil, err + } + + newM, err := observeSuccessfulSystems(u20.writeModeenv) + if err != nil { + return nil, fmt.Errorf("cannot mark successful recovery system: %v", err) + } + u20.writeModeenv = newM + return u20, nil +} + +func recoverySystemsBootState(dev snap.Device) *bootState20RecoverySystem { + return &bootState20RecoverySystem{dev: dev} +} + +// bootState20Model implements the successfulBootState interface for device +// model related bookkeeping +type bootState20Model struct { + dev snap.Device +} + +func (brs20 *bootState20Model) markSuccessful(update bootStateUpdate) (bootStateUpdate, error) { + u20, err := toBootStateUpdate20(update) + if err != nil { + return nil, err + } + + // sign key ID was not being populated in earlier versions of snapd, try + // to remedy that + if u20.modeenv.ModelSignKeyID == "" { + u20.writeModeenv.ModelSignKeyID = brs20.dev.Model().SignKeyID() + } + return u20, nil +} + +func modelBootState(dev snap.Device) *bootState20Model { + return &bootState20Model{dev: dev} +} diff --git a/boot/bootstate20_bloader_kernel_state.go b/boot/bootstate20_bloader_kernel_state.go new file mode 100644 index 00000000..9136d355 --- /dev/null +++ b/boot/bootstate20_bloader_kernel_state.go @@ -0,0 +1,331 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2019-2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package boot + +import ( + "fmt" + + "github.com/snapcore/snapd/bootloader" + "github.com/snapcore/snapd/snap" +) + +// extractedRunKernelImageBootloaderKernelState implements bootloaderKernelState20 for +// bootloaders that implement ExtractedRunKernelImageBootloader +type extractedRunKernelImageBootloaderKernelState struct { + // the bootloader + ebl bootloader.ExtractedRunKernelImageBootloader + // the current kernel status as read by the bootloader's bootenv + currentKernelStatus string + // the current kernel on the bootloader (not the try-kernel) + currentKernel snap.PlaceInfo +} + +func (bks *extractedRunKernelImageBootloaderKernelState) load() error { + // get the kernel_status + m, err := bks.ebl.GetBootVars("kernel_status") + if err != nil { + return err + } + + bks.currentKernelStatus = m["kernel_status"] + + // get the current kernel for this bootloader to compare during commit() for + // markSuccessful() if we booted the current kernel or not + kernel, err := bks.ebl.Kernel() + if err != nil { + return fmt.Errorf("cannot identify kernel snap with bootloader %s: %v", bks.ebl.Name(), err) + } + + bks.currentKernel = kernel + + return nil +} + +func (bks *extractedRunKernelImageBootloaderKernelState) kernel() snap.PlaceInfo { + return bks.currentKernel +} + +func (bks *extractedRunKernelImageBootloaderKernelState) tryKernel() (snap.PlaceInfo, error) { + return bks.ebl.TryKernel() +} + +func (bks *extractedRunKernelImageBootloaderKernelState) kernelStatus() string { + return bks.currentKernelStatus +} + +func (bks *extractedRunKernelImageBootloaderKernelState) markSuccessfulKernel(sn snap.PlaceInfo) error { + // set the boot vars first, then enable the successful kernel, then disable + // the old try-kernel, see the comment in bootState20MarkSuccessful.commit() + // for details + + // the ordering here is very important for boot reliability! + + // If we have successfully just booted from a try-kernel and are + // marking it successful (this implies that snap_kernel=="trying" as set + // by the boot script), we need to do the following in order (since we + // have the added complexity of moving the kernel symlink): + // 1. Update kernel_status to "" + // 2. Move kernel symlink to point to the new try kernel + // 3. Remove try-kernel symlink + // 4. Remove old kernel from modeenv (this happens one level up from this + // function) + // + // If we got rebooted after step 1, then the bootloader is booting the wrong + // kernel, but is at least booting a known good kernel and snapd in + // user-space would be able to figure out the inconsistency. + // If we got rebooted after step 2, the bootloader would boot from the new + // try-kernel which is okay because we were in the middle of committing + // that new kernel as good and all that's left is for snapd to cleanup + // the left-over try-kernel symlink. + // + // If instead we had moved the kernel symlink first to point to the new try + // kernel, and got rebooted before the kernel_status was updated, we would + // have kernel_status="trying" which would cause the bootloader to think + // the boot failed, and revert to booting using the kernel symlink, but that + // now points to the new kernel we were trying and we did not successfully + // boot from that kernel to know we should trust it. + // + // Removing the old kernel from the modeenv needs to happen after it is + // impossible for the bootloader to boot from that kernel, otherwise we + // could end up in a state where the bootloader doesn't want to boot the + // new kernel, but the initramfs doesn't trust the old kernel and we are + // stuck. As such, do this last, after the symlink no longer exists. + // + // The try-kernel symlink removal should happen last because it will not + // affect anything, except that if it was removed before updating + // kernel_status to "", the bootloader will think that the try kernel failed + // to boot and fall back to booting the old kernel which is safe. + + // always set the boot vars first before mutating any of the kernel symlinks + // etc. + // for markSuccessful, we will always set the status to Default, even if + // technically this boot wasn't "successful" - it was successful in the + // sense that we booted some combination of boot snaps and made it all the + // way to snapd in user space + if bks.currentKernelStatus != DefaultStatus { + m := map[string]string{ + "kernel_status": DefaultStatus, + } + + // set the boot variables + err := bks.ebl.SetBootVars(m) + if err != nil { + return err + } + } + + // if the kernel we booted is not the current one, we must have tried + // a new kernel, so enable that one as the current one now + if bks.currentKernel.Filename() != sn.Filename() { + err := bks.ebl.EnableKernel(sn) + if err != nil { + return err + } + } + + // always disable the try kernel snap to cleanup in case we have upgrade + // failures which leave behind try-kernel.efi + err := bks.ebl.DisableTryKernel() + if err != nil { + return err + } + + return nil +} + +func (bks *extractedRunKernelImageBootloaderKernelState) setNextKernel(sn snap.PlaceInfo, status string) error { + // always enable the try-kernel first, if we did the reverse and got + // rebooted after setting the boot vars but before enabling the try-kernel + // we could get stuck where the bootloader can't find the try-kernel and + // gets stuck waiting for a user to reboot, at which point we would fallback + // see i.e. https://github.com/snapcore/pc-amd64-gadget/issues/36 + if sn.Filename() != bks.currentKernel.Filename() { + err := bks.ebl.EnableTryKernel(sn) + if err != nil { + return err + } + } + + // only if the new kernel status is different from what we read should we + // run SetBootVars() to minimize wear/corruption possibility on the bootenv + if status != bks.currentKernelStatus { + m := map[string]string{ + "kernel_status": status, + } + + // set the boot variables + return bks.ebl.SetBootVars(m) + } + + return nil +} + +func (bks *extractedRunKernelImageBootloaderKernelState) setNextKernelNoTry(sn snap.PlaceInfo) error { + if sn.Filename() != bks.currentKernel.Filename() { + err := bks.ebl.EnableKernel(sn) + if err != nil { + return err + } + } + + // Make sure that no try-kernel.efi link is left around. We do + // not really care if this method fails as depending on when + // we are undoing it might be there or not. + bks.ebl.DisableTryKernel() + + if bks.currentKernelStatus != DefaultStatus { + m := map[string]string{ + "kernel_status": DefaultStatus, + } + + // set the boot variables + return bks.ebl.SetBootVars(m) + } + + return nil +} + +// envRefExtractedKernelBootloaderKernelState implements bootloaderKernelState20 for +// bootloaders that only support using bootloader env and i.e. don't support +// ExtractedRunKernelImageBootloader +type envRefExtractedKernelBootloaderKernelState struct { + // the bootloader + bl bootloader.Bootloader + + // the current state of env + env map[string]string + + // the state of env to commit + toCommit map[string]string + + // the current kernel + kern snap.PlaceInfo +} + +func (envbks *envRefExtractedKernelBootloaderKernelState) load() error { + // for uc20, we only care about kernel_status, snap_kernel, and + // snap_try_kernel + m, err := envbks.bl.GetBootVars("kernel_status", "snap_kernel", "snap_try_kernel") + if err != nil { + return err + } + + // the default commit env is the same state as the current env + envbks.env = m + envbks.toCommit = make(map[string]string, len(m)) + for k, v := range m { + envbks.toCommit[k] = v + } + + // snap_kernel is the current kernel snap + // parse the filename here because the kernel() method doesn't return an err + sn, err := snap.ParsePlaceInfoFromSnapFileName(envbks.env["snap_kernel"]) + if err != nil { + return err + } + + envbks.kern = sn + + return nil +} + +func (envbks *envRefExtractedKernelBootloaderKernelState) kernel() snap.PlaceInfo { + return envbks.kern +} + +func (envbks *envRefExtractedKernelBootloaderKernelState) tryKernel() (snap.PlaceInfo, error) { + // empty snap_try_kernel is special case + if envbks.env["snap_try_kernel"] == "" { + return nil, bootloader.ErrNoTryKernelRef + } + sn, err := snap.ParsePlaceInfoFromSnapFileName(envbks.env["snap_try_kernel"]) + if err != nil { + return nil, err + } + + return sn, nil +} + +func (envbks *envRefExtractedKernelBootloaderKernelState) kernelStatus() string { + return envbks.env["kernel_status"] +} + +func (envbks *envRefExtractedKernelBootloaderKernelState) commonStateCommitUpdate(sn snap.PlaceInfo, bootvar string) bool { + envChanged := false + + // check kernel_status + if envbks.env["kernel_status"] != envbks.toCommit["kernel_status"] { + envChanged = true + } + + // if the specified snap is not the current snap, update the bootvar + if sn.Filename() != envbks.kern.Filename() { + envbks.toCommit[bootvar] = sn.Filename() + envChanged = true + } + + return envChanged +} + +func (envbks *envRefExtractedKernelBootloaderKernelState) markSuccessfulKernel(sn snap.PlaceInfo) error { + // the ordering here doesn't matter, as the only actual state we mutate is + // writing the bootloader env vars, so just do that once at the end after + // processing all the changes + + // always set kernel_status to DefaultStatus + envbks.toCommit["kernel_status"] = DefaultStatus + envChanged := envbks.commonStateCommitUpdate(sn, "snap_kernel") + + // if the snap_try_kernel is set, we should unset that to both cleanup after + // a successful trying -> "" transition, but also to cleanup if we got + // rebooted during the process and have it leftover + if envbks.env["snap_try_kernel"] != "" { + envChanged = true + envbks.toCommit["snap_try_kernel"] = "" + } + + if envChanged { + return envbks.bl.SetBootVars(envbks.toCommit) + } + + return nil +} + +func (envbks *envRefExtractedKernelBootloaderKernelState) setNextKernel(sn snap.PlaceInfo, status string) error { + envbks.toCommit["kernel_status"] = status + bootenvChanged := envbks.commonStateCommitUpdate(sn, "snap_try_kernel") + + if bootenvChanged { + return envbks.bl.SetBootVars(envbks.toCommit) + } + + return nil +} + +func (envbks *envRefExtractedKernelBootloaderKernelState) setNextKernelNoTry(sn snap.PlaceInfo) error { + envbks.toCommit["kernel_status"] = "" + bootenvChanged := envbks.commonStateCommitUpdate(sn, "snap_kernel") + + if bootenvChanged { + return envbks.bl.SetBootVars(envbks.toCommit) + } + + return nil +} diff --git a/boot/boottest/bootenv.go b/boot/boottest/bootenv.go new file mode 100644 index 00000000..a15b28d1 --- /dev/null +++ b/boot/boottest/bootenv.go @@ -0,0 +1,183 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2014-2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package boottest + +import ( + "fmt" + + "github.com/snapcore/snapd/bootloader/bootloadertest" + "github.com/snapcore/snapd/snap" +) + +// Bootenv16 implements manipulating a UC16/18 boot env for testing. +type Bootenv16 struct { + *bootloadertest.MockBootloader + statusVar string +} + +// MockUC16Bootenv wraps a mock bootloader for UC16/18 boot env +// manipulation. +func MockUC16Bootenv(b *bootloadertest.MockBootloader) *Bootenv16 { + return &Bootenv16{ + MockBootloader: b, + statusVar: "snap_mode", + } +} + +// SetBootKernel sets the current boot kernel string. Should be +// something like "pc-kernel_1234.snap". +func (b16 Bootenv16) SetBootKernel(kernel string) { + b16.SetBootVars(map[string]string{"snap_kernel": kernel}) +} + +// SetBootTryKernel sets the try boot kernel string. Should be +// something like "pc-kernel_1235.snap". +func (b16 Bootenv16) SetBootTryKernel(kernel string) { + b16.SetBootVars(map[string]string{"snap_try_kernel": kernel}) +} + +// SetBootBase sets the current boot base string. Should be something +// like "core_1234.snap". +func (b16 Bootenv16) SetBootBase(base string) { + b16.SetBootVars(map[string]string{"snap_core": base}) +} + +// SetTryingDuringReboot indicates that new kernel or base are being tried +// same as done by bootloader config. +func (b16 Bootenv16) SetTryingDuringReboot(which []snap.Type) error { + if b16.BootVars[b16.statusVar] != "try" { + return fmt.Errorf("bootloader must be in 'try' mode") + } + b16.BootVars[b16.statusVar] = "trying" + return nil +} + +func includesType(which []snap.Type, t snap.Type) bool { + for _, t1 := range which { + if t1 == t { + return true + } + } + return false +} + +func exactlyType(which []snap.Type, t snap.Type) bool { + if len(which) != 1 { + return false + } + if which[0] != t { + return false + } + return true +} + +// SetRollbackAcrossReboot will simulate a rollback across reboots. This +// means that the bootloader had "snap_try_{core,kernel}" set but this +// boot failed. In this case the bootloader will clear +// "snap_try_{core,kernel}" and "snap_mode" which means the "old" kernel,core +// in "snap_{core,kernel}" will be used. which indicates whether rollback +// applies to kernel, base or both. +func (b16 Bootenv16) SetRollbackAcrossReboot(which []snap.Type) error { + if b16.BootVars[b16.statusVar] != "try" { + return fmt.Errorf("rollback can only be simulated in 'try' mode") + } + rollbackBase := includesType(which, snap.TypeBase) + rollbackKernel := includesType(which, snap.TypeKernel) + if !rollbackBase && !rollbackKernel { + return fmt.Errorf("rollback of either base or kernel must be requested") + } + if rollbackBase && b16.BootVars["snap_core"] == "" && b16.BootVars["snap_kernel"] == "" { + return fmt.Errorf("base rollback can only be simulated if snap_core is set") + } + if rollbackKernel && b16.BootVars["snap_kernel"] == "" { + return fmt.Errorf("kernel rollback can only be simulated if snap_kernel is set") + } + // clean only statusVar - the try vars will be cleaned by snapd NOT by the + // bootloader + b16.BootVars[b16.statusVar] = "" + return nil +} + +// RunBootenv20 implements manipulating a UC20 run-mode boot env for +// testing. +type RunBootenv20 struct { + *bootloadertest.MockExtractedRunKernelImageBootloader +} + +// MockUC20EnvRefExtractedKernelRunBootenv wraps a mock bootloader for UC20 run-mode boot +// env manipulation. +func MockUC20EnvRefExtractedKernelRunBootenv(b *bootloadertest.MockBootloader) *Bootenv16 { + // TODO:UC20: implement this w/o returning Bootenv16 because that doesn't + // make a lot of sense to the caller + return &Bootenv16{ + MockBootloader: b, + statusVar: "kernel_status", + } +} + +// MockUC20RunBootenv wraps a mock bootloader for UC20 run-mode boot +// env manipulation. +func MockUC20RunBootenv(b *bootloadertest.MockBootloader) *RunBootenv20 { + return &RunBootenv20{b.WithExtractedRunKernelImage()} +} + +// TODO:UC20: expose actual snap-boostrap logic for testing + +// SetTryingDuringReboot indicates that new kernel or base are being tried +// same as done by bootloader config. +func (b20 RunBootenv20) SetTryingDuringReboot(which []snap.Type) error { + if !exactlyType(which, snap.TypeKernel) { + return fmt.Errorf("for now only kernel related simulation is supported") + } + if b20.BootVars["kernel_status"] != "try" { + return fmt.Errorf("bootloader must be in 'try' mode") + } + b20.BootVars["kernel_status"] = "trying" + return nil +} + +// SetRollbackAcrossReboot will simulate a rollback across reboots for either +// a new base or kernel or both, as indicated by which. +// TODO: only kernel is supported for now. +func (b20 RunBootenv20) SetRollbackAcrossReboot(which []snap.Type) error { + if !exactlyType(which, snap.TypeKernel) { + return fmt.Errorf("for now only kernel related simulation is supported") + } + if b20.BootVars["kernel_status"] != "try" { + return fmt.Errorf("rollback can only be simulated in 'try' mode") + } + // clean try bootvars and snap_mode + b20.BootVars["kernel_status"] = "" + return nil +} + +// RunBootenvNotScript20 implements manipulating a UC20 run-mode boot +// env for testing, for the case of not scriptable bootloader +// (i.e. piboot). +type RunBootenvNotScript20 struct { + *bootloadertest.MockExtractedRecoveryKernelNotScriptableBootloader +} + +// MockUC20RunBootenvNotScript wraps a mock bootloader for UC20 +// run-mode boot env manipulation, for the case of not scriptable +// bootloader (i.e. piboot). +func MockUC20RunBootenvNotScript(b *bootloadertest.MockBootloader) *RunBootenvNotScript20 { + return &RunBootenvNotScript20{b.RecoveryAware().WithNotScriptable().WithExtractedRecoveryKernel()} +} diff --git a/boot/boottest/device.go b/boot/boottest/device.go new file mode 100644 index 00000000..0c86efdb --- /dev/null +++ b/boot/boottest/device.go @@ -0,0 +1,138 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2014-2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package boottest + +import ( + "strings" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/snap" +) + +type mockDevice struct { + bootSnap string + mode string + hasModes bool + isClassic bool + + model *asserts.Model +} + +// MockDevice implements boot.Device. It wraps a string like +// [@], no means classic, empty +// defaults to "run" for UC16/18. If mode is set HasModeenv +// returns true for UC20 and an empty boot snap name panics. +// It returns for Base, Kernel and gadget, for more +// control mock a DeviceContext. +func MockDevice(s string) snap.Device { + bootsnap, mode, uc20 := snapAndMode(s) + if uc20 && bootsnap == "" { + panic("MockDevice with no snap name and @mode is unsupported") + } + return &mockDevice{ + bootSnap: bootsnap, + mode: mode, + hasModes: uc20, + isClassic: bootsnap == "", + } +} + +// mockDeviceWithModes implements boot.Device and returns true for +// HasModeenv. Arguments are mode (empty means "run"), model, and a +// boolean specifying if this is a classic with modes or a UC device. +// If model is nil a default model is used (MakeMockUC20Model or +// MakeMockClassicWithModesModel is called). +func mockDeviceWithModes(mode string, model *asserts.Model, isClassic bool) snap.Device { + if mode == "" { + mode = "run" + } + if model == nil { + if isClassic { + model = MakeMockClassicWithModesModel() + } else { + model = MakeMockUC20Model() + } + } + return &mockDevice{ + bootSnap: model.Kernel(), + mode: mode, + hasModes: true, + isClassic: isClassic, + model: model, + } +} + +// MockUC20Device mocks a UC with modes device. +// Arguments are mode (empty means "run"), and model. +func MockUC20Device(mode string, model *asserts.Model) snap.Device { + if model != nil && model.Classic() { + panic("MockUC20Device called with classic model") + } + isClassic := false + return mockDeviceWithModes(mode, model, isClassic) +} + +// MockClassicWithModesDevice mocks a classic with modes device. +// Arguments are mode (empty means "run"), and model. +func MockClassicWithModesDevice(mode string, model *asserts.Model) snap.Device { + if model != nil && !model.Classic() { + panic("MockClassicWithModesDevice called with Ubuntu Core model") + } + isClassic := true + return mockDeviceWithModes(mode, model, isClassic) +} + +func snapAndMode(str string) (snap, mode string, uc20 bool) { + parts := strings.SplitN(string(str), "@", 2) + if len(parts) == 1 || parts[1] == "" { + return parts[0], "run", false + } + return parts[0], parts[1], true +} + +func (d *mockDevice) Kernel() string { return d.bootSnap } +func (d *mockDevice) Classic() bool { return d.isClassic } +func (d *mockDevice) RunMode() bool { return d.mode == "run" } +func (d *mockDevice) HasModeenv() bool { return d.hasModes } +func (d *mockDevice) IsCoreBoot() bool { + if d.model != nil { + return d.model.Kernel() != "" + } + return d.hasModes || !d.isClassic +} +func (d *mockDevice) IsClassicBoot() bool { return !d.IsCoreBoot() } +func (d *mockDevice) Base() string { + if d.model != nil { + return d.model.Base() + } + return d.bootSnap +} +func (d *mockDevice) Gadget() string { + if d.model != nil { + return d.model.Gadget() + } + return d.bootSnap +} +func (d *mockDevice) Model() *asserts.Model { + if d.model == nil { + panic("Device.Model called but MockUC20Device not used") + } + return d.model +} diff --git a/boot/boottest/device_test.go b/boot/boottest/device_test.go new file mode 100644 index 00000000..a0a483df --- /dev/null +++ b/boot/boottest/device_test.go @@ -0,0 +1,140 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package boottest_test + +import ( + "testing" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/boot/boottest" +) + +func TestBoottest(t *testing.T) { TestingT(t) } + +type boottestSuite struct{} + +var _ = Suite(&boottestSuite{}) + +func (s *boottestSuite) TestMockDeviceClassic(c *C) { + dev := boottest.MockDevice("") + c.Check(dev.Classic(), Equals, true) + c.Check(dev.Kernel(), Equals, "") + c.Check(dev.Base(), Equals, "") + c.Check(dev.Gadget(), Equals, "") + c.Check(dev.RunMode(), Equals, true) + c.Check(dev.HasModeenv(), Equals, false) + c.Check(dev.IsClassicBoot(), Equals, true) + c.Check(dev.IsCoreBoot(), Equals, false) + + c.Check(func() { dev.Model() }, PanicMatches, "Device.Model called.*") + + c.Check(func() { boottest.MockDevice("@run") }, Panics, "MockDevice with no snap name and @mode is unsupported") +} + +func (s *boottestSuite) TestMockDeviceBaseOrKernel(c *C) { + dev := boottest.MockDevice("boot-snap") + c.Check(dev.Classic(), Equals, false) + c.Check(dev.Kernel(), Equals, "boot-snap") + c.Check(dev.Base(), Equals, "boot-snap") + c.Check(dev.Gadget(), Equals, "boot-snap") + c.Check(dev.RunMode(), Equals, true) + c.Check(dev.HasModeenv(), Equals, false) + c.Check(dev.IsClassicBoot(), Equals, false) + c.Check(dev.IsCoreBoot(), Equals, true) + c.Check(func() { dev.Model() }, Panics, "Device.Model called but MockUC20Device not used") + + dev = boottest.MockDevice("boot-snap@run") + c.Check(dev.Classic(), Equals, false) + c.Check(dev.Kernel(), Equals, "boot-snap") + c.Check(dev.Base(), Equals, "boot-snap") + c.Check(dev.Gadget(), Equals, "boot-snap") + c.Check(dev.RunMode(), Equals, true) + c.Check(dev.HasModeenv(), Equals, true) + c.Check(dev.IsClassicBoot(), Equals, false) + c.Check(dev.IsCoreBoot(), Equals, true) + c.Check(func() { dev.Model() }, PanicMatches, "Device.Model called.*") + + dev = boottest.MockDevice("boot-snap@recover") + c.Check(dev.Classic(), Equals, false) + c.Check(dev.Kernel(), Equals, "boot-snap") + c.Check(dev.Base(), Equals, "boot-snap") + c.Check(dev.Gadget(), Equals, "boot-snap") + c.Check(dev.RunMode(), Equals, false) + c.Check(dev.HasModeenv(), Equals, true) + c.Check(dev.IsClassicBoot(), Equals, false) + c.Check(dev.IsCoreBoot(), Equals, true) + c.Check(func() { dev.Model() }, PanicMatches, "Device.Model called.*") +} + +func (s *boottestSuite) testMockDeviceWithModes(c *C, isUC bool) { + makeMockModel := boottest.MakeMockClassicWithModesModel + makeMockDevice := boottest.MockClassicWithModesDevice + modelName := "my-model-classic-modes" + if isUC { + makeMockModel = boottest.MakeMockUC20Model + makeMockDevice = boottest.MockUC20Device + modelName = "my-model-uc20" + } + dev := makeMockDevice("", nil) + c.Check(dev.HasModeenv(), Equals, true) + c.Check(dev.Classic(), Equals, !isUC) + c.Check(dev.RunMode(), Equals, true) + c.Check(dev.Kernel(), Equals, "pc-kernel") + c.Check(dev.Base(), Equals, "core20") + c.Check(dev.Gadget(), Equals, "pc") + c.Check(dev.IsClassicBoot(), Equals, false) + c.Check(dev.IsCoreBoot(), Equals, true) + + c.Check(dev.Model().Model(), Equals, modelName) + + dev = makeMockDevice("run", nil) + c.Check(dev.RunMode(), Equals, true) + + dev = makeMockDevice("recover", nil) + c.Check(dev.RunMode(), Equals, false) + + model := makeMockModel(map[string]interface{}{ + "model": "other-model-uc20", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "pc-linux", + "id": "pclinuxdidididididididididididid", + "type": "kernel", + }, + map[string]interface{}{ + "name": "pc", + "id": "pcididididididididididididididid", + "type": "gadget", + }, + }, + }) + dev = makeMockDevice("recover", model) + c.Check(dev.RunMode(), Equals, false) + c.Check(dev.Kernel(), Equals, "pc-linux") + c.Check(dev.Model().Model(), Equals, "other-model-uc20") + c.Check(dev.Model(), Equals, model) +} + +func (s *boottestSuite) TestMockDeviceWithModes(c *C) { + isUC := true + s.testMockDeviceWithModes(c, isUC) + s.testMockDeviceWithModes(c, !isUC) +} diff --git a/boot/boottest/model.go b/boot/boottest/model.go new file mode 100644 index 00000000..de0fc9ca --- /dev/null +++ b/boot/boottest/model.go @@ -0,0 +1,100 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package boottest + +import ( + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/asserts/assertstest" +) + +func MakeMockModel(overrides ...map[string]interface{}) *asserts.Model { + headers := map[string]interface{}{ + "type": "model", + "authority-id": "my-brand", + "series": "16", + "brand-id": "my-brand", + "model": "my-model", + "display-name": "My Model", + "architecture": "amd64", + "base": "core18", + "gadget": "pc=18", + "kernel": "pc-kernel=18", + "timestamp": "2018-01-01T08:00:00+00:00", + } + return assertstest.FakeAssertion(append([]map[string]interface{}{headers}, overrides...)...).(*asserts.Model) +} + +func MakeMockUC20Model(overrides ...map[string]interface{}) *asserts.Model { + headers := map[string]interface{}{ + "type": "model", + "authority-id": "my-brand", + "series": "16", + "brand-id": "my-brand", + "model": "my-model-uc20", + "display-name": "My Model", + "architecture": "amd64", + "base": "core20", + "grade": "dangerous", + "timestamp": "2019-11-01T08:00:00+00:00", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "pc-kernel", + "id": "pckernelidididididididididididid", + "type": "kernel", + }, + map[string]interface{}{ + "name": "pc", + "id": "pcididididididididididididididid", + "type": "gadget", + }, + }, + } + return assertstest.FakeAssertion(append([]map[string]interface{}{headers}, overrides...)...).(*asserts.Model) +} + +func MakeMockClassicWithModesModel(overrides ...map[string]interface{}) *asserts.Model { + headers := map[string]interface{}{ + "type": "model", + "classic": "true", + "distribution": "ubuntu", + "authority-id": "my-brand", + "series": "16", + "brand-id": "my-brand", + "model": "my-model-classic-modes", + "display-name": "My Model", + "architecture": "amd64", + "base": "core20", + "grade": "dangerous", + "timestamp": "2019-11-01T08:00:00+00:00", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "pc-kernel", + "id": "pckernelidididididididididididid", + "type": "kernel", + }, + map[string]interface{}{ + "name": "pc", + "id": "pcididididididididididididididid", + "type": "gadget", + }, + }, + } + return assertstest.FakeAssertion(append([]map[string]interface{}{headers}, overrides...)...).(*asserts.Model) +} diff --git a/boot/cmdline.go b/boot/cmdline.go new file mode 100644 index 00000000..fa759520 --- /dev/null +++ b/boot/cmdline.go @@ -0,0 +1,400 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2021 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package boot + +import ( + "errors" + "fmt" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/bootloader" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/gadget" + "github.com/snapcore/snapd/logger" + "github.com/snapcore/snapd/osutil/kcmdline" + "github.com/snapcore/snapd/strutil" +) + +const ( + // ModeRun indicates the regular operating system mode of the device. + ModeRun = "run" + // ModeInstall is a mode in which a new system is installed on the + // device. + ModeInstall = "install" + // ModeRecover is a mode in which the device boots into the recovery + // system. + ModeRecover = "recover" + // ModeFactoryReset is a mode in which the device performs a factory + // reset. + ModeFactoryReset = "factory-reset" + // ModeRunCVM is Azure CVM specific run mode fde + classic debs + ModeRunCVM = "cloudimg-rootfs" +) + +var ( + validModes = []string{ModeInstall, ModeRecover, ModeFactoryReset, ModeRun, ModeRunCVM} +) + +// ModeAndRecoverySystemFromKernelCommandLine returns the current system mode +// and the recovery system label as passed in the kernel command line by the +// bootloader. +func ModeAndRecoverySystemFromKernelCommandLine() (mode, sysLabel string, err error) { + m, err := kcmdline.KeyValues("snapd_recovery_mode", "snapd_recovery_system") + if err != nil { + return "", "", err + } + var modeOk bool + mode, modeOk = m["snapd_recovery_mode"] + + // no mode specified gets interpreted as install + if modeOk { + if mode == "" { + mode = ModeInstall + } else if !strutil.ListContains(validModes, mode) { + return "", "", fmt.Errorf("cannot use unknown mode %q", mode) + } + } + + sysLabel = m["snapd_recovery_system"] + + switch { + case mode == "" && sysLabel == "": + return "", "", fmt.Errorf("cannot detect mode nor recovery system to use") + case mode == "" && sysLabel != "": + return "", "", fmt.Errorf("cannot specify system label without a mode") + case mode == ModeInstall && sysLabel == "": + return "", "", fmt.Errorf("cannot specify install mode without system label") + case mode == ModeRun && sysLabel != "": + // XXX: should we silently ignore the label? at least log for now + logger.Noticef(`ignoring recovery system label %q in "run" mode`, sysLabel) + sysLabel = "" + } + return mode, sysLabel, nil +} + +var errBootConfigNotManaged = errors.New("boot config is not managed") + +func getBootloaderManagingItsAssets(where string, opts *bootloader.Options) (bootloader.TrustedAssetsBootloader, error) { + bl, err := bootloader.Find(where, opts) + if err != nil { + return nil, fmt.Errorf("internal error: cannot find trusted assets bootloader under %q: %v", where, err) + } + mbl, ok := bl.(bootloader.TrustedAssetsBootloader) + if !ok { + // the bootloader cannot manage its scripts + return nil, errBootConfigNotManaged + } + return mbl, nil +} + +// bootVarsForTrustedCommandLineFromGadget returns a set of boot +// variables that carry the command line arguments defined by the +// gadget and some system options (cmdlineApped). This is only useful +// if snapd is managing the boot config. +func bootVarsForTrustedCommandLineFromGadget(gadgetDirOrSnapPath, cmdlineAppend string, defaultCmdline string, model gadget.Model) (map[string]string, error) { + extraOrFull, full, removeArgs, err := gadget.KernelCommandLineFromGadget(gadgetDirOrSnapPath, model) + if err != nil { + return nil, fmt.Errorf("cannot use kernel command line from gadget: %v", err) + } + logger.Debugf("trusted command line: from gadget: %q, from options: %q", + extraOrFull, cmdlineAppend) + + extraOrFull = strutil.JoinNonEmpty([]string{extraOrFull, cmdlineAppend}, " ") + + keepDefaultArgs := kcmdline.RemoveMatchingFilter(defaultCmdline, removeArgs) + + // gadget has the kernel command line + args := map[string]string{ + "snapd_extra_cmdline_args": "", + "snapd_full_cmdline_args": "", + } + if full { + args["snapd_full_cmdline_args"] = extraOrFull + } else { + args["snapd_full_cmdline_args"] = strutil.JoinNonEmpty(append(keepDefaultArgs, extraOrFull), " ") + } + if len(args["snapd_full_cmdline_args"]) == 0 { + // grub.cfg tests if snapd_full_cmdline_args is set by looking if it is not empty. + // Here, it should be set, but empty. So adding a space will force grub.cfg to use it. + args["snapd_full_cmdline_args"] = " " + } + return args, nil +} + +const ( + currentEdition = iota + candidateEdition +) + +func composeCommandLine(currentOrCandidate int, mode, system, gadgetDirOrSnapPath string, model gadget.Model) (string, error) { + if mode != ModeRun && mode != ModeRecover && mode != ModeFactoryReset { + return "", fmt.Errorf("internal error: unsupported command line mode %q", mode) + } + // get the run mode bootloader under the native run partition layout + opts := &bootloader.Options{ + Role: bootloader.RoleRunMode, + NoSlashBoot: true, + } + bootloaderRootDir := InitramfsUbuntuBootDir + components := bootloader.CommandLineComponents{ + ModeArg: "snapd_recovery_mode=run", + } + if mode == ModeRecover || mode == ModeFactoryReset { + if system == "" { + return "", fmt.Errorf("internal error: system is unset") + } + // dealing with recovery system bootloader + opts.Role = bootloader.RoleRecovery + bootloaderRootDir = InitramfsUbuntuSeedDir + // recovery mode & system command line arguments + modeArg := "snapd_recovery_mode=recover" + if mode == ModeFactoryReset { + modeArg = "snapd_recovery_mode=factory-reset" + } + components = bootloader.CommandLineComponents{ + ModeArg: modeArg, + SystemArg: fmt.Sprintf("snapd_recovery_system=%v", system), + } + } + mbl, err := getBootloaderManagingItsAssets(bootloaderRootDir, opts) + if err != nil { + if err == errBootConfigNotManaged { + return "", nil + } + return "", err + } + if gadgetDirOrSnapPath != "" { + extraOrFull, full, removeArgs, err := gadget.KernelCommandLineFromGadget(gadgetDirOrSnapPath, model) + components.RemoveArgs = removeArgs + if err != nil { + return "", fmt.Errorf("cannot use kernel command line from gadget: %v", err) + } + // gadget provides some part of the kernel command line + if full { + components.FullArgs = extraOrFull + } else { + components.ExtraArgs = extraOrFull + } + } + if currentOrCandidate == currentEdition { + return mbl.CommandLine(components) + } else { + return mbl.CandidateCommandLine(components) + } +} + +// ComposeRecoveryCommandLine composes the kernel command line used when booting +// a given system in recover mode. +func ComposeRecoveryCommandLine(model *asserts.Model, system, gadgetDirOrSnapPath string) (string, error) { + if model.Grade() == asserts.ModelGradeUnset { + return "", nil + } + return composeCommandLine(currentEdition, ModeRecover, system, gadgetDirOrSnapPath, model) +} + +// ComposeCommandLine composes the kernel command line used when booting the +// system in run mode. +func ComposeCommandLine(model *asserts.Model, gadgetDirOrSnapPath string) (string, error) { + if model.Grade() == asserts.ModelGradeUnset { + return "", nil + } + return composeCommandLine(currentEdition, ModeRun, "", gadgetDirOrSnapPath, model) +} + +// ComposeCandidateCommandLine composes the kernel command line used when +// booting the system in run mode with the current built-in edition of managed +// boot assets. +func ComposeCandidateCommandLine(model *asserts.Model, gadgetDirOrSnapPath string) (string, error) { + if model.Grade() == asserts.ModelGradeUnset { + return "", nil + } + return composeCommandLine(candidateEdition, ModeRun, "", gadgetDirOrSnapPath, model) +} + +// ComposeCandidateRecoveryCommandLine composes the kernel command line used +// when booting the given system in recover mode with the current built-in +// edition of managed boot assets. +func ComposeCandidateRecoveryCommandLine(model *asserts.Model, system, gadgetDirOrSnapPath string) (string, error) { + if model.Grade() == asserts.ModelGradeUnset { + return "", nil + } + return composeCommandLine(candidateEdition, ModeRecover, system, gadgetDirOrSnapPath, model) +} + +// observeSuccessfulCommandLine observes a successful boot with a command line +// and takes an action based on the contents of the modeenv. The current kernel +// command lines in the modeenv can have up to 2 entries when the managed +// bootloader boot config gets updated. +func observeSuccessfulCommandLine(model *asserts.Model, m *Modeenv) (*Modeenv, error) { + // TODO:UC20 only care about run mode for now + if m.Mode != "run" { + return m, nil + } + + switch len(m.CurrentKernelCommandLines) { + case 0: + // maybe a compatibility scenario, no command lines tracked in + // modeenv yet, this can happen when having booted with a newer + // snapd + return observeSuccessfulCommandLineCompatBoot(model, m) + case 1: + // no command line update + return m, nil + default: + return observeSuccessfulCommandLineUpdate(m) + } +} + +// observeSuccessfulCommandLineUpdate observes a successful boot with a command +// line which is expected to be listed among the current kernel command line +// entries carried in the modeenv. One of those entries must match the current +// kernel command line of a running system and will be recorded alone as in use. +func observeSuccessfulCommandLineUpdate(m *Modeenv) (*Modeenv, error) { + newM, err := m.Copy() + if err != nil { + return nil, err + } + + // get the current command line + cmdlineBootedWith, err := kcmdline.KernelCommandLine() + if err != nil { + return nil, err + } + if !strutil.ListContains([]string(m.CurrentKernelCommandLines), cmdlineBootedWith) { + return nil, fmt.Errorf("current command line content %q not matching any expected entry", + cmdlineBootedWith) + } + newM.CurrentKernelCommandLines = bootCommandLines{cmdlineBootedWith} + + return newM, nil +} + +// observeSuccessfulCommandLineCompatBoot observes a successful boot with a +// kernel command line, where the list of current kernel command lines in the +// modeenv is unpopulated. This handles a compatibility scenario with systems +// that were installed using a previous version of snapd. It verifies that the +// expected kernel command line matches the one the system booted with and +// populates modeenv kernel command line list accordingly. +func observeSuccessfulCommandLineCompatBoot(model *asserts.Model, m *Modeenv) (*Modeenv, error) { + // since this is a compatibility scenario, the kernel command line + // arguments would not have come from the gadget before either + cmdlineExpected, err := ComposeCommandLine(model, "") + if err != nil { + return nil, err + } + if cmdlineExpected == "" { + // there is no particular command line expected for this model + // and system bootloader, indicating that the command line is + // not being tracked + return m, nil + } + cmdlineBootedWith, err := kcmdline.KernelCommandLine() + if err != nil { + return nil, err + } + if cmdlineExpected != cmdlineBootedWith { + return nil, fmt.Errorf("unexpected current command line: %q", cmdlineBootedWith) + } + newM, err := m.Copy() + if err != nil { + return nil, err + } + newM.CurrentKernelCommandLines = bootCommandLines{cmdlineExpected} + return newM, nil +} + +type commandLineUpdateReason int + +const ( + commandLineUpdateReasonSnapd commandLineUpdateReason = iota + commandLineUpdateReasonGadget +) + +// observeCommandLineUpdate observes a pending kernel command line change caused +// by an update of boot config or the gadget snap. When needed, the modeenv is +// updated with a candidate command line and the encryption keys are resealed. +// This helper should be called right before updating the managed boot config. +func observeCommandLineUpdate(model *asserts.Model, reason commandLineUpdateReason, gadgetSnapOrDir, cmdlineOpt string) (updated bool, err error) { + // TODO:UC20: consider updating a recovery system command line + + m, err := loadModeenv() + if err != nil { + return false, err + } + + if len(m.CurrentKernelCommandLines) == 0 { + return false, fmt.Errorf("internal error: current kernel command lines is unset") + } + // this is the current expected command line which was recorded by + // bootstate + cmdline := m.CurrentKernelCommandLines[0] + // this is the new expected command line + var candidateCmdline string + switch reason { + case commandLineUpdateReasonSnapd: + // pending boot config update + candidateCmdline, err = ComposeCandidateCommandLine(model, gadgetSnapOrDir) + case commandLineUpdateReasonGadget: + // pending gadget update + candidateCmdline, err = ComposeCommandLine(model, gadgetSnapOrDir) + } + if err != nil { + return false, err + } + // Add part coming from options + candidateCmdline = strutil.JoinNonEmpty( + []string{candidateCmdline, cmdlineOpt}, " ") + if cmdline == candidateCmdline { + // command line is the same or no actual change in modeenv + return false, nil + } + logger.Debugf("kernel commandline changes from %q to %q", cmdline, candidateCmdline) + // actual change of the command line content + m.CurrentKernelCommandLines = bootCommandLines{cmdline, candidateCmdline} + + if err := m.Write(); err != nil { + return false, err + } + + expectReseal := true + if err := resealKeyToModeenv(dirs.GlobalRootDir, m, expectReseal, nil); err != nil { + return false, err + } + return true, nil +} + +// kernelCommandLinesForResealWithFallback provides the list of kernel command +// lines for use during reseal. During normal operation, the command lines will +// be listed in the modeenv. +func kernelCommandLinesForResealWithFallback(modeenv *Modeenv) (cmdlines []string, err error) { + if len(modeenv.CurrentKernelCommandLines) > 0 { + return modeenv.CurrentKernelCommandLines, nil + } + // fallback for when reseal is called before mark boot successful set a + // default during snapd update, since this is a compatibility scenario + // there would be no kernel command lines arguments coming from the + // gadget either + gadgetDir := "" + cmdline, err := composeCommandLine(currentEdition, ModeRun, "", gadgetDir, modeenv.ModelForSealing()) + if err != nil { + return nil, err + } + return []string{cmdline}, nil +} diff --git a/boot/cmdline_test.go b/boot/cmdline_test.go new file mode 100644 index 00000000..a626cf81 --- /dev/null +++ b/boot/cmdline_test.go @@ -0,0 +1,510 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2021 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package boot_test + +import ( + "fmt" + "os" + "path/filepath" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/boot" + "github.com/snapcore/snapd/boot/boottest" + "github.com/snapcore/snapd/bootloader" + "github.com/snapcore/snapd/bootloader/bootloadertest" + "github.com/snapcore/snapd/gadget/gadgettest" + "github.com/snapcore/snapd/osutil/kcmdline" + "github.com/snapcore/snapd/snap/snaptest" + "github.com/snapcore/snapd/testutil" +) + +var _ = Suite(&kernelCommandLineSuite{}) + +// baseBootSuite is used to setup the common test environment +type kernelCommandLineSuite struct { + testutil.BaseTest + rootDir string +} + +func (s *kernelCommandLineSuite) SetUpTest(c *C) { + s.BaseTest.SetUpTest(c) + s.rootDir = c.MkDir() + + err := os.MkdirAll(filepath.Join(s.rootDir, "proc"), 0755) + c.Assert(err, IsNil) + restore := kcmdline.MockProcCmdline(filepath.Join(s.rootDir, "proc/cmdline")) + s.AddCleanup(restore) +} + +func (s *kernelCommandLineSuite) mockProcCmdlineContent(c *C, newContent string) { + mockProcCmdline := filepath.Join(s.rootDir, "proc/cmdline") + err := os.WriteFile(mockProcCmdline, []byte(newContent), 0644) + c.Assert(err, IsNil) +} + +func (s *kernelCommandLineSuite) TestModeAndLabel(c *C) { + for _, tc := range []struct { + cmd string + mode string + label string + err string + }{{ + cmd: "snapd_recovery_mode= snapd_recovery_system=this-is-a-label other-option=foo", + mode: boot.ModeInstall, + label: "this-is-a-label", + }, { + cmd: "snapd_recovery_system=label foo=bar foobaz=\\0\\0123 snapd_recovery_mode=install", + label: "label", + mode: boot.ModeInstall, + }, { + cmd: "snapd_recovery_mode=run snapd_recovery_system=1234", + mode: boot.ModeRun, + }, { + cmd: "snapd_recovery_mode=recover snapd_recovery_system=1234", + label: "1234", + mode: boot.ModeRecover, + }, { + cmd: "snapd_recovery_mode=factory-reset snapd_recovery_system=1234", + label: "1234", + mode: boot.ModeFactoryReset, + }, { + cmd: "option=1 other-option=\0123 none", + err: "cannot detect mode nor recovery system to use", + }, { + cmd: "snapd_recovery_mode=install-foo", + err: `cannot use unknown mode "install-foo"`, + }, { + // no recovery system label + cmd: "snapd_recovery_mode=install foo=bar", + err: `cannot specify install mode without system label`, + }, { + cmd: "snapd_recovery_system=1234", + err: `cannot specify system label without a mode`, + }, { + // multiple kernel command line params end up using the last one - this + // effectively matches the kernel handling too + cmd: "snapd_recovery_mode=install snapd_recovery_system=1234 snapd_recovery_mode=run", + mode: "run", + // label gets unset because it's not used for run mode + label: "", + }, { + cmd: "snapd_recovery_system=not-this-one snapd_recovery_mode=install snapd_recovery_system=1234", + mode: "install", + label: "1234", + }, { + cmd: "snapd_recovery_mode=cloudimg-rootfs", + mode: boot.ModeRunCVM, + }} { + c.Logf("tc: %q", tc) + s.mockProcCmdlineContent(c, tc.cmd) + + mode, label, err := boot.ModeAndRecoverySystemFromKernelCommandLine() + if tc.err == "" { + c.Assert(err, IsNil) + c.Check(mode, Equals, tc.mode) + c.Check(label, Equals, tc.label) + } else { + c.Assert(err, ErrorMatches, tc.err) + } + } +} + +func (s *kernelCommandLineSuite) TestComposeCommandLineNotManagedHappy(c *C) { + model := boottest.MakeMockUC20Model() + + bl := bootloadertest.Mock("btloader", c.MkDir()) + bootloader.Force(bl) + defer bootloader.Force(nil) + + cmdline, err := boot.ComposeRecoveryCommandLine(model, "20200314", "") + c.Assert(err, IsNil) + c.Assert(cmdline, Equals, "") + + cmdline, err = boot.ComposeCommandLine(model, "") + c.Assert(err, IsNil) + c.Assert(cmdline, Equals, "") + + tbl := bl.WithTrustedAssets() + bootloader.Force(tbl) + + cmdline, err = boot.ComposeRecoveryCommandLine(model, "20200314", "") + c.Assert(err, IsNil) + c.Assert(cmdline, Equals, "snapd_recovery_mode=recover snapd_recovery_system=20200314") + + cmdline, err = boot.ComposeCommandLine(model, "") + c.Assert(err, IsNil) + c.Assert(cmdline, Equals, "snapd_recovery_mode=run") +} + +func (s *kernelCommandLineSuite) TestComposeCommandLineNotUC20(c *C) { + model := boottest.MakeMockModel() + + bl := bootloadertest.Mock("btloader", c.MkDir()) + bootloader.Force(bl) + defer bootloader.Force(nil) + cmdline, err := boot.ComposeRecoveryCommandLine(model, "20200314", "") + c.Assert(err, IsNil) + c.Check(cmdline, Equals, "") + + cmdline, err = boot.ComposeCommandLine(model, "") + c.Assert(err, IsNil) + c.Check(cmdline, Equals, "") +} + +func (s *kernelCommandLineSuite) TestComposeCommandLineManagedHappy(c *C) { + model := boottest.MakeMockUC20Model() + + tbl := bootloadertest.Mock("btloader", c.MkDir()).WithTrustedAssets() + bootloader.Force(tbl) + defer bootloader.Force(nil) + + tbl.StaticCommandLine = "panic=-1" + + cmdline, err := boot.ComposeRecoveryCommandLine(model, "20200314", "") + c.Assert(err, IsNil) + c.Assert(cmdline, Equals, "snapd_recovery_mode=recover snapd_recovery_system=20200314 panic=-1") + cmdline, err = boot.ComposeCommandLine(model, "") + c.Assert(err, IsNil) + c.Assert(cmdline, Equals, "snapd_recovery_mode=run panic=-1") + + cmdline, err = boot.ComposeRecoveryCommandLine(model, "20200314", "") + c.Assert(err, IsNil) + c.Assert(cmdline, Equals, "snapd_recovery_mode=recover snapd_recovery_system=20200314 panic=-1") + cmdline, err = boot.ComposeCommandLine(model, "") + c.Assert(err, IsNil) + c.Assert(cmdline, Equals, "snapd_recovery_mode=run panic=-1") +} + +func (s *kernelCommandLineSuite) TestComposeCandidateCommandLineManagedHappy(c *C) { + model := boottest.MakeMockUC20Model() + + tbl := bootloadertest.Mock("btloader", c.MkDir()).WithTrustedAssets() + bootloader.Force(tbl) + defer bootloader.Force(nil) + + tbl.StaticCommandLine = "panic=-1" + tbl.CandidateStaticCommandLine = "candidate panic=0" + + cmdline, err := boot.ComposeCandidateCommandLine(model, "") + c.Assert(err, IsNil) + c.Assert(cmdline, Equals, "snapd_recovery_mode=run candidate panic=0") +} + +func (s *kernelCommandLineSuite) TestComposeCandidateRecoveryCommandLineManagedHappy(c *C) { + model := boottest.MakeMockUC20Model() + + tbl := bootloadertest.Mock("btloader", c.MkDir()).WithTrustedAssets() + bootloader.Force(tbl) + defer bootloader.Force(nil) + + tbl.StaticCommandLine = "panic=-1" + tbl.CandidateStaticCommandLine = "candidate panic=0" + + cmdline, err := boot.ComposeCandidateRecoveryCommandLine(model, "1234", "") + c.Assert(err, IsNil) + c.Check(cmdline, Equals, "snapd_recovery_mode=recover snapd_recovery_system=1234 candidate panic=0") + + cmdline, err = boot.ComposeCandidateRecoveryCommandLine(model, "", "") + c.Assert(err, ErrorMatches, "internal error: system is unset") + c.Check(cmdline, Equals, "") +} + +const gadgetSnapYaml = `name: gadget +version: 1.0 +type: gadget +` + +func (s *kernelCommandLineSuite) TestComposeCommandLineWithGadget(c *C) { + model := boottest.MakeMockUC20Model() + + mockGadgetYaml := ` +volumes: + volumename: + bootloader: grub +` + + tbl := bootloadertest.Mock("btloader", c.MkDir()).WithTrustedAssets() + bootloader.Force(tbl) + defer bootloader.Force(nil) + + tbl.StaticCommandLine = "panic=-1" + tbl.CandidateStaticCommandLine = "candidate panic=0" + + for _, tc := range []struct { + which string + files [][]string + expCommandLine string + errMsg string + }{{ + which: "current", + files: [][]string{ + {"cmdline.extra", "cmdline extra"}, + }, + expCommandLine: "snapd_recovery_mode=run panic=-1 cmdline extra", + }, { + which: "candidate", + files: [][]string{ + {"cmdline.extra", "cmdline extra"}, + }, + expCommandLine: "snapd_recovery_mode=run candidate panic=0 cmdline extra", + }, { + which: "current", + files: [][]string{ + {"cmdline.full", "cmdline full"}, + }, + expCommandLine: "snapd_recovery_mode=run cmdline full", + }, { + which: "candidate", + files: [][]string{ + {"cmdline.full", "cmdline full"}, + }, + expCommandLine: "snapd_recovery_mode=run cmdline full", + }, { + which: "candidate", + files: [][]string{ + {"cmdline.extra", `bad-quote="`}, + }, + errMsg: `cannot use kernel command line from gadget: invalid kernel command line in cmdline.extra: unbalanced quoting`, + }} { + sf := snaptest.MakeTestSnapWithFiles(c, gadgetSnapYaml, append([][]string{ + {"meta/snap.yaml", gadgetSnapYaml}, + {"meta/gadget.yaml", mockGadgetYaml}, + }, tc.files...)) + var cmdline string + var err error + switch tc.which { + case "current": + cmdline, err = boot.ComposeCommandLine(model, sf) + case "candidate": + cmdline, err = boot.ComposeCandidateCommandLine(model, sf) + default: + c.Fatalf("unexpected command line type") + } + if tc.errMsg == "" { + c.Assert(err, IsNil) + c.Assert(cmdline, Equals, tc.expCommandLine) + } else { + c.Assert(err, ErrorMatches, tc.errMsg) + } + } +} + +func (s *kernelCommandLineSuite) TestComposeRecoveryCommandLineWithGadget(c *C) { + model := boottest.MakeMockUC20Model() + + mockGadgetYaml := ` +volumes: + volumename: + bootloader: grub +` + + tbl := bootloadertest.Mock("btloader", c.MkDir()).WithTrustedAssets() + bootloader.Force(tbl) + defer bootloader.Force(nil) + + tbl.StaticCommandLine = "panic=-1" + tbl.CandidateStaticCommandLine = "candidate panic=0" + system := "1234" + + for _, tc := range []struct { + which string + files [][]string + expCommandLine string + errMsg string + }{{ + which: "current", + files: [][]string{ + {"cmdline.extra", "cmdline extra"}, + }, + expCommandLine: "snapd_recovery_mode=recover snapd_recovery_system=1234 panic=-1 cmdline extra", + }, { + which: "candidate", + files: [][]string{ + {"cmdline.extra", "cmdline extra"}, + }, + expCommandLine: "snapd_recovery_mode=recover snapd_recovery_system=1234 candidate panic=0 cmdline extra", + }, { + which: "current", + files: [][]string{ + {"cmdline.full", "cmdline full"}, + }, + expCommandLine: "snapd_recovery_mode=recover snapd_recovery_system=1234 cmdline full", + }, { + which: "candidate", + files: [][]string{ + {"cmdline.full", "cmdline full"}, + }, + expCommandLine: "snapd_recovery_mode=recover snapd_recovery_system=1234 cmdline full", + }, { + which: "candidate", + files: [][]string{ + {"cmdline.extra", `bad-quote="`}, + }, + errMsg: `cannot use kernel command line from gadget: invalid kernel command line in cmdline.extra: unbalanced quoting`, + }} { + sf := snaptest.MakeTestSnapWithFiles(c, gadgetSnapYaml, append([][]string{ + {"meta/snap.yaml", gadgetSnapYaml}, + {"meta/gadget.yaml", mockGadgetYaml}, + }, tc.files...)) + var cmdline string + var err error + switch tc.which { + case "current": + cmdline, err = boot.ComposeRecoveryCommandLine(model, system, sf) + case "candidate": + cmdline, err = boot.ComposeCandidateRecoveryCommandLine(model, system, sf) + default: + c.Fatalf("unexpected command line type") + } + if tc.errMsg == "" { + c.Assert(err, IsNil) + c.Assert(cmdline, Equals, tc.expCommandLine) + } else { + c.Assert(err, ErrorMatches, tc.errMsg) + } + } +} + +func (s *kernelCommandLineSuite) TestBootVarsForGadgetCommandLine(c *C) { + model := &gadgettest.ModelCharacteristics{} + + mockGadgetYaml := ` +volumes: + volumename: + bootloader: grub +` + + for _, tc := range []struct { + errMsg string + files [][]string + cmdlineAppend string + expectedVars map[string]string + append []string + remove []string + }{{ + files: [][]string{ + {"cmdline.extra", "foo bar baz"}, + }, + expectedVars: map[string]string{ + "snapd_extra_cmdline_args": "", + "snapd_full_cmdline_args": "default foo bar baz", + }, + }, { + files: [][]string{ + {"cmdline.extra", "snapd.debug=1"}, + }, + expectedVars: map[string]string{ + "snapd_extra_cmdline_args": "", + "snapd_full_cmdline_args": "default snapd.debug=1", + }, + }, { + files: [][]string{ + {"cmdline.extra", "snapd_foo"}, + }, + errMsg: `cannot use kernel command line from gadget: invalid kernel command line in cmdline.extra: disallowed kernel argument \"snapd_foo\"`, + }, { + files: [][]string{ + {"cmdline.full", "full foo bar baz"}, + }, + expectedVars: map[string]string{ + "snapd_extra_cmdline_args": "", + "snapd_full_cmdline_args": "full foo bar baz", + }, + }, { + cmdlineAppend: "foo bar baz", + expectedVars: map[string]string{ + "snapd_extra_cmdline_args": "", + "snapd_full_cmdline_args": "default foo bar baz", + }, + }, { + files: [][]string{ + {"cmdline.extra", "foo bar baz"}, + }, + cmdlineAppend: "x=y z", + expectedVars: map[string]string{ + "snapd_extra_cmdline_args": "", + "snapd_full_cmdline_args": "default foo bar baz x=y z", + }, + }, { + files: [][]string{ + {"cmdline.full", "full foo bar baz"}, + }, + cmdlineAppend: "x=y z", + expectedVars: map[string]string{ + "snapd_extra_cmdline_args": "", + "snapd_full_cmdline_args": "full foo bar baz x=y z", + }, + }, { + // with no arguments boot variables should be cleared + files: [][]string{}, + expectedVars: map[string]string{ + "snapd_extra_cmdline_args": "", + "snapd_full_cmdline_args": "default", + }, + }, { + expectedVars: map[string]string{ + "snapd_extra_cmdline_args": "", + "snapd_full_cmdline_args": `default bar baz=* "with spaces"`, + }, + append: []string{"bar", "baz=*", `'"with spaces"'`}, + }, { + expectedVars: map[string]string{ + "snapd_extra_cmdline_args": "", + "snapd_full_cmdline_args": "nodefault", + }, + append: []string{"nodefault"}, + remove: []string{"default"}, + }, { + expectedVars: map[string]string{ + "snapd_extra_cmdline_args": "", + "snapd_full_cmdline_args": " ", + }, + remove: []string{"default"}, + }} { + gadgetYaml := mockGadgetYaml + if len(tc.append) > 0 || len(tc.remove) > 0 { + gadgetYaml = fmt.Sprintf("%skernel-cmdline:\n", gadgetYaml) + } + if len(tc.append) > 0 { + gadgetYaml = fmt.Sprintf("%s append:\n", gadgetYaml) + } + for _, append := range tc.append { + gadgetYaml = fmt.Sprintf("%s - %s\n", gadgetYaml, append) + } + if len(tc.remove) > 0 { + gadgetYaml = fmt.Sprintf("%s remove:\n", gadgetYaml) + } + for _, remove := range tc.remove { + gadgetYaml = fmt.Sprintf("%s - %s\n", gadgetYaml, remove) + } + sf := snaptest.MakeTestSnapWithFiles(c, gadgetSnapYaml, append([][]string{ + {"meta/snap.yaml", gadgetSnapYaml}, + {"meta/gadget.yaml", gadgetYaml}, + }, tc.files...)) + vars, err := boot.BootVarsForTrustedCommandLineFromGadget(sf, tc.cmdlineAppend, "default", model) + if tc.errMsg == "" { + c.Assert(err, IsNil) + c.Assert(vars, DeepEquals, tc.expectedVars) + } else { + c.Assert(err, ErrorMatches, tc.errMsg) + } + } +} diff --git a/boot/debug.go b/boot/debug.go new file mode 100644 index 00000000..9ba525ef --- /dev/null +++ b/boot/debug.go @@ -0,0 +1,138 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package boot + +import ( + "fmt" + "io" + "strings" + + "github.com/snapcore/snapd/bootloader" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/osutil" +) + +// DebugDumpBootVars writes a dump of the snapd bootvars to the given writer +func DebugDumpBootVars(w io.Writer, dir string, uc20 bool) error { + opts := &bootloader.Options{ + NoSlashBoot: dir != "" && dir != "/", + } + switch dir { + // is it any of the well-known UC20 boot partition mount locations? + case InitramfsUbuntuBootDir: + opts.Role = bootloader.RoleRunMode + uc20 = true + case InitramfsUbuntuSeedDir: + opts.Role = bootloader.RoleRecovery + uc20 = true + } + if !opts.NoSlashBoot && !uc20 { + // this may still be a UC20 system + if osutil.FileExists(dirs.SnapModeenvFile) { + uc20 = true + } + } + allKeys := []string{ + "snap_mode", + "snap_core", + "snap_try_core", + "snap_kernel", + "snap_try_kernel", + } + if uc20 { + if !opts.NoSlashBoot { + // no root directory set, default to run mode + opts.Role = bootloader.RoleRunMode + } + // keys relevant to all uc20 bootloader implementations + allKeys = []string{ + "snapd_recovery_mode", + "snapd_recovery_system", + "snapd_recovery_kernel", + "snap_kernel", + "snap_try_kernel", + "kernel_status", + "recovery_system_status", + "try_recovery_system", + "snapd_good_recovery_systems", + "snapd_extra_cmdline_args", + "snapd_full_cmdline_args", + } + } + bloader, err := bootloader.Find(dir, opts) + if err != nil { + return err + } + + bootVars, err := bloader.GetBootVars(allKeys...) + if err != nil { + return err + } + for _, k := range allKeys { + fmt.Fprintf(w, "%s=%s\n", k, bootVars[k]) + } + return nil +} + +// DebugSetBootVars is a debug helper that takes a list of = entries +// and sets them for the configured bootloader. +func DebugSetBootVars(dir string, recoveryBootloader bool, varEqVal []string) error { + opts := &bootloader.Options{ + NoSlashBoot: dir != "" && dir != "/", + } + if opts.NoSlashBoot || osutil.FileExists(dirs.SnapModeenvFile) { + // implied UC20 bootloader + opts.Role = bootloader.RoleRunMode + } + // try some well known UC20 root dirs + switch dir { + case InitramfsUbuntuBootDir: + if recoveryBootloader { + return fmt.Errorf("cannot use run bootloader root-dir with a recovery flag") + } + opts.Role = bootloader.RoleRunMode + case InitramfsUbuntuSeedDir: + opts.Role = bootloader.RoleRecovery + } + if recoveryBootloader { + // UC20 recovery bootloader + opts.Role = bootloader.RoleRecovery + if !opts.NoSlashBoot { + // no root dir was provided, use the default one for a + // recovery bootloader + dir = InitramfsUbuntuSeedDir + } + } + bloader, err := bootloader.Find(dir, opts) + if err != nil { + return err + } + + toSet := map[string]string{} + + for _, req := range varEqVal { + split := strings.SplitN(req, "=", 2) + if len(split) != 2 { + return fmt.Errorf("incorrect setting %q", varEqVal) + } + toSet[split[0]] = split[1] + } + return bloader.SetBootVars(toSet) +} diff --git a/boot/errors.go b/boot/errors.go new file mode 100644 index 00000000..c32d1324 --- /dev/null +++ b/boot/errors.go @@ -0,0 +1,49 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package boot + +import ( + "errors" + "fmt" +) + +// trySnapError is an error that only applies to the try snaps where multiple +// snaps are returned, this is mainly and primarily used in revisions(). +type trySnapError string + +func (sre trySnapError) Error() string { + return string(sre) +} + +func newTrySnapErrorf(format string, args ...interface{}) error { + return trySnapError(fmt.Sprintf(format, args...)) +} + +// isTrySnapError returns true if the given error is an error resulting from +// accessing information about the try snap or the trying status. +func isTrySnapError(err error) bool { + switch err.(type) { + case trySnapError: + return true + } + return false +} + +var errTrySnapFallback = errors.New("fallback to original snap") diff --git a/boot/export_test.go b/boot/export_test.go new file mode 100644 index 00000000..0232a256 --- /dev/null +++ b/boot/export_test.go @@ -0,0 +1,287 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2014-2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package boot + +import ( + "fmt" + "sync/atomic" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/bootloader" + "github.com/snapcore/snapd/kernel/fde" + "github.com/snapcore/snapd/secboot" + "github.com/snapcore/snapd/secboot/keys" + "github.com/snapcore/snapd/seed" + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/testutil" + "github.com/snapcore/snapd/timings" +) + +func NewCoreBootParticipant(s snap.PlaceInfo, t snap.Type, dev snap.Device) *coreBootParticipant { + bs, err := bootStateFor(t, dev) + if err != nil { + panic(err) + } + return &coreBootParticipant{s: s, bs: bs} +} + +func NewCoreKernel(s snap.PlaceInfo, d snap.Device) *coreKernel { + return &coreKernel{s, bootloaderOptionsForDeviceKernel(d)} +} + +type Trivial = trivial + +func (m *Modeenv) WasRead() bool { + return m.read +} + +func (m *Modeenv) DeepEqual(m2 *Modeenv) bool { + return m.deepEqual(m2) +} + +var ( + ModeenvKnownKeys = modeenvKnownKeys + + MarshalModeenvEntryTo = marshalModeenvEntryTo + UnmarshalModeenvValueFromCfg = unmarshalModeenvValueFromCfg + + NewTrustedAssetsCache = newTrustedAssetsCache + + ObserveSuccessfulBootWithAssets = observeSuccessfulBootAssets + SealKeyToModeenv = sealKeyToModeenvImpl + ResealKeyToModeenv = resealKeyToModeenv + RecoveryBootChainsForSystems = recoveryBootChainsForSystems + RunModeBootChains = runModeBootChains + SealKeyModelParams = sealKeyModelParams + + BootVarsForTrustedCommandLineFromGadget = bootVarsForTrustedCommandLineFromGadget + + WriteModelToUbuntuBoot = writeModelToUbuntuBoot +) + +type BootAssetsMap = bootAssetsMap +type BootCommandLines = bootCommandLines +type TrackedAsset = trackedAsset + +func (t *TrackedAsset) Equals(blName, name, hash string) error { + equal := t.hash == hash && + t.name == name && + t.blName == blName + if !equal { + return fmt.Errorf("not equal to bootloader %q tracked asset %v:%v", t.blName, t.name, t.hash) + } + return nil +} + +func (t *TrackedAsset) GetHash() string { + return t.hash +} + +func (o *TrustedAssetsInstallObserver) CurrentTrustedBootAssetsMap() BootAssetsMap { + return o.currentTrustedBootAssetsMap() +} + +func (o *TrustedAssetsInstallObserver) CurrentTrustedRecoveryBootAssetsMap() BootAssetsMap { + return o.currentTrustedRecoveryBootAssetsMap() +} + +func (o *TrustedAssetsInstallObserver) CurrentDataEncryptionKey() keys.EncryptionKey { + return o.dataEncryptionKey +} + +func (o *TrustedAssetsInstallObserver) CurrentSaveEncryptionKey() keys.EncryptionKey { + return o.saveEncryptionKey +} + +func MockSecbootProvisionTPM(f func(mode secboot.TPMProvisionMode, lockoutAuthFile string) error) (restore func()) { + restore = testutil.Backup(&secbootProvisionTPM) + secbootProvisionTPM = f + return restore +} + +func MockSecbootSealKeys(f func(keys []secboot.SealKeyRequest, params *secboot.SealKeysParams) error) (restore func()) { + old := secbootSealKeys + secbootSealKeys = f + return func() { + secbootSealKeys = old + } +} + +func MockSecbootSealKeysWithFDESetupHook(f func(runHook fde.RunSetupHookFunc, keys []secboot.SealKeyRequest, params *secboot.SealKeysWithFDESetupHookParams) error) (restore func()) { + old := secbootSealKeysWithFDESetupHook + secbootSealKeysWithFDESetupHook = f + return func() { + secbootSealKeysWithFDESetupHook = old + } +} + +func MockSeedReadSystemEssential(f func(seedDir, label string, essentialTypes []snap.Type, tm timings.Measurer) (*asserts.Model, []*seed.Snap, error)) (restore func()) { + old := seedReadSystemEssential + seedReadSystemEssential = f + return func() { + seedReadSystemEssential = old + } +} + +func MockSecbootPCRHandleOfSealedKey(f func(p string) (uint32, error)) (restore func()) { + restore = testutil.Backup(&secbootPCRHandleOfSealedKey) + secbootPCRHandleOfSealedKey = f + return restore +} + +func MockSecbootReleasePCRResourceHandles(f func(handles ...uint32) error) (restore func()) { + restore = testutil.Backup(&secbootReleasePCRResourceHandles) + secbootReleasePCRResourceHandles = f + return restore +} + +func (o *TrustedAssetsUpdateObserver) InjectChangedAsset(blName, assetName, hash string, recovery bool) { + ta := &trackedAsset{ + blName: blName, + name: assetName, + hash: hash, + } + if !recovery { + o.changedAssets = append(o.changedAssets, ta) + } else { + o.seedChangedAssets = append(o.seedChangedAssets, ta) + } +} + +type BootAsset = bootAsset +type BootChain = bootChain +type PredictableBootChains = predictableBootChains + +const ( + BootChainEquivalent = bootChainEquivalent + BootChainDifferent = bootChainDifferent + BootChainUnrevisioned = bootChainUnrevisioned +) + +var ( + ToPredictableBootChain = toPredictableBootChain + ToPredictableBootChains = toPredictableBootChains + PredictableBootChainsEqualForReseal = predictableBootChainsEqualForReseal + BootAssetsToLoadChains = bootAssetsToLoadChains + BootAssetLess = bootAssetLess + WriteBootChains = writeBootChains + ReadBootChains = readBootChains + IsResealNeeded = isResealNeeded + + SetImageBootFlags = setImageBootFlags + NextBootFlags = nextBootFlags + SetNextBootFlags = setNextBootFlags + + ModelUniqueID = modelUniqueID +) + +func SetBootFlagsInBootloader(flags []string, rootDir string) error { + blVars := make(map[string]string, 1) + + if err := setImageBootFlags(flags, blVars); err != nil { + return err + } + + // now find the recovery bootloader in the system dir and set the value on + // it + opts := &bootloader.Options{ + Role: bootloader.RoleRecovery, + } + bl, err := bootloader.Find(rootDir, opts) + if err != nil { + return err + } + + return bl.SetBootVars(blVars) +} + +func (b *bootChain) SecbootModelForSealing() secboot.ModelForSealing { + return b.modelForSealing() +} + +func (b *bootChain) SetKernelBootFile(kbf bootloader.BootFile) { + b.kernelBootFile = kbf +} + +func (b *bootChain) KernelBootFile() bootloader.BootFile { + return b.kernelBootFile +} + +func MockRebootArgsPath(argsPath string) (restore func()) { + oldRebootArgsPath := rebootArgsPath + rebootArgsPath = argsPath + return func() { rebootArgsPath = oldRebootArgsPath } +} + +func MockBootloaderFind(f func(rootdir string, opts *bootloader.Options) (bootloader.Bootloader, error)) (restore func()) { + r := testutil.Backup(&bootloaderFind) + bootloaderFind = f + return r +} + +func MockHasFDESetupHook(f func(*snap.Info) (bool, error)) (restore func()) { + oldHasFDESetupHook := HasFDESetupHook + HasFDESetupHook = f + return func() { + HasFDESetupHook = oldHasFDESetupHook + } +} + +func MockRunFDESetupHook(f fde.RunSetupHookFunc) (restore func()) { + oldRunFDESetupHook := RunFDESetupHook + RunFDESetupHook = f + return func() { RunFDESetupHook = oldRunFDESetupHook } +} + +func MockResealKeyToModeenvUsingFDESetupHook(f func(string, *Modeenv, bool) error) (restore func()) { + old := resealKeyToModeenvUsingFDESetupHook + resealKeyToModeenvUsingFDESetupHook = f + return func() { + resealKeyToModeenvUsingFDESetupHook = old + } +} + +func MockModeenvLocked() (restore func()) { + atomic.AddInt32(&modeenvLocked, 1) + return func() { + atomic.AddInt32(&modeenvLocked, -1) + } +} + +func MockAdditionalBootFlags(bootFlags []string) (restore func()) { + old := understoodBootFlags + understoodBootFlags = append(understoodBootFlags, bootFlags...) + return func() { + understoodBootFlags = old + } +} + +func MockWriteModelToUbuntuBoot(mock func(*asserts.Model) error) (restore func()) { + old := writeModelToUbuntuBoot + writeModelToUbuntuBoot = mock + return func() { + writeModelToUbuntuBoot = old + } +} + +func EnableTestingRebootFunction() (restore func()) { + testingRebootItself = true + return func() { testingRebootItself = false } +} diff --git a/boot/flags.go b/boot/flags.go new file mode 100644 index 00000000..a5988ab3 --- /dev/null +++ b/boot/flags.go @@ -0,0 +1,376 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package boot + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/snapcore/snapd/bootloader" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/gadget" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/strutil" +) + +var ( + errNotUC20 = fmt.Errorf("cannot get boot flags on pre-UC20 device") + + understoodBootFlags = []string{ + // the factory boot flag is set to indicate that this is a + // boot inside a factory environment + "factory", + } +) + +type unknownFlagError string + +func (e unknownFlagError) Error() string { + return string(e) +} + +func IsUnknownBootFlagError(e error) bool { + _, ok := e.(unknownFlagError) + return ok +} + +// splitBootFlagString splits the given comma delimited list of boot flags, removing +// empty strings. +// Note that this explicitly does not filter out unsupported boot flags in the +// off chance that an old version of the initramfs is reading new boot flags +// written by a new version of snapd in userspace on a previous boot. +func splitBootFlagString(s string) []string { + flags := []string{} + for _, flag := range strings.Split(s, ",") { + if flag != "" { + flags = append(flags, flag) + } + } + + return flags +} + +func checkBootFlagList(flags []string, allowList []string) ([]string, error) { + allowedFlags := make([]string, 0, len(flags)) + disallowedFlags := make([]string, 0, len(flags)) + if len(allowList) != 0 { + // then we need to enforce the allow list + for _, flag := range flags { + if strutil.ListContains(allowList, flag) { + allowedFlags = append(allowedFlags, flag) + } else { + if flag == "" { + // this is to make it more obvious + disallowedFlags = append(disallowedFlags, `""`) + } else { + disallowedFlags = append(disallowedFlags, flag) + } + } + } + } + if len(allowedFlags) != len(flags) { + return allowedFlags, unknownFlagError(fmt.Sprintf("unknown boot flags %v not allowed", disallowedFlags)) + } + return flags, nil +} + +func serializeBootFlags(flags []string) string { + // drop empty strings before serializing + nonEmptyFlags := make([]string, 0, len(flags)) + for _, flag := range flags { + if strings.TrimSpace(flag) != "" { + nonEmptyFlags = append(nonEmptyFlags, flag) + } + } + + return strings.Join(nonEmptyFlags, ",") +} + +// setImageBootFlags sets the provided flags in the provided +// bootenv-representing map. It first checks them. +func setImageBootFlags(flags []string, blVars map[string]string) error { + // check that the flagList is supported + if _, err := checkBootFlagList(flags, understoodBootFlags); err != nil { + return err + } + + // also ensure that the serialized value of the boot flags fits inside the + // bootenv value, on lk systems the max size of a bootenv value is 255 chars + s := serializeBootFlags(flags) + if len(s) > 254 { + return fmt.Errorf("internal error: boot flags too large to fit inside bootenv value") + } + + blVars["snapd_boot_flags"] = s + return nil +} + +// InitramfsActiveBootFlags returns the set of boot flags that are currently set +// for the current boot, by querying them directly from the source. This method +// is only meant to be used from the initramfs, since it may query the bootenv +// or query the modeenv depending on the current mode of the system. +// For detecting the current set of boot flags outside of the initramfs, use +// BootFlags(), which will query for the runtime version of the flags in /run +// that the initramfs will have setup for userspace. +// Note that no filtering is done on the flags in order to allow new flags to be +// used by a userspace that is newer than the initramfs, but empty flags will be +// dropped automatically. +// Only to be used on UC20+ systems with recovery systems. +func InitramfsActiveBootFlags(mode string, rootfsDir string) ([]string, error) { + switch mode { + case ModeRecover: + // no boot flags are consumed / used on recover mode, so return nothing + return nil, nil + + case ModeRunCVM: + // no boot flags are consumed / used on CVM mode, so return nothing + return nil, nil + + case ModeRun: + // boot flags come from the modeenv + modeenv, err := ReadModeenv(rootfsDir) + if err != nil { + return nil, err + } + + // TODO: consider passing in the modeenv or returning the modeenv here + // to reduce the number of times we read the modeenv ? + return modeenv.BootFlags, nil + + case ModeFactoryReset: + // Reuse the code from ModeInstall as we have a lot of + // identical conditions. + fallthrough + case ModeInstall: + // boot flags always come from the bootenv of the recovery bootloader + // in install mode + return readBootFlagsFromRecoveryBootloader() + + default: + return nil, fmt.Errorf("internal error: unsupported mode %q", mode) + } +} + +func readBootFlagsFromRecoveryBootloader() ([]string, error) { + opts := &bootloader.Options{ + Role: bootloader.RoleRecovery, + } + bl, err := bootloader.Find(InitramfsUbuntuSeedDir, opts) + if err != nil { + return nil, err + } + + m, err := bl.GetBootVars("snapd_boot_flags") + if err != nil { + return nil, err + } + + return splitBootFlagString(m["snapd_boot_flags"]), nil +} + +// InitramfsExposeBootFlagsForSystem sets the boot flags for the current boot in +// the /run file that will be consulted in userspace by BootFlags() below. It is +// meant to be used only from the initramfs. +// Note that no filtering is done on the flags in order to allow new flags to be +// used by a userspace that is newer than the initramfs, but empty flags will be +// dropped automatically. +// Only to be used on UC20+ systems with recovery systems. +func InitramfsExposeBootFlagsForSystem(flags []string) error { + s := serializeBootFlags(flags) + + if err := os.MkdirAll(filepath.Dir(snapBootFlagsFile), 0755); err != nil { + return err + } + + return os.WriteFile(snapBootFlagsFile, []byte(s), 0644) +} + +// BootFlags returns the current set of boot flags active for this boot. It uses +// the initramfs-capture values in /run. The flags from the initramfs are +// checked against the currently understood set of flags, so that if there are +// unrecognized flags, they are removed from the returned list and the returned +// error will have IsUnknownFlagErroror() return true. This is to allow gracefully +// ignoring unknown boot flags while still processing supported flags. +// Only to be used on UC20+ systems with recovery systems. +func BootFlags(dev snap.Device) ([]string, error) { + if !dev.HasModeenv() { + return nil, errNotUC20 + } + + // read the file that the initramfs wrote in /run, we don't use the modeenv + // or bootenv to avoid ambiguity about whether the flags in the modeenv or + // bootenv are for this boot or the next one, but the initramfs will always + // copy the flags that were set into /run, so we always know the current + // boot's flags are written in /run + b, err := os.ReadFile(snapBootFlagsFile) + if err != nil { + return nil, err + } + + flags := splitBootFlagString(string(b)) + if allowFlags, err := checkBootFlagList(flags, understoodBootFlags); err != nil { + if e, ok := err.(unknownFlagError); ok { + return allowFlags, e + } + return nil, err + } + return flags, nil +} + +// nextBootFlags returns the set of boot flags that are applicable for the next +// boot. This information always comes from the modeenv, since the only +// situation where boot flags are set for the next boot and we query their state +// is during run mode. The next boot flags for install mode are not queried +// during prepare-image time, since they are only written to the bootenv at +// prepare-image time. +// Only to be used on UC20+ systems with recovery systems. +// TODO: should this accept a modeenv that was previously read from i.e. +// devicestate manager? +func nextBootFlags(dev snap.Device) ([]string, error) { + if !dev.HasModeenv() { + return nil, errNotUC20 + } + + m, err := ReadModeenv("") + if err != nil { + return nil, err + } + + return m.BootFlags, nil +} + +// setNextBootFlags sets the boot flags for the next boot to take effect after +// rebooting. This information always gets saved to the modeenv. +// Only to be used on UC20+ systems with recovery systems. +func setNextBootFlags(dev snap.Device, rootDir string, flags []string) error { + if !dev.HasModeenv() { + return errNotUC20 + } + + // XXX take the modeenv lock? + + m, err := ReadModeenv(rootDir) + if err != nil { + return err + } + + // for run time, enforce the allow list so we don't write unsupported boot + // flags + if _, err := checkBootFlagList(flags, understoodBootFlags); err != nil { + return err + } + + m.BootFlags = flags + + return m.Write() +} + +// HostUbuntuDataForMode returns a list of locations where the run +// mode root filesystem is mounted for the given mode. +// For run mode, it's "/run/mnt/data" and "/". +// For install mode it's "/run/mnt/ubuntu-data". +// For factory-reset mode it's "/run/mnt/ubuntu-data" +// For recover mode it's either "/host/ubuntu-data" or nil if that is not +// mounted. Note that, for recover mode, this function only returns a non-empty +// return value if the partition is mounted and trusted, there are certain +// corner-cases where snap-bootstrap in the initramfs may have mounted +// ubuntu-data in an untrusted manner, but for the purposes of this function +// that is ignored. +// This is primarily meant to be consumed by "snap{,ctl} system-mode". +// +// TODO: pass a "snap.Device" here and add "SystemMode() string" to that +func HostUbuntuDataForMode(mode string, mod gadget.Model) ([]string, error) { + var runDataRootfsMountLocations []string + switch mode { + case ModeRun: + // in run mode we have both /run/mnt/data and "/" + runDataRootfsMountLocations = []string{InitramfsDataDir, dirs.GlobalRootDir} + case ModeRecover: + // TODO: should this be it's own dedicated helper to read degraded.json? + + // for recover mode, the source of truth to determine if we have the + // host mount is snap-bootstrap's /run/snapd/snap-bootstrap/degraded.json, so + // we have to go parse that + degradedJSONFile := filepath.Join(dirs.SnapBootstrapRunDir, "degraded.json") + b, err := os.ReadFile(degradedJSONFile) + if err != nil { + return nil, err + } + + degradedJSON := struct { + UbuntuData struct { + MountState string `json:"mount-state"` + MountLocation string `json:"mount-location"` + } `json:"ubuntu-data"` + }{} + + err = json.Unmarshal(b, °radedJSON) + if err != nil { + return nil, err + } + + // don't permit mounted-untrusted state, only mounted state is allowed + if degradedJSON.UbuntuData.MountState == "mounted" { + runDataRootfsMountLocations = []string{degradedJSON.UbuntuData.MountLocation} + } + // otherwise leave it empty + + case ModeInstall: + // On *Core* the var we have is + // /run/mnt/ubuntu-data/writable, but the caller + // probably wants /run/mnt/ubuntu-data there. For classic + // the dir is /run/mnt/ubuntu-data already + + // note that we may be running in install mode before this directory is + // actually created so check if it exists first + var installModeLocation string + if mod.Classic() { + installModeLocation = InstallHostWritableDir(mod) + } else { + installModeLocation = filepath.Dir(InstallHostWritableDir(mod)) + } + if exists, _, _ := osutil.DirExists(installModeLocation); exists { + runDataRootfsMountLocations = []string{installModeLocation} + } + + case ModeFactoryReset: + // In factory reset, our conditions are a lot similar to install mode, + // as we recreate the ubuntu-data partition. Make similar assumptions + // and checks like ModeInstall. Take into account ubuntu-data might not + // be mounted when this check is called. + var factoryResetModeLocation string + if mod.Classic() { + factoryResetModeLocation = InstallHostWritableDir(mod) + } else { + factoryResetModeLocation = filepath.Dir(InstallHostWritableDir(mod)) + } + if exists, _, _ := osutil.DirExists(factoryResetModeLocation); exists { + runDataRootfsMountLocations = []string{factoryResetModeLocation} + } + default: + return nil, ErrUnsupportedSystemMode + } + + return runDataRootfsMountLocations, nil +} diff --git a/boot/flags_test.go b/boot/flags_test.go new file mode 100644 index 00000000..621b21d9 --- /dev/null +++ b/boot/flags_test.go @@ -0,0 +1,568 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package boot_test + +import ( + "errors" + "os" + "path/filepath" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/boot" + "github.com/snapcore/snapd/boot/boottest" + "github.com/snapcore/snapd/bootloader" + "github.com/snapcore/snapd/bootloader/grubenv" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/testutil" +) + +type bootFlagsSuite struct { + baseBootenvSuite +} + +var _ = Suite(&bootFlagsSuite{}) + +func (s *bootFlagsSuite) TestBootFlagsFamilyClassic(c *C) { + classicDev := boottest.MockDevice("") + + // make bootloader.Find fail but shouldn't matter + bootloader.ForceError(errors.New("broken bootloader")) + defer bootloader.ForceError(nil) + + _, err := boot.NextBootFlags(classicDev) + c.Assert(err, ErrorMatches, `cannot get boot flags on pre-UC20 device`) + + err = boot.SetNextBootFlags(classicDev, "", []string{"foo"}) + c.Assert(err, ErrorMatches, `cannot get boot flags on pre-UC20 device`) + + _, err = boot.BootFlags(classicDev) + c.Assert(err, ErrorMatches, `cannot get boot flags on pre-UC20 device`) +} + +func (s *bootFlagsSuite) TestBootFlagsFamilyUC16(c *C) { + coreDev := boottest.MockDevice("some-snap") + + // make bootloader.Find fail but shouldn't matter + bootloader.ForceError(errors.New("broken bootloader")) + defer bootloader.ForceError(nil) + + _, err := boot.NextBootFlags(coreDev) + c.Assert(err, ErrorMatches, `cannot get boot flags on pre-UC20 device`) + + err = boot.SetNextBootFlags(coreDev, "", []string{"foo"}) + c.Assert(err, ErrorMatches, `cannot get boot flags on pre-UC20 device`) + + _, err = boot.BootFlags(coreDev) + c.Assert(err, ErrorMatches, `cannot get boot flags on pre-UC20 device`) +} + +func setupRealGrub(c *C, rootDir, baseDir string, opts *bootloader.Options) bootloader.Bootloader { + if rootDir == "" { + rootDir = dirs.GlobalRootDir + } + grubCfg := filepath.Join(rootDir, baseDir, "grub.cfg") + err := os.MkdirAll(filepath.Dir(grubCfg), 0755) + c.Assert(err, IsNil) + + err = os.WriteFile(grubCfg, nil, 0644) + c.Assert(err, IsNil) + + genv := grubenv.NewEnv(filepath.Join(rootDir, baseDir, "grubenv")) + err = genv.Save() + c.Assert(err, IsNil) + + grubBl, err := bootloader.Find(rootDir, opts) + c.Assert(err, IsNil) + c.Assert(grubBl.Name(), Equals, "grub") + + return grubBl +} + +func (s *bootFlagsSuite) TestInitramfsActiveBootFlagsUC20InstallModeHappy(c *C) { + dir := c.MkDir() + + dirs.SetRootDir(dir) + defer func() { dirs.SetRootDir("") }() + + blDir := boot.InitramfsUbuntuSeedDir + + setupRealGrub(c, blDir, "EFI/ubuntu", &bootloader.Options{Role: bootloader.RoleRecovery}) + + flags, err := boot.InitramfsActiveBootFlags(boot.ModeInstall, filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data")) + c.Assert(err, IsNil) + c.Assert(flags, HasLen, 0) + + // if we set some flags via ubuntu-image customizations then we get them + // back + + err = boot.SetBootFlagsInBootloader([]string{"factory"}, blDir) + c.Assert(err, IsNil) + + flags, err = boot.InitramfsActiveBootFlags(boot.ModeInstall, filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data")) + c.Assert(err, IsNil) + c.Assert(flags, DeepEquals, []string{"factory"}) +} + +func (s *bootFlagsSuite) TestInitramfsActiveBootFlagsUC20FactoryResetModeHappy(c *C) { + // FactoryReset and Install run identical code, as their condition match pretty closely + // so this unit test is to reconfirm that we expect same behavior as we see in the unit + // test for install mode. + dir := c.MkDir() + + dirs.SetRootDir(dir) + defer func() { dirs.SetRootDir("") }() + + blDir := boot.InitramfsUbuntuSeedDir + + setupRealGrub(c, blDir, "EFI/ubuntu", &bootloader.Options{Role: bootloader.RoleRecovery}) + + flags, err := boot.InitramfsActiveBootFlags(boot.ModeFactoryReset, filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data")) + c.Assert(err, IsNil) + c.Assert(flags, HasLen, 0) + + // if we set some flags via ubuntu-image customizations then we get them + // back + + err = boot.SetBootFlagsInBootloader([]string{"factory"}, blDir) + c.Assert(err, IsNil) + + flags, err = boot.InitramfsActiveBootFlags(boot.ModeFactoryReset, filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data")) + c.Assert(err, IsNil) + c.Assert(flags, DeepEquals, []string{"factory"}) +} + +func (s *bootFlagsSuite) TestSetImageBootFlagsVerification(c *C) { + longVal := "longer-than-256-char-value" + for i := 0; i < 256; i++ { + longVal += "X" + } + + r := boot.MockAdditionalBootFlags([]string{longVal}) + defer r() + + blVars := make(map[string]string) + + err := boot.SetImageBootFlags([]string{"not-a-real-flag"}, blVars) + c.Assert(err, ErrorMatches, `unknown boot flags \[not-a-real-flag\] not allowed`) + + err = boot.SetImageBootFlags([]string{longVal}, blVars) + c.Assert(err, ErrorMatches, "internal error: boot flags too large to fit inside bootenv value") +} + +func (s *bootFlagsSuite) TestInitramfsActiveBootFlagsUC20RecoverModeNoop(c *C) { + dir := c.MkDir() + + dirs.SetRootDir(dir) + defer func() { dirs.SetRootDir("") }() + + blDir := boot.InitramfsUbuntuSeedDir + + // create a grubenv to ensure that we don't return any values from there + grubBl := setupRealGrub(c, blDir, "EFI/ubuntu", &bootloader.Options{Role: bootloader.RoleRecovery}) + + // also create the modeenv to make sure we don't peek there either + m := boot.Modeenv{ + Mode: boot.ModeRun, + BootFlags: []string{}, + } + + err := os.MkdirAll(filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data"), 0755) + c.Assert(err, IsNil) + + err = m.WriteTo(filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data")) + c.Assert(err, IsNil) + + flags, err := boot.InitramfsActiveBootFlags(boot.ModeRecover, filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data")) + c.Assert(err, IsNil) + c.Assert(flags, HasLen, 0) + + err = grubBl.SetBootVars(map[string]string{"snapd_boot_flags": "factory"}) + c.Assert(err, IsNil) + + m.BootFlags = []string{"modeenv-boot-flag"} + err = m.WriteTo(filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data")) + c.Assert(err, IsNil) + + // still no flags since we are in recovery mode + flags, err = boot.InitramfsActiveBootFlags(boot.ModeRecover, filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data")) + c.Assert(err, IsNil) + c.Assert(flags, HasLen, 0) +} + +func (s *bootFlagsSuite) testInitramfsActiveBootFlagsUC20RRunModeHappy(c *C, flagsDir string) { + dir := c.MkDir() + + dirs.SetRootDir(dir) + defer func() { dirs.SetRootDir("") }() + + // setup a basic empty modeenv + m := boot.Modeenv{ + Mode: boot.ModeRun, + BootFlags: []string{}, + } + + err := os.MkdirAll(flagsDir, 0755) + c.Assert(err, IsNil) + + err = m.WriteTo(flagsDir) + c.Assert(err, IsNil) + + flags, err := boot.InitramfsActiveBootFlags(boot.ModeRun, flagsDir) + c.Assert(err, IsNil) + c.Assert(flags, HasLen, 0) + + m.BootFlags = []string{"factory", "other-flag"} + err = m.WriteTo(flagsDir) + c.Assert(err, IsNil) + + // now some flags after we set them in the modeenv + flags, err = boot.InitramfsActiveBootFlags(boot.ModeRun, flagsDir) + c.Assert(err, IsNil) + c.Assert(flags, DeepEquals, []string{"factory", "other-flag"}) +} + +func (s *bootFlagsSuite) TestInitramfsActiveBootFlagsUC20RRunModeHappy(c *C) { + s.testInitramfsActiveBootFlagsUC20RRunModeHappy(c, filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data")) + s.testInitramfsActiveBootFlagsUC20RRunModeHappy(c, c.MkDir()) +} + +func (s *bootFlagsSuite) TestInitramfsSetBootFlags(c *C) { + tt := []struct { + flags []string + expFlags []string + expFlagFile string + bootFlagsErr string + bootFlagsErrUnknown bool + }{ + { + flags: []string{"factory"}, + expFlags: []string{"factory"}, + expFlagFile: "factory", + }, + { + flags: []string{"factory", "unknown-new-flag"}, + expFlagFile: "factory,unknown-new-flag", + expFlags: []string{"factory"}, + bootFlagsErr: `unknown boot flags \[unknown-new-flag\] not allowed`, + bootFlagsErrUnknown: true, + }, + { + flags: []string{"", "", "", "factory"}, + expFlags: []string{"factory"}, + expFlagFile: "factory", + }, + { + flags: []string{}, + expFlags: []string{}, + }, + } + + uc20Dev := boottest.MockUC20Device("run", nil) + + for _, t := range tt { + err := boot.InitramfsExposeBootFlagsForSystem(t.flags) + c.Assert(err, IsNil) + c.Assert(filepath.Join(dirs.SnapRunDir, "boot-flags"), testutil.FileEquals, t.expFlagFile) + + // also read the flags as if from user space to make sure they match + flags, err := boot.BootFlags(uc20Dev) + if t.bootFlagsErr != "" { + c.Assert(err, ErrorMatches, t.bootFlagsErr) + if t.bootFlagsErrUnknown { + c.Assert(boot.IsUnknownBootFlagError(err), Equals, true) + } + } else { + c.Assert(err, IsNil) + } + c.Assert(flags, DeepEquals, t.expFlags) + } +} + +func (s *bootFlagsSuite) TestUserspaceBootFlagsUC20(c *C) { + tt := []struct { + beforeFlags []string + flags []string + expFlags []string + err string + }{ + { + beforeFlags: []string{}, + flags: []string{"factory"}, + expFlags: []string{"factory"}, + }, + { + flags: []string{"factory", "new-unsupported-flag"}, + err: `unknown boot flags \[new-unsupported-flag\] not allowed`, + }, + { + flags: []string{""}, + err: `unknown boot flags \[\"\"\] not allowed`, + }, + { + beforeFlags: []string{}, + flags: []string{}, + }, + { + beforeFlags: []string{"factory"}, + flags: []string{}, + }, + { + beforeFlags: []string{"foobar"}, + flags: []string{"factory"}, + expFlags: []string{"factory"}, + }, + } + + uc20Dev := boottest.MockUC20Device("run", nil) + + m := boot.Modeenv{ + Mode: boot.ModeInstall, + BootFlags: []string{}, + } + + for _, t := range tt { + m.BootFlags = t.beforeFlags + err := m.WriteTo("") + c.Assert(err, IsNil) + + err = boot.SetNextBootFlags(uc20Dev, "", t.flags) + if t.err != "" { + c.Assert(err, ErrorMatches, t.err) + continue + } + + c.Assert(err, IsNil) + + // re-read modeenv + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Assert(m2.BootFlags, DeepEquals, t.expFlags) + + // get the next boot flags with NextBootFlags and compare with expected + flags, err := boot.NextBootFlags(uc20Dev) + c.Assert(flags, DeepEquals, t.expFlags) + c.Assert(err, IsNil) + } +} + +func (s *bootFlagsSuite) TestRunModeRootfs(c *C) { + uc20Dev := boottest.MockUC20Device("run", nil) + classicModesDev := boottest.MockClassicWithModesDevice("run", nil) + + tt := []struct { + mode string + dev snap.Device + createExpDirs bool + expDirs []string + noExpDirRootPrefix bool + degradedJSON string + err string + comment string + }{ + { + mode: boot.ModeRun, + dev: uc20Dev, + expDirs: []string{"/run/mnt/data", ""}, + comment: "run mode", + }, + { + mode: boot.ModeRun, + dev: classicModesDev, + expDirs: []string{"/run/mnt/data", ""}, + comment: "run mode (classic)", + }, + { + mode: boot.ModeInstall, + dev: uc20Dev, + comment: "install mode before partition creation", + }, + { + mode: boot.ModeInstall, + dev: classicModesDev, + comment: "install mode before partition creation (classic)", + }, + { + mode: boot.ModeFactoryReset, + dev: uc20Dev, + comment: "factory-reset mode before partition is recreated", + }, + { + mode: boot.ModeFactoryReset, + dev: uc20Dev, + comment: "factory-reset mode before partition is recreated (classic)", + }, + { + mode: boot.ModeInstall, + dev: uc20Dev, + expDirs: []string{"/run/mnt/ubuntu-data"}, + createExpDirs: true, + comment: "install mode after partition creation", + }, + { + mode: boot.ModeInstall, + dev: classicModesDev, + expDirs: []string{"/run/mnt/ubuntu-data"}, + createExpDirs: true, + comment: "install mode after partition creation (classic)", + }, + { + mode: boot.ModeFactoryReset, + dev: uc20Dev, + expDirs: []string{"/run/mnt/ubuntu-data"}, + createExpDirs: true, + comment: "factory-reset mode after partition creation", + }, + { + mode: boot.ModeFactoryReset, + dev: classicModesDev, + expDirs: []string{"/run/mnt/ubuntu-data"}, + createExpDirs: true, + comment: "factory-reset mode after partition creation (classic)", + }, + { + mode: boot.ModeRecover, + dev: uc20Dev, + degradedJSON: ` + { + "ubuntu-data": { + "mount-state": "mounted", + "mount-location": "/host/ubuntu-data" + } + } + `, + expDirs: []string{"/host/ubuntu-data"}, + noExpDirRootPrefix: true, + comment: "recover degraded.json default mounted location", + }, + { + mode: boot.ModeRecover, + dev: uc20Dev, + degradedJSON: ` + { + "ubuntu-data": { + "mount-state": "mounted", + "mount-location": "/host/elsewhere/ubuntu-data" + } + } + `, + expDirs: []string{"/host/elsewhere/ubuntu-data"}, + noExpDirRootPrefix: true, + comment: "recover degraded.json alternative mounted location", + }, + { + mode: boot.ModeRecover, + dev: uc20Dev, + degradedJSON: ` + { + "ubuntu-data": { + "mount-state": "error-mounting" + } + } + `, + comment: "recover degraded.json error-mounting", + }, + { + mode: boot.ModeRecover, + dev: uc20Dev, + degradedJSON: ` + { + "ubuntu-data": { + "mount-state": "mounted-untrusted" + } + } + `, + comment: "recover degraded.json mounted-untrusted", + }, + { + mode: boot.ModeRecover, + dev: uc20Dev, + degradedJSON: ` + { + "ubuntu-data": { + "mount-state": "absent-but-optional" + } + } + `, + comment: "recover degraded.json absent-but-optional", + }, + { + mode: boot.ModeRecover, + dev: uc20Dev, + degradedJSON: ` + { + "ubuntu-data": { + "mount-state": "new-wild-unknown-state" + } + } + `, + comment: "recover degraded.json new-wild-unknown-state", + }, + { + mode: "", + dev: uc20Dev, + err: "system mode is unsupported", + comment: "unsupported system mode", + }, + } + for _, t := range tt { + comment := Commentf(t.comment) + if t.degradedJSON != "" { + rootdir := c.MkDir() + dirs.SetRootDir(rootdir) + defer func() { dirs.SetRootDir("") }() + + degradedJSON := filepath.Join(dirs.SnapBootstrapRunDir, "degraded.json") + err := os.MkdirAll(dirs.SnapBootstrapRunDir, 0755) + c.Assert(err, IsNil, comment) + + err = os.WriteFile(degradedJSON, []byte(t.degradedJSON), 0644) + c.Assert(err, IsNil, comment) + } + + if t.createExpDirs { + for _, dir := range t.expDirs { + err := os.MkdirAll(filepath.Join(dirs.GlobalRootDir, dir), 0755) + c.Assert(err, IsNil, comment) + } + } + + dataMountDirs, err := boot.HostUbuntuDataForMode(t.mode, t.dev.Model()) + if t.err != "" { + c.Assert(err, ErrorMatches, t.err, comment) + c.Assert(dataMountDirs, IsNil) + continue + } + c.Assert(err, IsNil, comment) + + if t.expDirs != nil && !t.noExpDirRootPrefix { + // prefix all the dirs in expDirs with dirs.GlobalRootDir for easier + // test case writing above + prefixedDir := make([]string, len(t.expDirs)) + for i, dir := range t.expDirs { + prefixedDir[i] = filepath.Join(dirs.GlobalRootDir, dir) + } + c.Assert(dataMountDirs, DeepEquals, prefixedDir, comment) + } else { + c.Assert(dataMountDirs, DeepEquals, t.expDirs, comment) + } + } +} diff --git a/boot/initramfs.go b/boot/initramfs.go new file mode 100644 index 00000000..06a8b3d8 --- /dev/null +++ b/boot/initramfs.go @@ -0,0 +1,194 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package boot + +import ( + "os/exec" + "time" + + "github.com/snapcore/snapd/bootloader" + "github.com/snapcore/snapd/logger" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/osutil/kcmdline" + "github.com/snapcore/snapd/snap" +) + +// InitramfsRunModeSelectSnapsToMount returns a map of the snap paths to mount +// for the specified snap types. +func InitramfsRunModeSelectSnapsToMount( + typs []snap.Type, + modeenv *Modeenv, + rootfsDir string, +) (map[snap.Type]snap.PlaceInfo, error) { + var sn snap.PlaceInfo + var err error + m := make(map[snap.Type]snap.PlaceInfo) + for _, typ := range typs { + // TODO: consider passing a bootStateUpdate20 instead? + var selectSnapFn func(*Modeenv, string) (snap.PlaceInfo, error) + switch typ { + case snap.TypeBase: + bs := &bootState20Base{} + selectSnapFn = bs.selectAndCommitSnapInitramfsMount + case snap.TypeGadget: + // Do not mount if modeenv does not have gadget entry + if modeenv.Gadget == "" { + continue + } + selectSnapFn = selectGadgetSnap + case snap.TypeKernel: + blOpts := &bootloader.Options{ + Role: bootloader.RoleRunMode, + NoSlashBoot: true, + } + blDir := InitramfsUbuntuBootDir + bs := &bootState20Kernel{ + blDir: blDir, + blOpts: blOpts, + } + selectSnapFn = bs.selectAndCommitSnapInitramfsMount + } + sn, err = selectSnapFn(modeenv, rootfsDir) + if err != nil { + return nil, err + } + + m[typ] = sn + } + + return m, nil +} + +// EnsureNextBootToRunMode will mark the bootenv of the recovery bootloader such +// that recover mode is now ready to switch back to run mode upon any reboot. +func EnsureNextBootToRunMode(systemLabel string) error { + // at the end of the initramfs we need to set the bootenv such that a reboot + // now at any point will rollback to run mode without additional config or + // actions + + opts := &bootloader.Options{ + // setup the recovery bootloader + Role: bootloader.RoleRecovery, + } + + bl, err := bootloader.Find(InitramfsUbuntuSeedDir, opts) + if err != nil { + return err + } + + m := map[string]string{ + "snapd_recovery_system": systemLabel, + "snapd_recovery_mode": "run", + } + return bl.SetBootVars(m) +} + +// initramfsReboot triggers a reboot from the initramfs immediately +var initramfsReboot = func() error { + if osutil.IsTestBinary() { + panic("initramfsReboot must be mocked in tests") + } + + out, err := exec.Command("/sbin/reboot").CombinedOutput() + if err != nil { + return osutil.OutputErr(out, err) + } + + // reboot command in practice seems to not return, but apparently it is + // theoretically possible it could return, so to account for this we will + // loop for a "long" time waiting for the system to be rebooted, and panic + // after a timeout so that if something goes wrong with the reboot we do + // exit with some info about the expected reboot + time.Sleep(10 * time.Minute) + panic("expected reboot to happen within 10 minutes after calling /sbin/reboot") +} + +func MockInitramfsReboot(f func() error) (restore func()) { + osutil.MustBeTestBinary("initramfsReboot only can be mocked in tests") + old := initramfsReboot + initramfsReboot = f + return func() { + initramfsReboot = old + } +} + +// InitramfsReboot requests the system to reboot. Can be called while in +// initramfs. +func InitramfsReboot() error { + return initramfsReboot() +} + +// This function implements logic that is usually part of the +// bootloader, but that it is not possible to implement in, for +// instance, piboot. See handling of kernel_status in +// bootloader/assets/data/grub.cfg. +func updateNotScriptableBootloaderStatus(bl bootloader.NotScriptableBootloader) error { + blVars, err := bl.GetBootVars("kernel_status") + if err != nil { + return err + } + curKernStatus := blVars["kernel_status"] + if curKernStatus == "" { + return nil + } + + kVals, err := kcmdline.KeyValues("kernel_status") + if err != nil { + return err + } + // "" would be the value for the error case, which at this point is any + // case different to kernel_status=trying in kernel command line and + // kernel_status=try in configuration file. Note that kernel_status in + // the file should be only "try" or empty, and for the latter we should + // have returned a few lines up. + newStatus := "" + if kVals["kernel_status"] == "trying" && curKernStatus == "try" { + newStatus = "trying" + } + + logger.Debugf("setting %s kernel_status from %s to %s", + bl.Name(), curKernStatus, newStatus) + return bl.SetBootVarsFromInitramfs(map[string]string{"kernel_status": newStatus}) +} + +// InitramfsRunModeUpdateBootloaderVars updates bootloader variables +// from the initramfs. This is necessary only for piboot at the +// moment. +func InitramfsRunModeUpdateBootloaderVars() error { + // For very limited bootloaders we need to change the kernel + // status from the initramfs as we cannot do that from the + // bootloader + blOpts := &bootloader.Options{ + Role: bootloader.RoleRunMode, + NoSlashBoot: true, + } + + bl, err := bootloader.Find(InitramfsUbuntuBootDir, blOpts) + if err == nil { + if nsb, ok := bl.(bootloader.NotScriptableBootloader); ok { + if err := updateNotScriptableBootloaderStatus(nsb); err != nil { + logger.Noticef("cannot update %s kernel status: %v", bl.Name(), err) + return err + } + } + } + + return nil +} diff --git a/boot/initramfs20dirs.go b/boot/initramfs20dirs.go new file mode 100644 index 00000000..619592be --- /dev/null +++ b/boot/initramfs20dirs.go @@ -0,0 +1,143 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package boot + +import ( + "path/filepath" + + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/gadget" +) + +var ( + // InitramfsRunMntDir is the directory where ubuntu partitions are mounted + // during the initramfs. + InitramfsRunMntDir string + + // InitramfsDataDir is the location of system-data role partition + // (typically a partition labeled "ubuntu-data") during the initramfs. + InitramfsDataDir string + + // InitramfsHostUbuntuDataDir is the location of the host ubuntu-data + // during the initramfs, typically used in recover mode. + InitramfsHostUbuntuDataDir string + + // InitramfsUbuntuBootDir is the location of ubuntu-boot during the + // initramfs. + InitramfsUbuntuBootDir string + + // InitramfsUbuntuSeedDir is the location of ubuntu-seed during the + // initramfs. + InitramfsUbuntuSeedDir string + + // InitramfsUbuntuSaveDir is the location of ubuntu-save during the + // initramfs. + InitramfsUbuntuSaveDir string + + // InstallHostFDESaveDir is the directory of the FDE data on the + // ubuntu-save partition during install mode. For other modes, + // use dirs.SnapSaveFDEDirUnder(). + InstallHostFDESaveDir string + + // InstallHostSaveDir is the directory of device data on ubuntu-save during + // install mode. For other modes use dirs.SnapDeviceSaveDir + InstallHostDeviceSaveDir string + + // InitramfsSeedEncryptionKeyDir is the location of the encrypted partition + // keys during the initramfs on ubuntu-seed. + InitramfsSeedEncryptionKeyDir string + + // InitramfsBootEncryptionKeyDir is the location of the encrypted partition + // keys during the initramfs on ubuntu-boot. + InitramfsBootEncryptionKeyDir string + + // InstallUbuntuDataDir is the location of the data partition during + // install mode. This should always be on a physical partition. + InstallUbuntuDataDir string + + // snapBootFlagsFile is the location of the file that is used + // internally for saving the current boot flags active for this boot. + snapBootFlagsFile string +) + +// InstallHostWritableDir is the location of the writable partition of the +// installed host during install mode. This should always be on a physical +// partition. +func InstallHostWritableDir(mod gadget.Model) string { + if mod.Classic() { + return InstallUbuntuDataDir + } + return filepath.Join(InstallUbuntuDataDir, "system-data") +} + +// InitramfsHostWritableDir is the location of the host writable +// partition during the initramfs, typically used in recover mode. +func InitramfsHostWritableDir(mod gadget.Model) string { + if mod.Classic() { + return InitramfsHostUbuntuDataDir + } + return filepath.Join(InitramfsHostUbuntuDataDir, "system-data") +} + +// InitramfsWritableDir is the location of the writable partition during the +// initramfs. Note that this may refer to a temporary filesystem or a +// physical partition depending on what system mode the system is in. +// +// This needs the "isRunMode" in the future for when we implement a +// recovery system on "classic+modes". In this scenario in "run" mode +// we have the debian based rootfs in /run/mnt/ubuntu-data *but* in +// "recover" mode the rootfs comes from a base snap like "core22" so +// we need to generate "ubuntu-core" like paths. +func InitramfsWritableDir(mod gadget.Model, isRunMode bool) string { + if mod.Classic() && isRunMode { + return InitramfsDataDir + } + return filepath.Join(InitramfsDataDir, "system-data") +} + +// InstallHostFDEDataDir is the location of the FDE data during install mode. +func InstallHostFDEDataDir(mod gadget.Model) string { + return dirs.SnapFDEDirUnder(InstallHostWritableDir(mod)) +} + +func setInitramfsDirVars(rootdir string) { + InitramfsRunMntDir = filepath.Join(rootdir, "run/mnt") + InitramfsDataDir = filepath.Join(InitramfsRunMntDir, "data") + InitramfsHostUbuntuDataDir = filepath.Join(InitramfsRunMntDir, "host", "ubuntu-data") + InitramfsUbuntuBootDir = filepath.Join(InitramfsRunMntDir, "ubuntu-boot") + InitramfsUbuntuSeedDir = filepath.Join(InitramfsRunMntDir, "ubuntu-seed") + InitramfsUbuntuSaveDir = filepath.Join(InitramfsRunMntDir, "ubuntu-save") + + InstallHostDeviceSaveDir = filepath.Join(InitramfsUbuntuSaveDir, "device") + InstallHostFDESaveDir = filepath.Join(InstallHostDeviceSaveDir, "fde") + InitramfsSeedEncryptionKeyDir = filepath.Join(InitramfsUbuntuSeedDir, "device/fde") + InitramfsBootEncryptionKeyDir = filepath.Join(InitramfsUbuntuBootDir, "device/fde") + + InstallUbuntuDataDir = filepath.Join(InitramfsRunMntDir, "ubuntu-data") + + snapBootFlagsFile = filepath.Join(dirs.SnapRunDir, "boot-flags") +} + +func init() { + setInitramfsDirVars(dirs.GlobalRootDir) + // register to change the values of Initramfs* dir values when the global + // root dir changes + dirs.AddRootDirCallback(setInitramfsDirVars) +} diff --git a/boot/initramfs_test.go b/boot/initramfs_test.go new file mode 100644 index 00000000..0673eead --- /dev/null +++ b/boot/initramfs_test.go @@ -0,0 +1,884 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package boot_test + +import ( + "fmt" + "os" + "path/filepath" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/boot" + "github.com/snapcore/snapd/boot/boottest" + "github.com/snapcore/snapd/bootloader" + "github.com/snapcore/snapd/bootloader/bootloadertest" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/gadget" + "github.com/snapcore/snapd/gadget/gadgettest" + "github.com/snapcore/snapd/osutil/kcmdline" + "github.com/snapcore/snapd/snap" +) + +type initramfsSuite struct { + baseBootenvSuite +} + +var _ = Suite(&initramfsSuite{}) + +func (s *initramfsSuite) SetUpTest(c *C) { + s.baseBootenvSuite.SetUpTest(c) +} + +func (s *initramfsSuite) TestEnsureNextBootToRunMode(c *C) { + // with no bootloader available we can't mark successful + err := boot.EnsureNextBootToRunMode("label") + c.Assert(err, ErrorMatches, "cannot determine bootloader") + + // forcing a bootloader works + bloader := bootloadertest.Mock("mock", c.MkDir()) + bootloader.Force(bloader) + defer bootloader.Force(nil) + + err = boot.EnsureNextBootToRunMode("label") + c.Assert(err, IsNil) + + // the bloader vars have been updated + m, err := bloader.GetBootVars("snapd_recovery_mode", "snapd_recovery_system") + c.Assert(err, IsNil) + c.Assert(m, DeepEquals, map[string]string{ + "snapd_recovery_mode": "run", + "snapd_recovery_system": "label", + }) +} + +func (s *initramfsSuite) TestEnsureNextBootToRunModeRealBootloader(c *C) { + // create a real grub.cfg on ubuntu-seed + err := os.MkdirAll(filepath.Join(boot.InitramfsUbuntuSeedDir, "EFI/ubuntu"), 0755) + c.Assert(err, IsNil) + + err = os.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "EFI/ubuntu", "grub.cfg"), nil, 0644) + c.Assert(err, IsNil) + + err = boot.EnsureNextBootToRunMode("somelabel") + c.Assert(err, IsNil) + + opts := &bootloader.Options{ + // setup the recovery bootloader + Role: bootloader.RoleRecovery, + } + bloader, err := bootloader.Find(boot.InitramfsUbuntuSeedDir, opts) + c.Assert(err, IsNil) + c.Assert(bloader.Name(), Equals, "grub") + + // the bloader vars have been updated + m, err := bloader.GetBootVars("snapd_recovery_mode", "snapd_recovery_system") + c.Assert(err, IsNil) + c.Assert(m, DeepEquals, map[string]string{ + "snapd_recovery_mode": "run", + "snapd_recovery_system": "somelabel", + }) +} + +func makeSnapFilesOnInitramfsUbuntuData(c *C, rootfsDir string, comment CommentInterface, snaps ...snap.PlaceInfo) (restore func()) { + // also make sure the snaps also exist on ubuntu-data + snapDir := dirs.SnapBlobDirUnder(rootfsDir) + err := os.MkdirAll(snapDir, 0755) + c.Assert(err, IsNil, comment) + paths := make([]string, 0, len(snaps)) + for _, sn := range snaps { + snPath := filepath.Join(snapDir, sn.Filename()) + paths = append(paths, snPath) + err = os.WriteFile(snPath, nil, 0644) + c.Assert(err, IsNil, comment) + } + return func() { + for _, path := range paths { + err := os.Remove(path) + c.Assert(err, IsNil, comment) + } + } +} + +func (s *initramfsSuite) TestInitramfsRunModeSelectSnapsToMount(c *C) { + // make some snap infos we will use in the tests + kernel1, err := snap.ParsePlaceInfoFromSnapFileName("pc-kernel_1.snap") + c.Assert(err, IsNil) + + kernel2, err := snap.ParsePlaceInfoFromSnapFileName("pc-kernel_2.snap") + c.Assert(err, IsNil) + + base1, err := snap.ParsePlaceInfoFromSnapFileName("core20_1.snap") + c.Assert(err, IsNil) + + base2, err := snap.ParsePlaceInfoFromSnapFileName("core20_2.snap") + c.Assert(err, IsNil) + + gadget, err := snap.ParsePlaceInfoFromSnapFileName("pc_1.snap") + c.Assert(err, IsNil) + + baseT := snap.TypeBase + kernelT := snap.TypeKernel + gadgetT := snap.TypeGadget + + tt := []struct { + m *boot.Modeenv + expectedM *boot.Modeenv + typs []snap.Type + kernel snap.PlaceInfo + trykernel snap.PlaceInfo + blvars map[string]string + snapsToMake []snap.PlaceInfo + expected map[snap.Type]snap.PlaceInfo + errPattern string + expRebootPanic string + rootfsDir string + comment string + runBootEnvMethodToFail string + runBootEnvError error + }{ + // + // default paths + // + + // default base path + { + m: &boot.Modeenv{Mode: "run", Base: base1.Filename()}, + typs: []snap.Type{baseT}, + snapsToMake: []snap.PlaceInfo{base1}, + expected: map[snap.Type]snap.PlaceInfo{baseT: base1}, + rootfsDir: filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data"), + comment: "default base path", + }, + // gadget base path + { + m: &boot.Modeenv{Mode: "run", Gadget: gadget.Filename()}, + typs: []snap.Type{gadgetT}, + snapsToMake: []snap.PlaceInfo{gadget}, + expected: map[snap.Type]snap.PlaceInfo{gadgetT: gadget}, + rootfsDir: filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data"), + comment: "default gadget path", + }, + // gadget base path, but not in modeenv, so it is not selected + { + m: &boot.Modeenv{Mode: "run"}, + typs: []snap.Type{gadgetT}, + snapsToMake: []snap.PlaceInfo{gadget}, + expected: map[snap.Type]snap.PlaceInfo{}, + rootfsDir: filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data"), + comment: "default gadget path", + }, + // default kernel path + { + m: &boot.Modeenv{Mode: "run", CurrentKernels: []string{kernel1.Filename()}}, + kernel: kernel1, + typs: []snap.Type{kernelT}, + snapsToMake: []snap.PlaceInfo{kernel1}, + expected: map[snap.Type]snap.PlaceInfo{kernelT: kernel1}, + rootfsDir: filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data"), + comment: "default kernel path", + }, + // gadget base path for classic with modes + { + m: &boot.Modeenv{Mode: "run", Gadget: gadget.Filename()}, + typs: []snap.Type{gadgetT}, + snapsToMake: []snap.PlaceInfo{gadget}, + expected: map[snap.Type]snap.PlaceInfo{gadgetT: gadget}, + rootfsDir: boot.InitramfsDataDir, + comment: "default gadget path for classic with modes", + }, + // default kernel path for classic with modes + { + m: &boot.Modeenv{Mode: "run", CurrentKernels: []string{kernel1.Filename()}}, + kernel: kernel1, + typs: []snap.Type{kernelT}, + snapsToMake: []snap.PlaceInfo{kernel1}, + expected: map[snap.Type]snap.PlaceInfo{kernelT: kernel1}, + rootfsDir: boot.InitramfsDataDir, + comment: "default kernel path for classic with modes", + }, + // dangling link for try kernel should be ignored if not trying status + { + m: &boot.Modeenv{Mode: "run", CurrentKernels: []string{kernel1.Filename(), "pc-kernel_badrev.snap"}}, + kernel: kernel1, + typs: []snap.Type{kernelT}, + blvars: map[string]string{"kernel_status": boot.DefaultStatus}, + snapsToMake: []snap.PlaceInfo{kernel1}, + expected: map[snap.Type]snap.PlaceInfo{kernelT: kernel1}, + rootfsDir: filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data"), + comment: "bad try kernel but we don't reboot", + runBootEnvMethodToFail: "TryKernel", + runBootEnvError: fmt.Errorf("cannot read dangling symlink"), + }, + + // + // happy kernel upgrade paths + // + + // kernel upgrade path + { + m: &boot.Modeenv{Mode: "run", CurrentKernels: []string{kernel1.Filename(), kernel2.Filename()}}, + kernel: kernel1, + trykernel: kernel2, + typs: []snap.Type{kernelT}, + blvars: map[string]string{"kernel_status": boot.TryingStatus}, + snapsToMake: []snap.PlaceInfo{kernel1, kernel2}, + expected: map[snap.Type]snap.PlaceInfo{kernelT: kernel2}, + rootfsDir: filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data"), + comment: "successful kernel upgrade path", + }, + // extraneous kernel extracted/set, but kernel_status is default, + // so the bootloader will ignore that and boot the default kernel + // note that this test case is a bit ambiguous as we don't actually know + // in the initramfs that the bootloader actually booted the default + // kernel, we are just assuming that the bootloader implementation in + // the real world is robust enough to only boot the try kernel if and + // only if kernel_status is not DefaultStatus + { + m: &boot.Modeenv{Mode: "run", CurrentKernels: []string{kernel1.Filename(), kernel2.Filename()}}, + kernel: kernel1, + trykernel: kernel2, + typs: []snap.Type{kernelT}, + blvars: map[string]string{"kernel_status": boot.DefaultStatus}, + snapsToMake: []snap.PlaceInfo{kernel1, kernel2}, + expected: map[snap.Type]snap.PlaceInfo{kernelT: kernel1}, + rootfsDir: filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data"), + comment: "fallback kernel upgrade path, due to kernel_status empty (default)", + }, + + // + // unhappy reboot fallback kernel paths + // + + // kernel upgrade path, but reboots to fallback due to untrusted kernel from modeenv + { + m: &boot.Modeenv{Mode: "run", CurrentKernels: []string{kernel1.Filename()}}, + kernel: kernel1, + trykernel: kernel2, + typs: []snap.Type{kernelT}, + blvars: map[string]string{"kernel_status": boot.TryingStatus}, + snapsToMake: []snap.PlaceInfo{kernel1, kernel2}, + expRebootPanic: "reboot due to modeenv untrusted try kernel", + rootfsDir: filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data"), + comment: "fallback kernel upgrade path, due to modeenv untrusted try kernel", + }, + // kernel upgrade path, but reboots to fallback due to try kernel file not existing + { + m: &boot.Modeenv{Mode: "run", CurrentKernels: []string{kernel1.Filename(), kernel2.Filename()}}, + kernel: kernel1, + trykernel: kernel2, + typs: []snap.Type{kernelT}, + blvars: map[string]string{"kernel_status": boot.TryingStatus}, + snapsToMake: []snap.PlaceInfo{kernel1}, + expRebootPanic: "reboot due to try kernel file not existing", + rootfsDir: filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data"), + comment: "fallback kernel upgrade path, due to try kernel file not existing", + }, + // kernel upgrade path, but reboots to fallback due to invalid kernel_status + { + m: &boot.Modeenv{Mode: "run", CurrentKernels: []string{kernel1.Filename(), kernel2.Filename()}}, + kernel: kernel1, + trykernel: kernel2, + typs: []snap.Type{kernelT}, + blvars: map[string]string{"kernel_status": boot.TryStatus}, + snapsToMake: []snap.PlaceInfo{kernel1, kernel2}, + expRebootPanic: "reboot due to kernel_status wrong", + rootfsDir: filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data"), + comment: "fallback kernel upgrade path, due to kernel_status wrong", + }, + // bad try status and no try kernel found + { + m: &boot.Modeenv{Mode: "run", CurrentKernels: []string{kernel1.Filename(), "pc-kernel_badrev.snap"}}, + kernel: kernel1, + typs: []snap.Type{kernelT}, + blvars: map[string]string{"kernel_status": boot.TryStatus}, + snapsToMake: []snap.PlaceInfo{kernel1}, + expected: map[snap.Type]snap.PlaceInfo{kernelT: kernel1}, + rootfsDir: filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data"), + comment: "bad try status, we reboot", + runBootEnvMethodToFail: "TryKernel", + runBootEnvError: fmt.Errorf("cannot read dangling symlink"), + expRebootPanic: "reboot due to bad try status", + }, + + // + // unhappy initramfs fail kernel paths + // + + // fallback kernel not trusted in modeenv + { + m: &boot.Modeenv{Mode: "run"}, + kernel: kernel1, + typs: []snap.Type{kernelT}, + snapsToMake: []snap.PlaceInfo{kernel1}, + errPattern: fmt.Sprintf("fallback kernel snap %q is not trusted in the modeenv", kernel1.Filename()), + rootfsDir: filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data"), + comment: "fallback kernel not trusted in modeenv", + }, + // fallback kernel file doesn't exist + { + m: &boot.Modeenv{Mode: "run", CurrentKernels: []string{kernel1.Filename()}}, + kernel: kernel1, + typs: []snap.Type{kernelT}, + errPattern: fmt.Sprintf("kernel snap %q does not exist on ubuntu-data", kernel1.Filename()), + rootfsDir: filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data"), + comment: "fallback kernel file doesn't exist", + }, + + // + // happy base upgrade paths + // + + // successful base upgrade path + { + m: &boot.Modeenv{ + Mode: "run", + Base: base1.Filename(), + TryBase: base2.Filename(), + BaseStatus: boot.TryStatus, + }, + expectedM: &boot.Modeenv{ + Mode: "run", + Base: base1.Filename(), + TryBase: base2.Filename(), + BaseStatus: boot.TryingStatus, + }, + typs: []snap.Type{baseT}, + snapsToMake: []snap.PlaceInfo{base1, base2}, + expected: map[snap.Type]snap.PlaceInfo{baseT: base2}, + rootfsDir: filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data"), + comment: "successful base upgrade path", + }, + // base upgrade path, but uses fallback due to try base file not existing + { + m: &boot.Modeenv{ + Mode: "run", + Base: base1.Filename(), + TryBase: base2.Filename(), + BaseStatus: boot.TryStatus, + }, + expectedM: &boot.Modeenv{ + Mode: "run", + Base: base1.Filename(), + TryBase: base2.Filename(), + BaseStatus: boot.TryStatus, + }, + typs: []snap.Type{baseT}, + snapsToMake: []snap.PlaceInfo{base1}, + expected: map[snap.Type]snap.PlaceInfo{baseT: base1}, + rootfsDir: filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data"), + comment: "fallback base upgrade path, due to missing try base file", + }, + // base upgrade path, but uses fallback due to base_status trying + { + m: &boot.Modeenv{ + Mode: "run", + Base: base1.Filename(), + TryBase: base2.Filename(), + BaseStatus: boot.TryingStatus, + }, + expectedM: &boot.Modeenv{ + Mode: "run", + Base: base1.Filename(), + TryBase: base2.Filename(), + BaseStatus: boot.DefaultStatus, + }, + typs: []snap.Type{baseT}, + snapsToMake: []snap.PlaceInfo{base1, base2}, + expected: map[snap.Type]snap.PlaceInfo{baseT: base1}, + rootfsDir: filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data"), + comment: "fallback base upgrade path, due to base_status trying", + }, + // base upgrade path, but uses fallback due to base_status default + { + m: &boot.Modeenv{ + Mode: "run", + Base: base1.Filename(), + TryBase: base2.Filename(), + BaseStatus: boot.DefaultStatus, + }, + expectedM: &boot.Modeenv{ + Mode: "run", + Base: base1.Filename(), + TryBase: base2.Filename(), + BaseStatus: boot.DefaultStatus, + }, + typs: []snap.Type{baseT}, + snapsToMake: []snap.PlaceInfo{base1, base2}, + expected: map[snap.Type]snap.PlaceInfo{baseT: base1}, + rootfsDir: filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data"), + comment: "fallback base upgrade path, due to missing base_status", + }, + + // + // unhappy base paths + // + + // base snap unset + { + m: &boot.Modeenv{Mode: "run"}, + typs: []snap.Type{baseT}, + snapsToMake: []snap.PlaceInfo{base1}, + errPattern: "no currently usable base snaps: cannot get snap revision: modeenv base boot variable is empty", + rootfsDir: filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data"), + comment: "base snap unset in modeenv", + }, + // base snap file doesn't exist + { + m: &boot.Modeenv{Mode: "run", Base: base1.Filename()}, + typs: []snap.Type{baseT}, + errPattern: fmt.Sprintf("base snap %q does not exist on ubuntu-data", base1.Filename()), + rootfsDir: filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data"), + comment: "base snap unset in modeenv", + }, + // unhappy, but silent path with fallback, due to invalid try base snap name + { + m: &boot.Modeenv{ + Mode: "run", + Base: base1.Filename(), + TryBase: "bogusname", + BaseStatus: boot.TryStatus, + }, + typs: []snap.Type{baseT}, + snapsToMake: []snap.PlaceInfo{base1}, + expected: map[snap.Type]snap.PlaceInfo{baseT: base1}, + rootfsDir: filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data"), + comment: "corrupted base snap name", + }, + + // + // combined cases + // + + // default + { + m: &boot.Modeenv{ + Mode: "run", + Base: base1.Filename(), + CurrentKernels: []string{kernel1.Filename()}, + }, + expectedM: &boot.Modeenv{ + Mode: "run", + Base: base1.Filename(), + CurrentKernels: []string{kernel1.Filename()}, + }, + kernel: kernel1, + typs: []snap.Type{baseT, kernelT}, + snapsToMake: []snap.PlaceInfo{base1, kernel1}, + expected: map[snap.Type]snap.PlaceInfo{ + baseT: base1, + kernelT: kernel1, + }, + rootfsDir: filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data"), + comment: "default combined kernel + base", + }, + // combined, upgrade only the kernel + { + m: &boot.Modeenv{ + Mode: "run", + Base: base1.Filename(), + CurrentKernels: []string{kernel1.Filename(), kernel2.Filename()}, + }, + expectedM: &boot.Modeenv{ + Mode: "run", + Base: base1.Filename(), + CurrentKernels: []string{kernel1.Filename(), kernel2.Filename()}, + }, + kernel: kernel1, + trykernel: kernel2, + typs: []snap.Type{baseT, kernelT}, + blvars: map[string]string{"kernel_status": boot.TryingStatus}, + snapsToMake: []snap.PlaceInfo{base1, kernel1, kernel2}, + expected: map[snap.Type]snap.PlaceInfo{ + baseT: base1, + kernelT: kernel2, + }, + rootfsDir: filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data"), + comment: "combined kernel + base, successful kernel upgrade", + }, + // combined, upgrade only the base + { + m: &boot.Modeenv{ + Mode: "run", + Base: base1.Filename(), + TryBase: base2.Filename(), + BaseStatus: boot.TryStatus, + CurrentKernels: []string{kernel1.Filename()}, + }, + expectedM: &boot.Modeenv{ + Mode: "run", + Base: base1.Filename(), + TryBase: base2.Filename(), + BaseStatus: boot.TryingStatus, + CurrentKernels: []string{kernel1.Filename()}, + }, + kernel: kernel1, + typs: []snap.Type{baseT, kernelT}, + snapsToMake: []snap.PlaceInfo{base1, base2, kernel1}, + expected: map[snap.Type]snap.PlaceInfo{ + baseT: base2, + kernelT: kernel1, + }, + rootfsDir: filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data"), + comment: "combined kernel + base, successful base upgrade", + }, + // bonus points: combined upgrade kernel and base + { + m: &boot.Modeenv{ + Mode: "run", + Base: base1.Filename(), + TryBase: base2.Filename(), + BaseStatus: boot.TryStatus, + CurrentKernels: []string{kernel1.Filename(), kernel2.Filename()}, + }, + expectedM: &boot.Modeenv{ + Mode: "run", + Base: base1.Filename(), + TryBase: base2.Filename(), + BaseStatus: boot.TryingStatus, + CurrentKernels: []string{kernel1.Filename(), kernel2.Filename()}, + }, + kernel: kernel1, + trykernel: kernel2, + typs: []snap.Type{baseT, kernelT}, + blvars: map[string]string{"kernel_status": boot.TryingStatus}, + snapsToMake: []snap.PlaceInfo{base1, base2, kernel1, kernel2}, + expected: map[snap.Type]snap.PlaceInfo{ + baseT: base2, + kernelT: kernel2, + }, + rootfsDir: filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data"), + comment: "combined kernel + base, successful base + kernel upgrade", + }, + // combined, fallback upgrade on kernel + { + m: &boot.Modeenv{ + Mode: "run", + Base: base1.Filename(), + CurrentKernels: []string{kernel1.Filename(), kernel2.Filename()}, + }, + expectedM: &boot.Modeenv{ + Mode: "run", + Base: base1.Filename(), + CurrentKernels: []string{kernel1.Filename(), kernel2.Filename()}, + }, + kernel: kernel1, + trykernel: kernel2, + typs: []snap.Type{baseT, kernelT}, + blvars: map[string]string{"kernel_status": boot.DefaultStatus}, + snapsToMake: []snap.PlaceInfo{base1, kernel1, kernel2}, + expected: map[snap.Type]snap.PlaceInfo{ + baseT: base1, + kernelT: kernel1, + }, + rootfsDir: filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data"), + comment: "combined kernel + base, fallback kernel upgrade, due to missing boot var", + }, + // combined, fallback upgrade on base + { + m: &boot.Modeenv{ + Mode: "run", + Base: base1.Filename(), + TryBase: base2.Filename(), + BaseStatus: boot.TryingStatus, + CurrentKernels: []string{kernel1.Filename()}, + }, + expectedM: &boot.Modeenv{ + Mode: "run", + Base: base1.Filename(), + TryBase: base2.Filename(), + BaseStatus: boot.DefaultStatus, + CurrentKernels: []string{kernel1.Filename()}, + }, + kernel: kernel1, + typs: []snap.Type{baseT, kernelT}, + snapsToMake: []snap.PlaceInfo{base1, base2, kernel1}, + expected: map[snap.Type]snap.PlaceInfo{ + baseT: base1, + kernelT: kernel1, + }, + rootfsDir: filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data"), + comment: "combined kernel + base, fallback base upgrade, due to base_status trying", + }, + } + + // do both the normal uc20 bootloader and the env ref bootloader + bloaderTable := []struct { + bl interface { + bootloader.Bootloader + SetEnabledKernel(s snap.PlaceInfo) (restore func()) + SetEnabledTryKernel(s snap.PlaceInfo) (restore func()) + } + name string + }{ + { + boottest.MockUC20RunBootenv(bootloadertest.Mock("mock", c.MkDir())), + "env ref extracted kernel", + }, + { + boottest.MockUC20EnvRefExtractedKernelRunBootenv(bootloadertest.Mock("mock", c.MkDir())), + "extracted run kernel image", + }, + } + + for _, tbl := range bloaderTable { + bl := tbl.bl + for _, t := range tt { + var cleanups []func() + + comment := Commentf("[%s] %s", tbl.name, t.comment) + if t.runBootEnvMethodToFail != "" { + if rbe, ok := tbl.bl.(*boottest.RunBootenv20); ok { + cleanups = append(cleanups, rbe.MockExtractedRunKernelImageMixin.SetRunKernelImageFunctionError( + t.runBootEnvMethodToFail, t.runBootEnvError)) + } + } + + // we use a panic to simulate a reboot + if t.expRebootPanic != "" { + r := boot.MockInitramfsReboot(func() error { + panic(t.expRebootPanic) + }) + cleanups = append(cleanups, r) + } + + bootloader.Force(bl) + cleanups = append(cleanups, func() { bootloader.Force(nil) }) + + // set the bl kernel / try kernel + if t.kernel != nil { + cleanups = append(cleanups, bl.SetEnabledKernel(t.kernel)) + } + + if t.trykernel != nil { + cleanups = append(cleanups, bl.SetEnabledTryKernel(t.trykernel)) + } + + if t.blvars != nil { + c.Assert(bl.SetBootVars(t.blvars), IsNil, comment) + cleanBootVars := make(map[string]string, len(t.blvars)) + for k := range t.blvars { + cleanBootVars[k] = "" + } + cleanups = append(cleanups, func() { + c.Assert(bl.SetBootVars(cleanBootVars), IsNil, comment) + }) + } + + if len(t.snapsToMake) != 0 { + r := makeSnapFilesOnInitramfsUbuntuData(c, t.rootfsDir, comment, t.snapsToMake...) + cleanups = append(cleanups, r) + } + + // write the modeenv to somewhere so we can read it and pass that to + // InitramfsRunModeChooseSnapsToMount + err := t.m.WriteTo(t.rootfsDir) + // remove it because we are writing many modeenvs in this single test + cleanups = append(cleanups, func() { + c.Assert(os.Remove(dirs.SnapModeenvFileUnder(t.rootfsDir)), IsNil, Commentf(t.comment)) + }) + c.Assert(err, IsNil, comment) + + m, err := boot.ReadModeenv(t.rootfsDir) + c.Assert(err, IsNil, comment) + + if t.expRebootPanic != "" { + f := func() { boot.InitramfsRunModeSelectSnapsToMount(t.typs, m, t.rootfsDir) } + c.Assert(f, PanicMatches, t.expRebootPanic, comment) + } else { + mountSnaps, err := boot.InitramfsRunModeSelectSnapsToMount(t.typs, m, t.rootfsDir) + if t.errPattern != "" { + c.Assert(err, ErrorMatches, t.errPattern, comment) + } else { + c.Assert(err, IsNil, comment) + c.Assert(mountSnaps, DeepEquals, t.expected, comment) + } + } + + // check that the modeenv changed as expected + if t.expectedM != nil { + newM, err := boot.ReadModeenv(t.rootfsDir) + c.Assert(err, IsNil, comment) + c.Assert(newM.Base, Equals, t.expectedM.Base, comment) + c.Assert(newM.BaseStatus, Equals, t.expectedM.BaseStatus, comment) + c.Assert(newM.TryBase, Equals, t.expectedM.TryBase, comment) + + // shouldn't be changing in the initramfs, but be safe + c.Assert(newM.CurrentKernels, DeepEquals, t.expectedM.CurrentKernels, comment) + } + + // clean up + for _, r := range cleanups { + r() + } + } + } +} + +func (s *initramfsSuite) TestInitramfsRunModeUpdateBootloaderVars(c *C) { + bloader := bootloadertest.Mock("noscripts", c.MkDir()).WithNotScriptable() + bootloader.Force(bloader) + defer bootloader.Force(nil) + + tt := []struct { + cmdline string + initialStatus string + finalStatus string + }{ + { + cmdline: "kernel_status=trying", + initialStatus: "try", + finalStatus: "trying", + }, + { + cmdline: "kernel_status=trying", + initialStatus: "badstate", + finalStatus: "", + }, + { + cmdline: "kernel_status=trying", + initialStatus: "", + finalStatus: "", + }, + { + cmdline: "", + initialStatus: "try", + finalStatus: "", + }, + { + cmdline: "", + initialStatus: "trying", + finalStatus: "", + }, + { + cmdline: "quiet splash", + initialStatus: "try", + finalStatus: "", + }, + } + + for _, t := range tt { + bloader.SetBootVars(map[string]string{"kernel_status": t.initialStatus}) + + cmdlineFile := filepath.Join(c.MkDir(), "cmdline") + err := os.WriteFile(cmdlineFile, []byte(t.cmdline), 0644) + c.Assert(err, IsNil) + r := kcmdline.MockProcCmdline(cmdlineFile) + defer r() + + err = boot.InitramfsRunModeUpdateBootloaderVars() + c.Assert(err, IsNil) + vars, err := bloader.GetBootVars("kernel_status") + c.Assert(err, IsNil) + c.Assert(vars, DeepEquals, map[string]string{"kernel_status": t.finalStatus}) + } +} + +func (s *initramfsSuite) TestInitramfsRunModeUpdateBootloaderVarsNotNotScriptable(c *C) { + // Make sure the method does not change status if the + // bootloader does not implement NotScriptableBootloader + + bloader := bootloadertest.Mock("noscripts", c.MkDir()) + bootloader.Force(bloader) + defer bootloader.Force(nil) + + bloader.SetBootVars(map[string]string{"kernel_status": "try"}) + + cmdlineFile := filepath.Join(c.MkDir(), "cmdline") + err := os.WriteFile(cmdlineFile, []byte("kernel_status=trying"), 0644) + c.Assert(err, IsNil) + r := kcmdline.MockProcCmdline(cmdlineFile) + defer r() + + err = boot.InitramfsRunModeUpdateBootloaderVars() + c.Assert(err, IsNil) + vars, err := bloader.GetBootVars("kernel_status") + c.Assert(err, IsNil) + c.Assert(vars, DeepEquals, map[string]string{"kernel_status": "try"}) +} + +func (s *initramfsSuite) TestInitramfsRunModeUpdateBootloaderVarsErrOnGetBootVars(c *C) { + bloader := bootloadertest.Mock("noscripts", c.MkDir()).WithNotScriptable() + bootloader.Force(bloader) + defer bootloader.Force(nil) + + errMsg := "cannot get boot environment" + bloader.GetErr = fmt.Errorf(errMsg) + + cmdlineFile := filepath.Join(c.MkDir(), "cmdline") + err := os.WriteFile(cmdlineFile, []byte("kernel_status=trying"), 0644) + c.Assert(err, IsNil) + r := kcmdline.MockProcCmdline(cmdlineFile) + defer r() + + err = boot.InitramfsRunModeUpdateBootloaderVars() + c.Assert(err, ErrorMatches, errMsg) +} + +func (s *initramfsSuite) TestInitramfsRunModeUpdateBootloaderVarsErrNoCmdline(c *C) { + bloader := bootloadertest.Mock("noscripts", c.MkDir()).WithNotScriptable() + bootloader.Force(bloader) + defer bootloader.Force(nil) + + bloader.SetBootVars(map[string]string{"kernel_status": "try"}) + + err := boot.InitramfsRunModeUpdateBootloaderVars() + c.Assert(err, ErrorMatches, ".*cmdline: no such file or directory") +} + +func (s *initramfsSuite) TestInitramfsRunModeUpdateBootloaderVarsNoBootloaderHappy(c *C) { + err := boot.InitramfsRunModeUpdateBootloaderVars() + c.Assert(err, IsNil) +} + +var classicModel = &gadgettest.ModelCharacteristics{ + IsClassic: true, + HasModes: true, +} + +var coreModel = &gadgettest.ModelCharacteristics{ + IsClassic: false, + HasModes: true, +} + +func (s *initramfsSuite) TestInstallHostWritableDir(c *C) { + c.Check(boot.InstallHostWritableDir(classicModel), Equals, filepath.Join(dirs.GlobalRootDir, "/run/mnt/ubuntu-data")) + c.Check(boot.InstallHostWritableDir(coreModel), Equals, filepath.Join(dirs.GlobalRootDir, "/run/mnt/ubuntu-data/system-data")) +} + +func (s *initramfsSuite) TestInitramfsHostWritableDir(c *C) { + c.Check(boot.InitramfsHostWritableDir(classicModel), Equals, filepath.Join(dirs.GlobalRootDir, "/run/mnt/host/ubuntu-data")) + c.Check(boot.InitramfsHostWritableDir(coreModel), Equals, filepath.Join(dirs.GlobalRootDir, "/run/mnt/host/ubuntu-data/system-data")) +} + +func (s *initramfsSuite) TestInitramfsWritableDir(c *C) { + for _, tc := range []struct { + model gadget.Model + runMode bool + expectedDir string + }{ + {classicModel, true, "/run/mnt/data"}, + {classicModel, false, "/run/mnt/data/system-data"}, + {coreModel, true, "/run/mnt/data/system-data"}, + {coreModel, false, "/run/mnt/data/system-data"}, + } { + c.Check(boot.InitramfsWritableDir(tc.model, tc.runMode), Equals, filepath.Join(dirs.GlobalRootDir, tc.expectedDir)) + } +} diff --git a/boot/kernel_os.go b/boot/kernel_os.go new file mode 100644 index 00000000..1f6c7045 --- /dev/null +++ b/boot/kernel_os.go @@ -0,0 +1,87 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2014-2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package boot + +import ( + "fmt" + + "github.com/snapcore/snapd/bootloader" + "github.com/snapcore/snapd/snap" +) + +type coreBootParticipant struct { + s snap.PlaceInfo + bs bootState +} + +// ensure coreBootParticipant is a BootParticipant +var _ BootParticipant = (*coreBootParticipant)(nil) + +func (*coreBootParticipant) IsTrivial() bool { return false } + +func (bp *coreBootParticipant) SetNextBoot(bootCtx NextBootContext) (rebootInfo RebootInfo, err error) { + modeenvLock() + defer modeenvUnlock() + + const errPrefix = "cannot set next boot: %s" + + rebootInfo, u, err := bp.bs.setNext(bp.s, bootCtx) + if err != nil { + return RebootInfo{RebootRequired: false}, fmt.Errorf(errPrefix, err) + } + + if u != nil { + if err := u.commit(); err != nil { + return RebootInfo{RebootRequired: false}, fmt.Errorf(errPrefix, err) + } + } + + return rebootInfo, nil +} + +type coreKernel struct { + s snap.PlaceInfo + bopts *bootloader.Options +} + +// ensure coreKernel is a Kernel +var _ BootKernel = (*coreKernel)(nil) + +func (*coreKernel) IsTrivial() bool { return false } + +func (k *coreKernel) RemoveKernelAssets() error { + // XXX: shouldn't we check the snap type? + bootloader, err := bootloader.Find("", k.bopts) + if err != nil { + return fmt.Errorf("cannot remove kernel assets: %s", err) + } + + // ask bootloader to remove the kernel assets if needed + return bootloader.RemoveKernelAssets(k.s) +} + +func (k *coreKernel) ExtractKernelAssets(snapf snap.Container) error { + bootloader, err := bootloader.Find("", k.bopts) + if err != nil { + return fmt.Errorf("cannot extract kernel assets: %s", err) + } + // ask bootloader to extract the kernel assets if needed + return bootloader.ExtractKernelAssets(k.s, snapf) +} diff --git a/boot/kernel_os_test.go b/boot/kernel_os_test.go new file mode 100644 index 00000000..e9ad959c --- /dev/null +++ b/boot/kernel_os_test.go @@ -0,0 +1,804 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2014-2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package boot_test + +import ( + "errors" + "os" + "path/filepath" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/boot" + "github.com/snapcore/snapd/boot/boottest" + "github.com/snapcore/snapd/bootloader" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/snap/snapfile" + "github.com/snapcore/snapd/snap/snaptest" + "github.com/snapcore/snapd/testutil" +) + +const packageKernel = ` +name: ubuntu-kernel +version: 4.0-1 +type: kernel +vendor: Someone +` + +func (s *bootenvSuite) TestExtractKernelAssetsError(c *C) { + bootloader.ForceError(errors.New("brkn")) + err := boot.NewCoreKernel(&snap.Info{}, boottest.MockDevice("")).ExtractKernelAssets(nil) + c.Check(err, ErrorMatches, `cannot extract kernel assets: brkn`) +} + +func (s *bootenvSuite) TestRemoveKernelAssetsError(c *C) { + bootloader.ForceError(errors.New("brkn")) + err := boot.NewCoreKernel(&snap.Info{}, boottest.MockDevice("")).RemoveKernelAssets() + c.Check(err, ErrorMatches, `cannot remove kernel assets: brkn`) +} + +func (s *bootenvSuite) TestSetNextBootError(c *C) { + coreDev := boottest.MockDevice("some-snap") + + s.bootloader.GetErr = errors.New("zap") + _, err := boot.NewCoreBootParticipant(&snap.Info{}, snap.TypeKernel, coreDev).SetNextBoot(boot.NextBootContext{BootWithoutTry: false}) + c.Check(err, ErrorMatches, `cannot set next boot: zap`) + + bootloader.ForceError(errors.New("brkn")) + _, err = boot.NewCoreBootParticipant(&snap.Info{}, snap.TypeKernel, coreDev).SetNextBoot(boot.NextBootContext{BootWithoutTry: false}) + c.Check(err, ErrorMatches, `cannot set next boot: brkn`) +} + +func (s *bootenvSuite) TestSetNextBootForCore(c *C) { + coreDev := boottest.MockDevice("core") + + info := &snap.Info{} + info.SnapType = snap.TypeOS + info.RealName = "core" + info.Revision = snap.R(100) + + bs := boot.NewCoreBootParticipant(info, info.Type(), coreDev) + rebootInfo, err := bs.SetNextBoot(boot.NextBootContext{BootWithoutTry: false}) + c.Assert(err, IsNil) + + v, err := s.bootloader.GetBootVars("snap_try_core", "snap_mode") + c.Assert(err, IsNil) + c.Assert(v, DeepEquals, map[string]string{ + "snap_try_core": "core_100.snap", + "snap_mode": boot.TryStatus, + }) + + c.Check(rebootInfo, Equals, boot.RebootInfo{RebootRequired: true}) +} + +func (s *bootenvSuite) TestSetNextBootWithBaseForCore(c *C) { + coreDev := boottest.MockDevice("core18") + + info := &snap.Info{} + info.SnapType = snap.TypeBase + info.RealName = "core18" + info.Revision = snap.R(1818) + + bs := boot.NewCoreBootParticipant(info, info.Type(), coreDev) + rebootInfo, err := bs.SetNextBoot(boot.NextBootContext{BootWithoutTry: false}) + c.Assert(err, IsNil) + + v, err := s.bootloader.GetBootVars("snap_try_core", "snap_mode") + c.Assert(err, IsNil) + c.Assert(v, DeepEquals, map[string]string{ + "snap_try_core": "core18_1818.snap", + "snap_mode": boot.TryStatus, + }) + + c.Check(rebootInfo, Equals, boot.RebootInfo{RebootRequired: true}) +} + +func (s *bootenvSuite) TestSetNextBootForKernel(c *C) { + coreDev := boottest.MockDevice("krnl") + + info := &snap.Info{} + info.SnapType = snap.TypeKernel + info.RealName = "krnl" + info.Revision = snap.R(42) + + bp := boot.NewCoreBootParticipant(info, snap.TypeKernel, coreDev) + rebootInfo, err := bp.SetNextBoot(boot.NextBootContext{BootWithoutTry: false}) + c.Assert(err, IsNil) + + v, err := s.bootloader.GetBootVars("snap_try_kernel", "snap_mode") + c.Assert(err, IsNil) + c.Assert(v, DeepEquals, map[string]string{ + "snap_try_kernel": "krnl_42.snap", + "snap_mode": boot.TryStatus, + }) + + bootVars := map[string]string{ + "snap_kernel": "krnl_40.snap", + "snap_try_kernel": "krnl_42.snap"} + s.bootloader.SetBootVars(bootVars) + c.Check(rebootInfo, Equals, boot.RebootInfo{RebootRequired: true}) + + // simulate good boot + bootVars = map[string]string{"snap_kernel": "krnl_42.snap"} + s.bootloader.SetBootVars(bootVars) + + rebootInfo, err = bp.SetNextBoot(boot.NextBootContext{BootWithoutTry: false}) + c.Assert(err, IsNil) + c.Check(rebootInfo, Equals, boot.RebootInfo{RebootRequired: false}) +} + +func (s *bootenv20Suite) TestSetNextBoot20ForKernel(c *C) { + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + + r := setupUC20Bootenv( + c, + s.bootloader, + s.normalDefaultState, + ) + defer r() + + bs := boot.NewCoreBootParticipant(s.kern2, snap.TypeKernel, coreDev) + c.Assert(bs.IsTrivial(), Equals, false) + rebootInfo, err := bs.SetNextBoot(boot.NextBootContext{BootWithoutTry: false}) + c.Assert(err, IsNil) + + // check that kernel_status is now try + v, err := s.bootloader.GetBootVars("kernel_status") + c.Assert(err, IsNil) + c.Assert(v, DeepEquals, map[string]string{ + "kernel_status": boot.TryStatus, + }) + + c.Check(rebootInfo, DeepEquals, boot.RebootInfo{ + RebootRequired: true, + BootloaderOptions: &bootloader.Options{ + Role: bootloader.RoleRunMode, + }, + }) + + // check that SetNextBoot enabled kernel2 as a TryKernel + actual, _ := s.bootloader.GetRunKernelImageFunctionSnapCalls("EnableTryKernel") + c.Assert(actual, DeepEquals, []snap.PlaceInfo{s.kern2}) + + // also didn't move any try kernels to trusted kernels + actual, _ = s.bootloader.GetRunKernelImageFunctionSnapCalls("EnableKernel") + c.Assert(actual, DeepEquals, []snap.PlaceInfo(nil)) + + // check that SetNextBoot asked the bootloader for a kernel + _, nKernelCalls := s.bootloader.GetRunKernelImageFunctionSnapCalls("Kernel") + c.Assert(nKernelCalls, Equals, 1) + + // and that the modeenv now has this kernel listed + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Assert(m2.CurrentKernels, DeepEquals, []string{s.kern1.Filename(), s.kern2.Filename()}) +} + +func (s *bootenv20EnvRefKernelSuite) TestSetNextBoot20ForKernel(c *C) { + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + + r := setupUC20Bootenv( + c, + s.bootloader, + s.normalDefaultState, + ) + defer r() + + bs := boot.NewCoreBootParticipant(s.kern2, snap.TypeKernel, coreDev) + c.Assert(bs.IsTrivial(), Equals, false) + rebootInfo, err := bs.SetNextBoot(boot.NextBootContext{BootWithoutTry: false}) + c.Assert(err, IsNil) + + m := s.bootloader.BootVars + c.Assert(m, DeepEquals, map[string]string{ + "kernel_status": boot.TryStatus, + "snap_try_kernel": s.kern2.Filename(), + "snap_kernel": s.kern1.Filename(), + }) + + c.Check(rebootInfo, DeepEquals, boot.RebootInfo{ + RebootRequired: true, + BootloaderOptions: &bootloader.Options{ + Role: bootloader.RoleRunMode, + }, + }) + + // and that the modeenv now has this kernel listed + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Assert(m2.CurrentKernels, DeepEquals, []string{s.kern1.Filename(), s.kern2.Filename()}) +} + +func (s *bootenvSuite) TestSetNextBootForKernelForTheSameKernel(c *C) { + coreDev := boottest.MockDevice("krnl") + + info := &snap.Info{} + info.SnapType = snap.TypeKernel + info.RealName = "krnl" + info.Revision = snap.R(40) + + bootVars := map[string]string{"snap_kernel": "krnl_40.snap"} + s.bootloader.SetBootVars(bootVars) + + rebootInfo, err := boot.NewCoreBootParticipant(info, snap.TypeKernel, coreDev).SetNextBoot(boot.NextBootContext{BootWithoutTry: false}) + c.Assert(err, IsNil) + + v, err := s.bootloader.GetBootVars("snap_kernel") + c.Assert(err, IsNil) + c.Assert(v, DeepEquals, map[string]string{ + "snap_kernel": "krnl_40.snap", + }) + + c.Check(rebootInfo, Equals, boot.RebootInfo{RebootRequired: false}) +} + +func (s *bootenv20Suite) TestSetNextBoot20ForKernelForTheSameKernel(c *C) { + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + + r := setupUC20Bootenv( + c, + s.bootloader, + s.normalDefaultState, + ) + defer r() + + bs := boot.NewCoreBootParticipant(s.kern1, snap.TypeKernel, coreDev) + c.Assert(bs.IsTrivial(), Equals, false) + rebootInfo, err := bs.SetNextBoot(boot.NextBootContext{BootWithoutTry: false}) + c.Assert(err, IsNil) + + // check that kernel_status is cleared + v, err := s.bootloader.GetBootVars("kernel_status") + c.Assert(err, IsNil) + c.Assert(v, DeepEquals, map[string]string{ + "kernel_status": boot.DefaultStatus, + }) + + c.Check(rebootInfo, Equals, boot.RebootInfo{RebootRequired: false}) + + // check that SetNextBoot didn't try to enable any try kernels + actual, _ := s.bootloader.GetRunKernelImageFunctionSnapCalls("EnableTryKernel") + c.Assert(actual, HasLen, 0) + + // also didn't move any try kernels to trusted kernels + actual, _ = s.bootloader.GetRunKernelImageFunctionSnapCalls("EnableKernel") + c.Assert(actual, HasLen, 0) + + // check that SetNextBoot asked the bootloader for a kernel + _, nKernelCalls := s.bootloader.GetRunKernelImageFunctionSnapCalls("Kernel") + c.Assert(nKernelCalls, Equals, 1) + + // and that the modeenv now has this kernel listed + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Assert(m2.CurrentKernels, DeepEquals, []string{s.kern1.Filename()}) +} + +func (s *bootenv20EnvRefKernelSuite) TestSetNextBoot20ForKernelForTheSameKernel(c *C) { + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + + r := setupUC20Bootenv( + c, + s.bootloader, + s.normalDefaultState, + ) + defer r() + + bs := boot.NewCoreBootParticipant(s.kern1, snap.TypeKernel, coreDev) + c.Assert(bs.IsTrivial(), Equals, false) + rebootInfo, err := bs.SetNextBoot(boot.NextBootContext{BootWithoutTry: false}) + c.Assert(err, IsNil) + + // check that kernel_status is cleared + m := s.bootloader.BootVars + c.Assert(m, DeepEquals, map[string]string{ + "kernel_status": boot.DefaultStatus, + "snap_kernel": s.kern1.Filename(), + "snap_try_kernel": "", + }) + + c.Check(rebootInfo, Equals, boot.RebootInfo{RebootRequired: false}) + + // and that the modeenv now has this kernel listed + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Assert(m2.CurrentKernels, DeepEquals, []string{s.kern1.Filename()}) +} + +func (s *bootenvSuite) TestSetNextBootForKernelForTheSameKernelTryMode(c *C) { + coreDev := boottest.MockDevice("krnl") + + info := &snap.Info{} + info.SnapType = snap.TypeKernel + info.RealName = "krnl" + info.Revision = snap.R(40) + + bootVars := map[string]string{ + "snap_kernel": "krnl_40.snap", + "snap_try_kernel": "krnl_99.snap", + "snap_mode": boot.TryStatus} + s.bootloader.SetBootVars(bootVars) + + rebootInfo, err := boot.NewCoreBootParticipant(info, snap.TypeKernel, coreDev).SetNextBoot(boot.NextBootContext{BootWithoutTry: false}) + c.Assert(err, IsNil) + + v, err := s.bootloader.GetBootVars("snap_kernel", "snap_try_kernel", "snap_mode") + c.Assert(err, IsNil) + c.Assert(v, DeepEquals, map[string]string{ + "snap_kernel": "krnl_40.snap", + "snap_try_kernel": "", + "snap_mode": boot.DefaultStatus, + }) + + c.Check(rebootInfo, Equals, boot.RebootInfo{RebootRequired: false}) +} + +func (s *bootenv20Suite) TestSetNextBoot20ForKernelForTheSameKernelTryMode(c *C) { + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + + // set all the same vars as if we were doing trying, except don't set a try + // kernel + r := setupUC20Bootenv( + c, + s.bootloader, + &bootenv20Setup{ + modeenv: &boot.Modeenv{ + Mode: "run", + Base: s.base1.Filename(), + CurrentKernels: []string{s.kern1.Filename()}, + }, + kern: s.kern1, + // no try-kernel + kernStatus: boot.TryStatus, + }, + ) + defer r() + + bs := boot.NewCoreBootParticipant(s.kern1, snap.TypeKernel, coreDev) + c.Assert(bs.IsTrivial(), Equals, false) + rebootInfo, err := bs.SetNextBoot(boot.NextBootContext{BootWithoutTry: false}) + c.Assert(err, IsNil) + + // check that kernel_status is cleared + v, err := s.bootloader.GetBootVars("kernel_status") + c.Assert(err, IsNil) + c.Assert(v, DeepEquals, map[string]string{ + "kernel_status": boot.DefaultStatus, + }) + + c.Check(rebootInfo, Equals, boot.RebootInfo{RebootRequired: false}) + + // check that SetNextBoot didn't try to enable any try kernels + actual, _ := s.bootloader.GetRunKernelImageFunctionSnapCalls("EnableTryKernel") + c.Assert(actual, HasLen, 0) + + // also didn't move any try kernels to trusted kernels + actual, _ = s.bootloader.GetRunKernelImageFunctionSnapCalls("EnableKernel") + c.Assert(actual, HasLen, 0) + + // check that SetNextBoot asked the bootloader for a kernel + _, nKernelCalls := s.bootloader.GetRunKernelImageFunctionSnapCalls("Kernel") + c.Assert(nKernelCalls, Equals, 1) + + // and that the modeenv didn't change + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Assert(m2.CurrentKernels, DeepEquals, []string{s.kern1.Filename()}) +} + +func (s *bootenv20EnvRefKernelSuite) TestSetNextBoot20ForKernelForTheSameKernelTryMode(c *C) { + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + + // set all the same vars as if we were doing trying, except don't set a try + // kernel + r := setupUC20Bootenv( + c, + s.bootloader, + &bootenv20Setup{ + modeenv: &boot.Modeenv{ + Mode: "run", + Base: s.base1.Filename(), + CurrentKernels: []string{s.kern1.Filename()}, + }, + kern: s.kern1, + // no try-kernel + kernStatus: boot.TryStatus, + }, + ) + defer r() + + bs := boot.NewCoreBootParticipant(s.kern1, snap.TypeKernel, coreDev) + c.Assert(bs.IsTrivial(), Equals, false) + rebootInfo, err := bs.SetNextBoot(boot.NextBootContext{BootWithoutTry: false}) + c.Assert(err, IsNil) + + // check that kernel_status is cleared + m := s.bootloader.BootVars + c.Assert(m, DeepEquals, map[string]string{ + "kernel_status": boot.DefaultStatus, + "snap_kernel": s.kern1.Filename(), + "snap_try_kernel": "", + }) + + c.Check(rebootInfo, Equals, boot.RebootInfo{RebootRequired: false}) + + // and that the modeenv didn't change + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Assert(m2.CurrentKernels, DeepEquals, []string{s.kern1.Filename()}) +} + +type ubootSuite struct { + baseBootenvSuite +} + +var _ = Suite(&ubootSuite{}) + +// forceUbootBootloader sets up a uboot bootloader, in the uc16/uc18 style +// where all env is stored in a single uboot.env +func (s *ubootSuite) forceUbootBootloader(c *C) { + bootloader.Force(nil) + + mockGadgetDir := c.MkDir() + // this is testing the uc16/uc18 style uboot bootloader layout, the file + // must be non-empty for uc16/uc18 gadget config install behavior + err := os.WriteFile(filepath.Join(mockGadgetDir, "uboot.conf"), []byte{1}, 0644) + c.Assert(err, IsNil) + err = bootloader.InstallBootConfig(mockGadgetDir, dirs.GlobalRootDir, nil) + c.Assert(err, IsNil) + + bloader, err := bootloader.Find("", nil) + c.Assert(err, IsNil) + c.Check(bloader, NotNil) + s.forceBootloader(bloader) + + fn := filepath.Join(s.bootdir, "/uboot/uboot.env") + c.Assert(osutil.FileExists(fn), Equals, true) +} + +// forceUbootBootloader sets up a uboot bootloader, in the uc20 style where we +// have a separate boot.sel file for snapd specific bootloader env +func (s *ubootSuite) forceUC20UbootBootloader(c *C) { + bootloader.Force(nil) + + // for the uboot bootloader InstallBootConfig we pass in + // NoSlashBoot because that's where the gadget assets get + // installed to + installOpts := &bootloader.Options{ + Role: bootloader.RoleRunMode, + NoSlashBoot: true, + } + + mockGadgetDir := c.MkDir() + // this must be empty for uc20 behavior + // TODO:UC20: update this test for the new behavior when that is implemented + err := os.WriteFile(filepath.Join(mockGadgetDir, "uboot.conf"), nil, 0644) + c.Assert(err, IsNil) + err = bootloader.InstallBootConfig(mockGadgetDir, dirs.GlobalRootDir, installOpts) + c.Assert(err, IsNil) + + // in reality for uc20, we will bind mount /uboot/ubuntu/ onto + // /boot/uboot, so to emulate this at runtime for the tests, just put files + // into "/uboot" under bootdir for the test to see things that on disk are + // at "/uboot/ubuntu" as "/boot/uboot/" + + fn := filepath.Join(dirs.GlobalRootDir, "/uboot/ubuntu/boot.sel") + c.Assert(osutil.FileExists(fn), Equals, true) + + targetFile := filepath.Join(s.bootdir, "uboot", "boot.sel") + err = os.MkdirAll(filepath.Dir(targetFile), 0755) + c.Assert(err, IsNil) + err = os.Rename(fn, targetFile) + c.Assert(err, IsNil) + + // find the run mode bootloader under /boot + runtimeOpts := &bootloader.Options{ + Role: bootloader.RoleRunMode, + } + + bloader, err := bootloader.Find("", runtimeOpts) + c.Assert(err, IsNil) + c.Check(bloader, NotNil) + s.forceBootloader(bloader) + c.Assert(bloader.Name(), Equals, "uboot") +} + +func (s *ubootSuite) TestExtractKernelAssetsAndRemoveOnUboot(c *C) { + + // test for both uc16/uc18 style uboot bootloader and for uc20 style bootloader + bloaderSetups := []func(){ + func() { s.forceUbootBootloader(c) }, + func() { s.forceUC20UbootBootloader(c) }, + } + + for _, setup := range bloaderSetups { + setup() + + files := [][]string{ + {"kernel.img", "I'm a kernel"}, + {"initrd.img", "...and I'm an initrd"}, + {"dtbs/foo.dtb", "g'day, I'm foo.dtb"}, + {"dtbs/bar.dtb", "hello, I'm bar.dtb"}, + // must be last + {"meta/kernel.yaml", "version: 4.2"}, + } + + si := &snap.SideInfo{ + RealName: "ubuntu-kernel", + Revision: snap.R(42), + } + fn := snaptest.MakeTestSnapWithFiles(c, packageKernel, files) + snapf, err := snapfile.Open(fn) + c.Assert(err, IsNil) + + info, err := snap.ReadInfoFromSnapFile(snapf, si) + c.Assert(err, IsNil) + + bp := boot.NewCoreKernel(info, boottest.MockDevice("")) + err = bp.ExtractKernelAssets(snapf) + c.Assert(err, IsNil) + + // this is where the kernel/initrd is unpacked + kernelAssetsDir := filepath.Join(s.bootdir, "/uboot/ubuntu-kernel_42.snap") + for _, def := range files { + if def[0] == "meta/kernel.yaml" { + break + } + + fullFn := filepath.Join(kernelAssetsDir, def[0]) + c.Check(fullFn, testutil.FileEquals, def[1]) + } + + // it's idempotent + err = bp.ExtractKernelAssets(snapf) + c.Assert(err, IsNil) + + // remove + err = bp.RemoveKernelAssets() + c.Assert(err, IsNil) + c.Check(osutil.FileExists(kernelAssetsDir), Equals, false) + + // it's idempotent + err = bp.RemoveKernelAssets() + c.Assert(err, IsNil) + + } + +} + +type grubSuite struct { + baseBootenvSuite +} + +var _ = Suite(&grubSuite{}) + +func (s *grubSuite) SetUpTest(c *C) { + s.baseBootenvSuite.SetUpTest(c) + s.forceGrubBootloader(c) +} + +func (s *grubSuite) forceGrubBootloader(c *C) bootloader.Bootloader { + bootloader.Force(nil) + + // make mock grub bootenv dir + mockGadgetDir := c.MkDir() + err := os.WriteFile(filepath.Join(mockGadgetDir, "grub.conf"), nil, 0644) + c.Assert(err, IsNil) + err = bootloader.InstallBootConfig(mockGadgetDir, dirs.GlobalRootDir, nil) + c.Assert(err, IsNil) + + bloader, err := bootloader.Find("", nil) + c.Assert(err, IsNil) + c.Check(bloader, NotNil) + bloader.SetBootVars(map[string]string{ + "snap_kernel": "kernel_41.snap", + "snap_core": "core_21.snap", + }) + s.forceBootloader(bloader) + + fn := filepath.Join(s.bootdir, "/grub/grub.cfg") + c.Assert(osutil.FileExists(fn), Equals, true) + return bloader +} + +func (s *grubSuite) TestExtractKernelAssetsNoUnpacksKernelForGrub(c *C) { + files := [][]string{ + {"kernel.img", "I'm a kernel"}, + {"initrd.img", "...and I'm an initrd"}, + {"meta/kernel.yaml", "version: 4.2"}, + } + si := &snap.SideInfo{ + RealName: "ubuntu-kernel", + Revision: snap.R(42), + } + fn := snaptest.MakeTestSnapWithFiles(c, packageKernel, files) + snapf, err := snapfile.Open(fn) + c.Assert(err, IsNil) + + info, err := snap.ReadInfoFromSnapFile(snapf, si) + c.Assert(err, IsNil) + + bp := boot.NewCoreKernel(info, boottest.MockDevice("")) + err = bp.ExtractKernelAssets(snapf) + c.Assert(err, IsNil) + + // kernel is *not* here + kernimg := filepath.Join(s.bootdir, "grub", "ubuntu-kernel_42.snap", "kernel.img") + c.Assert(osutil.FileExists(kernimg), Equals, false) + + // it's idempotent + err = bp.ExtractKernelAssets(snapf) + c.Assert(err, IsNil) +} + +func (s *grubSuite) TestExtractKernelForceWorks(c *C) { + files := [][]string{ + {"kernel.img", "I'm a kernel"}, + {"initrd.img", "...and I'm an initrd"}, + {"meta/force-kernel-extraction", ""}, + {"meta/kernel.yaml", "version: 4.2"}, + } + si := &snap.SideInfo{ + RealName: "ubuntu-kernel", + Revision: snap.R(42), + } + fn := snaptest.MakeTestSnapWithFiles(c, packageKernel, files) + snapf, err := snapfile.Open(fn) + c.Assert(err, IsNil) + + info, err := snap.ReadInfoFromSnapFile(snapf, si) + c.Assert(err, IsNil) + + bp := boot.NewCoreKernel(info, boottest.MockDevice("")) + err = bp.ExtractKernelAssets(snapf) + c.Assert(err, IsNil) + + // kernel is extracted + kernimg := filepath.Join(s.bootdir, "/grub/ubuntu-kernel_42.snap/kernel.img") + c.Assert(osutil.FileExists(kernimg), Equals, true) + // initrd + initrdimg := filepath.Join(s.bootdir, "/grub/ubuntu-kernel_42.snap/initrd.img") + c.Assert(osutil.FileExists(initrdimg), Equals, true) + + // it's idempotent + err = bp.ExtractKernelAssets(snapf) + c.Assert(err, IsNil) + + // ensure that removal of assets also works + err = bp.RemoveKernelAssets() + c.Assert(err, IsNil) + exists, _, err := osutil.DirExists(filepath.Dir(kernimg)) + c.Assert(err, IsNil) + c.Check(exists, Equals, false) + + // it's idempotent + err = bp.RemoveKernelAssets() + c.Assert(err, IsNil) +} + +func (s *bootenv20RebootBootloaderSuite) TestSetNextBoot20ForKernel(c *C) { + coreDev := boottest.MockUC20Device("", nil) + c.Assert(coreDev.HasModeenv(), Equals, true) + + r := setupUC20Bootenv( + c, + s.bootloader, + s.normalDefaultState, + ) + defer r() + + bs := boot.NewCoreBootParticipant(s.kern2, snap.TypeKernel, coreDev) + c.Assert(bs.IsTrivial(), Equals, false) + rebootInfo, err := bs.SetNextBoot(boot.NextBootContext{BootWithoutTry: false}) + c.Assert(err, IsNil) + + m := s.bootloader.BootVars + c.Assert(m, DeepEquals, map[string]string{ + "kernel_status": boot.TryStatus, + "snap_try_kernel": s.kern2.Filename(), + "snap_kernel": s.kern1.Filename(), + }) + + c.Assert(rebootInfo.RebootRequired, Equals, true) + // Test that we get the bootloader options + c.Assert(rebootInfo.BootloaderOptions, DeepEquals, &bootloader.Options{Role: bootloader.RoleRunMode}) + + // and that the modeenv now has this kernel listed + m2, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Assert(m2.CurrentKernels, DeepEquals, []string{s.kern1.Filename(), s.kern2.Filename()}) +} + +func (s *bootenvSuite) TestSetNextBootForCoreUndo(c *C) { + coreDev := boottest.MockDevice("core") + + info := &snap.Info{} + info.SnapType = snap.TypeOS + info.RealName = "core" + info.Revision = snap.R(100) + + bs := boot.NewCoreBootParticipant(info, info.Type(), coreDev) + rebootInfo, err := bs.SetNextBoot(boot.NextBootContext{BootWithoutTry: true}) + c.Assert(err, IsNil) + + v, err := s.bootloader.GetBootVars("snap_core", "snap_try_core", "snap_mode") + c.Assert(err, IsNil) + c.Assert(v, DeepEquals, map[string]string{ + "snap_core": "core_100.snap", + "snap_try_core": "", + "snap_mode": boot.DefaultStatus, + }) + + c.Check(rebootInfo, Equals, boot.RebootInfo{RebootRequired: true}) +} + +func (s *bootenvSuite) TestSetNextBootWithBaseForCoreUndo(c *C) { + coreDev := boottest.MockDevice("core18") + + info := &snap.Info{} + info.SnapType = snap.TypeBase + info.RealName = "core18" + info.Revision = snap.R(1818) + + bs := boot.NewCoreBootParticipant(info, info.Type(), coreDev) + rebootInfo, err := bs.SetNextBoot(boot.NextBootContext{BootWithoutTry: true}) + c.Assert(err, IsNil) + + v, err := s.bootloader.GetBootVars("snap_core", "snap_try_core", "snap_mode") + c.Assert(err, IsNil) + c.Assert(v, DeepEquals, map[string]string{ + "snap_core": "core18_1818.snap", + "snap_try_core": "", + "snap_mode": boot.DefaultStatus, + }) + + c.Check(rebootInfo, Equals, boot.RebootInfo{RebootRequired: true}) +} + +func (s *bootenvSuite) TestSetNextBootForKernelUndo(c *C) { + coreDev := boottest.MockDevice("krnl") + + info := &snap.Info{} + info.SnapType = snap.TypeKernel + info.RealName = "krnl" + info.Revision = snap.R(42) + + bp := boot.NewCoreBootParticipant(info, snap.TypeKernel, coreDev) + rebootInfo, err := bp.SetNextBoot(boot.NextBootContext{BootWithoutTry: true}) + c.Assert(err, IsNil) + + v, err := s.bootloader.GetBootVars("snap_kernel", "snap_try_kernel", "snap_mode") + c.Assert(err, IsNil) + c.Assert(v, DeepEquals, map[string]string{ + "snap_kernel": "krnl_42.snap", + "snap_try_kernel": "", + "snap_mode": boot.DefaultStatus, + }) + + c.Check(rebootInfo, Equals, boot.RebootInfo{RebootRequired: true}) +} diff --git a/boot/makebootable.go b/boot/makebootable.go new file mode 100644 index 00000000..9ef1ef53 --- /dev/null +++ b/boot/makebootable.go @@ -0,0 +1,653 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2014-2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package boot + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/bootloader" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/gadget" + "github.com/snapcore/snapd/logger" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/snap/snapfile" + "github.com/snapcore/snapd/strutil" +) + +var sealKeyToModeenv = sealKeyToModeenvImpl + +// BootableSet represents the boot snaps of a system to be made bootable. +type BootableSet struct { + Base *snap.Info + BasePath string + Kernel *snap.Info + KernelPath string + Gadget *snap.Info + GadgetPath string + + RecoverySystemLabel string + // RecoverySystemDir is a path to a directory with recovery system + // assets. The path is relative to the recovery bootloader root + // directory. + RecoverySystemDir string + + UnpackedGadgetDir string + + // Recovery is set when making the recovery partition bootable. + Recovery bool +} + +// MakeBootableImage sets up the given bootable set and target filesystem +// such that the image can be booted. +// +// rootdir points to an image filesystem (UC 16/18) or an image recovery +// filesystem (UC20 at prepare-image time). +// On UC20, bootWith.Recovery must be true, as this function makes the recovery +// system bootable. It does not make a run system bootable, for that +// functionality see MakeRunnableSystem, which is meant to be used at runtime +// from UC20 install mode. +// For a UC20 image a set of boot flags that will be set in the recovery +// boot environment can be specified. +func MakeBootableImage(model *asserts.Model, rootdir string, bootWith *BootableSet, bootFlags []string) error { + if model.Grade() == asserts.ModelGradeUnset { + if len(bootFlags) != 0 { + return fmt.Errorf("no boot flags support for UC16/18") + } + return makeBootable16(model, rootdir, bootWith) + } + + if !bootWith.Recovery { + return fmt.Errorf("internal error: MakeBootableImage called at runtime, use MakeRunnableSystem instead") + } + return makeBootable20(model, rootdir, bootWith, bootFlags) +} + +// MakeBootablePartition configures a partition mounted on rootdir +// using information from bootWith and bootFlags. Contrarily to +// MakeBootableImage this happens in a live system. +func MakeBootablePartition(partDir string, opts *bootloader.Options, bootWith *BootableSet, bootMode string, bootFlags []string) error { + if bootWith.RecoverySystemDir != "" { + return fmt.Errorf("internal error: RecoverySystemDir unexpectedly set for MakeBootablePartition") + } + return configureBootloader(partDir, opts, bootWith, bootMode, bootFlags) +} + +// makeBootable16 setups the image filesystem for boot with UC16 +// and UC18 models. This entails: +// - installing the bootloader configuration from the gadget +// - creating symlinks for boot snaps from seed to the runtime blob dir +// - setting boot env vars pointing to the revisions of the boot snaps to use +// - extracting kernel assets as needed by the bootloader +func makeBootable16(model *asserts.Model, rootdir string, bootWith *BootableSet) error { + opts := &bootloader.Options{ + PrepareImageTime: true, + } + + // install the bootloader configuration from the gadget + if err := bootloader.InstallBootConfig(bootWith.UnpackedGadgetDir, rootdir, opts); err != nil { + return err + } + + // setup symlinks for kernel and boot base from the blob directory + // to the seed snaps + + snapBlobDir := dirs.SnapBlobDirUnder(rootdir) + if err := os.MkdirAll(snapBlobDir, 0755); err != nil { + return err + } + + for _, fn := range []string{bootWith.BasePath, bootWith.KernelPath} { + dst := filepath.Join(snapBlobDir, filepath.Base(fn)) + // construct a relative symlink from the blob dir + // to the seed snap file + relSymlink, err := filepath.Rel(snapBlobDir, fn) + if err != nil { + return fmt.Errorf("cannot build symlink for boot snap: %v", err) + } + if err := os.Symlink(relSymlink, dst); err != nil { + return err + } + } + + // Set bootvars for kernel/core snaps so the system boots and + // does the first-time initialization. There is also no + // mounted kernel/core/base snap, but just the blobs. + bl, err := bootloader.Find(rootdir, opts) + if err != nil { + return fmt.Errorf("cannot set kernel/core boot variables: %s", err) + } + + m := map[string]string{ + "snap_mode": "", + "snap_try_core": "", + "snap_try_kernel": "", + } + if model.DisplayName() != "" { + m["snap_menuentry"] = model.DisplayName() + } + + setBoot := func(name, fn string) { + m[name] = filepath.Base(fn) + } + // base + setBoot("snap_core", bootWith.BasePath) + + // kernel + kernelf, err := snapfile.Open(bootWith.KernelPath) + if err != nil { + return err + } + if err := bl.ExtractKernelAssets(bootWith.Kernel, kernelf); err != nil { + return err + } + setBoot("snap_kernel", bootWith.KernelPath) + + if err := bl.SetBootVars(m); err != nil { + return err + } + + return nil +} + +func configureBootloader(rootdir string, opts *bootloader.Options, bootWith *BootableSet, bootMode string, bootFlags []string) error { + blVars := make(map[string]string, 3) + if len(bootFlags) != 0 { + if err := setImageBootFlags(bootFlags, blVars); err != nil { + return err + } + } + + // install the bootloader configuration from the gadget + if err := bootloader.InstallBootConfig(bootWith.UnpackedGadgetDir, rootdir, opts); err != nil { + return err + } + + // now install the recovery system specific boot config + bl, err := bootloader.Find(rootdir, opts) + if err != nil { + return fmt.Errorf("internal error: cannot find bootloader: %v", err) + } + + blVars["snapd_recovery_mode"] = bootMode + if bootWith.RecoverySystemLabel != "" { + // record which recovery system is to be used on the bootloader, note + // that this goes on the main bootloader environment, and not on the + // recovery system bootloader environment, for example for grub + // bootloader, this env var is set on the ubuntu-seed root grubenv, and + // not on the recovery system grubenv in the systems/20200314/ subdir on + // ubuntu-seed + blVars["snapd_recovery_system"] = bootWith.RecoverySystemLabel + } + + if err := bl.SetBootVars(blVars); err != nil { + return fmt.Errorf("cannot set recovery environment: %v", err) + } + + return nil +} + +func makeBootable20(model *asserts.Model, rootdir string, bootWith *BootableSet, bootFlags []string) error { + // we can only make a single recovery system bootable right now + recoverySystems, err := filepath.Glob(filepath.Join(rootdir, "systems/*")) + if err != nil { + return fmt.Errorf("cannot validate recovery systems: %v", err) + } + if len(recoverySystems) > 1 { + return fmt.Errorf("cannot make multiple recovery systems bootable yet") + } + + if bootWith.RecoverySystemLabel == "" { + return fmt.Errorf("internal error: recovery system label unset") + } + + opts := &bootloader.Options{ + PrepareImageTime: true, + // setup the recovery bootloader + Role: bootloader.RoleRecovery, + } + if err := configureBootloader(rootdir, opts, bootWith, ModeInstall, bootFlags); err != nil { + return fmt.Errorf("cannot install bootloader: %v", err) + } + + return MakeRecoverySystemBootable(model, rootdir, bootWith.RecoverySystemDir, &RecoverySystemBootableSet{ + Kernel: bootWith.Kernel, + KernelPath: bootWith.KernelPath, + GadgetSnapOrDir: bootWith.UnpackedGadgetDir, + PrepareImageTime: true, + }) +} + +// RecoverySystemBootableSet is a set of snaps relevant to booting a recovery +// system. +type RecoverySystemBootableSet struct { + Kernel *snap.Info + KernelPath string + GadgetSnapOrDir string + // PrepareImageTime is true when the structure is being used when + // preparing a bootable system image. + PrepareImageTime bool +} + +// MakeRecoverySystemBootable prepares a recovery system under a path relative +// to recovery bootloader's rootdir for booting. +func MakeRecoverySystemBootable(model *asserts.Model, rootdir string, relativeRecoverySystemDir string, bootWith *RecoverySystemBootableSet) error { + opts := &bootloader.Options{ + // XXX: this is only needed by LK, it is unclear whether LK does + // too much when extracting recovery kernel assets, in the end + // it is currently not possible to create a recovery system at + // runtime when using LK. + PrepareImageTime: bootWith.PrepareImageTime, + // setup the recovery bootloader + Role: bootloader.RoleRecovery, + } + + bl, err := bootloader.Find(rootdir, opts) + if err != nil { + return fmt.Errorf("internal error: cannot find bootloader: %v", err) + } + + // on e.g. ARM we need to extract the kernel assets on the recovery + // system as well, but the bootloader does not load any environment from + // the recovery system + erkbl, ok := bl.(bootloader.ExtractedRecoveryKernelImageBootloader) + if ok { + kernelf, err := snapfile.Open(bootWith.KernelPath) + if err != nil { + return err + } + + err = erkbl.ExtractRecoveryKernelAssets( + relativeRecoverySystemDir, + bootWith.Kernel, + kernelf, + ) + if err != nil { + return fmt.Errorf("cannot extract recovery system kernel assets: %v", err) + } + + return nil + } + + rbl, ok := bl.(bootloader.RecoveryAwareBootloader) + if !ok { + return fmt.Errorf("cannot use %s bootloader: does not support recovery systems", bl.Name()) + } + kernelPath, err := filepath.Rel(rootdir, bootWith.KernelPath) + if err != nil { + return fmt.Errorf("cannot construct kernel boot path: %v", err) + } + recoveryBlVars := map[string]string{ + "snapd_recovery_kernel": filepath.Join("/", kernelPath), + } + if tbl, ok := bl.(bootloader.TrustedAssetsBootloader); ok { + // Look at gadget default values for system.kernel.*cmdline-append options + cmdlineAppend, err := buildOptionalKernelCommandLine(model, bootWith.GadgetSnapOrDir) + if err != nil { + return fmt.Errorf("while retrieving system.kernel.*cmdline-append defaults: %v", err) + } + candidate := false + defaultCmdLine, err := tbl.DefaultCommandLine(candidate) + if err != nil { + return err + } + // to set cmdlineAppend. + recoveryCmdlineArgs, err := bootVarsForTrustedCommandLineFromGadget(bootWith.GadgetSnapOrDir, cmdlineAppend, defaultCmdLine, model) + if err != nil { + return fmt.Errorf("cannot obtain recovery system command line: %v", err) + } + for k, v := range recoveryCmdlineArgs { + recoveryBlVars[k] = v + } + } + + if err := rbl.SetRecoverySystemEnv(relativeRecoverySystemDir, recoveryBlVars); err != nil { + return fmt.Errorf("cannot set recovery system environment: %v", err) + } + return nil +} + +type makeRunnableOptions struct { + Standalone bool + AfterDataReset bool + SeedDir string + StateUnlocker Unlocker +} + +func copyBootSnap(orig string, dstInfo *snap.Info, dstSnapBlobDir string) error { + // if the source path is a symlink, don't copy the symlink, copy the + // target file instead of copying the symlink, as the initramfs won't + // follow the symlink when it goes to mount the base and kernel snaps by + // design as the initramfs should only be using trusted things from + // ubuntu-data to boot in run mode + if osutil.IsSymlink(orig) { + link, err := os.Readlink(orig) + if err != nil { + return err + } + orig = link + } + // note that we need to use the "Filename()" here because unasserted + // snaps will have names like pc-kernel_5.19.4.snap but snapd expects + // "pc-kernel_x1.snap" + dst := filepath.Join(dstSnapBlobDir, dstInfo.Filename()) + if err := osutil.CopyFile(orig, dst, osutil.CopyFlagPreserveAll|osutil.CopyFlagSync); err != nil { + return err + } + return nil +} + +func makeRunnableSystem(model *asserts.Model, bootWith *BootableSet, sealer *TrustedAssetsInstallObserver, makeOpts makeRunnableOptions) error { + if model.Grade() == asserts.ModelGradeUnset { + return fmt.Errorf("internal error: cannot make pre-UC20 system runnable") + } + if bootWith.RecoverySystemDir != "" { + return fmt.Errorf("internal error: RecoverySystemDir unexpectedly set for MakeRunnableSystem") + } + modeenvLock() + defer modeenvUnlock() + + // TODO:UC20: + // - figure out what to do for uboot gadgets, currently we require them to + // install the boot.sel onto ubuntu-boot directly, but the file should be + // managed by snapd instead + + // copy kernel/base/gadget into the ubuntu-data partition + snapBlobDir := dirs.SnapBlobDirUnder(InstallHostWritableDir(model)) + if err := os.MkdirAll(snapBlobDir, 0755); err != nil { + return err + } + for _, origDest := range []struct { + orig string + destInfo *snap.Info + }{ + {orig: bootWith.BasePath, destInfo: bootWith.Base}, + {orig: bootWith.KernelPath, destInfo: bootWith.Kernel}, + {orig: bootWith.GadgetPath, destInfo: bootWith.Gadget}} { + if err := copyBootSnap(origDest.orig, origDest.destInfo, snapBlobDir); err != nil { + return err + } + } + + // replicate the boot assets cache in host's writable + if err := CopyBootAssetsCacheToRoot(InstallHostWritableDir(model)); err != nil { + return fmt.Errorf("cannot replicate boot assets cache: %v", err) + } + + var currentTrustedBootAssets bootAssetsMap + var currentTrustedRecoveryBootAssets bootAssetsMap + if sealer != nil { + currentTrustedBootAssets = sealer.currentTrustedBootAssetsMap() + currentTrustedRecoveryBootAssets = sealer.currentTrustedRecoveryBootAssetsMap() + } + recoverySystemLabel := bootWith.RecoverySystemLabel + // write modeenv on the ubuntu-data partition + modeenv := &Modeenv{ + Mode: "run", + RecoverySystem: recoverySystemLabel, + // default to the system we were installed from + CurrentRecoverySystems: []string{recoverySystemLabel}, + // which is also considered to be good + GoodRecoverySystems: []string{recoverySystemLabel}, + CurrentTrustedBootAssets: currentTrustedBootAssets, + CurrentTrustedRecoveryBootAssets: currentTrustedRecoveryBootAssets, + // kernel command lines are set later once a boot config is + // installed + CurrentKernelCommandLines: nil, + // keep this comment to make gofmt 1.9 happy + Gadget: bootWith.Gadget.Filename(), + CurrentKernels: []string{bootWith.Kernel.Filename()}, + BrandID: model.BrandID(), + Model: model.Model(), + // TODO: test this + Classic: model.Classic(), + Grade: string(model.Grade()), + ModelSignKeyID: model.SignKeyID(), + } + // Note on classic systems there is no boot base, the system boots + // from debs. + if !model.Classic() { + modeenv.Base = bootWith.Base.Filename() + } + + // get the ubuntu-boot bootloader and extract the kernel there + opts := &bootloader.Options{ + // Bootloader for run mode + Role: bootloader.RoleRunMode, + // At this point the run mode bootloader is under the native + // run partition layout, no /boot mount. + NoSlashBoot: true, + } + // the bootloader config may have been installed when the ubuntu-boot + // partition was created, but for a trusted assets the bootloader config + // will be installed further down; for now identify the run mode + // bootloader by looking at the gadget + bl, err := bootloader.ForGadget(bootWith.UnpackedGadgetDir, InitramfsUbuntuBootDir, opts) + if err != nil { + return fmt.Errorf("internal error: cannot identify run system bootloader: %v", err) + } + + // extract the kernel first and mark kernel_status ready + kernelf, err := snapfile.Open(bootWith.KernelPath) + if err != nil { + return err + } + + err = bl.ExtractKernelAssets(bootWith.Kernel, kernelf) + if err != nil { + return err + } + + blVars := map[string]string{ + "kernel_status": "", + } + + ebl, ok := bl.(bootloader.ExtractedRunKernelImageBootloader) + if ok { + // the bootloader supports additional extracted kernel handling + + // enable the kernel on the bootloader and finally transition to + // run-mode last in case we get rebooted in between anywhere here + + // it's okay to enable the kernel before writing the boot vars, because + // we haven't written snapd_recovery_mode=run, which is the critical + // thing that will inform the bootloader to try booting from ubuntu-boot + if err := ebl.EnableKernel(bootWith.Kernel); err != nil { + return err + } + } else { + // the bootloader does not support additional handling of + // extracted kernel images, we must name the kernel to be used + // explicitly in bootloader variables + blVars["snap_kernel"] = bootWith.Kernel.Filename() + } + + // set the ubuntu-boot bootloader variables before triggering transition to + // try and boot from ubuntu-boot (that transition happens when we write + // snapd_recovery_mode below) + if err := bl.SetBootVars(blVars); err != nil { + return fmt.Errorf("cannot set run system environment: %v", err) + } + + tbl, ok := bl.(bootloader.TrustedAssetsBootloader) + if ok { + // the bootloader can manage its boot config + + // installing boot config must be performed after the boot + // partition has been populated with gadget data + if err := bl.InstallBootConfig(bootWith.UnpackedGadgetDir, opts); err != nil { + return fmt.Errorf("cannot install managed bootloader assets: %v", err) + } + // determine the expected command line + cmdline, err := ComposeCandidateCommandLine(model, bootWith.UnpackedGadgetDir) + if err != nil { + return fmt.Errorf("cannot compose the candidate command line: %v", err) + } + modeenv.CurrentKernelCommandLines = bootCommandLines{cmdline} + + // Look at gadget default values for system.kernel.*cmdline-append options + cmdlineAppend, err := buildOptionalKernelCommandLine(model, bootWith.UnpackedGadgetDir) + if err != nil { + return fmt.Errorf("while retrieving system.kernel.*cmdline-append defaults: %v", err) + } + + candidate := false + defaultCmdLine, err := tbl.DefaultCommandLine(candidate) + if err != nil { + return err + } + + cmdlineVars, err := bootVarsForTrustedCommandLineFromGadget(bootWith.UnpackedGadgetDir, cmdlineAppend, defaultCmdLine, model) + if err != nil { + return fmt.Errorf("cannot prepare bootloader variables for kernel command line: %v", err) + } + if err := bl.SetBootVars(cmdlineVars); err != nil { + return fmt.Errorf("cannot set run system kernel command line arguments: %v", err) + } + } + + // all fields that needed to be set in the modeenv must have been set by + // now, write modeenv to disk + if err := modeenv.WriteTo(InstallHostWritableDir(model)); err != nil { + return fmt.Errorf("cannot write modeenv: %v", err) + } + + if sealer != nil { + hasHook, err := HasFDESetupHook(bootWith.Kernel) + if err != nil { + return fmt.Errorf("cannot check for fde-setup hook: %v", err) + } + + flags := sealKeyToModeenvFlags{ + HasFDESetupHook: hasHook, + FactoryReset: makeOpts.AfterDataReset, + SeedDir: makeOpts.SeedDir, + StateUnlocker: makeOpts.StateUnlocker, + } + if makeOpts.Standalone { + flags.SnapsDir = snapBlobDir + } + // seal the encryption key to the parameters specified in modeenv + if err := sealKeyToModeenv(sealer.dataEncryptionKey, sealer.saveEncryptionKey, model, modeenv, flags); err != nil { + return err + } + } + + // so far so good, we managed to install the system, so it can be used + // for recovery as well + if err := MarkRecoveryCapableSystem(recoverySystemLabel); err != nil { + return fmt.Errorf("cannot record %q as a recovery capable system: %v", recoverySystemLabel, err) + } + return nil +} + +func buildOptionalKernelCommandLine(model *asserts.Model, gadgetSnapOrDir string) (string, error) { + sf, err := snapfile.Open(gadgetSnapOrDir) + if err != nil { + return "", fmt.Errorf("cannot open gadget snap: %v", err) + } + gadgetInfo, err := gadget.ReadInfoFromSnapFile(sf, nil) + if err != nil { + return "", fmt.Errorf("cannot read gadget data: %v", err) + } + + defaults := gadget.SystemDefaults(gadgetInfo.Defaults) + + var cmdlineAppend, cmdlineAppendDangerous string + + if cmdlineAppendIf, ok := defaults["system.kernel.cmdline-append"]; ok { + cmdlineAppend, ok = cmdlineAppendIf.(string) + if !ok { + return "", fmt.Errorf("system.kernel.cmdline-append is not a string") + } + } + + if cmdlineAppendIf, ok := defaults["system.kernel.dangerous-cmdline-append"]; ok { + cmdlineAppendDangerous, ok = cmdlineAppendIf.(string) + if !ok { + return "", fmt.Errorf("system.kernel.dangerous-cmdline-append is not a string") + } + if model.Grade() != asserts.ModelDangerous { + // Print a warning and ignore + logger.Noticef("WARNING: system.kernel.dangerous-cmdline-append ignored by non-dangerous models") + return "", nil + } + } + + if cmdlineAppend != "" { + // TODO perform validation against what is allowed by the gadget + } + + cmdlineAppend = strutil.JoinNonEmpty([]string{cmdlineAppend, cmdlineAppendDangerous}, " ") + + return cmdlineAppend, nil +} + +// MakeRunnableSystem is like MakeBootableImage in that it sets up a system to +// be able to boot, but is unique in that it is intended to be called from UC20 +// install mode and makes the run system bootable (hence it is called +// "runnable"). +// Note that this function does not update the recovery bootloader env to +// actually transition to run mode here, that is left to the caller via +// something like boot.EnsureNextBootToRunMode(). This is to enable separately +// setting up a run system and actually transitioning to it, with hooks, etc. +// running in between. +func MakeRunnableSystem(model *asserts.Model, bootWith *BootableSet, sealer *TrustedAssetsInstallObserver) error { + return makeRunnableSystem(model, bootWith, sealer, makeRunnableOptions{ + SeedDir: dirs.SnapSeedDir, + }) +} + +// MakeRunnableStandaloneSystem operates like MakeRunnableSystem but does +// not assume that the run system being set up is related to the current +// system. This is appropriate e.g when installing from a classic installer. +func MakeRunnableStandaloneSystem(model *asserts.Model, bootWith *BootableSet, sealer *TrustedAssetsInstallObserver, unlocker Unlocker) error { + // TODO consider merging this back into MakeRunnableSystem but need + // to consider the properties of the different input used for sealing + return makeRunnableSystem(model, bootWith, sealer, makeRunnableOptions{ + Standalone: true, + SeedDir: dirs.SnapSeedDir, + StateUnlocker: unlocker, + }) +} + +// MakeRunnableStandaloneSystemFromInitrd is the same as MakeRunnableStandaloneSystem +// but uses seed dir path expected in initrd. +func MakeRunnableStandaloneSystemFromInitrd(model *asserts.Model, bootWith *BootableSet, sealer *TrustedAssetsInstallObserver) error { + // TODO consider merging this back into MakeRunnableSystem but need + // to consider the properties of the different input used for sealing + return makeRunnableSystem(model, bootWith, sealer, makeRunnableOptions{ + Standalone: true, + SeedDir: filepath.Join(InitramfsRunMntDir, "ubuntu-seed"), + }) +} + +// MakeRunnableSystemAfterDataReset sets up the system to be able to boot, but it is +// intended to be called from UC20 factory reset mode right before switching +// back to the new run system. +func MakeRunnableSystemAfterDataReset(model *asserts.Model, bootWith *BootableSet, sealer *TrustedAssetsInstallObserver) error { + return makeRunnableSystem(model, bootWith, sealer, makeRunnableOptions{ + AfterDataReset: true, + SeedDir: dirs.SnapSeedDir, + }) +} diff --git a/boot/makebootable_test.go b/boot/makebootable_test.go new file mode 100644 index 00000000..1835be33 --- /dev/null +++ b/boot/makebootable_test.go @@ -0,0 +1,2390 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2014-2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package boot_test + +import ( + "fmt" + "os" + "path/filepath" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/arch/archtest" + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/boot" + "github.com/snapcore/snapd/boot/boottest" + "github.com/snapcore/snapd/bootloader" + "github.com/snapcore/snapd/bootloader/assets" + "github.com/snapcore/snapd/bootloader/bootloadertest" + "github.com/snapcore/snapd/bootloader/grubenv" + "github.com/snapcore/snapd/bootloader/ubootenv" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/gadget" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/release" + "github.com/snapcore/snapd/secboot" + "github.com/snapcore/snapd/secboot/keys" + "github.com/snapcore/snapd/seed" + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/snap/snapfile" + "github.com/snapcore/snapd/snap/snaptest" + "github.com/snapcore/snapd/strutil" + "github.com/snapcore/snapd/testutil" + "github.com/snapcore/snapd/timings" +) + +type makeBootableSuite struct { + baseBootenvSuite + + bootloader *bootloadertest.MockBootloader +} + +var _ = Suite(&makeBootableSuite{}) + +func (s *makeBootableSuite) SetUpTest(c *C) { + s.baseBootenvSuite.SetUpTest(c) + + s.bootloader = bootloadertest.Mock("mock", c.MkDir()) + s.forceBootloader(s.bootloader) + + s.AddCleanup(archtest.MockArchitecture("amd64")) + snippets := []assets.ForEditions{ + {FirstEdition: 1, Snippet: []byte("console=ttyS0 console=tty1 panic=-1")}, + } + s.AddCleanup(assets.MockSnippetsForEdition("grub.cfg:static-cmdline", snippets)) + s.AddCleanup(assets.MockSnippetsForEdition("grub-recovery.cfg:static-cmdline", snippets)) +} + +func makeSnap(c *C, name, yaml string, revno snap.Revision) (fn string, info *snap.Info) { + return makeSnapWithFiles(c, name, yaml, revno, nil) +} + +func makeSnapWithFiles(c *C, name, yaml string, revno snap.Revision, files [][]string) (fn string, info *snap.Info) { + si := &snap.SideInfo{ + RealName: name, + Revision: revno, + } + fn = snaptest.MakeTestSnapWithFiles(c, yaml, files) + snapf, err := snapfile.Open(fn) + c.Assert(err, IsNil) + info, err = snap.ReadInfoFromSnapFile(snapf, si) + c.Assert(err, IsNil) + return fn, info +} + +func (s *makeBootableSuite) TestMakeBootableImage(c *C) { + bootloader.Force(nil) + model := boottest.MakeMockModel() + + grubCfg := []byte("#grub cfg") + unpackedGadgetDir := c.MkDir() + err := os.WriteFile(filepath.Join(unpackedGadgetDir, "grub.conf"), grubCfg, 0644) + c.Assert(err, IsNil) + + seedSnapsDirs := filepath.Join(s.rootdir, "/var/lib/snapd/seed", "snaps") + err = os.MkdirAll(seedSnapsDirs, 0755) + c.Assert(err, IsNil) + + baseFn, baseInfo := makeSnap(c, "core18", `name: core18 +type: base +version: 4.0 +`, snap.R(3)) + baseInSeed := filepath.Join(seedSnapsDirs, baseInfo.Filename()) + err = os.Rename(baseFn, baseInSeed) + c.Assert(err, IsNil) + kernelFn, kernelInfo := makeSnap(c, "pc-kernel", `name: pc-kernel +type: kernel +version: 4.0 +`, snap.R(5)) + kernelInSeed := filepath.Join(seedSnapsDirs, kernelInfo.Filename()) + err = os.Rename(kernelFn, kernelInSeed) + c.Assert(err, IsNil) + + bootWith := &boot.BootableSet{ + Base: baseInfo, + BasePath: baseInSeed, + Kernel: kernelInfo, + KernelPath: kernelInSeed, + UnpackedGadgetDir: unpackedGadgetDir, + } + + err = boot.MakeBootableImage(model, s.rootdir, bootWith, nil) + c.Assert(err, IsNil) + + // check the bootloader config + seedGenv := grubenv.NewEnv(filepath.Join(s.rootdir, "boot/grub/grubenv")) + c.Assert(seedGenv.Load(), IsNil) + c.Check(seedGenv.Get("snap_kernel"), Equals, "pc-kernel_5.snap") + c.Check(seedGenv.Get("snap_core"), Equals, "core18_3.snap") + c.Check(seedGenv.Get("snap_menuentry"), Equals, "My Model") + + // check symlinks from snap blob dir + kernelBlob := filepath.Join(dirs.SnapBlobDirUnder(s.rootdir), kernelInfo.Filename()) + dst, err := os.Readlink(filepath.Join(dirs.SnapBlobDirUnder(s.rootdir), kernelInfo.Filename())) + c.Assert(err, IsNil) + c.Check(dst, Equals, "../seed/snaps/pc-kernel_5.snap") + c.Check(kernelBlob, testutil.FilePresent) + + baseBlob := filepath.Join(dirs.SnapBlobDirUnder(s.rootdir), baseInfo.Filename()) + dst, err = os.Readlink(filepath.Join(dirs.SnapBlobDirUnder(s.rootdir), baseInfo.Filename())) + c.Assert(err, IsNil) + c.Check(dst, Equals, "../seed/snaps/core18_3.snap") + c.Check(baseBlob, testutil.FilePresent) + + // check that the bootloader (grub here) configuration was copied + c.Check(filepath.Join(s.rootdir, "boot", "grub/grub.cfg"), testutil.FileEquals, grubCfg) +} + +type makeBootable20Suite struct { + baseBootenvSuite + + bootloader *bootloadertest.MockRecoveryAwareBootloader +} + +type makeBootable20UbootSuite struct { + baseBootenvSuite + + bootloader *bootloadertest.MockExtractedRecoveryKernelImageBootloader +} + +var _ = Suite(&makeBootable20Suite{}) +var _ = Suite(&makeBootable20UbootSuite{}) + +func (s *makeBootable20Suite) SetUpTest(c *C) { + s.baseBootenvSuite.SetUpTest(c) + + s.bootloader = bootloadertest.Mock("mock", c.MkDir()).RecoveryAware() + s.forceBootloader(s.bootloader) + s.AddCleanup(archtest.MockArchitecture("amd64")) + snippets := []assets.ForEditions{ + {FirstEdition: 1, Snippet: []byte("console=ttyS0 console=tty1 panic=-1")}, + } + s.AddCleanup(assets.MockSnippetsForEdition("grub.cfg:static-cmdline", snippets)) + s.AddCleanup(assets.MockSnippetsForEdition("grub-recovery.cfg:static-cmdline", snippets)) +} + +func (s *makeBootable20UbootSuite) SetUpTest(c *C) { + s.baseBootenvSuite.SetUpTest(c) + + s.bootloader = bootloadertest.Mock("mock", c.MkDir()).ExtractedRecoveryKernelImage() + s.forceBootloader(s.bootloader) +} + +const gadgetYaml = ` +volumes: + pc: + bootloader: grub + structure: + - name: ubuntu-seed + role: system-seed + filesystem: vfat + type: EF,C12A7328-F81F-11D2-BA4B-00A0C93EC93B + size: 1200M + - name: ubuntu-boot + role: system-boot + filesystem: ext4 + type: 83,0FC63DAF-8483-4772-8E79-3D69D8477DE4 + size: 750M + - name: ubuntu-data + role: system-data + filesystem: ext4 + type: 83,0FC63DAF-8483-4772-8E79-3D69D8477DE4 + size: 1G +` + +func (s *makeBootable20Suite) TestMakeBootableImage20(c *C) { + bootloader.Force(nil) + model := boottest.MakeMockUC20Model() + + unpackedGadgetDir := c.MkDir() + grubRecoveryCfg := "#grub-recovery cfg" + grubRecoveryCfgAsset := "#grub-recovery cfg from assets" + grubCfg := "#grub cfg" + snaptest.PopulateDir(unpackedGadgetDir, [][]string{ + {"grub-recovery.conf", grubRecoveryCfg}, + {"grub.conf", grubCfg}, + {"meta/snap.yaml", gadgetSnapYaml}, + {"meta/gadget.yaml", gadgetYaml}, + }) + restore := assets.MockInternal("grub-recovery.cfg", []byte(grubRecoveryCfgAsset)) + defer restore() + + // on uc20 the seed layout if different + seedSnapsDirs := filepath.Join(s.rootdir, "/snaps") + err := os.MkdirAll(seedSnapsDirs, 0755) + c.Assert(err, IsNil) + + baseFn, baseInfo := makeSnap(c, "core20", `name: core20 +type: base +version: 5.0 +`, snap.R(3)) + baseInSeed := filepath.Join(seedSnapsDirs, baseInfo.Filename()) + err = os.Rename(baseFn, baseInSeed) + c.Assert(err, IsNil) + kernelFn, kernelInfo := makeSnapWithFiles(c, "pc-kernel", `name: pc-kernel +type: kernel +version: 5.0 +`, snap.R(5), [][]string{ + {"kernel.efi", "I'm a kernel.efi"}, + }) + kernelInSeed := filepath.Join(seedSnapsDirs, kernelInfo.Filename()) + err = os.Rename(kernelFn, kernelInSeed) + c.Assert(err, IsNil) + + label := "20191209" + recoverySystemDir := filepath.Join("/systems", label) + bootWith := &boot.BootableSet{ + Base: baseInfo, + BasePath: baseInSeed, + Kernel: kernelInfo, + KernelPath: kernelInSeed, + RecoverySystemDir: recoverySystemDir, + RecoverySystemLabel: label, + UnpackedGadgetDir: unpackedGadgetDir, + Recovery: true, + } + + err = boot.MakeBootableImage(model, s.rootdir, bootWith, nil) + c.Assert(err, IsNil) + + // ensure only a single file got copied (the grub.cfg) + files, err := filepath.Glob(filepath.Join(s.rootdir, "EFI/ubuntu/*")) + c.Assert(err, IsNil) + // grub.cfg and grubenv + c.Check(files, HasLen, 2) + // check that the recovery bootloader configuration was installed with + // the correct content + c.Check(filepath.Join(s.rootdir, "EFI/ubuntu/grub.cfg"), testutil.FileEquals, grubRecoveryCfgAsset) + + // ensure no /boot was setup + c.Check(filepath.Join(s.rootdir, "boot"), testutil.FileAbsent) + + // ensure the correct recovery system configuration was set + seedGenv := grubenv.NewEnv(filepath.Join(s.rootdir, "EFI/ubuntu/grubenv")) + c.Assert(seedGenv.Load(), IsNil) + c.Check(seedGenv.Get("snapd_recovery_system"), Equals, label) + + systemGenv := grubenv.NewEnv(filepath.Join(s.rootdir, recoverySystemDir, "grubenv")) + c.Assert(systemGenv.Load(), IsNil) + c.Check(systemGenv.Get("snapd_recovery_kernel"), Equals, "/snaps/pc-kernel_5.snap") +} + +func (s *makeBootable20Suite) TestMakeBootableImage20BootFlags(c *C) { + bootloader.Force(nil) + model := boottest.MakeMockUC20Model() + + unpackedGadgetDir := c.MkDir() + grubRecoveryCfg := "#grub-recovery cfg" + grubRecoveryCfgAsset := "#grub-recovery cfg from assets" + grubCfg := "#grub cfg" + snaptest.PopulateDir(unpackedGadgetDir, [][]string{ + {"grub-recovery.conf", grubRecoveryCfg}, + {"grub.conf", grubCfg}, + {"meta/snap.yaml", gadgetSnapYaml}, + {"meta/gadget.yaml", gadgetYaml}, + }) + restore := assets.MockInternal("grub-recovery.cfg", []byte(grubRecoveryCfgAsset)) + defer restore() + + // on uc20 the seed layout if different + seedSnapsDirs := filepath.Join(s.rootdir, "/snaps") + err := os.MkdirAll(seedSnapsDirs, 0755) + c.Assert(err, IsNil) + + baseFn, baseInfo := makeSnap(c, "core20", `name: core20 +type: base +version: 5.0 +`, snap.R(3)) + baseInSeed := filepath.Join(seedSnapsDirs, baseInfo.Filename()) + err = os.Rename(baseFn, baseInSeed) + c.Assert(err, IsNil) + kernelFn, kernelInfo := makeSnapWithFiles(c, "pc-kernel", `name: pc-kernel +type: kernel +version: 5.0 +`, snap.R(5), [][]string{ + {"kernel.efi", "I'm a kernel.efi"}, + }) + kernelInSeed := filepath.Join(seedSnapsDirs, kernelInfo.Filename()) + err = os.Rename(kernelFn, kernelInSeed) + c.Assert(err, IsNil) + + label := "20191209" + recoverySystemDir := filepath.Join("/systems", label) + bootWith := &boot.BootableSet{ + Base: baseInfo, + BasePath: baseInSeed, + Kernel: kernelInfo, + KernelPath: kernelInSeed, + RecoverySystemDir: recoverySystemDir, + RecoverySystemLabel: label, + UnpackedGadgetDir: unpackedGadgetDir, + Recovery: true, + } + bootFlags := []string{"factory"} + + err = boot.MakeBootableImage(model, s.rootdir, bootWith, bootFlags) + c.Assert(err, IsNil) + + // ensure the correct recovery system configuration was set + seedGenv := grubenv.NewEnv(filepath.Join(s.rootdir, "EFI/ubuntu/grubenv")) + c.Assert(seedGenv.Load(), IsNil) + c.Check(seedGenv.Get("snapd_recovery_system"), Equals, label) + c.Check(seedGenv.Get("snapd_boot_flags"), Equals, "factory") + + systemGenv := grubenv.NewEnv(filepath.Join(s.rootdir, recoverySystemDir, "grubenv")) + c.Assert(systemGenv.Load(), IsNil) + c.Check(systemGenv.Get("snapd_recovery_kernel"), Equals, "/snaps/pc-kernel_5.snap") + +} + +func (s *makeBootable20Suite) testMakeBootableImage20CustomKernelArgs(c *C, whichFile, content, errMsg string) { + bootloader.Force(nil) + model := boottest.MakeMockUC20Model() + + unpackedGadgetDir := c.MkDir() + grubCfg := "#grub cfg" + snaptest.PopulateDir(unpackedGadgetDir, [][]string{ + {"grub.conf", grubCfg}, + {"meta/snap.yaml", gadgetSnapYaml}, + {"meta/gadget.yaml", gadgetYaml}, + {whichFile, content}, + }) + + // on uc20 the seed layout if different + seedSnapsDirs := filepath.Join(s.rootdir, "/snaps") + err := os.MkdirAll(seedSnapsDirs, 0755) + c.Assert(err, IsNil) + + baseFn, baseInfo := makeSnap(c, "core20", `name: core20 +type: base +version: 5.0 +`, snap.R(3)) + baseInSeed := filepath.Join(seedSnapsDirs, baseInfo.Filename()) + err = os.Rename(baseFn, baseInSeed) + c.Assert(err, IsNil) + kernelFn, kernelInfo := makeSnapWithFiles(c, "pc-kernel", `name: pc-kernel +type: kernel +version: 5.0 +`, snap.R(5), [][]string{ + {"kernel.efi", "I'm a kernel.efi"}, + }) + kernelInSeed := filepath.Join(seedSnapsDirs, kernelInfo.Filename()) + err = os.Rename(kernelFn, kernelInSeed) + c.Assert(err, IsNil) + + label := "20191209" + recoverySystemDir := filepath.Join("/systems", label) + bootWith := &boot.BootableSet{ + Base: baseInfo, + BasePath: baseInSeed, + Kernel: kernelInfo, + KernelPath: kernelInSeed, + RecoverySystemDir: recoverySystemDir, + RecoverySystemLabel: label, + UnpackedGadgetDir: unpackedGadgetDir, + Recovery: true, + } + + err = boot.MakeBootableImage(model, s.rootdir, bootWith, nil) + if errMsg != "" { + c.Assert(err, ErrorMatches, errMsg) + return + } + c.Assert(err, IsNil) + + // ensure the correct recovery system configuration was set + seedGenv := grubenv.NewEnv(filepath.Join(s.rootdir, "EFI/ubuntu/grubenv")) + c.Assert(seedGenv.Load(), IsNil) + c.Check(seedGenv.Get("snapd_recovery_system"), Equals, label) + // and kernel command line + systemGenv := grubenv.NewEnv(filepath.Join(s.rootdir, recoverySystemDir, "grubenv")) + c.Assert(systemGenv.Load(), IsNil) + c.Check(systemGenv.Get("snapd_recovery_kernel"), Equals, "/snaps/pc-kernel_5.snap") + switch whichFile { + case "cmdline.extra": + blopts := &bootloader.Options{ + Role: bootloader.RoleRecovery, + } + bl, err := bootloader.Find(s.rootdir, blopts) + c.Assert(err, IsNil) + tbl, ok := bl.(bootloader.TrustedAssetsBootloader) + if ok { + candidate := false + defaultCmdLine, err := tbl.DefaultCommandLine(candidate) + c.Assert(err, IsNil) + c.Check(systemGenv.Get("snapd_extra_cmdline_args"), Equals, "") + c.Check(systemGenv.Get("snapd_full_cmdline_args"), Equals, strutil.JoinNonEmpty([]string{defaultCmdLine, content}, " ")) + } else { + c.Check(systemGenv.Get("snapd_extra_cmdline_args"), Equals, content) + c.Check(systemGenv.Get("snapd_full_cmdline_args"), Equals, "") + } + case "cmdline.full": + c.Check(systemGenv.Get("snapd_extra_cmdline_args"), Equals, "") + c.Check(systemGenv.Get("snapd_full_cmdline_args"), Equals, content) + } +} + +func (s *makeBootable20Suite) TestMakeBootableImage20CustomKernelExtraArgs(c *C) { + s.testMakeBootableImage20CustomKernelArgs(c, "cmdline.extra", "foo bar baz", "") +} + +func (s *makeBootable20Suite) TestMakeBootableImage20CustomKernelFullArgs(c *C) { + s.testMakeBootableImage20CustomKernelArgs(c, "cmdline.full", "foo bar baz", "") +} + +func (s *makeBootable20Suite) TestMakeBootableImage20CustomKernelInvalidArgs(c *C) { + errMsg := `cannot obtain recovery system command line: cannot use kernel command line from gadget: invalid kernel command line in cmdline.extra: disallowed kernel argument "snapd_foo=bar"` + s.testMakeBootableImage20CustomKernelArgs(c, "cmdline.extra", "snapd_foo=bar", errMsg) +} + +func (s *makeBootable20Suite) TestMakeBootableImage20UnsetRecoverySystemLabelError(c *C) { + model := boottest.MakeMockUC20Model() + + unpackedGadgetDir := c.MkDir() + grubRecoveryCfg := []byte("#grub-recovery cfg") + err := os.WriteFile(filepath.Join(unpackedGadgetDir, "grub-recovery.conf"), grubRecoveryCfg, 0644) + c.Assert(err, IsNil) + grubCfg := []byte("#grub cfg") + err = os.WriteFile(filepath.Join(unpackedGadgetDir, "grub.conf"), grubCfg, 0644) + c.Assert(err, IsNil) + + label := "20191209" + recoverySystemDir := filepath.Join("/systems", label) + bootWith := &boot.BootableSet{ + RecoverySystemDir: recoverySystemDir, + UnpackedGadgetDir: unpackedGadgetDir, + Recovery: true, + } + + err = boot.MakeBootableImage(model, s.rootdir, bootWith, nil) + c.Assert(err, ErrorMatches, "internal error: recovery system label unset") +} + +func (s *makeBootable20Suite) TestMakeBootableImage20MultipleRecoverySystemsError(c *C) { + model := boottest.MakeMockUC20Model() + + bootWith := &boot.BootableSet{Recovery: true} + err := os.MkdirAll(filepath.Join(s.rootdir, "systems/20191204"), 0755) + c.Assert(err, IsNil) + err = os.MkdirAll(filepath.Join(s.rootdir, "systems/20191205"), 0755) + c.Assert(err, IsNil) + + err = boot.MakeBootableImage(model, s.rootdir, bootWith, nil) + c.Assert(err, ErrorMatches, "cannot make multiple recovery systems bootable yet") +} + +func (s *makeBootable20Suite) TestMakeSystemRunnable16Fails(c *C) { + model := boottest.MakeMockModel() + + err := boot.MakeRunnableSystem(model, nil, nil) + c.Assert(err, ErrorMatches, `internal error: cannot make pre-UC20 system runnable`) +} + +func (s *makeBootable20Suite) testMakeSystemRunnable20(c *C, standalone, factoryReset, classic bool, fromInitrd bool) { + restore := release.MockOnClassic(classic) + defer restore() + dirs.SetRootDir(dirs.GlobalRootDir) + + bootloader.Force(nil) + + var model *asserts.Model + if classic { + model = boottest.MakeMockUC20Model(map[string]interface{}{ + "classic": "true", + "distribution": "ubuntu", + }) + } else { + model = boottest.MakeMockUC20Model() + } + seedSnapsDirs := filepath.Join(s.rootdir, "/snaps") + err := os.MkdirAll(seedSnapsDirs, 0755) + c.Assert(err, IsNil) + + // grub on ubuntu-seed + mockSeedGrubDir := filepath.Join(boot.InitramfsUbuntuSeedDir, "EFI", "ubuntu") + mockSeedGrubCfg := filepath.Join(mockSeedGrubDir, "grub.cfg") + err = os.MkdirAll(filepath.Dir(mockSeedGrubCfg), 0755) + c.Assert(err, IsNil) + err = os.WriteFile(mockSeedGrubCfg, []byte("# Snapd-Boot-Config-Edition: 1\n"), 0644) + c.Assert(err, IsNil) + genv := grubenv.NewEnv(filepath.Join(mockSeedGrubDir, "grubenv")) + c.Assert(genv.Save(), IsNil) + + // setup recovery boot assets + err = os.MkdirAll(filepath.Join(boot.InitramfsUbuntuSeedDir, "EFI/boot"), 0755) + c.Assert(err, IsNil) + // SHA3-384: 39efae6545f16e39633fbfbef0d5e9fdd45a25d7df8764978ce4d81f255b038046a38d9855e42e5c7c4024e153fd2e37 + err = os.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "EFI/boot/bootx64.efi"), + []byte("recovery shim content"), 0644) + c.Assert(err, IsNil) + // SHA3-384: aa3c1a83e74bf6dd40dd64e5c5bd1971d75cdf55515b23b9eb379f66bf43d4661d22c4b8cf7d7a982d2013ab65c1c4c5 + err = os.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "EFI/boot/grubx64.efi"), + []byte("recovery grub content"), 0644) + c.Assert(err, IsNil) + + // grub on ubuntu-boot + mockBootGrubDir := filepath.Join(boot.InitramfsUbuntuBootDir, "EFI", "ubuntu") + mockBootGrubCfg := filepath.Join(mockBootGrubDir, "grub.cfg") + err = os.MkdirAll(filepath.Dir(mockBootGrubCfg), 0755) + c.Assert(err, IsNil) + err = os.WriteFile(mockBootGrubCfg, nil, 0644) + c.Assert(err, IsNil) + + unpackedGadgetDir := c.MkDir() + grubRecoveryCfg := []byte("#grub-recovery cfg") + grubRecoveryCfgAsset := []byte("#grub-recovery cfg from assets") + grubCfg := []byte("#grub cfg") + grubCfgAsset := []byte("# Snapd-Boot-Config-Edition: 1\n#grub cfg from assets") + snaptest.PopulateDir(unpackedGadgetDir, [][]string{ + {"grub-recovery.conf", string(grubRecoveryCfg)}, + {"grub.conf", string(grubCfg)}, + {"bootx64.efi", "shim content"}, + {"grubx64.efi", "grub content"}, + {"meta/snap.yaml", gadgetSnapYaml}, + {"meta/gadget.yaml", gadgetYaml}, + }) + restore = assets.MockInternal("grub-recovery.cfg", grubRecoveryCfgAsset) + defer restore() + restore = assets.MockInternal("grub.cfg", grubCfgAsset) + defer restore() + + // make the snaps symlinks so that we can ensure that makebootable follows + // the symlinks and copies the files and not the symlinks + baseFn, baseInfo := makeSnap(c, "core20", `name: core20 +type: base +version: 5.0 +`, snap.R(3)) + baseInSeed := filepath.Join(seedSnapsDirs, baseInfo.Filename()) + err = os.Symlink(baseFn, baseInSeed) + c.Assert(err, IsNil) + gadgetFn, gadgetInfo := makeSnap(c, "pc", `name: pc +type: gadget +version: 5.0 +`, snap.R(4)) + gadgetInSeed := filepath.Join(seedSnapsDirs, gadgetInfo.Filename()) + err = os.Symlink(gadgetFn, gadgetInSeed) + c.Assert(err, IsNil) + kernelFn, kernelInfo := makeSnapWithFiles(c, "pc-kernel", `name: pc-kernel +type: kernel +version: 5.0 +`, snap.R(5), + [][]string{ + {"kernel.efi", "I'm a kernel.efi"}, + }, + ) + kernelInSeed := filepath.Join(seedSnapsDirs, kernelInfo.Filename()) + err = os.Symlink(kernelFn, kernelInSeed) + c.Assert(err, IsNil) + + bootWith := &boot.BootableSet{ + RecoverySystemLabel: "20191216", + BasePath: baseInSeed, + Base: baseInfo, + Gadget: gadgetInfo, + GadgetPath: gadgetInSeed, + KernelPath: kernelInSeed, + Kernel: kernelInfo, + Recovery: false, + UnpackedGadgetDir: unpackedGadgetDir, + } + + // set up observer state + useEncryption := true + obs, err := boot.TrustedAssetsInstallObserverForModel(model, unpackedGadgetDir, useEncryption) + c.Assert(obs, NotNil) + c.Assert(err, IsNil) + + // only grubx64.efi gets installed to system-boot + _, err = obs.Observe(gadget.ContentWrite, gadget.SystemBoot, boot.InitramfsUbuntuBootDir, "EFI/boot/grubx64.efi", + &gadget.ContentChange{After: filepath.Join(unpackedGadgetDir, "grubx64.efi")}) + c.Assert(err, IsNil) + + // observe recovery assets + err = obs.ObserveExistingTrustedRecoveryAssets(boot.InitramfsUbuntuSeedDir) + c.Assert(err, IsNil) + + // set encryption key + myKey := keys.EncryptionKey{} + myKey2 := keys.EncryptionKey{} + for i := range myKey { + myKey[i] = byte(i) + myKey2[i] = byte(128 + i) + } + obs.ChosenEncryptionKeys(myKey, myKey2) + + // set a mock recovery kernel + readSystemEssentialCalls := 0 + restore = boot.MockSeedReadSystemEssential(func(seedDir, label string, essentialTypes []snap.Type, tm timings.Measurer) (*asserts.Model, []*seed.Snap, error) { + if fromInitrd { + c.Assert(seedDir, Equals, filepath.Join(boot.InitramfsRunMntDir, "ubuntu-seed")) + } else { + c.Assert(seedDir, Equals, dirs.SnapSeedDir) + } + readSystemEssentialCalls++ + return model, []*seed.Snap{mockKernelSeedSnap(snap.R(1)), mockGadgetSeedSnap(c, nil)}, nil + }) + defer restore() + + provisionCalls := 0 + restore = boot.MockSecbootProvisionTPM(func(mode secboot.TPMProvisionMode, lockoutAuthFile string) error { + provisionCalls++ + c.Check(lockoutAuthFile, Equals, filepath.Join(boot.InstallHostFDESaveDir, "tpm-lockout-auth")) + if factoryReset { + c.Check(mode, Equals, secboot.TPMPartialReprovision) + } else { + c.Check(mode, Equals, secboot.TPMProvisionFull) + } + return nil + }) + defer restore() + + pcrHandleOfKeyCalls := 0 + restore = boot.MockSecbootPCRHandleOfSealedKey(func(p string) (uint32, error) { + pcrHandleOfKeyCalls++ + c.Check(provisionCalls, Equals, 0) + if !factoryReset { + c.Errorf("unexpected call in non-factory-reset scenario") + return 0, fmt.Errorf("unexpected call") + } + c.Check(p, Equals, + filepath.Join(s.rootdir, "/run/mnt/ubuntu-seed/device/fde/ubuntu-save.recovery.sealed-key")) + // trigger use of alt handles as current key is using the main handle + return secboot.FallbackObjectPCRPolicyCounterHandle, nil + }) + defer restore() + + releasePCRHandleCalls := 0 + restore = boot.MockSecbootReleasePCRResourceHandles(func(handles ...uint32) error { + c.Check(factoryReset, Equals, true) + releasePCRHandleCalls++ + c.Check(handles, DeepEquals, []uint32{ + secboot.AltRunObjectPCRPolicyCounterHandle, + secboot.AltFallbackObjectPCRPolicyCounterHandle, + }) + return nil + }) + defer restore() + + hasFDESetupHookCalled := false + restore = boot.MockHasFDESetupHook(func(kernel *snap.Info) (bool, error) { + c.Check(kernel, Equals, kernelInfo) + hasFDESetupHookCalled = true + return false, nil + }) + defer restore() + + // set mock key sealing + sealKeysCalls := 0 + restore = boot.MockSecbootSealKeys(func(keys []secboot.SealKeyRequest, params *secboot.SealKeysParams) error { + c.Assert(provisionCalls, Equals, 1, Commentf("TPM must have been provisioned before")) + sealKeysCalls++ + switch sealKeysCalls { + case 1: + c.Check(keys, HasLen, 1) + c.Check(keys[0].Key, DeepEquals, myKey) + c.Check(keys[0].KeyFile, Equals, + filepath.Join(s.rootdir, "/run/mnt/ubuntu-boot/device/fde/ubuntu-data.sealed-key")) + if factoryReset { + c.Check(params.PCRPolicyCounterHandle, Equals, secboot.AltRunObjectPCRPolicyCounterHandle) + } else { + c.Check(params.PCRPolicyCounterHandle, Equals, secboot.RunObjectPCRPolicyCounterHandle) + } + case 2: + c.Check(keys, HasLen, 2) + c.Check(keys[0].Key, DeepEquals, myKey) + c.Check(keys[1].Key, DeepEquals, myKey2) + c.Check(keys[0].KeyFile, Equals, + filepath.Join(s.rootdir, + "/run/mnt/ubuntu-seed/device/fde/ubuntu-data.recovery.sealed-key")) + if factoryReset { + c.Check(params.PCRPolicyCounterHandle, Equals, secboot.AltFallbackObjectPCRPolicyCounterHandle) + c.Check(keys[1].KeyFile, Equals, + filepath.Join(s.rootdir, + "/run/mnt/ubuntu-seed/device/fde/ubuntu-save.recovery.sealed-key.factory-reset")) + + } else { + c.Check(params.PCRPolicyCounterHandle, Equals, secboot.FallbackObjectPCRPolicyCounterHandle) + c.Check(keys[1].KeyFile, Equals, + filepath.Join(s.rootdir, + "/run/mnt/ubuntu-seed/device/fde/ubuntu-save.recovery.sealed-key")) + } + default: + c.Errorf("unexpected additional call to secboot.SealKeys (call # %d)", sealKeysCalls) + } + c.Assert(params.ModelParams, HasLen, 1) + + shim := bootloader.NewBootFile("", filepath.Join(s.rootdir, + "var/lib/snapd/boot-assets/grub/bootx64.efi-39efae6545f16e39633fbfbef0d5e9fdd45a25d7df8764978ce4d81f255b038046a38d9855e42e5c7c4024e153fd2e37"), + bootloader.RoleRecovery) + grub := bootloader.NewBootFile("", filepath.Join(s.rootdir, + "var/lib/snapd/boot-assets/grub/grubx64.efi-aa3c1a83e74bf6dd40dd64e5c5bd1971d75cdf55515b23b9eb379f66bf43d4661d22c4b8cf7d7a982d2013ab65c1c4c5"), + bootloader.RoleRecovery) + runGrub := bootloader.NewBootFile("", filepath.Join(s.rootdir, + "var/lib/snapd/boot-assets/grub/grubx64.efi-5ee042c15e104b825d6bc15c41cdb026589f1ec57ed966dd3f29f961d4d6924efc54b187743fa3a583b62722882d405d"), + bootloader.RoleRunMode) + kernel := bootloader.NewBootFile("/var/lib/snapd/seed/snaps/pc-kernel_1.snap", "kernel.efi", bootloader.RoleRecovery) + var runKernelPath string + var runKernel bootloader.BootFile + switch { + case !standalone: + runKernelPath = "/var/lib/snapd/snaps/pc-kernel_5.snap" + case classic: + runKernelPath = "/run/mnt/ubuntu-data/var/lib/snapd/snaps/pc-kernel_5.snap" + case !classic: + runKernelPath = "/run/mnt/ubuntu-data/system-data/var/lib/snapd/snaps/pc-kernel_5.snap" + } + runKernel = bootloader.NewBootFile(filepath.Join(s.rootdir, runKernelPath), "kernel.efi", bootloader.RoleRunMode) + switch sealKeysCalls { + case 1: + c.Assert(params.ModelParams[0].EFILoadChains, DeepEquals, []*secboot.LoadChain{ + secboot.NewLoadChain(shim, secboot.NewLoadChain(grub, secboot.NewLoadChain(kernel))), + secboot.NewLoadChain(shim, secboot.NewLoadChain(grub, secboot.NewLoadChain(runGrub, secboot.NewLoadChain(runKernel)))), + }) + c.Assert(params.ModelParams[0].KernelCmdlines, DeepEquals, []string{ + "snapd_recovery_mode=factory-reset snapd_recovery_system=20191216 console=ttyS0 console=tty1 panic=-1", + "snapd_recovery_mode=recover snapd_recovery_system=20191216 console=ttyS0 console=tty1 panic=-1", + "snapd_recovery_mode=run console=ttyS0 console=tty1 panic=-1", + }) + case 2: + c.Assert(params.ModelParams[0].EFILoadChains, DeepEquals, []*secboot.LoadChain{ + secboot.NewLoadChain(shim, secboot.NewLoadChain(grub, secboot.NewLoadChain(kernel))), + }) + c.Assert(params.ModelParams[0].KernelCmdlines, DeepEquals, []string{ + "snapd_recovery_mode=factory-reset snapd_recovery_system=20191216 console=ttyS0 console=tty1 panic=-1", + "snapd_recovery_mode=recover snapd_recovery_system=20191216 console=ttyS0 console=tty1 panic=-1", + }) + default: + c.Errorf("unexpected additional call to secboot.SealKeys (call # %d)", sealKeysCalls) + } + + c.Assert(params.ModelParams[0].Model.Model(), Equals, "my-model-uc20") + + return nil + }) + defer restore() + + switch { + case standalone && fromInitrd: + err = boot.MakeRunnableStandaloneSystemFromInitrd(model, bootWith, obs) + case standalone && !fromInitrd: + u := mockUnlocker{} + err = boot.MakeRunnableStandaloneSystem(model, bootWith, obs, u.unlocker) + c.Check(u.unlocked, Equals, 1) + case factoryReset && !fromInitrd: + err = boot.MakeRunnableSystemAfterDataReset(model, bootWith, obs) + default: + err = boot.MakeRunnableSystem(model, bootWith, obs) + } + c.Assert(err, IsNil) + + // also do the logical thing and make the next boot go to run mode + err = boot.EnsureNextBootToRunMode("20191216") + c.Assert(err, IsNil) + + // ensure grub.cfg in boot was installed from internal assets + c.Check(mockBootGrubCfg, testutil.FileEquals, string(grubCfgAsset)) + + var installHostWritableDir string + if classic { + installHostWritableDir = filepath.Join(dirs.GlobalRootDir, "/run/mnt/ubuntu-data") + } else { + installHostWritableDir = filepath.Join(dirs.GlobalRootDir, "/run/mnt/ubuntu-data/system-data") + } + + // ensure base/gadget/kernel got copied to /var/lib/snapd/snaps + core20Snap := filepath.Join(dirs.SnapBlobDirUnder(installHostWritableDir), "core20_3.snap") + gadgetSnap := filepath.Join(dirs.SnapBlobDirUnder(installHostWritableDir), "pc_4.snap") + pcKernelSnap := filepath.Join(dirs.SnapBlobDirUnder(installHostWritableDir), "pc-kernel_5.snap") + c.Check(core20Snap, testutil.FilePresent) + c.Check(gadgetSnap, testutil.FilePresent) + c.Check(pcKernelSnap, testutil.FilePresent) + c.Check(osutil.IsSymlink(core20Snap), Equals, false) + c.Check(osutil.IsSymlink(pcKernelSnap), Equals, false) + + // ensure the bootvars got updated the right way + mockSeedGrubenv := filepath.Join(mockSeedGrubDir, "grubenv") + c.Assert(mockSeedGrubenv, testutil.FilePresent) + c.Check(mockSeedGrubenv, testutil.FileContains, "snapd_recovery_mode=run") + c.Check(mockSeedGrubenv, testutil.FileContains, "snapd_good_recovery_systems=20191216") + mockBootGrubenv := filepath.Join(mockBootGrubDir, "grubenv") + c.Check(mockBootGrubenv, testutil.FilePresent) + + // ensure that kernel_status is empty, we specifically want this to be set + // to the empty string + // use (?m) to match multi-line file in the regex here, because the file is + // a grubenv with padding #### blocks + c.Check(mockBootGrubenv, testutil.FileMatches, `(?m)^kernel_status=$`) + + // check that we have the extracted kernel in the right places, both in the + // old uc16/uc18 location and the new ubuntu-boot partition grub dir + extractedKernel := filepath.Join(mockBootGrubDir, "pc-kernel_5.snap", "kernel.efi") + c.Check(extractedKernel, testutil.FilePresent) + + // the new uc20 location + extractedKernelSymlink := filepath.Join(mockBootGrubDir, "kernel.efi") + c.Check(extractedKernelSymlink, testutil.FilePresent) + + // ensure modeenv looks correct + var ubuntuDataModeEnvPath, classicLine, base string + if classic { + base = "" + ubuntuDataModeEnvPath = filepath.Join(s.rootdir, "/run/mnt/ubuntu-data/var/lib/snapd/modeenv") + classicLine = "\nclassic=true" + } else { + base = "\nbase=core20_3.snap" + ubuntuDataModeEnvPath = filepath.Join(s.rootdir, "/run/mnt/ubuntu-data/system-data/var/lib/snapd/modeenv") + } + expectedModeenv := fmt.Sprintf(`mode=run +recovery_system=20191216 +current_recovery_systems=20191216 +good_recovery_systems=20191216%s +gadget=pc_4.snap +current_kernels=pc-kernel_5.snap +model=my-brand/my-model-uc20%s +grade=dangerous +model_sign_key_id=Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij +current_trusted_boot_assets={"grubx64.efi":["5ee042c15e104b825d6bc15c41cdb026589f1ec57ed966dd3f29f961d4d6924efc54b187743fa3a583b62722882d405d"]} +current_trusted_recovery_boot_assets={"bootx64.efi":["39efae6545f16e39633fbfbef0d5e9fdd45a25d7df8764978ce4d81f255b038046a38d9855e42e5c7c4024e153fd2e37"],"grubx64.efi":["aa3c1a83e74bf6dd40dd64e5c5bd1971d75cdf55515b23b9eb379f66bf43d4661d22c4b8cf7d7a982d2013ab65c1c4c5"]} +current_kernel_command_lines=["snapd_recovery_mode=run console=ttyS0 console=tty1 panic=-1"] +`, base, classicLine) + c.Check(ubuntuDataModeEnvPath, testutil.FileEquals, expectedModeenv) + copiedGrubBin := filepath.Join( + dirs.SnapBootAssetsDirUnder(installHostWritableDir), + "grub", + "grubx64.efi-5ee042c15e104b825d6bc15c41cdb026589f1ec57ed966dd3f29f961d4d6924efc54b187743fa3a583b62722882d405d", + ) + copiedRecoveryGrubBin := filepath.Join( + dirs.SnapBootAssetsDirUnder(installHostWritableDir), + "grub", + "grubx64.efi-aa3c1a83e74bf6dd40dd64e5c5bd1971d75cdf55515b23b9eb379f66bf43d4661d22c4b8cf7d7a982d2013ab65c1c4c5", + ) + copiedRecoveryShimBin := filepath.Join( + dirs.SnapBootAssetsDirUnder(installHostWritableDir), + "grub", + "bootx64.efi-39efae6545f16e39633fbfbef0d5e9fdd45a25d7df8764978ce4d81f255b038046a38d9855e42e5c7c4024e153fd2e37", + ) + + // only one file in the cache under new root + checkContentGlob(c, filepath.Join(dirs.SnapBootAssetsDirUnder(installHostWritableDir), "grub", "*"), []string{ + copiedRecoveryShimBin, + copiedGrubBin, + copiedRecoveryGrubBin, + }) + // with the right content + c.Check(copiedGrubBin, testutil.FileEquals, "grub content") + c.Check(copiedRecoveryGrubBin, testutil.FileEquals, "recovery grub content") + c.Check(copiedRecoveryShimBin, testutil.FileEquals, "recovery shim content") + + // we checked for fde-setup-hook + c.Check(hasFDESetupHookCalled, Equals, true) + // make sure TPM was provisioned + c.Check(provisionCalls, Equals, 1) + // make sure SealKey was called for the run object and the fallback object + c.Check(sealKeysCalls, Equals, 2) + // PCR handle checks + if factoryReset { + c.Check(pcrHandleOfKeyCalls, Equals, 1) + c.Check(releasePCRHandleCalls, Equals, 1) + } else { + c.Check(pcrHandleOfKeyCalls, Equals, 0) + c.Check(releasePCRHandleCalls, Equals, 0) + } + + // make sure the marker file for sealed key was created + c.Check(filepath.Join(installHostWritableDir, "/var/lib/snapd/device/fde/sealed-keys"), testutil.FilePresent) + + // make sure we wrote the boot chains data file + c.Check(filepath.Join(installHostWritableDir, "/var/lib/snapd/device/fde/boot-chains"), testutil.FilePresent) +} + +func (s *makeBootable20Suite) TestMakeSystemRunnable20Install(c *C) { + const standalone = false + const factoryReset = false + const classic = false + const fromInitrd = false + s.testMakeSystemRunnable20(c, standalone, factoryReset, classic, fromInitrd) +} + +func (s *makeBootable20Suite) TestMakeSystemRunnable20InstallOnClassic(c *C) { + const standalone = false + const factoryReset = false + const classic = true + const fromInitrd = false + s.testMakeSystemRunnable20(c, standalone, factoryReset, classic, fromInitrd) +} + +func (s *makeBootable20Suite) TestMakeSystemRunnable20FactoryReset(c *C) { + const standalone = false + const factoryReset = true + const classic = false + const fromInitrd = false + s.testMakeSystemRunnable20(c, standalone, factoryReset, classic, fromInitrd) +} + +func (s *makeBootable20Suite) TestMakeSystemRunnable20FactoryResetOnClassic(c *C) { + const standalone = false + const factoryReset = true + const classic = true + const fromInitrd = false + s.testMakeSystemRunnable20(c, standalone, factoryReset, classic, fromInitrd) +} + +func (s *makeBootable20Suite) TestMakeSystemRunnable20InstallFromInitrd(c *C) { + const standalone = true + const factoryReset = false + const classic = false + const fromInitrd = true + s.testMakeSystemRunnable20(c, standalone, factoryReset, classic, fromInitrd) +} + +func (s *makeBootable20Suite) TestMakeRunnableSystem20ModeInstallBootConfigErr(c *C) { + bootloader.Force(nil) + + model := boottest.MakeMockUC20Model() + seedSnapsDirs := filepath.Join(s.rootdir, "/snaps") + err := os.MkdirAll(seedSnapsDirs, 0755) + c.Assert(err, IsNil) + + // grub on ubuntu-seed + mockSeedGrubDir := filepath.Join(boot.InitramfsUbuntuSeedDir, "EFI", "ubuntu") + err = os.MkdirAll(mockSeedGrubDir, 0755) + c.Assert(err, IsNil) + // no recovery grub.cfg so that test fails if it ever reaches that point + + // grub on ubuntu-boot + mockBootGrubDir := filepath.Join(boot.InitramfsUbuntuBootDir, "EFI", "ubuntu") + mockBootGrubCfg := filepath.Join(mockBootGrubDir, "grub.cfg") + err = os.MkdirAll(filepath.Dir(mockBootGrubCfg), 0755) + c.Assert(err, IsNil) + err = os.WriteFile(mockBootGrubCfg, nil, 0644) + c.Assert(err, IsNil) + + unpackedGadgetDir := c.MkDir() + + // make the snaps symlinks so that we can ensure that makebootable follows + // the symlinks and copies the files and not the symlinks + baseFn, baseInfo := makeSnap(c, "core20", `name: core20 +type: base +version: 5.0 +`, snap.R(3)) + baseInSeed := filepath.Join(seedSnapsDirs, baseInfo.Filename()) + err = os.Symlink(baseFn, baseInSeed) + c.Assert(err, IsNil) + kernelFn, kernelInfo := makeSnapWithFiles(c, "pc-kernel", `name: pc-kernel +type: kernel +version: 5.0 +`, snap.R(5), + [][]string{ + {"kernel.efi", "I'm a kernel.efi"}, + }, + ) + kernelInSeed := filepath.Join(seedSnapsDirs, kernelInfo.Filename()) + err = os.Symlink(kernelFn, kernelInSeed) + c.Assert(err, IsNil) + gadgetFn, gadgetInfo := makeSnap(c, "pc", `name: pc +type: gadget +version: 5.0 +`, snap.R(4)) + gadgetInSeed := filepath.Join(seedSnapsDirs, gadgetInfo.Filename()) + err = os.Symlink(gadgetFn, gadgetInSeed) + c.Assert(err, IsNil) + + bootWith := &boot.BootableSet{ + RecoverySystemLabel: "20191216", + BasePath: baseInSeed, + Base: baseInfo, + KernelPath: kernelInSeed, + Kernel: kernelInfo, + Gadget: gadgetInfo, + GadgetPath: gadgetInSeed, + Recovery: false, + UnpackedGadgetDir: unpackedGadgetDir, + } + + // no grub marker in gadget directory raises an error + err = boot.MakeRunnableSystem(model, bootWith, nil) + c.Assert(err, ErrorMatches, "internal error: cannot identify run system bootloader: cannot determine bootloader") + + // set up grub.cfg in gadget + grubCfg := []byte("#grub cfg") + err = os.WriteFile(filepath.Join(unpackedGadgetDir, "grub.conf"), grubCfg, 0644) + c.Assert(err, IsNil) + + // no write access to destination directory + restore := assets.MockInternal("grub.cfg", nil) + defer restore() + err = boot.MakeRunnableSystem(model, bootWith, nil) + c.Assert(err, ErrorMatches, `cannot install managed bootloader assets: internal error: no boot asset for "grub.cfg"`) +} + +func (s *makeBootable20Suite) TestMakeRunnableSystem20RunModeSealKeyErr(c *C) { + bootloader.Force(nil) + + model := boottest.MakeMockUC20Model() + seedSnapsDirs := filepath.Join(s.rootdir, "/snaps") + err := os.MkdirAll(seedSnapsDirs, 0755) + c.Assert(err, IsNil) + + // grub on ubuntu-seed + mockSeedGrubDir := filepath.Join(boot.InitramfsUbuntuSeedDir, "EFI", "ubuntu") + mockSeedGrubCfg := filepath.Join(mockSeedGrubDir, "grub.cfg") + err = os.MkdirAll(filepath.Dir(mockSeedGrubCfg), 0755) + c.Assert(err, IsNil) + err = os.WriteFile(mockSeedGrubCfg, []byte("# Snapd-Boot-Config-Edition: 1\n"), 0644) + c.Assert(err, IsNil) + + // setup recovery boot assets + err = os.MkdirAll(filepath.Join(boot.InitramfsUbuntuSeedDir, "EFI/boot"), 0755) + c.Assert(err, IsNil) + // SHA3-384: 39efae6545f16e39633fbfbef0d5e9fdd45a25d7df8764978ce4d81f255b038046a38d9855e42e5c7c4024e153fd2e37 + err = os.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "EFI/boot/bootx64.efi"), + []byte("recovery shim content"), 0644) + c.Assert(err, IsNil) + // SHA3-384: aa3c1a83e74bf6dd40dd64e5c5bd1971d75cdf55515b23b9eb379f66bf43d4661d22c4b8cf7d7a982d2013ab65c1c4c5 + err = os.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "EFI/boot/grubx64.efi"), + []byte("recovery grub content"), 0644) + c.Assert(err, IsNil) + + // grub on ubuntu-boot + mockBootGrubDir := filepath.Join(boot.InitramfsUbuntuBootDir, "EFI", "ubuntu") + mockBootGrubCfg := filepath.Join(mockBootGrubDir, "grub.cfg") + err = os.MkdirAll(filepath.Dir(mockBootGrubCfg), 0755) + c.Assert(err, IsNil) + err = os.WriteFile(mockBootGrubCfg, nil, 0644) + c.Assert(err, IsNil) + + unpackedGadgetDir := c.MkDir() + grubRecoveryCfg := []byte("#grub-recovery cfg") + grubRecoveryCfgAsset := []byte("#grub-recovery cfg from assets") + grubCfg := []byte("#grub cfg") + grubCfgAsset := []byte("# Snapd-Boot-Config-Edition: 1\n#grub cfg from assets") + snaptest.PopulateDir(unpackedGadgetDir, [][]string{ + {"grub-recovery.conf", string(grubRecoveryCfg)}, + {"grub.conf", string(grubCfg)}, + {"bootx64.efi", "shim content"}, + {"grubx64.efi", "grub content"}, + {"meta/snap.yaml", gadgetSnapYaml}, + {"meta/gadget.yaml", gadgetYaml}, + }) + restore := assets.MockInternal("grub-recovery.cfg", grubRecoveryCfgAsset) + defer restore() + restore = assets.MockInternal("grub.cfg", grubCfgAsset) + defer restore() + + // make the snaps symlinks so that we can ensure that makebootable follows + // the symlinks and copies the files and not the symlinks + baseFn, baseInfo := makeSnap(c, "core20", `name: core20 +type: base +version: 5.0 +`, snap.R(3)) + baseInSeed := filepath.Join(seedSnapsDirs, baseInfo.Filename()) + err = os.Symlink(baseFn, baseInSeed) + c.Assert(err, IsNil) + kernelFn, kernelInfo := makeSnapWithFiles(c, "pc-kernel", `name: pc-kernel +type: kernel +version: 5.0 +`, snap.R(5), + [][]string{ + {"kernel.efi", "I'm a kernel.efi"}, + }, + ) + kernelInSeed := filepath.Join(seedSnapsDirs, kernelInfo.Filename()) + err = os.Symlink(kernelFn, kernelInSeed) + c.Assert(err, IsNil) + gadgetFn, gadgetInfo := makeSnap(c, "pc", `name: pc +type: gadget +version: 5.0 +`, snap.R(4)) + gadgetInSeed := filepath.Join(seedSnapsDirs, gadgetInfo.Filename()) + err = os.Symlink(gadgetFn, gadgetInSeed) + c.Assert(err, IsNil) + + bootWith := &boot.BootableSet{ + RecoverySystemLabel: "20191216", + BasePath: baseInSeed, + Base: baseInfo, + KernelPath: kernelInSeed, + Kernel: kernelInfo, + Gadget: gadgetInfo, + GadgetPath: gadgetInSeed, + Recovery: false, + UnpackedGadgetDir: unpackedGadgetDir, + } + + // set up observer state + useEncryption := true + obs, err := boot.TrustedAssetsInstallObserverForModel(model, unpackedGadgetDir, useEncryption) + c.Assert(obs, NotNil) + c.Assert(err, IsNil) + + // only grubx64.efi gets installed to system-boot + _, err = obs.Observe(gadget.ContentWrite, gadget.SystemBoot, boot.InitramfsUbuntuBootDir, "EFI/boot/grubx64.efi", + &gadget.ContentChange{After: filepath.Join(unpackedGadgetDir, "grubx64.efi")}) + c.Assert(err, IsNil) + + // observe recovery assets + err = obs.ObserveExistingTrustedRecoveryAssets(boot.InitramfsUbuntuSeedDir) + c.Assert(err, IsNil) + + // set encryption key + myKey := keys.EncryptionKey{} + myKey2 := keys.EncryptionKey{} + for i := range myKey { + myKey[i] = byte(i) + myKey2[i] = byte(128 + i) + } + obs.ChosenEncryptionKeys(myKey, myKey2) + + // set a mock recovery kernel + readSystemEssentialCalls := 0 + restore = boot.MockSeedReadSystemEssential(func(seedDir, label string, essentialTypes []snap.Type, tm timings.Measurer) (*asserts.Model, []*seed.Snap, error) { + readSystemEssentialCalls++ + return model, []*seed.Snap{mockKernelSeedSnap(snap.R(1)), mockGadgetSeedSnap(c, nil)}, nil + }) + defer restore() + + provisionCalls := 0 + restore = boot.MockSecbootProvisionTPM(func(mode secboot.TPMProvisionMode, lockoutAuthFile string) error { + provisionCalls++ + c.Check(lockoutAuthFile, Equals, filepath.Join(boot.InstallHostFDESaveDir, "tpm-lockout-auth")) + c.Check(mode, Equals, secboot.TPMProvisionFull) + return nil + }) + defer restore() + // set mock key sealing + sealKeysCalls := 0 + restore = boot.MockSecbootSealKeys(func(keys []secboot.SealKeyRequest, params *secboot.SealKeysParams) error { + sealKeysCalls++ + switch sealKeysCalls { + case 1: + c.Check(keys, HasLen, 1) + c.Check(keys[0].Key, DeepEquals, myKey) + case 2: + c.Check(keys, HasLen, 2) + c.Check(keys[0].Key, DeepEquals, myKey) + c.Check(keys[1].Key, DeepEquals, myKey2) + default: + c.Errorf("unexpected additional call to secboot.SealKeys (call # %d)", sealKeysCalls) + } + c.Assert(params.ModelParams, HasLen, 1) + + shim := bootloader.NewBootFile("", filepath.Join(s.rootdir, + "var/lib/snapd/boot-assets/grub/bootx64.efi-39efae6545f16e39633fbfbef0d5e9fdd45a25d7df8764978ce4d81f255b038046a38d9855e42e5c7c4024e153fd2e37"), + bootloader.RoleRecovery) + grub := bootloader.NewBootFile("", filepath.Join(s.rootdir, + "var/lib/snapd/boot-assets/grub/grubx64.efi-aa3c1a83e74bf6dd40dd64e5c5bd1971d75cdf55515b23b9eb379f66bf43d4661d22c4b8cf7d7a982d2013ab65c1c4c5"), + bootloader.RoleRecovery) + runGrub := bootloader.NewBootFile("", filepath.Join(s.rootdir, + "var/lib/snapd/boot-assets/grub/grubx64.efi-5ee042c15e104b825d6bc15c41cdb026589f1ec57ed966dd3f29f961d4d6924efc54b187743fa3a583b62722882d405d"), + bootloader.RoleRunMode) + kernel := bootloader.NewBootFile("/var/lib/snapd/seed/snaps/pc-kernel_1.snap", "kernel.efi", bootloader.RoleRecovery) + runKernel := bootloader.NewBootFile(filepath.Join(s.rootdir, "var/lib/snapd/snaps/pc-kernel_5.snap"), "kernel.efi", bootloader.RoleRunMode) + + c.Assert(params.ModelParams[0].EFILoadChains, DeepEquals, []*secboot.LoadChain{ + secboot.NewLoadChain(shim, secboot.NewLoadChain(grub, secboot.NewLoadChain(kernel))), + secboot.NewLoadChain(shim, secboot.NewLoadChain(grub, secboot.NewLoadChain(runGrub, secboot.NewLoadChain(runKernel)))), + }) + c.Assert(params.ModelParams[0].KernelCmdlines, DeepEquals, []string{ + "snapd_recovery_mode=factory-reset snapd_recovery_system=20191216 console=ttyS0 console=tty1 panic=-1", + "snapd_recovery_mode=recover snapd_recovery_system=20191216 console=ttyS0 console=tty1 panic=-1", + "snapd_recovery_mode=run console=ttyS0 console=tty1 panic=-1", + }) + c.Assert(params.ModelParams[0].Model.Model(), Equals, "my-model-uc20") + + return fmt.Errorf("seal error") + }) + defer restore() + + err = boot.MakeRunnableSystem(model, bootWith, obs) + c.Assert(err, ErrorMatches, "cannot seal the encryption keys: seal error") + // the TPM was provisioned + c.Check(provisionCalls, Equals, 1) +} + +func (s *makeBootable20Suite) testMakeSystemRunnable20WithCustomKernelArgs(c *C, whichFile, content, errMsg string, cmdlines map[string]string) { + if cmdlines == nil { + cmdlines = map[string]string{} + } + bootloader.Force(nil) + + model := boottest.MakeMockUC20Model() + seedSnapsDirs := filepath.Join(s.rootdir, "/snaps") + err := os.MkdirAll(seedSnapsDirs, 0755) + c.Assert(err, IsNil) + + // grub on ubuntu-seed + mockSeedGrubDir := filepath.Join(boot.InitramfsUbuntuSeedDir, "EFI", "ubuntu") + mockSeedGrubCfg := filepath.Join(mockSeedGrubDir, "grub.cfg") + err = os.MkdirAll(filepath.Dir(mockSeedGrubCfg), 0755) + c.Assert(err, IsNil) + err = os.WriteFile(mockSeedGrubCfg, []byte("# Snapd-Boot-Config-Edition: 1\n"), 0644) + c.Assert(err, IsNil) + genv := grubenv.NewEnv(filepath.Join(mockSeedGrubDir, "grubenv")) + c.Assert(genv.Save(), IsNil) + + // setup recovery boot assets + err = os.MkdirAll(filepath.Join(boot.InitramfsUbuntuSeedDir, "EFI/boot"), 0755) + c.Assert(err, IsNil) + // SHA3-384: 39efae6545f16e39633fbfbef0d5e9fdd45a25d7df8764978ce4d81f255b038046a38d9855e42e5c7c4024e153fd2e37 + err = os.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "EFI/boot/bootx64.efi"), + []byte("recovery shim content"), 0644) + c.Assert(err, IsNil) + // SHA3-384: aa3c1a83e74bf6dd40dd64e5c5bd1971d75cdf55515b23b9eb379f66bf43d4661d22c4b8cf7d7a982d2013ab65c1c4c5 + err = os.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "EFI/boot/grubx64.efi"), + []byte("recovery grub content"), 0644) + c.Assert(err, IsNil) + + // grub on ubuntu-boot + mockBootGrubDir := filepath.Join(boot.InitramfsUbuntuBootDir, "EFI", "ubuntu") + mockBootGrubCfg := filepath.Join(mockBootGrubDir, "grub.cfg") + err = os.MkdirAll(filepath.Dir(mockBootGrubCfg), 0755) + c.Assert(err, IsNil) + err = os.WriteFile(mockBootGrubCfg, nil, 0644) + c.Assert(err, IsNil) + + unpackedGadgetDir := c.MkDir() + grubRecoveryCfg := []byte("#grub-recovery cfg") + grubRecoveryCfgAsset := []byte("#grub-recovery cfg from assets") + grubCfg := []byte("#grub cfg") + grubCfgAsset := []byte("# Snapd-Boot-Config-Edition: 1\n#grub cfg from assets") + gadgetFiles := [][]string{ + {"grub-recovery.conf", string(grubRecoveryCfg)}, + {"grub.conf", string(grubCfg)}, + {"bootx64.efi", "shim content"}, + {"grubx64.efi", "grub content"}, + {"meta/snap.yaml", gadgetSnapYaml}, + {"meta/gadget.yaml", gadgetYaml}, + {whichFile, content}, + } + snaptest.PopulateDir(unpackedGadgetDir, gadgetFiles) + restore := assets.MockInternal("grub-recovery.cfg", grubRecoveryCfgAsset) + defer restore() + restore = assets.MockInternal("grub.cfg", grubCfgAsset) + defer restore() + + // make the snaps symlinks so that we can ensure that makebootable follows + // the symlinks and copies the files and not the symlinks + baseFn, baseInfo := makeSnap(c, "core20", `name: core20 +type: base +version: 5.0 +`, snap.R(3)) + baseInSeed := filepath.Join(seedSnapsDirs, baseInfo.Filename()) + err = os.Symlink(baseFn, baseInSeed) + c.Assert(err, IsNil) + gadgetFn, gadgetInfo := makeSnap(c, "pc", `name: pc +type: gadget +version: 5.0 +`, snap.R(4)) + gadgetInSeed := filepath.Join(seedSnapsDirs, gadgetInfo.Filename()) + err = os.Symlink(gadgetFn, gadgetInSeed) + c.Assert(err, IsNil) + kernelFn, kernelInfo := makeSnapWithFiles(c, "pc-kernel", `name: pc-kernel +type: kernel +version: 5.0 +`, snap.R(5), + [][]string{ + {"kernel.efi", "I'm a kernel.efi"}, + }, + ) + kernelInSeed := filepath.Join(seedSnapsDirs, kernelInfo.Filename()) + err = os.Symlink(kernelFn, kernelInSeed) + c.Assert(err, IsNil) + + bootWith := &boot.BootableSet{ + RecoverySystemLabel: "20191216", + BasePath: baseInSeed, + Base: baseInfo, + Gadget: gadgetInfo, + GadgetPath: gadgetInSeed, + KernelPath: kernelInSeed, + Kernel: kernelInfo, + Recovery: false, + UnpackedGadgetDir: unpackedGadgetDir, + } + + // set up observer state + useEncryption := true + obs, err := boot.TrustedAssetsInstallObserverForModel(model, unpackedGadgetDir, useEncryption) + c.Assert(obs, NotNil) + c.Assert(err, IsNil) + + // only grubx64.efi gets installed to system-boot + _, err = obs.Observe(gadget.ContentWrite, gadget.SystemBoot, boot.InitramfsUbuntuBootDir, "EFI/boot/grubx64.efi", + &gadget.ContentChange{After: filepath.Join(unpackedGadgetDir, "grubx64.efi")}) + c.Assert(err, IsNil) + + // observe recovery assets + err = obs.ObserveExistingTrustedRecoveryAssets(boot.InitramfsUbuntuSeedDir) + c.Assert(err, IsNil) + + // set a mock recovery kernel + readSystemEssentialCalls := 0 + restore = boot.MockSeedReadSystemEssential(func(seedDir, label string, essentialTypes []snap.Type, tm timings.Measurer) (*asserts.Model, []*seed.Snap, error) { + readSystemEssentialCalls++ + return model, []*seed.Snap{mockKernelSeedSnap(snap.R(1)), mockGadgetSeedSnap(c, gadgetFiles)}, nil + }) + defer restore() + + provisionCalls := 0 + restore = boot.MockSecbootProvisionTPM(func(mode secboot.TPMProvisionMode, lockoutAuthFile string) error { + provisionCalls++ + c.Check(lockoutAuthFile, Equals, filepath.Join(boot.InstallHostFDESaveDir, "tpm-lockout-auth")) + c.Check(mode, Equals, secboot.TPMProvisionFull) + return nil + }) + defer restore() + // set mock key sealing + sealKeysCalls := 0 + restore = boot.MockSecbootSealKeys(func(keys []secboot.SealKeyRequest, params *secboot.SealKeysParams) error { + sealKeysCalls++ + switch sealKeysCalls { + case 1, 2: + // expecting only 2 calls + default: + c.Errorf("unexpected additional call to secboot.SealKeys (call # %d)", sealKeysCalls) + } + c.Assert(params.ModelParams, HasLen, 1) + + switch sealKeysCalls { + case 1: + c.Assert(params.ModelParams[0].KernelCmdlines, HasLen, 3) + c.Assert(params.ModelParams[0].KernelCmdlines, testutil.Contains, cmdlines["recover"]) + c.Assert(params.ModelParams[0].KernelCmdlines, testutil.Contains, cmdlines["factory-reset"]) + c.Assert(params.ModelParams[0].KernelCmdlines, testutil.Contains, cmdlines["run"]) + case 2: + c.Assert(params.ModelParams[0].KernelCmdlines, DeepEquals, []string{cmdlines["factory-reset"], cmdlines["recover"]}) + default: + c.Errorf("unexpected additional call to secboot.SealKeys (call # %d)", sealKeysCalls) + } + + c.Assert(params.ModelParams[0].Model.Model(), Equals, "my-model-uc20") + + return nil + }) + defer restore() + + err = boot.MakeRunnableSystem(model, bootWith, obs) + if errMsg != "" { + c.Assert(err, ErrorMatches, errMsg) + return + } + c.Assert(err, IsNil) + + // also do the logical thing and make the next boot go to run mode + err = boot.EnsureNextBootToRunMode("20191216") + c.Assert(err, IsNil) + + // ensure grub.cfg in boot was installed from internal assets + c.Check(mockBootGrubCfg, testutil.FileEquals, string(grubCfgAsset)) + + // ensure the bootvars got updated the right way + mockSeedGrubenv := filepath.Join(mockSeedGrubDir, "grubenv") + c.Assert(mockSeedGrubenv, testutil.FilePresent) + c.Check(mockSeedGrubenv, testutil.FileContains, "snapd_recovery_mode=run") + c.Check(mockSeedGrubenv, testutil.FileContains, "snapd_good_recovery_systems=20191216") + mockBootGrubenv := filepath.Join(mockBootGrubDir, "grubenv") + c.Check(mockBootGrubenv, testutil.FilePresent) + systemGenv := grubenv.NewEnv(mockBootGrubenv) + c.Assert(systemGenv.Load(), IsNil) + + switch whichFile { + case "cmdline.extra": + blopts := &bootloader.Options{ + Role: bootloader.RoleRunMode, + NoSlashBoot: true, + } + bl, err := bootloader.Find(boot.InitramfsUbuntuBootDir, blopts) + c.Assert(err, IsNil) + tbl, ok := bl.(bootloader.TrustedAssetsBootloader) + if ok { + candidate := false + defaultCmdLine, err := tbl.DefaultCommandLine(candidate) + c.Assert(err, IsNil) + c.Check(systemGenv.Get("snapd_extra_cmdline_args"), Equals, "") + c.Check(systemGenv.Get("snapd_full_cmdline_args"), Equals, strutil.JoinNonEmpty([]string{defaultCmdLine, content}, " ")) + } else { + c.Check(systemGenv.Get("snapd_extra_cmdline_args"), Equals, content) + c.Check(systemGenv.Get("snapd_full_cmdline_args"), Equals, "") + } + case "cmdline.full": + c.Check(systemGenv.Get("snapd_extra_cmdline_args"), Equals, "") + c.Check(systemGenv.Get("snapd_full_cmdline_args"), Equals, content) + } + + // ensure modeenv looks correct + ubuntuDataModeEnvPath := filepath.Join(s.rootdir, "/run/mnt/ubuntu-data/system-data/var/lib/snapd/modeenv") + c.Check(ubuntuDataModeEnvPath, testutil.FileEquals, fmt.Sprintf(`mode=run +recovery_system=20191216 +current_recovery_systems=20191216 +good_recovery_systems=20191216 +base=core20_3.snap +gadget=pc_4.snap +current_kernels=pc-kernel_5.snap +model=my-brand/my-model-uc20 +grade=dangerous +model_sign_key_id=Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij +current_trusted_boot_assets={"grubx64.efi":["5ee042c15e104b825d6bc15c41cdb026589f1ec57ed966dd3f29f961d4d6924efc54b187743fa3a583b62722882d405d"]} +current_trusted_recovery_boot_assets={"bootx64.efi":["39efae6545f16e39633fbfbef0d5e9fdd45a25d7df8764978ce4d81f255b038046a38d9855e42e5c7c4024e153fd2e37"],"grubx64.efi":["aa3c1a83e74bf6dd40dd64e5c5bd1971d75cdf55515b23b9eb379f66bf43d4661d22c4b8cf7d7a982d2013ab65c1c4c5"]} +current_kernel_command_lines=["%v"] +`, cmdlines["run"])) + // make sure the TPM was provisioned + c.Check(provisionCalls, Equals, 1) + // make sure SealKey was called for the run object and the fallback object + c.Check(sealKeysCalls, Equals, 2) + + // make sure the marker file for sealed key was created + c.Check(filepath.Join(dirs.SnapFDEDirUnder(filepath.Join(dirs.GlobalRootDir, "/run/mnt/ubuntu-data/system-data")), "sealed-keys"), testutil.FilePresent) + + // make sure we wrote the boot chains data file + c.Check(filepath.Join(dirs.SnapFDEDirUnder(filepath.Join(dirs.GlobalRootDir, "/run/mnt/ubuntu-data/system-data")), "boot-chains"), testutil.FilePresent) +} + +func (s *makeBootable20Suite) TestMakeSystemRunnable20WithCustomKernelExtraArgs(c *C) { + cmdlines := map[string]string{ + "run": "snapd_recovery_mode=run console=ttyS0 console=tty1 panic=-1 foo bar baz", + "recover": "snapd_recovery_mode=recover snapd_recovery_system=20191216 console=ttyS0 console=tty1 panic=-1 foo bar baz", + "factory-reset": "snapd_recovery_mode=factory-reset snapd_recovery_system=20191216 console=ttyS0 console=tty1 panic=-1 foo bar baz", + } + s.testMakeSystemRunnable20WithCustomKernelArgs(c, "cmdline.extra", "foo bar baz", "", cmdlines) +} + +func (s *makeBootable20Suite) TestMakeSystemRunnable20WithCustomKernelFullArgs(c *C) { + cmdlines := map[string]string{ + "run": "snapd_recovery_mode=run foo bar baz", + "recover": "snapd_recovery_mode=recover snapd_recovery_system=20191216 foo bar baz", + "factory-reset": "snapd_recovery_mode=factory-reset snapd_recovery_system=20191216 foo bar baz", + } + s.testMakeSystemRunnable20WithCustomKernelArgs(c, "cmdline.full", "foo bar baz", "", cmdlines) +} + +func (s *makeBootable20Suite) TestMakeSystemRunnable20WithCustomKernelInvalidArgs(c *C) { + errMsg := `cannot compose the candidate command line: cannot use kernel command line from gadget: invalid kernel command line in cmdline.extra: disallowed kernel argument "snapd=unhappy"` + s.testMakeSystemRunnable20WithCustomKernelArgs(c, "cmdline.extra", "foo bar snapd=unhappy", errMsg, nil) +} + +func (s *makeBootable20Suite) TestMakeSystemRunnable20UnhappyMarkRecoveryCapable(c *C) { + bootloader.Force(nil) + + model := boottest.MakeMockUC20Model() + seedSnapsDirs := filepath.Join(s.rootdir, "/snaps") + err := os.MkdirAll(seedSnapsDirs, 0755) + c.Assert(err, IsNil) + + // grub on ubuntu-seed + mockSeedGrubDir := filepath.Join(boot.InitramfsUbuntuSeedDir, "EFI", "ubuntu") + mockSeedGrubCfg := filepath.Join(mockSeedGrubDir, "grub.cfg") + err = os.MkdirAll(filepath.Dir(mockSeedGrubCfg), 0755) + c.Assert(err, IsNil) + err = os.WriteFile(mockSeedGrubCfg, []byte("# Snapd-Boot-Config-Edition: 1\n"), 0644) + c.Assert(err, IsNil) + // there is no grubenv in ubuntu-seed so loading from it will fail + + // setup recovery boot assets + err = os.MkdirAll(filepath.Join(boot.InitramfsUbuntuSeedDir, "EFI/boot"), 0755) + c.Assert(err, IsNil) + err = os.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "EFI/boot/bootx64.efi"), + []byte("recovery shim content"), 0644) + c.Assert(err, IsNil) + err = os.WriteFile(filepath.Join(boot.InitramfsUbuntuSeedDir, "EFI/boot/grubx64.efi"), + []byte("recovery grub content"), 0644) + c.Assert(err, IsNil) + + // grub on ubuntu-boot + mockBootGrubDir := filepath.Join(boot.InitramfsUbuntuBootDir, "EFI", "ubuntu") + mockBootGrubCfg := filepath.Join(mockBootGrubDir, "grub.cfg") + err = os.MkdirAll(filepath.Dir(mockBootGrubCfg), 0755) + c.Assert(err, IsNil) + err = os.WriteFile(mockBootGrubCfg, nil, 0644) + c.Assert(err, IsNil) + + unpackedGadgetDir := c.MkDir() + grubRecoveryCfg := []byte("#grub-recovery cfg") + grubRecoveryCfgAsset := []byte("#grub-recovery cfg from assets") + grubCfg := []byte("#grub cfg") + grubCfgAsset := []byte("# Snapd-Boot-Config-Edition: 1\n#grub cfg from assets") + snaptest.PopulateDir(unpackedGadgetDir, [][]string{ + {"grub-recovery.conf", string(grubRecoveryCfg)}, + {"grub.conf", string(grubCfg)}, + {"bootx64.efi", "shim content"}, + {"grubx64.efi", "grub content"}, + {"meta/snap.yaml", gadgetSnapYaml}, + {"meta/gadget.yaml", gadgetYaml}, + }) + restore := assets.MockInternal("grub-recovery.cfg", grubRecoveryCfgAsset) + defer restore() + restore = assets.MockInternal("grub.cfg", grubCfgAsset) + defer restore() + + // make the snaps symlinks so that we can ensure that makebootable follows + // the symlinks and copies the files and not the symlinks + baseFn, baseInfo := makeSnap(c, "core20", `name: core20 +type: base +version: 5.0 +`, snap.R(3)) + baseInSeed := filepath.Join(seedSnapsDirs, baseInfo.Filename()) + err = os.Symlink(baseFn, baseInSeed) + c.Assert(err, IsNil) + kernelFn, kernelInfo := makeSnapWithFiles(c, "pc-kernel", `name: pc-kernel +type: kernel +version: 5.0 +`, snap.R(5), + [][]string{ + {"kernel.efi", "I'm a kernel.efi"}, + }, + ) + kernelInSeed := filepath.Join(seedSnapsDirs, kernelInfo.Filename()) + err = os.Symlink(kernelFn, kernelInSeed) + c.Assert(err, IsNil) + gadgetFn, gadgetInfo := makeSnap(c, "pc", `name: pc +type: gadget +version: 5.0 +`, snap.R(4)) + gadgetInSeed := filepath.Join(seedSnapsDirs, gadgetInfo.Filename()) + err = os.Symlink(gadgetFn, gadgetInSeed) + c.Assert(err, IsNil) + + bootWith := &boot.BootableSet{ + RecoverySystemLabel: "20191216", + BasePath: baseInSeed, + Base: baseInfo, + KernelPath: kernelInSeed, + Kernel: kernelInfo, + Gadget: gadgetInfo, + GadgetPath: gadgetInSeed, + Recovery: false, + UnpackedGadgetDir: unpackedGadgetDir, + } + + // set a mock recovery kernel + readSystemEssentialCalls := 0 + restore = boot.MockSeedReadSystemEssential(func(seedDir, label string, essentialTypes []snap.Type, tm timings.Measurer) (*asserts.Model, []*seed.Snap, error) { + readSystemEssentialCalls++ + return model, []*seed.Snap{mockKernelSeedSnap(snap.R(1)), mockGadgetSeedSnap(c, nil)}, nil + }) + defer restore() + + err = boot.MakeRunnableSystem(model, bootWith, nil) + c.Assert(err, ErrorMatches, `cannot record "20191216" as a recovery capable system: open .*/run/mnt/ubuntu-seed/EFI/ubuntu/grubenv: no such file or directory`) + +} + +func (s *makeBootable20UbootSuite) TestUbootMakeBootableImage20TraditionalUbootenvFails(c *C) { + bootloader.Force(nil) + model := boottest.MakeMockUC20Model() + + unpackedGadgetDir := c.MkDir() + ubootEnv := []byte("#uboot env") + err := os.WriteFile(filepath.Join(unpackedGadgetDir, "uboot.conf"), ubootEnv, 0644) + c.Assert(err, IsNil) + + // on uc20 the seed layout if different + seedSnapsDirs := filepath.Join(s.rootdir, "/snaps") + err = os.MkdirAll(seedSnapsDirs, 0755) + c.Assert(err, IsNil) + + baseFn, baseInfo := makeSnap(c, "core20", `name: core20 +type: base +version: 5.0 +`, snap.R(3)) + baseInSeed := filepath.Join(seedSnapsDirs, baseInfo.Filename()) + err = os.Rename(baseFn, baseInSeed) + c.Assert(err, IsNil) + kernelFn, kernelInfo := makeSnapWithFiles(c, "arm-kernel", `name: arm-kernel +type: kernel +version: 5.0 +`, snap.R(5), [][]string{ + {"kernel.img", "I'm a kernel"}, + {"initrd.img", "...and I'm an initrd"}, + {"dtbs/foo.dtb", "foo dtb"}, + {"dtbs/bar.dto", "bar dtbo"}, + }) + kernelInSeed := filepath.Join(seedSnapsDirs, kernelInfo.Filename()) + err = os.Rename(kernelFn, kernelInSeed) + c.Assert(err, IsNil) + + label := "20191209" + recoverySystemDir := filepath.Join("/systems", label) + bootWith := &boot.BootableSet{ + Base: baseInfo, + BasePath: baseInSeed, + Kernel: kernelInfo, + KernelPath: kernelInSeed, + RecoverySystemDir: recoverySystemDir, + RecoverySystemLabel: label, + UnpackedGadgetDir: unpackedGadgetDir, + Recovery: true, + } + + // TODO:UC20: enable this use case + err = boot.MakeBootableImage(model, s.rootdir, bootWith, nil) + c.Assert(err, ErrorMatches, `cannot install bootloader: non-empty uboot.env not supported on UC20\+ yet`) +} + +func (s *makeBootable20UbootSuite) TestUbootMakeBootableImage20BootScr(c *C) { + model := boottest.MakeMockUC20Model() + + unpackedGadgetDir := c.MkDir() + // the uboot.conf must be empty for this to work/do the right thing + err := os.WriteFile(filepath.Join(unpackedGadgetDir, "uboot.conf"), nil, 0644) + c.Assert(err, IsNil) + + // on uc20 the seed layout if different + seedSnapsDirs := filepath.Join(s.rootdir, "/snaps") + err = os.MkdirAll(seedSnapsDirs, 0755) + c.Assert(err, IsNil) + + baseFn, baseInfo := makeSnap(c, "core20", `name: core20 +type: base +version: 5.0 +`, snap.R(3)) + baseInSeed := filepath.Join(seedSnapsDirs, baseInfo.Filename()) + err = os.Rename(baseFn, baseInSeed) + c.Assert(err, IsNil) + kernelFn, kernelInfo := makeSnapWithFiles(c, "arm-kernel", `name: arm-kernel +type: kernel +version: 5.0 +`, snap.R(5), [][]string{ + {"kernel.img", "I'm a kernel"}, + {"initrd.img", "...and I'm an initrd"}, + {"dtbs/foo.dtb", "foo dtb"}, + {"dtbs/bar.dto", "bar dtbo"}, + }) + kernelInSeed := filepath.Join(seedSnapsDirs, kernelInfo.Filename()) + err = os.Rename(kernelFn, kernelInSeed) + c.Assert(err, IsNil) + + label := "20191209" + recoverySystemDir := filepath.Join("/systems", label) + bootWith := &boot.BootableSet{ + Base: baseInfo, + BasePath: baseInSeed, + Kernel: kernelInfo, + KernelPath: kernelInSeed, + RecoverySystemDir: recoverySystemDir, + RecoverySystemLabel: label, + UnpackedGadgetDir: unpackedGadgetDir, + Recovery: true, + } + + err = boot.MakeBootableImage(model, s.rootdir, bootWith, nil) + c.Assert(err, IsNil) + + // since uboot.conf was absent, we won't have installed the uboot.env, as + // it is expected that the gadget assets would have installed boot.scr + // instead + c.Check(filepath.Join(s.rootdir, "uboot.env"), testutil.FileAbsent) + + c.Check(s.bootloader.BootVars, DeepEquals, map[string]string{ + "snapd_recovery_system": label, + "snapd_recovery_mode": "install", + }) + + // ensure the correct recovery system configuration was set + c.Check( + s.bootloader.ExtractRecoveryKernelAssetsCalls, + DeepEquals, + []bootloadertest.ExtractedRecoveryKernelCall{{ + RecoverySystemDir: recoverySystemDir, + S: kernelInfo, + }}, + ) +} + +func (s *makeBootable20UbootSuite) TestUbootMakeBootableImage20BootSelNoHeaderFlagByte(c *C) { + bootloader.Force(nil) + model := boottest.MakeMockUC20Model() + + unpackedGadgetDir := c.MkDir() + // the uboot.conf must be empty for this to work/do the right thing + err := os.WriteFile(filepath.Join(unpackedGadgetDir, "uboot.conf"), nil, 0644) + c.Assert(err, IsNil) + + sampleEnv, err := ubootenv.Create(filepath.Join(unpackedGadgetDir, "boot.sel"), 4096, ubootenv.CreateOptions{HeaderFlagByte: false}) + c.Assert(err, IsNil) + err = sampleEnv.Save() + c.Assert(err, IsNil) + + // on uc20 the seed layout if different + seedSnapsDirs := filepath.Join(s.rootdir, "/snaps") + err = os.MkdirAll(seedSnapsDirs, 0755) + c.Assert(err, IsNil) + + baseFn, baseInfo := makeSnap(c, "core20", `name: core20 +type: base +version: 5.0 +`, snap.R(3)) + baseInSeed := filepath.Join(seedSnapsDirs, baseInfo.Filename()) + err = os.Rename(baseFn, baseInSeed) + c.Assert(err, IsNil) + kernelFn, kernelInfo := makeSnapWithFiles(c, "arm-kernel", `name: arm-kernel +type: kernel +version: 5.0 +`, snap.R(5), [][]string{ + {"kernel.img", "I'm a kernel"}, + {"initrd.img", "...and I'm an initrd"}, + {"dtbs/foo.dtb", "foo dtb"}, + {"dtbs/bar.dto", "bar dtbo"}, + }) + kernelInSeed := filepath.Join(seedSnapsDirs, kernelInfo.Filename()) + err = os.Rename(kernelFn, kernelInSeed) + c.Assert(err, IsNil) + + label := "20191209" + recoverySystemDir := filepath.Join("/systems", label) + bootWith := &boot.BootableSet{ + Base: baseInfo, + BasePath: baseInSeed, + Kernel: kernelInfo, + KernelPath: kernelInSeed, + RecoverySystemDir: recoverySystemDir, + RecoverySystemLabel: label, + UnpackedGadgetDir: unpackedGadgetDir, + Recovery: true, + } + + err = boot.MakeBootableImage(model, s.rootdir, bootWith, nil) + c.Assert(err, IsNil) + + // since uboot.conf was absent, we won't have installed the uboot.env, as + // it is expected that the gadget assets would have installed boot.scr + // instead + c.Check(filepath.Join(s.rootdir, "uboot.env"), testutil.FileAbsent) + + env, err := ubootenv.Open(filepath.Join(s.rootdir, "/uboot/ubuntu/boot.sel")) + c.Assert(err, IsNil) + + // Since we have a boot.sel w/o a header flag byte in our gadget, + // our recovery boot sel also should not have a header flag byte + c.Check(env.HeaderFlagByte(), Equals, false) +} + +func (s *makeBootable20UbootSuite) TestUbootMakeRunnableSystem20RunModeBootSel(c *C) { + bootloader.Force(nil) + + model := boottest.MakeMockUC20Model() + seedSnapsDirs := filepath.Join(s.rootdir, "/snaps") + err := os.MkdirAll(seedSnapsDirs, 0755) + c.Assert(err, IsNil) + + // uboot on ubuntu-seed + mockSeedUbootBootSel := filepath.Join(boot.InitramfsUbuntuSeedDir, "uboot/ubuntu/boot.sel") + err = os.MkdirAll(filepath.Dir(mockSeedUbootBootSel), 0755) + c.Assert(err, IsNil) + env, err := ubootenv.Create(mockSeedUbootBootSel, 4096, ubootenv.CreateOptions{HeaderFlagByte: true}) + c.Assert(err, IsNil) + c.Assert(env.Save(), IsNil) + + // uboot on ubuntu-boot (as if it was installed when creating the partition) + mockBootUbootBootSel := filepath.Join(boot.InitramfsUbuntuBootDir, "uboot/ubuntu/boot.sel") + err = os.MkdirAll(filepath.Dir(mockBootUbootBootSel), 0755) + c.Assert(err, IsNil) + env, err = ubootenv.Create(mockBootUbootBootSel, 4096, ubootenv.CreateOptions{HeaderFlagByte: true}) + c.Assert(err, IsNil) + c.Assert(env.Save(), IsNil) + + unpackedGadgetDir := c.MkDir() + c.Assert(os.WriteFile(filepath.Join(unpackedGadgetDir, "uboot.conf"), nil, 0644), IsNil) + + baseFn, baseInfo := makeSnap(c, "core20", `name: core20 +type: base +version: 5.0 +`, snap.R(3)) + baseInSeed := filepath.Join(seedSnapsDirs, baseInfo.Filename()) + err = os.Rename(baseFn, baseInSeed) + c.Assert(err, IsNil) + gadgetFn, gadgetInfo := makeSnap(c, "pc", `name: pc +type: gadget +version: 5.0 +`, snap.R(4)) + gadgetInSeed := filepath.Join(seedSnapsDirs, gadgetInfo.Filename()) + err = os.Symlink(gadgetFn, gadgetInSeed) + c.Assert(err, IsNil) + kernelSnapFiles := [][]string{ + {"kernel.img", "I'm a kernel"}, + {"initrd.img", "...and I'm an initrd"}, + {"dtbs/foo.dtb", "foo dtb"}, + {"dtbs/bar.dto", "bar dtbo"}, + } + kernelFn, kernelInfo := makeSnapWithFiles(c, "arm-kernel", `name: arm-kernel +type: kernel +version: 5.0 +`, snap.R(5), kernelSnapFiles) + kernelInSeed := filepath.Join(seedSnapsDirs, kernelInfo.Filename()) + err = os.Rename(kernelFn, kernelInSeed) + c.Assert(err, IsNil) + + bootWith := &boot.BootableSet{ + RecoverySystemLabel: "20191216", + BasePath: baseInSeed, + Base: baseInfo, + Gadget: gadgetInfo, + GadgetPath: gadgetInSeed, + KernelPath: kernelInSeed, + Kernel: kernelInfo, + Recovery: false, + UnpackedGadgetDir: unpackedGadgetDir, + } + err = boot.MakeRunnableSystem(model, bootWith, nil) + c.Assert(err, IsNil) + + // also do the logical next thing which is to ensure that the system + // reboots into run mode + err = boot.EnsureNextBootToRunMode("20191216") + c.Assert(err, IsNil) + + // ensure base/kernel got copied to /var/lib/snapd/snaps + c.Check(filepath.Join(dirs.SnapBlobDirUnder(filepath.Join(dirs.GlobalRootDir, "/run/mnt/ubuntu-data/system-data")), "core20_3.snap"), testutil.FilePresent) + c.Check(filepath.Join(dirs.SnapBlobDirUnder(filepath.Join(dirs.GlobalRootDir, "/run/mnt/ubuntu-data/system-data")), "arm-kernel_5.snap"), testutil.FilePresent) + + // ensure the bootvars on ubuntu-seed got updated the right way + mockSeedUbootenv := filepath.Join(boot.InitramfsUbuntuSeedDir, "uboot/ubuntu/boot.sel") + uenvSeed, err := ubootenv.Open(mockSeedUbootenv) + c.Assert(err, IsNil) + c.Assert(uenvSeed.Get("snapd_recovery_mode"), Equals, "run") + c.Assert(uenvSeed.HeaderFlagByte(), Equals, true) + + // now check ubuntu-boot boot.sel + mockBootUbootenv := filepath.Join(boot.InitramfsUbuntuBootDir, "uboot/ubuntu/boot.sel") + uenvBoot, err := ubootenv.Open(mockBootUbootenv) + c.Assert(err, IsNil) + c.Assert(uenvBoot.Get("snap_try_kernel"), Equals, "") + c.Assert(uenvBoot.Get("snap_kernel"), Equals, "arm-kernel_5.snap") + c.Assert(uenvBoot.Get("kernel_status"), Equals, boot.DefaultStatus) + c.Assert(uenvBoot.HeaderFlagByte(), Equals, true) + + // check that we have the extracted kernel in the right places, in the + // old uc16/uc18 location + for _, file := range kernelSnapFiles { + fName := file[0] + c.Check(filepath.Join(boot.InitramfsUbuntuBootDir, "uboot/ubuntu/arm-kernel_5.snap", fName), testutil.FilePresent) + } + + // ensure modeenv looks correct + ubuntuDataModeEnvPath := filepath.Join(s.rootdir, "/run/mnt/ubuntu-data/system-data/var/lib/snapd/modeenv") + c.Check(ubuntuDataModeEnvPath, testutil.FileEquals, `mode=run +recovery_system=20191216 +current_recovery_systems=20191216 +good_recovery_systems=20191216 +base=core20_3.snap +gadget=pc_4.snap +current_kernels=arm-kernel_5.snap +model=my-brand/my-model-uc20 +grade=dangerous +model_sign_key_id=Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij +`) +} + +func (s *makeBootable20Suite) TestMakeRecoverySystemBootableAtRuntime20(c *C) { + bootloader.Force(nil) + model := boottest.MakeMockUC20Model() + + // on uc20 the seed layout if different + seedSnapsDirs := filepath.Join(s.rootdir, "/snaps") + err := os.MkdirAll(seedSnapsDirs, 0755) + c.Assert(err, IsNil) + + kernelFn, kernelInfo := makeSnapWithFiles(c, "pc-kernel", `name: pc-kernel +type: kernel +version: 5.0 +`, snap.R(5), [][]string{ + {"kernel.efi", "I'm a kernel.efi"}, + }) + kernelInSeed := filepath.Join(seedSnapsDirs, kernelInfo.Filename()) + err = os.Rename(kernelFn, kernelInSeed) + c.Assert(err, IsNil) + + gadgets := map[string]string{} + for _, rev := range []snap.Revision{snap.R(1), snap.R(5)} { + gadgetFn, gadgetInfo := makeSnapWithFiles(c, "pc", gadgetSnapYaml, rev, [][]string{ + {"grub.conf", ""}, + {"meta/snap.yaml", gadgetSnapYaml}, + {"cmdline.full", fmt.Sprintf("args from gadget rev %s", rev.String())}, + {"meta/gadget.yaml", gadgetYaml}, + }) + gadgetInSeed := filepath.Join(seedSnapsDirs, gadgetInfo.Filename()) + err = os.Rename(gadgetFn, gadgetInSeed) + c.Assert(err, IsNil) + // keep track of the gadgets + gadgets[rev.String()] = gadgetInSeed + } + + snaptest.PopulateDir(s.rootdir, [][]string{ + {"EFI/ubuntu/grub.cfg", "this is grub"}, + {"EFI/ubuntu/grubenv", "canary"}, + }) + + label := "20191209" + recoverySystemDir := filepath.Join("/systems", label) + err = boot.MakeRecoverySystemBootable(model, s.rootdir, recoverySystemDir, &boot.RecoverySystemBootableSet{ + Kernel: kernelInfo, + KernelPath: kernelInSeed, + // use gadget revision 1 + GadgetSnapOrDir: gadgets["1"], + // like it's called when creating a new recovery system + PrepareImageTime: false, + }) + c.Assert(err, IsNil) + // the recovery partition grubenv was not modified + c.Check(filepath.Join(s.rootdir, "EFI/ubuntu/grubenv"), testutil.FileEquals, "canary") + + systemGenv := grubenv.NewEnv(filepath.Join(s.rootdir, recoverySystemDir, "grubenv")) + c.Assert(systemGenv.Load(), IsNil) + c.Check(systemGenv.Get("snapd_recovery_kernel"), Equals, "/snaps/pc-kernel_5.snap") + c.Check(systemGenv.Get("snapd_extra_cmdline_args"), Equals, "") + c.Check(systemGenv.Get("snapd_full_cmdline_args"), Equals, "args from gadget rev 1") + + // create another system under a new label + newLabel := "20210420" + newRecoverySystemDir := filepath.Join("/systems", newLabel) + // with a different gadget revision, but same kernel + err = boot.MakeRecoverySystemBootable(model, s.rootdir, newRecoverySystemDir, &boot.RecoverySystemBootableSet{ + Kernel: kernelInfo, + KernelPath: kernelInSeed, + GadgetSnapOrDir: gadgets["5"], + // like it's called when creating a new recovery system + PrepareImageTime: false, + }) + c.Assert(err, IsNil) + + systemGenv = grubenv.NewEnv(filepath.Join(s.rootdir, newRecoverySystemDir, "grubenv")) + c.Assert(systemGenv.Load(), IsNil) + c.Check(systemGenv.Get("snapd_recovery_kernel"), Equals, "/snaps/pc-kernel_5.snap") + c.Check(systemGenv.Get("snapd_extra_cmdline_args"), Equals, "") + c.Check(systemGenv.Get("snapd_full_cmdline_args"), Equals, "args from gadget rev 5") +} + +func (s *makeBootable20Suite) TestMakeBootablePartition(c *C) { + bootloader.Force(nil) + + unpackedGadgetDir := c.MkDir() + grubRecoveryCfg := "#grub-recovery cfg" + grubRecoveryCfgAsset := "#grub-recovery cfg from assets" + grubCfg := "#grub cfg" + snaptest.PopulateDir(unpackedGadgetDir, [][]string{ + {"grub-recovery.conf", grubRecoveryCfg}, + {"grub.conf", grubCfg}, + {"meta/snap.yaml", gadgetSnapYaml}, + }) + restore := assets.MockInternal("grub-recovery.cfg", []byte(grubRecoveryCfgAsset)) + defer restore() + + seedSnapsDirs := filepath.Join(s.rootdir, "/snaps") + err := os.MkdirAll(seedSnapsDirs, 0755) + c.Assert(err, IsNil) + + gadgetFn, gadgetInfo := makeSnap(c, "pc", `name: pc +type: gadget +version: 5.0 +`, snap.R(4)) + gadgetInSeed := filepath.Join(seedSnapsDirs, gadgetInfo.Filename()) + err = os.Symlink(gadgetFn, gadgetInSeed) + c.Assert(err, IsNil) + + baseFn, baseInfo := makeSnap(c, "core22", `name: core22 +type: base +version: 5.0 +`, snap.R(3)) + baseInSeed := filepath.Join(seedSnapsDirs, baseInfo.Filename()) + err = os.Rename(baseFn, baseInSeed) + c.Assert(err, IsNil) + kernelFn, kernelInfo := makeSnapWithFiles(c, "pc-kernel", `name: pc-kernel +type: kernel +version: 5.0 +`, snap.R(5), [][]string{ + {"kernel.efi", "I'm a kernel.efi"}, + }) + kernelInSeed := filepath.Join(seedSnapsDirs, kernelInfo.Filename()) + err = os.Rename(kernelFn, kernelInSeed) + c.Assert(err, IsNil) + + bootWith := &boot.BootableSet{ + Base: baseInfo, + BasePath: baseInSeed, + Kernel: kernelInfo, + KernelPath: kernelInSeed, + Gadget: gadgetInfo, + GadgetPath: gadgetInSeed, + RecoverySystemLabel: "", + UnpackedGadgetDir: unpackedGadgetDir, + Recovery: false, + } + + opts := &bootloader.Options{ + PrepareImageTime: false, + // We need the same configuration that a recovery partition, + // as we will chainload to grub in the boot partition. + Role: bootloader.RoleRecovery, + } + partMntDir := filepath.Join(s.rootdir, "/partition") + err = os.MkdirAll(partMntDir, 0755) + c.Assert(err, IsNil) + err = boot.MakeBootablePartition(partMntDir, opts, bootWith, boot.ModeRun, []string{}) + c.Assert(err, IsNil) + + // ensure we have only grub.cfg and grubenv + files, err := filepath.Glob(filepath.Join(partMntDir, "EFI/ubuntu/*")) + c.Assert(err, IsNil) + c.Check(files, HasLen, 2) + // and nothing else + files, err = filepath.Glob(filepath.Join(partMntDir, "EFI/*")) + c.Assert(err, IsNil) + c.Check(files, HasLen, 1) + files, err = filepath.Glob(filepath.Join(partMntDir, "*")) + c.Assert(err, IsNil) + c.Check(files, HasLen, 1) + // check that the recovery bootloader configuration was installed with + // the correct content + c.Check(filepath.Join(partMntDir, "EFI/ubuntu/grub.cfg"), testutil.FileEquals, grubRecoveryCfgAsset) + + // ensure the correct recovery system configuration was set + seedGenv := grubenv.NewEnv(filepath.Join(partMntDir, "EFI/ubuntu/grubenv")) + c.Assert(seedGenv.Load(), IsNil) + c.Check(seedGenv.Get("snapd_recovery_system"), Equals, "") + c.Check(seedGenv.Get("snapd_recovery_mode"), Equals, boot.ModeRun) + c.Check(seedGenv.Get("snapd_good_recovery_systems"), Equals, "") +} + +func (s *makeBootable20Suite) TestMakeRunnableSystemNoGoodRecoverySystems(c *C) { + bootloader.Force(nil) + model := boottest.MakeMockUC20Model() + seedSnapsDirs := filepath.Join(s.rootdir, "/snaps") + err := os.MkdirAll(seedSnapsDirs, 0755) + c.Assert(err, IsNil) + + // grub on ubuntu-seed + mockSeedGrubDir := filepath.Join(boot.InitramfsUbuntuSeedDir, "EFI", "ubuntu") + mockSeedGrubCfg := filepath.Join(mockSeedGrubDir, "grub.cfg") + err = os.MkdirAll(filepath.Dir(mockSeedGrubCfg), 0755) + c.Assert(err, IsNil) + err = os.WriteFile(mockSeedGrubCfg, []byte("# Snapd-Boot-Config-Edition: 1\n"), 0644) + c.Assert(err, IsNil) + genv := grubenv.NewEnv(filepath.Join(mockSeedGrubDir, "grubenv")) + c.Assert(genv.Save(), IsNil) + + // mock grub so it is detected as the current bootloader + unpackedGadgetDir := c.MkDir() + grubRecoveryCfg := []byte("#grub-recovery cfg") + grubRecoveryCfgAsset := []byte("#grub-recovery cfg from assets") + grubCfg := []byte("#grub cfg") + grubCfgAsset := []byte("# Snapd-Boot-Config-Edition: 1\n#grub cfg from assets") + snaptest.PopulateDir(unpackedGadgetDir, [][]string{ + {"grub-recovery.conf", string(grubRecoveryCfg)}, + {"grub.conf", string(grubCfg)}, + {"bootx64.efi", "shim content"}, + {"grubx64.efi", "grub content"}, + {"meta/snap.yaml", gadgetSnapYaml}, + {"meta/gadget.yaml", gadgetYaml}, + }) + restore := assets.MockInternal("grub-recovery.cfg", grubRecoveryCfgAsset) + defer restore() + restore = assets.MockInternal("grub.cfg", grubCfgAsset) + defer restore() + + // make the snaps symlinks so that we can ensure that makebootable follows + // the symlinks and copies the files and not the symlinks + baseFn, baseInfo := makeSnap(c, "core20", `name: core20 +type: base +version: 5.0 +`, snap.R(3)) + baseInSeed := filepath.Join(seedSnapsDirs, baseInfo.Filename()) + err = os.Symlink(baseFn, baseInSeed) + c.Assert(err, IsNil) + kernelFn, kernelInfo := makeSnapWithFiles(c, "pc-kernel", `name: pc-kernel +type: kernel +version: 5.0 +`, snap.R(5), + [][]string{ + {"kernel.efi", "I'm a kernel.efi"}, + }, + ) + kernelInSeed := filepath.Join(seedSnapsDirs, kernelInfo.Filename()) + err = os.Symlink(kernelFn, kernelInSeed) + c.Assert(err, IsNil) + gadgetFn, gadgetInfo := makeSnap(c, "pc", `name: pc +type: gadget +version: 5.0 +`, snap.R(4)) + gadgetInSeed := filepath.Join(seedSnapsDirs, gadgetInfo.Filename()) + err = os.Symlink(gadgetFn, gadgetInSeed) + c.Assert(err, IsNil) + + bootWith := &boot.BootableSet{ + BasePath: baseInSeed, + Base: baseInfo, + KernelPath: kernelInSeed, + Kernel: kernelInfo, + Gadget: gadgetInfo, + GadgetPath: gadgetInSeed, + Recovery: false, + UnpackedGadgetDir: unpackedGadgetDir, + } + + err = boot.MakeRunnableSystem(model, bootWith, nil) + c.Assert(err, IsNil) + + // ensure that there are no good recovery systems as RecoverySystemLabel was empty + mockSeedGrubenv := filepath.Join(mockSeedGrubDir, "grubenv") + c.Check(mockSeedGrubenv, testutil.FilePresent) + systemGenv := grubenv.NewEnv(mockSeedGrubenv) + c.Assert(systemGenv.Load(), IsNil) + c.Check(systemGenv.Get("snapd_good_recovery_systems"), Equals, "") +} + +func (s *makeBootable20Suite) TestMakeRunnableSystemStandaloneSnapsCopy(c *C) { + bootloader.Force(nil) + model := boottest.MakeMockUC20Model() + snapsDirs := filepath.Join(s.rootdir, "/somewhere") + err := os.MkdirAll(snapsDirs, 0755) + c.Assert(err, IsNil) + + // grub on ubuntu-seed + mockSeedGrubDir := filepath.Join(boot.InitramfsUbuntuSeedDir, "EFI", "ubuntu") + mockSeedGrubCfg := filepath.Join(mockSeedGrubDir, "grub.cfg") + err = os.MkdirAll(filepath.Dir(mockSeedGrubCfg), 0755) + c.Assert(err, IsNil) + err = os.WriteFile(mockSeedGrubCfg, []byte("# Snapd-Boot-Config-Edition: 1\n"), 0644) + c.Assert(err, IsNil) + genv := grubenv.NewEnv(filepath.Join(mockSeedGrubDir, "grubenv")) + c.Assert(genv.Save(), IsNil) + + // mock grub so it is detected as the current bootloader + unpackedGadgetDir := c.MkDir() + grubRecoveryCfg := []byte("#grub-recovery cfg") + grubRecoveryCfgAsset := []byte("#grub-recovery cfg from assets") + grubCfg := []byte("#grub cfg") + grubCfgAsset := []byte("# Snapd-Boot-Config-Edition: 1\n#grub cfg from assets") + snaptest.PopulateDir(unpackedGadgetDir, [][]string{ + {"grub-recovery.conf", string(grubRecoveryCfg)}, + {"grub.conf", string(grubCfg)}, + {"bootx64.efi", "shim content"}, + {"grubx64.efi", "grub content"}, + {"meta/snap.yaml", gadgetSnapYaml}, + {"meta/gadget.yaml", gadgetYaml}, + }) + restore := assets.MockInternal("grub-recovery.cfg", grubRecoveryCfgAsset) + defer restore() + restore = assets.MockInternal("grub.cfg", grubCfgAsset) + defer restore() + + // make the snaps symlinks so that we can ensure that makebootable follows + // the symlinks and copies the files and not the symlinks + baseFn, baseInfo := makeSnap(c, "core20", `name: core20 +type: base +version: 5.0 +`, snap.R(3)) + baseInSeed := filepath.Join(snapsDirs, "core20") + err = os.Symlink(baseFn, baseInSeed) + c.Assert(err, IsNil) + kernelFn, kernelInfo := makeSnapWithFiles(c, "pc-kernel", `name: pc-kernel +type: kernel +version: 4.1 +`, snap.R(5), + [][]string{ + {"kernel.efi", "I'm a kernel.efi"}, + }, + ) + kernelInSeed := filepath.Join(snapsDirs, "pc-kernel_4.1.snap") + err = os.Symlink(kernelFn, kernelInSeed) + c.Assert(err, IsNil) + gadgetFn, gadgetInfo := makeSnap(c, "pc", `name: pc +type: gadget +version: 3.0 +`, snap.R(4)) + gadgetInSeed := filepath.Join(snapsDirs, "pc_3.0.snap") + err = os.Symlink(gadgetFn, gadgetInSeed) + c.Assert(err, IsNil) + + bootWith := &boot.BootableSet{ + RecoverySystemLabel: "20221004", + BasePath: baseInSeed, + Base: baseInfo, + KernelPath: kernelInSeed, + Kernel: kernelInfo, + Gadget: gadgetInfo, + GadgetPath: gadgetInSeed, + Recovery: false, + UnpackedGadgetDir: unpackedGadgetDir, + } + + err = boot.MakeRunnableSystem(model, bootWith, nil) + c.Assert(err, IsNil) + + installHostWritableDir := filepath.Join(dirs.GlobalRootDir, "/run/mnt/ubuntu-data/system-data") + // ensure base/gadget/kernel got copied to /var/lib/snapd/snaps + core20Snap := filepath.Join(dirs.SnapBlobDirUnder(installHostWritableDir), "core20_3.snap") + gadgetSnap := filepath.Join(dirs.SnapBlobDirUnder(installHostWritableDir), "pc_4.snap") + pcKernelSnap := filepath.Join(dirs.SnapBlobDirUnder(installHostWritableDir), "pc-kernel_5.snap") + c.Check(core20Snap, testutil.FilePresent) + c.Check(gadgetSnap, testutil.FilePresent) + c.Check(pcKernelSnap, testutil.FilePresent) + c.Check(osutil.IsSymlink(core20Snap), Equals, false) + c.Check(osutil.IsSymlink(pcKernelSnap), Equals, false) + c.Check(osutil.IsSymlink(gadgetSnap), Equals, false) + + // check modeenv + ubuntuDataModeEnvPath := filepath.Join(s.rootdir, "/run/mnt/ubuntu-data/system-data/var/lib/snapd/modeenv") + expectedModeenv := `mode=run +recovery_system=20221004 +current_recovery_systems=20221004 +good_recovery_systems=20221004 +base=core20_3.snap +gadget=pc_4.snap +current_kernels=pc-kernel_5.snap +model=my-brand/my-model-uc20 +grade=dangerous +model_sign_key_id=Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij +current_kernel_command_lines=["snapd_recovery_mode=run console=ttyS0 console=tty1 panic=-1"] +` + c.Check(ubuntuDataModeEnvPath, testutil.FileEquals, expectedModeenv) +} + +func (s *makeBootable20Suite) TestMakeStandaloneSystemRunnable20Install(c *C) { + const standalone = true + const factoryReset = false + const classic = false + const fromInitrd = false + s.testMakeSystemRunnable20(c, standalone, factoryReset, classic, fromInitrd) +} + +func (s *makeBootable20Suite) TestMakeStandaloneSystemRunnable20InstallOnClassic(c *C) { + const standalone = true + const factoryReset = false + const classic = true + const fromInitrd = false + s.testMakeSystemRunnable20(c, standalone, factoryReset, classic, fromInitrd) +} + +func (s *makeBootable20Suite) testMakeBootableImageOptionalKernelArgs(c *C, model *asserts.Model, options map[string]string, expectedCmdline, errMsg string) { + bootloader.Force(nil) + + defaults := "defaults:\n system:\n" + for k, v := range options { + defaults += fmt.Sprintf(" %s: %s\n", k, v) + } + + unpackedGadgetDir := c.MkDir() + grubCfg := "#grub cfg" + snaptest.PopulateDir(unpackedGadgetDir, [][]string{ + {"grub.conf", grubCfg}, + {"meta/snap.yaml", gadgetSnapYaml}, + {"meta/gadget.yaml", gadgetYaml + defaults}, + }) + + // on uc20 the seed layout is different + seedSnapsDirs := filepath.Join(s.rootdir, "/snaps") + err := os.MkdirAll(seedSnapsDirs, 0755) + c.Assert(err, IsNil) + + baseFn, baseInfo := makeSnap(c, "core22", `name: core22 +type: base +version: 5.0 +`, snap.R(3)) + baseInSeed := filepath.Join(seedSnapsDirs, baseInfo.Filename()) + err = os.Rename(baseFn, baseInSeed) + c.Assert(err, IsNil) + kernelFn, kernelInfo := makeSnapWithFiles(c, "pc-kernel", `name: pc-kernel +type: kernel +version: 5.0 +`, snap.R(5), [][]string{ + {"kernel.efi", "I'm a kernel.efi"}, + }) + kernelInSeed := filepath.Join(seedSnapsDirs, kernelInfo.Filename()) + err = os.Rename(kernelFn, kernelInSeed) + c.Assert(err, IsNil) + + label := "20191209" + recoverySystemDir := filepath.Join("/systems", label) + bootWith := &boot.BootableSet{ + Base: baseInfo, + BasePath: baseInSeed, + Kernel: kernelInfo, + KernelPath: kernelInSeed, + RecoverySystemDir: recoverySystemDir, + RecoverySystemLabel: label, + UnpackedGadgetDir: unpackedGadgetDir, + Recovery: true, + } + + err = boot.MakeBootableImage(model, s.rootdir, bootWith, nil) + if errMsg != "" { + c.Assert(err, ErrorMatches, errMsg) + return + } + c.Assert(err, IsNil) + + // ensure the correct recovery system configuration was set + seedGenv := grubenv.NewEnv(filepath.Join(s.rootdir, "EFI/ubuntu/grubenv")) + c.Assert(seedGenv.Load(), IsNil) + c.Check(seedGenv.Get("snapd_recovery_system"), Equals, label) + // and kernel command line + systemGenv := grubenv.NewEnv(filepath.Join(s.rootdir, recoverySystemDir, "grubenv")) + c.Assert(systemGenv.Load(), IsNil) + c.Check(systemGenv.Get("snapd_recovery_kernel"), Equals, "/snaps/pc-kernel_5.snap") + blopts := &bootloader.Options{ + Role: bootloader.RoleRecovery, + } + bl, err := bootloader.Find(s.rootdir, blopts) + c.Assert(err, IsNil) + tbl, ok := bl.(bootloader.TrustedAssetsBootloader) + if ok { + candidate := false + defaultCmdLine, err := tbl.DefaultCommandLine(candidate) + c.Assert(err, IsNil) + c.Check(systemGenv.Get("snapd_full_cmdline_args"), Equals, strutil.JoinNonEmpty([]string{defaultCmdLine, expectedCmdline}, " ")) + c.Check(systemGenv.Get("snapd_extra_cmdline_args"), Equals, "") + } else { + c.Check(systemGenv.Get("snapd_extra_cmdline_args"), Equals, expectedCmdline) + c.Check(systemGenv.Get("snapd_full_cmdline_args"), Equals, "") + } +} + +func (s *makeBootable20Suite) TestMakeBootableImageOptionalKernelArgsHappy(c *C) { + model := boottest.MakeMockUC20Model() + const cmdline = "param1=val param2" + for _, opt := range []string{"system.kernel.cmdline-append", "system.kernel.dangerous-cmdline-append"} { + options := map[string]string{ + opt: cmdline, + } + s.testMakeBootableImageOptionalKernelArgs(c, model, options, cmdline, "") + } +} + +func (s *makeBootable20Suite) TestMakeBootableImageOptionalKernelArgsBothBootOptsSet(c *C) { + model := boottest.MakeMockUC20Model() + const cmdline = "param1=val param2" + const cmdlineDanger = "param3=val param4" + options := map[string]string{ + "system.kernel.cmdline-append": cmdline, + "system.kernel.dangerous-cmdline-append": cmdlineDanger, + } + s.testMakeBootableImageOptionalKernelArgs(c, model, options, cmdline+" "+cmdlineDanger, "") +} + +func (s *makeBootable20Suite) TestMakeBootableImageOptionalKernelArgsSignedAndDangerous(c *C) { + model := boottest.MakeMockUC20Model(map[string]interface{}{ + "grade": "signed", + }) + const cmdline = "param1=val param2" + options := map[string]string{ + "system.kernel.dangerous-cmdline-append": cmdline, + } + // The option is ignored if non-dangerous model + s.testMakeBootableImageOptionalKernelArgs(c, model, options, "", "") +} diff --git a/boot/modeenv.go b/boot/modeenv.go new file mode 100644 index 00000000..84213515 --- /dev/null +++ b/boot/modeenv.go @@ -0,0 +1,583 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2019-2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package boot + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "reflect" + "sort" + "strconv" + "strings" + + "github.com/mvo5/goconfigparser" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/release" + "github.com/snapcore/snapd/secboot" + "github.com/snapcore/snapd/snapdenv" +) + +type bootAssetsMap map[string][]string + +// bootCommandLines is a list of kernel command lines. The command lines are +// marshalled as JSON as a comma can be present in the module parameters. +type bootCommandLines []string + +// Modeenv is a file on UC20 that provides additional information +// about the current mode (run,recover,install) +type Modeenv struct { + Mode string `key:"mode"` + RecoverySystem string `key:"recovery_system"` + // CurrentRecoverySystems is a list of labels corresponding to recovery + // systems that have been tested or are in the process of being tried, + // thus only the run key is resealed for these systems. + CurrentRecoverySystems []string `key:"current_recovery_systems"` + // GoodRecoverySystems is a list of labels corresponding to recovery + // systems that were tested and are prepared to use for recovering. + // The fallback keys are resealed for these systems. + GoodRecoverySystems []string `key:"good_recovery_systems"` + Base string `key:"base"` + TryBase string `key:"try_base"` + BaseStatus string `key:"base_status"` + // Gadget is the currently active gadget snap + Gadget string `key:"gadget"` + CurrentKernels []string `key:"current_kernels"` + // Model, BrandID, Grade, SignKeyID describe the properties of current + // device model. + Model string `key:"model"` + BrandID string `key:"model,secondary"` + Classic bool `key:"classic"` + Grade string `key:"grade"` + ModelSignKeyID string `key:"model_sign_key_id"` + // TryModel, TryBrandID, TryGrade, TrySignKeyID describe the properties + // of the candidate model. + TryModel string `key:"try_model"` + TryBrandID string `key:"try_model,secondary"` + TryGrade string `key:"try_grade"` + TryModelSignKeyID string `key:"try_model_sign_key_id"` + // BootFlags is the set of boot flags. Whether this applies for the current + // or next boot is not indicated in the modeenv. When the modeenv is read in + // the initramfs these flags apply to the current boot and are copied into + // a file in /run that userspace should read instead of reading from this + // key. When setting boot flags for the next boot, then this key will be + // written to and used by the initramfs after rebooting. + BootFlags []string `key:"boot_flags"` + // CurrentTrustedBootAssets is a map of a run bootloader's asset names to + // a list of hashes of the asset contents. Typically the first entry in + // the list is a hash of an asset the system currently boots with (or is + // expected to have booted with). The second entry, if present, is the + // hash of an entry added when an update of the asset was being applied + // and will become the sole entry after a successful boot. + CurrentTrustedBootAssets bootAssetsMap `key:"current_trusted_boot_assets"` + // CurrentTrustedRecoveryBootAssetsMap is a map of a recovery bootloader's + // asset names to a list of hashes of the asset contents. Used similarly + // to CurrentTrustedBootAssets. + CurrentTrustedRecoveryBootAssets bootAssetsMap `key:"current_trusted_recovery_boot_assets"` + // CurrentKernelCommandLines is a list of the expected kernel command + // lines when booting into run mode. It will typically only be one + // element for normal operations, but may contain two elements during + // update scenarios. + CurrentKernelCommandLines bootCommandLines `key:"current_kernel_command_lines"` + // TODO:UC20 add a per recovery system list of kernel command lines + + // read is set to true when a modenv was read successfully + read bool + + // originRootdir is set to the root whence the modeenv was + // read from, and where it will be written back to + originRootdir string + + // extrakeys is all the keys in the modeenv we read from the file but don't + // understand, we keep track of this so that if we read a new modeenv with + // extra keys and need to rewrite it, we will write those new keys as well + extrakeys map[string]string +} + +var modeenvKnownKeys = make(map[string]bool) + +func init() { + st := reflect.TypeOf(Modeenv{}) + num := st.NumField() + for i := 0; i < num; i++ { + f := st.Field(i) + if f.PkgPath != "" { + // unexported + continue + } + key := f.Tag.Get("key") + if key == "" { + panic(fmt.Sprintf("modeenv %s field has no key tag", f.Name)) + } + const secondaryModifier = ",secondary" + if strings.HasSuffix(key, secondaryModifier) { + // secondary field in a group fields + // corresponding to one file key + key := key[:len(key)-len(secondaryModifier)] + if !modeenvKnownKeys[key] { + panic(fmt.Sprintf("modeenv %s field marked as secondary for not yet defined key %q", f.Name, key)) + } + continue + } + if modeenvKnownKeys[key] { + panic(fmt.Sprintf("modeenv key %q repeated on %s", key, f.Name)) + } + modeenvKnownKeys[key] = true + } +} + +func modeenvFile(rootdir string) string { + if rootdir == "" { + rootdir = dirs.GlobalRootDir + } + return dirs.SnapModeenvFileUnder(rootdir) +} + +// ReadModeenv attempts to read the modeenv file at +// /var/lib/snapd/modeenv. +func ReadModeenv(rootdir string) (*Modeenv, error) { + if snapdenv.Preseeding() { + return nil, fmt.Errorf("internal error: modeenv cannot be read during preseeding") + } + + modeenvPath := modeenvFile(rootdir) + cfg := goconfigparser.New() + cfg.AllowNoSectionHeader = true + if err := cfg.ReadFile(modeenvPath); err != nil { + return nil, err + } + + // TODO:UC20: should we check these errors and try to do something? + m := Modeenv{ + read: true, + originRootdir: rootdir, + extrakeys: make(map[string]string), + } + unmarshalModeenvValueFromCfg(cfg, "recovery_system", &m.RecoverySystem) + unmarshalModeenvValueFromCfg(cfg, "current_recovery_systems", &m.CurrentRecoverySystems) + unmarshalModeenvValueFromCfg(cfg, "good_recovery_systems", &m.GoodRecoverySystems) + unmarshalModeenvValueFromCfg(cfg, "boot_flags", &m.BootFlags) + + unmarshalModeenvValueFromCfg(cfg, "mode", &m.Mode) + if m.Mode == "" { + return nil, fmt.Errorf("internal error: mode is unset") + } + unmarshalModeenvValueFromCfg(cfg, "base", &m.Base) + unmarshalModeenvValueFromCfg(cfg, "base_status", &m.BaseStatus) + unmarshalModeenvValueFromCfg(cfg, "gadget", &m.Gadget) + unmarshalModeenvValueFromCfg(cfg, "try_base", &m.TryBase) + + // current_kernels is a comma-delimited list in a string + unmarshalModeenvValueFromCfg(cfg, "current_kernels", &m.CurrentKernels) + var bm modeenvModel + unmarshalModeenvValueFromCfg(cfg, "model", &bm) + m.BrandID = bm.brandID + m.Model = bm.model + unmarshalModeenvValueFromCfg(cfg, "classic", &m.Classic) + // expect the caller to validate the grade + unmarshalModeenvValueFromCfg(cfg, "grade", &m.Grade) + unmarshalModeenvValueFromCfg(cfg, "model_sign_key_id", &m.ModelSignKeyID) + var tryBm modeenvModel + unmarshalModeenvValueFromCfg(cfg, "try_model", &tryBm) + m.TryBrandID = tryBm.brandID + m.TryModel = tryBm.model + unmarshalModeenvValueFromCfg(cfg, "try_grade", &m.TryGrade) + unmarshalModeenvValueFromCfg(cfg, "try_model_sign_key_id", &m.TryModelSignKeyID) + + unmarshalModeenvValueFromCfg(cfg, "current_trusted_boot_assets", &m.CurrentTrustedBootAssets) + unmarshalModeenvValueFromCfg(cfg, "current_trusted_recovery_boot_assets", &m.CurrentTrustedRecoveryBootAssets) + unmarshalModeenvValueFromCfg(cfg, "current_kernel_command_lines", &m.CurrentKernelCommandLines) + + // save all the rest of the keys we don't understand + keys, err := cfg.Options("") + if err != nil { + return nil, err + } + for _, k := range keys { + if !modeenvKnownKeys[k] { + val, err := cfg.Get("", k) + if err != nil { + return nil, err + } + m.extrakeys[k] = val + } + } + + return &m, nil +} + +// deepEqual compares two modeenvs to ensure they are textually the same. It +// does not consider whether the modeenvs were read from disk or created purely +// in memory. It also does not sort or otherwise mutate any sub-objects, +// performing simple strict verification of sub-objects. +func (m *Modeenv) deepEqual(m2 *Modeenv) bool { + b, err := json.Marshal(m) + if err != nil { + return false + } + b2, err := json.Marshal(m2) + if err != nil { + return false + } + return bytes.Equal(b, b2) +} + +// Copy will make a deep copy of a Modeenv. +func (m *Modeenv) Copy() (*Modeenv, error) { + // to avoid hard-coding all fields here and manually copying everything, we + // take the easy way out and serialize to json then re-import into a + // empty Modeenv + b, err := json.Marshal(m) + if err != nil { + return nil, err + } + m2 := &Modeenv{} + err = json.Unmarshal(b, m2) + if err != nil { + return nil, err + } + + // manually copy the unexported fields as they won't be in the JSON + m2.read = m.read + m2.originRootdir = m.originRootdir + return m2, nil +} + +// Write outputs the modeenv to the file where it was read, only valid on +// modeenv that has been read. +func (m *Modeenv) Write() error { + if m.read { + return m.WriteTo(m.originRootdir) + } + return fmt.Errorf("internal error: must use WriteTo with modeenv not read from disk") +} + +// WriteTo outputs the modeenv to the file at /var/lib/snapd/modeenv. +func (m *Modeenv) WriteTo(rootdir string) error { + if snapdenv.Preseeding() { + return fmt.Errorf("internal error: modeenv cannot be written during preseeding") + } + + modeenvPath := modeenvFile(rootdir) + + if err := os.MkdirAll(filepath.Dir(modeenvPath), 0755); err != nil { + return err + } + buf := bytes.NewBuffer(nil) + if m.Mode == "" { + return fmt.Errorf("internal error: mode is unset") + } + marshalModeenvEntryTo(buf, "mode", m.Mode) + marshalModeenvEntryTo(buf, "recovery_system", m.RecoverySystem) + marshalModeenvEntryTo(buf, "current_recovery_systems", m.CurrentRecoverySystems) + marshalModeenvEntryTo(buf, "good_recovery_systems", m.GoodRecoverySystems) + marshalModeenvEntryTo(buf, "boot_flags", m.BootFlags) + marshalModeenvEntryTo(buf, "base", m.Base) + marshalModeenvEntryTo(buf, "try_base", m.TryBase) + marshalModeenvEntryTo(buf, "base_status", m.BaseStatus) + marshalModeenvEntryTo(buf, "gadget", m.Gadget) + marshalModeenvEntryTo(buf, "current_kernels", strings.Join(m.CurrentKernels, ",")) + if m.Model != "" || m.Grade != "" { + if m.Model == "" { + return fmt.Errorf("internal error: model is unset") + } + if m.BrandID == "" { + return fmt.Errorf("internal error: brand is unset") + } + marshalModeenvEntryTo(buf, "model", &modeenvModel{brandID: m.BrandID, model: m.Model}) + } + if m.Classic { + marshalModeenvEntryTo(buf, "classic", true) + } + // TODO: complain when grade or key are unset + marshalModeenvEntryTo(buf, "grade", m.Grade) + marshalModeenvEntryTo(buf, "model_sign_key_id", m.ModelSignKeyID) + if m.TryModel != "" || m.TryGrade != "" { + if m.TryModel == "" { + return fmt.Errorf("internal error: try model is unset") + } + if m.TryBrandID == "" { + return fmt.Errorf("internal error: try brand is unset") + } + marshalModeenvEntryTo(buf, "try_model", &modeenvModel{brandID: m.TryBrandID, model: m.TryModel}) + } + marshalModeenvEntryTo(buf, "try_grade", m.TryGrade) + marshalModeenvEntryTo(buf, "try_model_sign_key_id", m.TryModelSignKeyID) + marshalModeenvEntryTo(buf, "current_trusted_boot_assets", m.CurrentTrustedBootAssets) + marshalModeenvEntryTo(buf, "current_trusted_recovery_boot_assets", m.CurrentTrustedRecoveryBootAssets) + marshalModeenvEntryTo(buf, "current_kernel_command_lines", m.CurrentKernelCommandLines) + + // write all the extra keys at the end + // sort them for test convenience + extraKeys := make([]string, 0, len(m.extrakeys)) + for k := range m.extrakeys { + extraKeys = append(extraKeys, k) + } + sort.Strings(extraKeys) + for _, k := range extraKeys { + marshalModeenvEntryTo(buf, k, m.extrakeys[k]) + } + + if err := osutil.AtomicWriteFile(modeenvPath, buf.Bytes(), 0644, 0); err != nil { + return err + } + return nil +} + +// modelForSealing is a helper type that implements +// github.com/snapcore/secboot.SnapModel interface. +type modelForSealing struct { + brandID string + model string + classic bool + grade asserts.ModelGrade + modelSignKeyID string +} + +// verify interface match +var _ secboot.ModelForSealing = (*modelForSealing)(nil) + +func (m *modelForSealing) BrandID() string { return m.brandID } +func (m *modelForSealing) SignKeyID() string { return m.modelSignKeyID } +func (m *modelForSealing) Model() string { return m.model } +func (m *modelForSealing) Classic() bool { return m.classic } +func (m *modelForSealing) Grade() asserts.ModelGrade { return m.grade } +func (m *modelForSealing) Series() string { return release.Series } + +// modelUniqueID returns a unique ID which can be used as a map index of the +// provided model. +func modelUniqueID(m secboot.ModelForSealing) string { + return fmt.Sprintf("%s/%s,%s,%s", m.BrandID(), m.Model(), m.Grade(), m.SignKeyID()) +} + +// ModelForSealing returns a wrapper implementing +// github.com/snapcore/secboot.SnapModel interface which describes the current +// model. +func (m *Modeenv) ModelForSealing() secboot.ModelForSealing { + return &modelForSealing{ + brandID: m.BrandID, + model: m.Model, + classic: m.Classic, + grade: asserts.ModelGrade(m.Grade), + modelSignKeyID: m.ModelSignKeyID, + } +} + +// TryModelForSealing returns a wrapper implementing +// github.com/snapcore/secboot.SnapModel interface which describes the candidate +// or try model. +func (m *Modeenv) TryModelForSealing() secboot.ModelForSealing { + return &modelForSealing{ + brandID: m.TryBrandID, + model: m.TryModel, + classic: m.Classic, + grade: asserts.ModelGrade(m.TryGrade), + modelSignKeyID: m.TryModelSignKeyID, + } +} + +func (m *Modeenv) setModel(model *asserts.Model) { + m.Model = model.Model() + m.BrandID = model.BrandID() + m.Grade = string(model.Grade()) + m.ModelSignKeyID = model.SignKeyID() +} + +func (m *Modeenv) setTryModel(model *asserts.Model) { + m.TryModel = model.Model() + m.TryBrandID = model.BrandID() + m.TryGrade = string(model.Grade()) + m.TryModelSignKeyID = model.SignKeyID() +} + +func (m *Modeenv) clearTryModel() { + m.TryModel = "" + m.TryBrandID = "" + m.TryGrade = "" + m.TryModelSignKeyID = "" +} + +type modeenvValueMarshaller interface { + MarshalModeenvValue() (string, error) +} + +type modeenvValueUnmarshaller interface { + UnmarshalModeenvValue(value string) error +} + +// marshalModeenvEntryTo marshals to out what as value for an entry +// with the given key. If what is empty this is a no-op. +func marshalModeenvEntryTo(out io.Writer, key string, what interface{}) error { + var asString string + switch v := what.(type) { + case string: + if v == "" { + return nil + } + asString = v + case []string: + if len(v) == 0 { + return nil + } + asString = asModeenvStringList(v) + case bool: + asString = strconv.FormatBool(v) + default: + if vm, ok := what.(modeenvValueMarshaller); ok { + marshalled, err := vm.MarshalModeenvValue() + if err != nil { + return fmt.Errorf("cannot marshal value for key %q: %v", key, err) + } + asString = marshalled + } else if jm, ok := what.(json.Marshaler); ok { + marshalled, err := jm.MarshalJSON() + if err != nil { + return fmt.Errorf("cannot marshal value for key %q as JSON: %v", key, err) + } + asString = string(marshalled) + if asString == "null" { + // no need to keep nulls in the modeenv + return nil + } + } else { + return fmt.Errorf("internal error: cannot marshal unsupported type %T value %v for key %q", what, what, key) + } + } + _, err := fmt.Fprintf(out, "%s=%s\n", key, asString) + return err +} + +// unmarshalModeenvValueFromCfg unmarshals the value of the entry with +// the given key to dest. If there's no such entry dest might be left +// empty. +func unmarshalModeenvValueFromCfg(cfg *goconfigparser.ConfigParser, key string, dest interface{}) error { + if dest == nil { + return fmt.Errorf("internal error: cannot unmarshal to nil") + } + kv, _ := cfg.Get("", key) + + switch v := dest.(type) { + case *string: + *v = kv + case *[]string: + *v = splitModeenvStringList(kv) + case *bool: + if kv == "" { + *v = false + return nil + } + var err error + *v, err = strconv.ParseBool(kv) + if err != nil { + return fmt.Errorf("cannot parse modeenv value %q to bool: %v", kv, err) + } + default: + if vm, ok := v.(modeenvValueUnmarshaller); ok { + if err := vm.UnmarshalModeenvValue(kv); err != nil { + return fmt.Errorf("cannot unmarshal modeenv value %q to %T: %v", kv, dest, err) + } + return nil + } else if jm, ok := v.(json.Unmarshaler); ok { + if len(kv) == 0 { + // leave jm empty + return nil + } + if err := jm.UnmarshalJSON([]byte(kv)); err != nil { + return fmt.Errorf("cannot unmarshal modeenv value %q as JSON to %T: %v", kv, dest, err) + } + return nil + } + return fmt.Errorf("internal error: cannot unmarshal value %q for unsupported type %T", kv, dest) + } + return nil +} + +func splitModeenvStringList(v string) []string { + if v == "" { + return nil + } + split := strings.Split(v, ",") + // drop empty strings + nonEmpty := make([]string, 0, len(split)) + for _, one := range split { + if one != "" { + nonEmpty = append(nonEmpty, one) + } + } + if len(nonEmpty) == 0 { + return nil + } + return nonEmpty +} + +func asModeenvStringList(v []string) string { + return strings.Join(v, ",") +} + +type modeenvModel struct { + brandID, model string +} + +func (m *modeenvModel) MarshalModeenvValue() (string, error) { + return fmt.Sprintf("%s/%s", m.brandID, m.model), nil +} + +func (m *modeenvModel) UnmarshalModeenvValue(brandSlashModel string) error { + if bsmSplit := strings.SplitN(brandSlashModel, "/", 2); len(bsmSplit) == 2 { + if bsmSplit[0] != "" && bsmSplit[1] != "" { + m.brandID = bsmSplit[0] + m.model = bsmSplit[1] + } + } + return nil +} + +func (b bootAssetsMap) MarshalJSON() ([]byte, error) { + asMap := map[string][]string(b) + return json.Marshal(asMap) +} + +func (b *bootAssetsMap) UnmarshalJSON(data []byte) error { + var asMap map[string][]string + if err := json.Unmarshal(data, &asMap); err != nil { + return err + } + *b = bootAssetsMap(asMap) + return nil +} + +func (s bootCommandLines) MarshalJSON() ([]byte, error) { + return json.Marshal([]string(s)) +} + +func (s *bootCommandLines) UnmarshalJSON(data []byte) error { + var asList []string + if err := json.Unmarshal(data, &asList); err != nil { + return err + } + *s = bootCommandLines(asList) + return nil +} diff --git a/boot/modeenv_test.go b/boot/modeenv_test.go new file mode 100644 index 00000000..2281bc22 --- /dev/null +++ b/boot/modeenv_test.go @@ -0,0 +1,976 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2019-2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package boot_test + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/mvo5/goconfigparser" + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/boot" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/snapdenv" + "github.com/snapcore/snapd/testutil" +) + +// baseBootSuite is used to setup the common test environment +type modeenvSuite struct { + testutil.BaseTest + + tmpdir string + mockModeenvPath string +} + +var _ = Suite(&modeenvSuite{}) + +func (s *modeenvSuite) SetUpTest(c *C) { + s.tmpdir = c.MkDir() + s.mockModeenvPath = filepath.Join(s.tmpdir, dirs.SnapModeenvFile) +} + +func (s *modeenvSuite) TestKnownKnown(c *C) { + // double check keys as found with reflect + c.Check(boot.ModeenvKnownKeys, DeepEquals, map[string]bool{ + "mode": true, + "recovery_system": true, + "current_recovery_systems": true, + "good_recovery_systems": true, + "boot_flags": true, + // keep this comment to make old go fmt happy + "base": true, + "gadget": true, + "try_base": true, + "base_status": true, + "current_kernels": true, + "model": true, + "classic": true, + "grade": true, + "model_sign_key_id": true, + "try_model": true, + "try_grade": true, + "try_model_sign_key_id": true, + // keep this comment to make old go fmt happy + "current_kernel_command_lines": true, + "current_trusted_boot_assets": true, + "current_trusted_recovery_boot_assets": true, + }) +} + +func (s *modeenvSuite) TestReadEmptyErrors(c *C) { + modeenv, err := boot.ReadModeenv("/no/such/file") + c.Assert(os.IsNotExist(err), Equals, true) + c.Assert(modeenv, IsNil) +} + +func (s *modeenvSuite) makeMockModeenvFile(c *C, content string) { + err := os.MkdirAll(filepath.Dir(s.mockModeenvPath), 0755) + c.Assert(err, IsNil) + err = os.WriteFile(s.mockModeenvPath, []byte(content), 0644) + c.Assert(err, IsNil) +} + +func (s *modeenvSuite) TestWasReadValidity(c *C) { + modeenv := &boot.Modeenv{} + c.Check(modeenv.WasRead(), Equals, false) +} + +func (s *modeenvSuite) TestReadEmpty(c *C) { + s.makeMockModeenvFile(c, "") + + modeenv, err := boot.ReadModeenv(s.tmpdir) + c.Assert(err, ErrorMatches, "internal error: mode is unset") + c.Assert(modeenv, IsNil) +} + +func (s *modeenvSuite) TestReadMode(c *C) { + s.makeMockModeenvFile(c, "mode=run") + + modeenv, err := boot.ReadModeenv(s.tmpdir) + c.Assert(err, IsNil) + c.Check(modeenv.Mode, Equals, "run") + c.Check(modeenv.RecoverySystem, Equals, "") + c.Check(modeenv.Base, Equals, "") + c.Check(modeenv.Gadget, Equals, "") +} + +func (s *modeenvSuite) TestDeepEqualDiskVsMemoryInvariant(c *C) { + s.makeMockModeenvFile(c, `mode=recovery +recovery_system=20191126 +base=core20_123.snap +gadget=pc_1.snap +try_base=core20_124.snap +base_status=try +`) + + diskModeenv, err := boot.ReadModeenv(s.tmpdir) + c.Assert(err, IsNil) + inMemoryModeenv := &boot.Modeenv{ + Mode: "recovery", + RecoverySystem: "20191126", + Base: "core20_123.snap", + Gadget: "pc_1.snap", + TryBase: "core20_124.snap", + BaseStatus: "try", + } + c.Assert(inMemoryModeenv.DeepEqual(diskModeenv), Equals, true) + c.Assert(diskModeenv.DeepEqual(inMemoryModeenv), Equals, true) +} + +func (s *modeenvSuite) TestCopyDeepEquals(c *C) { + s.makeMockModeenvFile(c, `mode=recovery +recovery_system=20191126 +base=core20_123.snap +try_base=core20_124.snap +base_status=try +current_trusted_boot_assets={"thing1":["hash1","hash2"],"thing2":["hash3"]} +current_kernel_command_lines=["foo", "bar"] +`) + + diskModeenv, err := boot.ReadModeenv(s.tmpdir) + c.Assert(err, IsNil) + inMemoryModeenv := &boot.Modeenv{ + Mode: "recovery", + RecoverySystem: "20191126", + Base: "core20_123.snap", + TryBase: "core20_124.snap", + BaseStatus: "try", + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "thing1": []string{"hash1", "hash2"}, + "thing2": []string{"hash3"}, + }, + CurrentKernelCommandLines: boot.BootCommandLines{ + "foo", "bar", + }, + } + + c.Assert(inMemoryModeenv.DeepEqual(diskModeenv), Equals, true) + c.Assert(diskModeenv.DeepEqual(inMemoryModeenv), Equals, true) + + diskModeenv2, err := diskModeenv.Copy() + c.Assert(err, IsNil) + c.Assert(diskModeenv.DeepEqual(diskModeenv2), Equals, true) + c.Assert(diskModeenv2.DeepEqual(diskModeenv), Equals, true) + c.Assert(inMemoryModeenv.DeepEqual(diskModeenv2), Equals, true) + c.Assert(diskModeenv2.DeepEqual(inMemoryModeenv), Equals, true) + + inMemoryModeenv2, err := inMemoryModeenv.Copy() + c.Assert(err, IsNil) + c.Assert(inMemoryModeenv.DeepEqual(inMemoryModeenv2), Equals, true) + c.Assert(inMemoryModeenv2.DeepEqual(inMemoryModeenv), Equals, true) + c.Assert(inMemoryModeenv2.DeepEqual(diskModeenv), Equals, true) + c.Assert(diskModeenv.DeepEqual(inMemoryModeenv2), Equals, true) +} + +func (s *modeenvSuite) TestCopyDiskWriteWorks(c *C) { + s.makeMockModeenvFile(c, `mode=recovery +recovery_system=20191126 +base=core20_123.snap +try_base=core20_124.snap +base_status=try +`) + + diskModeenv, err := boot.ReadModeenv(s.tmpdir) + c.Assert(err, IsNil) + dupDiskModeenv, err := diskModeenv.Copy() + c.Assert(err, IsNil) + + // move the original file out of the way + err = os.Rename(dirs.SnapModeenvFileUnder(s.tmpdir), dirs.SnapModeenvFileUnder(s.tmpdir)+".orig") + c.Assert(err, IsNil) + c.Assert(dirs.SnapModeenvFileUnder(s.tmpdir), testutil.FileAbsent) + + // write the duplicate, it should write to the same original location and it + // should be the same content + err = dupDiskModeenv.Write() + c.Assert(err, IsNil) + c.Assert(dirs.SnapModeenvFileUnder(s.tmpdir), testutil.FilePresent) + origBytes, err := os.ReadFile(dirs.SnapModeenvFileUnder(s.tmpdir) + ".orig") + c.Assert(err, IsNil) + // the files should be the same + c.Assert(dirs.SnapModeenvFileUnder(s.tmpdir), testutil.FileEquals, string(origBytes)) +} + +func (s *modeenvSuite) TestCopyMemoryWriteFails(c *C) { + inMemoryModeenv := &boot.Modeenv{ + Mode: "recovery", + RecoverySystem: "20191126", + Base: "core20_123.snap", + TryBase: "core20_124.snap", + BaseStatus: "try", + } + dupInMemoryModeenv, err := inMemoryModeenv.Copy() + c.Assert(err, IsNil) + + // write the duplicate, it should fail + err = dupInMemoryModeenv.Write() + c.Assert(err, ErrorMatches, "internal error: must use WriteTo with modeenv not read from disk") +} + +func (s *modeenvSuite) TestDeepEquals(c *C) { + // start with two identical modeenvs + modeenv1 := &boot.Modeenv{ + Mode: "recovery", + RecoverySystem: "20191126", + CurrentRecoverySystems: []string{"1", "2"}, + GoodRecoverySystems: []string{"3"}, + + Base: "core20_123.snap", + TryBase: "core20_124.snap", + BaseStatus: "try", + CurrentKernels: []string{"k1", "k2"}, + + Model: "model", + BrandID: "brand", + Grade: "secured", + ModelSignKeyID: "9tydnLa6MTJ-jaQTFUXEwHl1yRx7ZS4K5cyFDhYDcPzhS7uyEkDxdUjg9g08BtNn", + + BootFlags: []string{"foo", "factory"}, + + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "thing1": []string{"hash1", "hash2"}, + "thing2": []string{"hash3"}, + }, + + CurrentKernelCommandLines: boot.BootCommandLines{ + "foo", + "foo bar", + }, + } + + modeenv2 := &boot.Modeenv{ + Mode: "recovery", + RecoverySystem: "20191126", + CurrentRecoverySystems: []string{"1", "2"}, + GoodRecoverySystems: []string{"3"}, + + Base: "core20_123.snap", + TryBase: "core20_124.snap", + BaseStatus: "try", + CurrentKernels: []string{"k1", "k2"}, + + Model: "model", + BrandID: "brand", + Grade: "secured", + ModelSignKeyID: "9tydnLa6MTJ-jaQTFUXEwHl1yRx7ZS4K5cyFDhYDcPzhS7uyEkDxdUjg9g08BtNn", + + BootFlags: []string{"foo", "factory"}, + + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "thing1": []string{"hash1", "hash2"}, + "thing2": []string{"hash3"}, + }, + + CurrentKernelCommandLines: boot.BootCommandLines{ + "foo", + "foo bar", + }, + } + + // same object should be the same + c.Assert(modeenv1.DeepEqual(modeenv1), Equals, true) + + // no difference should be the same at the start + c.Assert(modeenv1.DeepEqual(modeenv2), Equals, true) + c.Assert(modeenv2.DeepEqual(modeenv1), Equals, true) + + // invert CurrentKernels + modeenv2.CurrentKernels = []string{"k2", "k1"} + c.Assert(modeenv1.DeepEqual(modeenv2), Equals, false) + c.Assert(modeenv2.DeepEqual(modeenv1), Equals, false) + + // make CurrentKernels capitalized + modeenv2.CurrentKernels = []string{"K1", "k2"} + c.Assert(modeenv1.DeepEqual(modeenv2), Equals, false) + c.Assert(modeenv2.DeepEqual(modeenv1), Equals, false) + + // make CurrentKernels disappear + modeenv2.CurrentKernels = nil + c.Assert(modeenv1.DeepEqual(modeenv2), Equals, false) + c.Assert(modeenv2.DeepEqual(modeenv1), Equals, false) + + // make it identical again + modeenv2.CurrentKernels = []string{"k1", "k2"} + c.Assert(modeenv1.DeepEqual(modeenv2), Equals, true) + // change kernel command lines + modeenv2.CurrentKernelCommandLines = boot.BootCommandLines{ + // reversed order + "foo bar", + "foo", + } + c.Assert(modeenv1.DeepEqual(modeenv2), Equals, false) + // clear kernel command lines list + modeenv2.CurrentKernelCommandLines = nil + c.Assert(modeenv1.DeepEqual(modeenv2), Equals, false) + + // make it identical again + modeenv2.CurrentKernelCommandLines = boot.BootCommandLines{ + "foo", + "foo bar", + } + c.Assert(modeenv1.DeepEqual(modeenv2), Equals, true) + + // change the list of current recovery systems + modeenv2.CurrentRecoverySystems = append(modeenv2.CurrentRecoverySystems, "1234") + c.Assert(modeenv1.DeepEqual(modeenv2), Equals, false) + // make it identical again + modeenv2.CurrentRecoverySystems = []string{"1", "2"} + c.Assert(modeenv1.DeepEqual(modeenv2), Equals, true) + + // change the list of good recovery systems + modeenv2.GoodRecoverySystems = append(modeenv2.GoodRecoverySystems, "999") + c.Assert(modeenv1.DeepEqual(modeenv2), Equals, false) + // restore it + modeenv2.GoodRecoverySystems = modeenv2.GoodRecoverySystems[:len(modeenv2.GoodRecoverySystems)-1] + + // change the sign key ID + modeenv2.ModelSignKeyID = "EAD4DbLxK_kn0gzNCXOs3kd6DeMU3f-L6BEsSEuJGBqCORR0gXkdDxMbOm11mRFu" + c.Assert(modeenv1.DeepEqual(modeenv2), Equals, false) +} + +func (s *modeenvSuite) TestReadModeWithRecoverySystem(c *C) { + s.makeMockModeenvFile(c, `mode=recovery +recovery_system=20191126 +`) + + modeenv, err := boot.ReadModeenv(s.tmpdir) + c.Assert(err, IsNil) + c.Check(modeenv.Mode, Equals, "recovery") + c.Check(modeenv.RecoverySystem, Equals, "20191126") +} + +func (s *modeenvSuite) TestReadModeenvWithUnknownKeysKeepsWrites(c *C) { + s.makeMockModeenvFile(c, `first_unknown=thing +mode=recovery +recovery_system=20191126 +unknown_key=some unknown value +a_key=other +`) + + modeenv, err := boot.ReadModeenv(s.tmpdir) + c.Assert(err, IsNil) + c.Check(modeenv.Mode, Equals, "recovery") + c.Check(modeenv.RecoverySystem, Equals, "20191126") + + c.Assert(modeenv.Write(), IsNil) + + c.Assert(s.mockModeenvPath, testutil.FileEquals, `mode=recovery +recovery_system=20191126 +a_key=other +first_unknown=thing +unknown_key=some unknown value +`) +} + +func (s *modeenvSuite) TestReadModeenvWithUnknownKeysDeepEqualsSameWithoutUnknownKeys(c *C) { + s.makeMockModeenvFile(c, `first_unknown=thing +mode=recovery +recovery_system=20191126 +try_base=core20_124.snap +base_status=try +unknown_key=some unknown value +current_trusted_boot_assets={"grubx64.efi":["hash1","hash2"]} +current_trusted_recovery_boot_assets={"bootx64.efi":["shimhash1","shimhash2"],"grubx64.efi":["recovery-hash1"]} +a_key=other +`) + + modeenvWithExtraKeys, err := boot.ReadModeenv(s.tmpdir) + c.Assert(err, IsNil) + c.Check(modeenvWithExtraKeys.Mode, Equals, "recovery") + c.Check(modeenvWithExtraKeys.RecoverySystem, Equals, "20191126") + + // should be the same as one that with just those keys in memory + c.Assert(modeenvWithExtraKeys.DeepEqual(&boot.Modeenv{ + Mode: "recovery", + RecoverySystem: "20191126", + TryBase: "core20_124.snap", + BaseStatus: boot.TryStatus, + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "grubx64.efi": []string{"hash1", "hash2"}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "bootx64.efi": []string{"shimhash1", "shimhash2"}, + "grubx64.efi": []string{"recovery-hash1"}, + }, + }), Equals, true) +} + +func (s *modeenvSuite) TestReadModeWithBase(c *C) { + s.makeMockModeenvFile(c, `mode=recovery +recovery_system=20191126 +base=core20_123.snap +try_base=core20_124.snap +base_status=try +`) + + modeenv, err := boot.ReadModeenv(s.tmpdir) + c.Assert(err, IsNil) + c.Check(modeenv.Mode, Equals, "recovery") + c.Check(modeenv.RecoverySystem, Equals, "20191126") + c.Check(modeenv.Base, Equals, "core20_123.snap") + c.Check(modeenv.TryBase, Equals, "core20_124.snap") + c.Check(modeenv.BaseStatus, Equals, boot.TryStatus) +} + +func (s *modeenvSuite) TestReadModeWithGrade(c *C) { + s.makeMockModeenvFile(c, `mode=run +grade=dangerous +`) + modeenv, err := boot.ReadModeenv(s.tmpdir) + c.Assert(err, IsNil) + c.Check(modeenv.Mode, Equals, "run") + c.Check(modeenv.Grade, Equals, "dangerous") + + s.makeMockModeenvFile(c, `mode=run +grade=some-random-grade-string +`) + modeenv, err = boot.ReadModeenv(s.tmpdir) + c.Assert(err, IsNil) + c.Check(modeenv.Mode, Equals, "run") + c.Check(modeenv.Grade, Equals, "some-random-grade-string") +} + +func (s *modeenvSuite) TestReadModeWithModel(c *C) { + tt := []struct { + entry string + model, brand string + }{ + { + entry: "my-brand/my-model", + brand: "my-brand", + model: "my-model", + }, { + entry: "my-brand/", + }, { + entry: "my-model/", + }, { + entry: "foobar", + }, { + entry: "/", + }, { + entry: ",", + }, { + entry: "", + }, + } + + for _, t := range tt { + s.makeMockModeenvFile(c, `mode=run +model=`+t.entry+"\n") + modeenv, err := boot.ReadModeenv(s.tmpdir) + c.Assert(err, IsNil) + c.Check(modeenv.Mode, Equals, "run") + c.Check(modeenv.Model, Equals, t.model) + c.Check(modeenv.BrandID, Equals, t.brand) + } +} + +func (s *modeenvSuite) TestReadModeWithCurrentKernels(c *C) { + + tt := []struct { + kernelString string + expectedKernels []string + }{ + { + "pc-kernel_1.snap", + []string{"pc-kernel_1.snap"}, + }, + { + "pc-kernel_1.snap,pc-kernel_2.snap", + []string{"pc-kernel_1.snap", "pc-kernel_2.snap"}, + }, + { + "pc-kernel_1.snap,,,,,pc-kernel_2.snap", + []string{"pc-kernel_1.snap", "pc-kernel_2.snap"}, + }, + // we should be robust in parsing the modeenv against garbage + { + `pc-kernel_1.snap,this-is-not-a-real-snap$%^&^%$#@#$%^%"$,pc-kernel_2.snap`, + []string{"pc-kernel_1.snap", `this-is-not-a-real-snap$%^&^%$#@#$%^%"$`, "pc-kernel_2.snap"}, + }, + {",,,", nil}, + {"", nil}, + } + + for _, t := range tt { + s.makeMockModeenvFile(c, `mode=recovery +recovery_system=20191126 +current_kernels=`+t.kernelString+"\n") + + modeenv, err := boot.ReadModeenv(s.tmpdir) + c.Assert(err, IsNil) + c.Check(modeenv.Mode, Equals, "recovery") + c.Check(modeenv.RecoverySystem, Equals, "20191126") + c.Check(len(modeenv.CurrentKernels), Equals, len(t.expectedKernels)) + if len(t.expectedKernels) != 0 { + c.Check(modeenv.CurrentKernels, DeepEquals, t.expectedKernels) + } + } +} + +func (s *modeenvSuite) TestWriteToNonExisting(c *C) { + c.Assert(s.mockModeenvPath, testutil.FileAbsent) + + modeenv := &boot.Modeenv{Mode: "run"} + err := modeenv.WriteTo(s.tmpdir) + c.Assert(err, IsNil) + + c.Assert(s.mockModeenvPath, testutil.FileEquals, "mode=run\n") +} + +func (s *modeenvSuite) TestWriteToExisting(c *C) { + s.makeMockModeenvFile(c, "mode=run") + + modeenv, err := boot.ReadModeenv(s.tmpdir) + c.Assert(err, IsNil) + modeenv.Mode = "recovery" + err = modeenv.WriteTo(s.tmpdir) + c.Assert(err, IsNil) + + c.Assert(s.mockModeenvPath, testutil.FileEquals, "mode=recovery\n") +} + +func (s *modeenvSuite) TestWriteExisting(c *C) { + s.makeMockModeenvFile(c, "mode=run") + + modeenv, err := boot.ReadModeenv(s.tmpdir) + c.Assert(err, IsNil) + modeenv.Mode = "recovery" + err = modeenv.Write() + c.Assert(err, IsNil) + + c.Assert(s.mockModeenvPath, testutil.FileEquals, "mode=recovery\n") +} + +func (s *modeenvSuite) TestWriteFreshError(c *C) { + modeenv := &boot.Modeenv{Mode: "recovery"} + + err := modeenv.Write() + c.Assert(err, ErrorMatches, `internal error: must use WriteTo with modeenv not read from disk`) +} + +func (s *modeenvSuite) TestWriteIncompleteModelBrand(c *C) { + modeenv := &boot.Modeenv{ + Mode: "run", + Grade: "dangerous", + } + + err := modeenv.WriteTo(s.tmpdir) + c.Assert(err, ErrorMatches, `internal error: model is unset`) + + modeenv.Model = "bar" + err = modeenv.WriteTo(s.tmpdir) + c.Assert(err, ErrorMatches, `internal error: brand is unset`) + + modeenv.BrandID = "foo" + modeenv.TryGrade = "dangerous" + err = modeenv.WriteTo(s.tmpdir) + c.Assert(err, ErrorMatches, `internal error: try model is unset`) + + modeenv.TryModel = "bar" + err = modeenv.WriteTo(s.tmpdir) + c.Assert(err, ErrorMatches, `internal error: try brand is unset`) + + modeenv.TryBrandID = "foo" + err = modeenv.WriteTo(s.tmpdir) + c.Assert(err, IsNil) +} + +func (s *modeenvSuite) TestWriteToNonExistingFull(c *C) { + c.Assert(s.mockModeenvPath, testutil.FileAbsent) + + modeenv := &boot.Modeenv{ + Mode: "run", + RecoverySystem: "20191128", + CurrentRecoverySystems: []string{"20191128", "2020-02-03", "20240101-FOO"}, + // keep this comment to make gofmt 1.9 happy + Base: "core20_321.snap", + TryBase: "core20_322.snap", + BaseStatus: boot.TryStatus, + CurrentKernels: []string{"pc-kernel_1.snap", "pc-kernel_2.snap"}, + } + err := modeenv.WriteTo(s.tmpdir) + c.Assert(err, IsNil) + + c.Assert(s.mockModeenvPath, testutil.FileEquals, `mode=run +recovery_system=20191128 +current_recovery_systems=20191128,2020-02-03,20240101-FOO +base=core20_321.snap +try_base=core20_322.snap +base_status=try +current_kernels=pc-kernel_1.snap,pc-kernel_2.snap +`) +} + +func (s *modeenvSuite) TestReadRecoverySystems(c *C) { + tt := []struct { + systemsString string + expectedSystems []string + }{ + { + "20191126", + []string{"20191126"}, + }, { + "20191128,2020-02-03,20240101-FOO", + []string{"20191128", "2020-02-03", "20240101-FOO"}, + }, + {",,,", nil}, + {"", nil}, + } + + for _, t := range tt { + c.Logf("tc: %q", t.systemsString) + s.makeMockModeenvFile(c, fmt.Sprintf(`mode=recovery +recovery_system=20191126 +current_recovery_systems=%[1]s +good_recovery_systems=%[1]s +`, t.systemsString)) + + modeenv, err := boot.ReadModeenv(s.tmpdir) + c.Assert(err, IsNil) + c.Check(modeenv.Mode, Equals, "recovery") + c.Check(modeenv.RecoverySystem, Equals, "20191126") + c.Check(modeenv.CurrentRecoverySystems, DeepEquals, t.expectedSystems) + c.Check(modeenv.GoodRecoverySystems, DeepEquals, t.expectedSystems) + } +} + +type fancyDataBothMarshallers struct { + Foo []string +} + +func (f *fancyDataBothMarshallers) MarshalModeenvValue() (string, error) { + return strings.Join(f.Foo, "#"), nil +} + +func (f *fancyDataBothMarshallers) UnmarshalModeenvValue(v string) error { + f.Foo = strings.Split(v, "#") + return nil +} + +func (f *fancyDataBothMarshallers) MarshalJSON() ([]byte, error) { + return nil, fmt.Errorf("unexpected call to JSON marshaller") +} + +func (f *fancyDataBothMarshallers) UnmarshalJSON(data []byte) error { + return fmt.Errorf("unexpected call to JSON unmarshaller") +} + +type fancyDataJSONOnly struct { + Foo []string +} + +func (f *fancyDataJSONOnly) MarshalJSON() ([]byte, error) { + return json.Marshal(f.Foo) +} + +func (f *fancyDataJSONOnly) UnmarshalJSON(data []byte) error { + return json.Unmarshal(data, &f.Foo) +} + +func (s *modeenvSuite) TestFancyMarshalUnmarshal(c *C) { + var buf bytes.Buffer + + dboth := fancyDataBothMarshallers{Foo: []string{"1", "two"}} + err := boot.MarshalModeenvEntryTo(&buf, "fancy", &dboth) + c.Assert(err, IsNil) + c.Check(buf.String(), Equals, `fancy=1#two +`) + + djson := fancyDataJSONOnly{Foo: []string{"1", "two", "with\nnewline"}} + err = boot.MarshalModeenvEntryTo(&buf, "fancy_json", &djson) + c.Assert(err, IsNil) + c.Check(buf.String(), Equals, `fancy=1#two +fancy_json=["1","two","with\nnewline"] +`) + + cfg := goconfigparser.New() + cfg.AllowNoSectionHeader = true + err = cfg.Read(&buf) + c.Assert(err, IsNil) + + var dbothRev fancyDataBothMarshallers + err = boot.UnmarshalModeenvValueFromCfg(cfg, "fancy", &dbothRev) + c.Assert(err, IsNil) + c.Check(dbothRev, DeepEquals, dboth) + + var djsonRev fancyDataJSONOnly + err = boot.UnmarshalModeenvValueFromCfg(cfg, "fancy_json", &djsonRev) + c.Assert(err, IsNil) + c.Check(djsonRev, DeepEquals, djson) +} + +func (s *modeenvSuite) TestFancyUnmarshalJSONEmpty(c *C) { + var buf bytes.Buffer + + cfg := goconfigparser.New() + cfg.AllowNoSectionHeader = true + err := cfg.Read(&buf) + c.Assert(err, IsNil) + + var djsonRev fancyDataJSONOnly + err = boot.UnmarshalModeenvValueFromCfg(cfg, "fancy_json", &djsonRev) + c.Assert(err, IsNil) + c.Check(djsonRev.Foo, IsNil) +} + +func (s *modeenvSuite) TestMarshalCurrentTrustedBootAssets(c *C) { + c.Assert(s.mockModeenvPath, testutil.FileAbsent) + + modeenv := &boot.Modeenv{ + Mode: "run", + RecoverySystem: "20191128", + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "grubx64.efi": []string{"hash1", "hash2"}, + }, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "grubx64.efi": []string{"recovery-hash1"}, + "bootx64.efi": []string{"shimhash1", "shimhash2"}, + }, + } + err := modeenv.WriteTo(s.tmpdir) + c.Assert(err, IsNil) + + c.Assert(s.mockModeenvPath, testutil.FileEquals, `mode=run +recovery_system=20191128 +current_trusted_boot_assets={"grubx64.efi":["hash1","hash2"]} +current_trusted_recovery_boot_assets={"bootx64.efi":["shimhash1","shimhash2"],"grubx64.efi":["recovery-hash1"]} +`) + + modeenvRead, err := boot.ReadModeenv(s.tmpdir) + c.Assert(err, IsNil) + c.Assert(modeenvRead.CurrentTrustedBootAssets, DeepEquals, boot.BootAssetsMap{ + "grubx64.efi": []string{"hash1", "hash2"}, + }) + c.Assert(modeenvRead.CurrentTrustedRecoveryBootAssets, DeepEquals, boot.BootAssetsMap{ + "grubx64.efi": []string{"recovery-hash1"}, + "bootx64.efi": []string{"shimhash1", "shimhash2"}, + }) +} + +func (s *modeenvSuite) TestMarshalKernelCommandLines(c *C) { + c.Assert(s.mockModeenvPath, testutil.FileAbsent) + + modeenv := &boot.Modeenv{ + Mode: "run", + RecoverySystem: "20191128", + CurrentKernelCommandLines: boot.BootCommandLines{ + `snapd_recovery_mode=run panic=-1 console=ttyS0,io,9600n8`, + `snapd_recovery_mode=run candidate panic=-1 console=ttyS0,io,9600n8`, + }, + } + err := modeenv.WriteTo(s.tmpdir) + c.Assert(err, IsNil) + + c.Assert(s.mockModeenvPath, testutil.FileEquals, `mode=run +recovery_system=20191128 +current_kernel_command_lines=["snapd_recovery_mode=run panic=-1 console=ttyS0,io,9600n8","snapd_recovery_mode=run candidate panic=-1 console=ttyS0,io,9600n8"] +`) + + modeenvRead, err := boot.ReadModeenv(s.tmpdir) + c.Assert(err, IsNil) + c.Assert(modeenvRead.CurrentKernelCommandLines, DeepEquals, boot.BootCommandLines{ + `snapd_recovery_mode=run panic=-1 console=ttyS0,io,9600n8`, + `snapd_recovery_mode=run candidate panic=-1 console=ttyS0,io,9600n8`, + }) +} + +func (s *modeenvSuite) TestModeenvWithModelGradeSignKeyID(c *C) { + s.makeMockModeenvFile(c, `mode=run +model=canonical/ubuntu-core-20-amd64 +grade=dangerous +model_sign_key_id=9tydnLa6MTJ-jaQTFUXEwHl1yRx7ZS4K5cyFDhYDcPzhS7uyEkDxdUjg9g08BtNn +try_model=developer1/testkeys-snapd-secured-core-20-amd64 +try_grade=secured +try_model_sign_key_id=EAD4DbLxK_kn0gzNCXOs3kd6DeMU3f-L6BEsSEuJGBqCORR0gXkdDxMbOm11mRFu +`) + + modeenv, err := boot.ReadModeenv(s.tmpdir) + c.Assert(err, IsNil) + c.Check(modeenv.Model, Equals, "ubuntu-core-20-amd64") + c.Check(modeenv.BrandID, Equals, "canonical") + c.Check(modeenv.Classic, Equals, false) + c.Check(modeenv.Grade, Equals, "dangerous") + c.Check(modeenv.ModelSignKeyID, Equals, "9tydnLa6MTJ-jaQTFUXEwHl1yRx7ZS4K5cyFDhYDcPzhS7uyEkDxdUjg9g08BtNn") + // candidate model + c.Check(modeenv.TryModel, Equals, "testkeys-snapd-secured-core-20-amd64") + c.Check(modeenv.TryBrandID, Equals, "developer1") + c.Check(modeenv.TryGrade, Equals, "secured") + c.Check(modeenv.TryModelSignKeyID, Equals, "EAD4DbLxK_kn0gzNCXOs3kd6DeMU3f-L6BEsSEuJGBqCORR0gXkdDxMbOm11mRFu") + + // change some model data now + modeenv.Model = "testkeys-snapd-signed-core-20-amd64" + modeenv.BrandID = "developer1" + modeenv.Grade = "signed" + modeenv.ModelSignKeyID = "EAD4DbLxK_kn0gzNCXOs3kd6DeMU3f-L6BEsSEuJGBqCORR0gXkdDxMbOm11mRFu" + + modeenv.TryModel = "bar" + modeenv.TryBrandID = "foo" + modeenv.TryGrade = "dangerous" + modeenv.TryModelSignKeyID = "9tydnLa6MTJ-jaQTFUXEwHl1yRx7ZS4K5cyFDhYDcPzhS7uyEkDxdUjg9g08BtNn" + + // and write it + c.Assert(modeenv.Write(), IsNil) + + c.Assert(s.mockModeenvPath, testutil.FileEquals, `mode=run +model=developer1/testkeys-snapd-signed-core-20-amd64 +grade=signed +model_sign_key_id=EAD4DbLxK_kn0gzNCXOs3kd6DeMU3f-L6BEsSEuJGBqCORR0gXkdDxMbOm11mRFu +try_model=foo/bar +try_grade=dangerous +try_model_sign_key_id=9tydnLa6MTJ-jaQTFUXEwHl1yRx7ZS4K5cyFDhYDcPzhS7uyEkDxdUjg9g08BtNn +`) +} + +func (s *modeenvSuite) TestModeenvWithClassicModelGradeSignKeyID(c *C) { + s.makeMockModeenvFile(c, `mode=run +model=canonical/ubuntu-classic-20-amd64 +grade=dangerous +classic=true +model_sign_key_id=9tydnLa6MTJ-jaQTFUXEwHl1yRx7ZS4K5cyFDhYDcPzhS7uyEkDxdUjg9g08BtNn +try_model=developer1/testkeys-snapd-secured-classic-20-amd64 +try_grade=secured +try_model_sign_key_id=EAD4DbLxK_kn0gzNCXOs3kd6DeMU3f-L6BEsSEuJGBqCORR0gXkdDxMbOm11mRFu +`) + + modeenv, err := boot.ReadModeenv(s.tmpdir) + c.Assert(err, IsNil) + c.Check(modeenv.Model, Equals, "ubuntu-classic-20-amd64") + c.Check(modeenv.BrandID, Equals, "canonical") + c.Check(modeenv.Classic, Equals, true) + c.Check(modeenv.Grade, Equals, "dangerous") + c.Check(modeenv.ModelSignKeyID, Equals, "9tydnLa6MTJ-jaQTFUXEwHl1yRx7ZS4K5cyFDhYDcPzhS7uyEkDxdUjg9g08BtNn") + // candidate model + c.Check(modeenv.TryModel, Equals, "testkeys-snapd-secured-classic-20-amd64") + c.Check(modeenv.TryBrandID, Equals, "developer1") + c.Check(modeenv.TryGrade, Equals, "secured") + c.Check(modeenv.TryModelSignKeyID, Equals, "EAD4DbLxK_kn0gzNCXOs3kd6DeMU3f-L6BEsSEuJGBqCORR0gXkdDxMbOm11mRFu") + + // change some model data now + modeenv.Model = "testkeys-snapd-signed-classic-20-amd64" + modeenv.BrandID = "developer1" + modeenv.Grade = "signed" + modeenv.ModelSignKeyID = "EAD4DbLxK_kn0gzNCXOs3kd6DeMU3f-L6BEsSEuJGBqCORR0gXkdDxMbOm11mRFu" + + modeenv.TryModel = "bar" + modeenv.TryBrandID = "foo" + modeenv.TryGrade = "dangerous" + modeenv.TryModelSignKeyID = "9tydnLa6MTJ-jaQTFUXEwHl1yRx7ZS4K5cyFDhYDcPzhS7uyEkDxdUjg9g08BtNn" + + // and write it + c.Assert(modeenv.Write(), IsNil) + + c.Assert(s.mockModeenvPath, testutil.FileEquals, `mode=run +model=developer1/testkeys-snapd-signed-classic-20-amd64 +classic=true +grade=signed +model_sign_key_id=EAD4DbLxK_kn0gzNCXOs3kd6DeMU3f-L6BEsSEuJGBqCORR0gXkdDxMbOm11mRFu +try_model=foo/bar +try_grade=dangerous +try_model_sign_key_id=9tydnLa6MTJ-jaQTFUXEwHl1yRx7ZS4K5cyFDhYDcPzhS7uyEkDxdUjg9g08BtNn +`) +} + +func (s *modeenvSuite) TestModelForSealing(c *C) { + s.makeMockModeenvFile(c, `mode=run +model=canonical/ubuntu-core-20-amd64 +grade=dangerous +model_sign_key_id=9tydnLa6MTJ-jaQTFUXEwHl1yRx7ZS4K5cyFDhYDcPzhS7uyEkDxdUjg9g08BtNn +try_model=developer1/testkeys-snapd-secured-core-20-amd64 +try_grade=secured +try_model_sign_key_id=EAD4DbLxK_kn0gzNCXOs3kd6DeMU3f-L6BEsSEuJGBqCORR0gXkdDxMbOm11mRFu +`) + + modeenv, err := boot.ReadModeenv(s.tmpdir) + c.Assert(err, IsNil) + + modelForSealing := modeenv.ModelForSealing() + c.Check(modelForSealing.Model(), Equals, "ubuntu-core-20-amd64") + c.Check(modelForSealing.BrandID(), Equals, "canonical") + c.Check(modelForSealing.Classic(), Equals, false) + c.Check(modelForSealing.Grade(), Equals, asserts.ModelGrade("dangerous")) + c.Check(modelForSealing.SignKeyID(), Equals, "9tydnLa6MTJ-jaQTFUXEwHl1yRx7ZS4K5cyFDhYDcPzhS7uyEkDxdUjg9g08BtNn") + c.Check(modelForSealing.Series(), Equals, "16") + c.Check(boot.ModelUniqueID(modelForSealing), Equals, + "canonical/ubuntu-core-20-amd64,dangerous,9tydnLa6MTJ-jaQTFUXEwHl1yRx7ZS4K5cyFDhYDcPzhS7uyEkDxdUjg9g08BtNn") + + tryModelForSealing := modeenv.TryModelForSealing() + c.Check(tryModelForSealing.Model(), Equals, "testkeys-snapd-secured-core-20-amd64") + c.Check(tryModelForSealing.BrandID(), Equals, "developer1") + c.Check(tryModelForSealing.Classic(), Equals, false) + c.Check(tryModelForSealing.Grade(), Equals, asserts.ModelGrade("secured")) + c.Check(tryModelForSealing.SignKeyID(), Equals, "EAD4DbLxK_kn0gzNCXOs3kd6DeMU3f-L6BEsSEuJGBqCORR0gXkdDxMbOm11mRFu") + c.Check(tryModelForSealing.Series(), Equals, "16") + c.Check(boot.ModelUniqueID(tryModelForSealing), Equals, + "developer1/testkeys-snapd-secured-core-20-amd64,secured,EAD4DbLxK_kn0gzNCXOs3kd6DeMU3f-L6BEsSEuJGBqCORR0gXkdDxMbOm11mRFu") +} + +func (s *modeenvSuite) TestClassicModelForSealing(c *C) { + s.makeMockModeenvFile(c, `mode=run +model=canonical/ubuntu-core-20-amd64 +classic=true +grade=dangerous +model_sign_key_id=9tydnLa6MTJ-jaQTFUXEwHl1yRx7ZS4K5cyFDhYDcPzhS7uyEkDxdUjg9g08BtNn +try_model=developer1/testkeys-snapd-secured-core-20-amd64 +try_grade=secured +try_model_sign_key_id=EAD4DbLxK_kn0gzNCXOs3kd6DeMU3f-L6BEsSEuJGBqCORR0gXkdDxMbOm11mRFu +`) + + modeenv, err := boot.ReadModeenv(s.tmpdir) + c.Assert(err, IsNil) + + modelForSealing := modeenv.ModelForSealing() + c.Check(modelForSealing.Model(), Equals, "ubuntu-core-20-amd64") + c.Check(modelForSealing.BrandID(), Equals, "canonical") + c.Check(modelForSealing.Classic(), Equals, true) + c.Check(modelForSealing.Grade(), Equals, asserts.ModelGrade("dangerous")) + c.Check(modelForSealing.SignKeyID(), Equals, "9tydnLa6MTJ-jaQTFUXEwHl1yRx7ZS4K5cyFDhYDcPzhS7uyEkDxdUjg9g08BtNn") + c.Check(modelForSealing.Series(), Equals, "16") + c.Check(boot.ModelUniqueID(modelForSealing), Equals, + "canonical/ubuntu-core-20-amd64,dangerous,9tydnLa6MTJ-jaQTFUXEwHl1yRx7ZS4K5cyFDhYDcPzhS7uyEkDxdUjg9g08BtNn") + + tryModelForSealing := modeenv.TryModelForSealing() + c.Check(tryModelForSealing.Model(), Equals, "testkeys-snapd-secured-core-20-amd64") + c.Check(tryModelForSealing.BrandID(), Equals, "developer1") + c.Check(tryModelForSealing.Classic(), Equals, true) + c.Check(tryModelForSealing.Grade(), Equals, asserts.ModelGrade("secured")) + c.Check(tryModelForSealing.SignKeyID(), Equals, "EAD4DbLxK_kn0gzNCXOs3kd6DeMU3f-L6BEsSEuJGBqCORR0gXkdDxMbOm11mRFu") + c.Check(tryModelForSealing.Series(), Equals, "16") + c.Check(boot.ModelUniqueID(tryModelForSealing), Equals, + "developer1/testkeys-snapd-secured-core-20-amd64,secured,EAD4DbLxK_kn0gzNCXOs3kd6DeMU3f-L6BEsSEuJGBqCORR0gXkdDxMbOm11mRFu") +} + +func (s *modeenvSuite) TestModeenvAccessFailsDuringPreseeding(c *C) { + restore := snapdenv.MockPreseeding(true) + defer restore() + + _, err := boot.ReadModeenv(s.tmpdir) + c.Assert(err, ErrorMatches, `internal error: modeenv cannot be read during preseeding`) + + var modeenv boot.Modeenv + err = modeenv.WriteTo(s.tmpdir) + c.Assert(err, ErrorMatches, `internal error: modeenv cannot be written during preseeding`) +} diff --git a/boot/model.go b/boot/model.go new file mode 100644 index 00000000..e10b6637 --- /dev/null +++ b/boot/model.go @@ -0,0 +1,160 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2021 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package boot + +import ( + "fmt" + "path/filepath" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/snap" +) + +// DeviceChange handles a change of the underlying device. Specifically it can +// be used during remodel when a new device is associated with a new model. The +// encryption keys will be resealed for both models. The device model file which +// is measured during boot will be updated. The recovery systems that belong to +// the old model will no longer be usable. +func DeviceChange(from snap.Device, to snap.Device, unlocker Unlocker) error { + if !to.HasModeenv() { + // nothing useful happens on a non-UC20 system here + return nil + } + modeenvLock() + defer modeenvUnlock() + + m, err := loadModeenv() + if err != nil { + return err + } + + newModel := to.Model() + oldModel := from.Model() + modified := false + if modelUniqueID(m.TryModelForSealing()) != modelUniqueID(newModel) { + // we either haven't been here yet, or a reboot occurred after + // try model was cleared and modeenv was rewritten + m.setTryModel(newModel) + modified = true + } + if modelUniqueID(m.ModelForSealing()) != modelUniqueID(oldModel) { + // a modeenv with new model was already written, restore + // the 'expected' original state, the model file on disk + // will match one of the models + m.setModel(oldModel) + modified = true + } + if modified { + if err := m.Write(); err != nil { + return err + } + } + + // reseal with both models now, such that we'd still be able to boot + // even if there is a reboot before the device/model file is updated, or + // before the final reseal with one model + const expectReseal = true + if err := resealKeyToModeenv(dirs.GlobalRootDir, m, expectReseal, unlocker); err != nil { + // best effort clear the modeenv's try model + m.clearTryModel() + if mErr := m.Write(); mErr != nil { + return fmt.Errorf("%v (restoring modeenv failed: %v)", err, mErr) + } + return err + } + + // update the device model file in boot (we may be overwriting the same + // model file if we reached this place before a reboot has occurred) + if err := writeModelToUbuntuBoot(to.Model()); err != nil { + err = fmt.Errorf("cannot write new model file: %v", err) + // the file has not been modified, so just clear the try model + m.clearTryModel() + if mErr := m.Write(); mErr != nil { + return fmt.Errorf("%v (restoring modeenv failed: %v)", err, mErr) + } + return err + } + + // now we can update the model to the new one + m.setModel(newModel) + // and clear the try model + m.clearTryModel() + + if err := m.Write(); err != nil { + // modeenv has not been written and still contains both the old + // and a new model, but the model file has been modified, + // restore the original model file + if restoreErr := writeModelToUbuntuBoot(from.Model()); restoreErr != nil { + return fmt.Errorf("%v (restoring model failed: %v)", err, restoreErr) + } + // however writing modeenv failed, so trying to clear the model + // and write it again could be pointless, let the failure + // percolate up the stack + return err + } + + // past a successful reseal, the old recovery systems become unusable and will + // not be able to access the data anymore + if err := resealKeyToModeenv(dirs.GlobalRootDir, m, expectReseal, unlocker); err != nil { + // resealing failed, but modeenv and the file have been modified + + // first restore the modeenv in case we reboot, such that if the + // post reboot code reseals, it will allow both models (in case + // even more reboots occur) + m.setModel(from.Model()) + m.setTryModel(newModel) + if mErr := m.Write(); mErr != nil { + return fmt.Errorf("%v (writing modeenv failed: %v)", err, mErr) + } + + // restore the original model file (we have resealed for both + // models previously) + if restoreErr := writeModelToUbuntuBoot(from.Model()); restoreErr != nil { + return fmt.Errorf("%v (restoring model failed: %v)", err, restoreErr) + } + + // drop the tried model + m.clearTryModel() + if mErr := m.Write(); mErr != nil { + return fmt.Errorf("%v (restoring modeenv failed: %v)", err, mErr) + } + + // resealing failed, so no point in trying it again + return err + } + return nil +} + +var writeModelToUbuntuBoot = writeModelToUbuntuBootImpl + +func writeModelToUbuntuBootImpl(model *asserts.Model) error { + modelPath := filepath.Join(InitramfsUbuntuBootDir, "device/model") + f, err := osutil.NewAtomicFile(modelPath, 0644, 0, osutil.NoChown, osutil.NoChown) + if err != nil { + return err + } + defer f.Cancel() + if err := asserts.NewEncoder(f).Encode(model); err != nil { + return err + } + return f.Commit() +} diff --git a/boot/model_test.go b/boot/model_test.go new file mode 100644 index 00000000..6cae8630 --- /dev/null +++ b/boot/model_test.go @@ -0,0 +1,1233 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2021 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package boot_test + +import ( + "fmt" + "os" + "path/filepath" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/asserts/assertstest" + "github.com/snapcore/snapd/boot" + "github.com/snapcore/snapd/boot/boottest" + "github.com/snapcore/snapd/bootloader" + "github.com/snapcore/snapd/bootloader/bootloadertest" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/secboot" + "github.com/snapcore/snapd/seed" + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/testutil" + "github.com/snapcore/snapd/timings" +) + +type modelSuite struct { + baseBootenvSuite + + oldUc20dev snap.Device + newUc20dev snap.Device + + runKernelBf bootloader.BootFile + recoveryKernelBf bootloader.BootFile + + keyID string + + readSystemEssentialCalls int +} + +var _ = Suite(&modelSuite{}) + +var ( + brandPrivKey, _ = assertstest.GenerateKey(752) +) + +func makeEncodableModel(signingAccounts *assertstest.SigningAccounts, overrides map[string]interface{}) *asserts.Model { + headers := map[string]interface{}{ + "model": "my-model-uc20", + "display-name": "My Model", + "architecture": "amd64", + "base": "core20", + "grade": "dangerous", + "snaps": []interface{}{ + map[string]interface{}{ + "name": "pc-kernel", + "id": "pckernelidididididididididididid", + "type": "kernel", + }, + map[string]interface{}{ + "name": "pc", + "id": "pcididididididididididididididid", + "type": "gadget", + }, + }, + } + for k, v := range overrides { + headers[k] = v + } + return signingAccounts.Model("canonical", headers["model"].(string), headers) +} + +func (s *modelSuite) SetUpTest(c *C) { + s.baseBootenvSuite.SetUpTest(c) + + store := assertstest.NewStoreStack("canonical", nil) + brands := assertstest.NewSigningAccounts(store) + brands.Register("my-brand", brandPrivKey, nil) + s.keyID = brands.Signing("canonical").KeyID + + restore := boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { return nil }) + s.AddCleanup(restore) + s.oldUc20dev = boottest.MockUC20Device("", makeEncodableModel(brands, nil)) + s.newUc20dev = boottest.MockUC20Device("", makeEncodableModel(brands, map[string]interface{}{ + "model": "my-new-model-uc20", + "grade": "secured", + })) + + model := s.oldUc20dev.Model() + + modeenv := &boot.Modeenv{ + Mode: "run", + // system 1234 corresponds to the new model + CurrentRecoverySystems: []string{"20200825", "1234"}, + GoodRecoverySystems: []string{"20200825", "1234"}, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": []string{"asset-hash-1"}, + }, + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": []string{"asset-hash-1"}, + }, + CurrentKernels: []string{"pc-kernel_500.snap"}, + CurrentKernelCommandLines: boot.BootCommandLines{ + "snapd_recovery_mode=run console=ttyS0 console=tty1 panic=-1", + }, + + Model: model.Model(), + BrandID: model.BrandID(), + Grade: string(model.Grade()), + ModelSignKeyID: model.SignKeyID(), + } + c.Assert(modeenv.WriteTo(""), IsNil) + + mockAssetsCache(c, s.rootdir, "trusted", []string{ + "asset-asset-hash-1", + }) + + mtbl := bootloadertest.Mock("trusted", s.bootdir).WithTrustedAssets() + mtbl.TrustedAssetsMap = map[string]string{"asset": "asset"} + mtbl.StaticCommandLine = "static cmdline" + mtbl.BootChainList = []bootloader.BootFile{ + bootloader.NewBootFile("", "asset", bootloader.RoleRunMode), + s.runKernelBf, + } + mtbl.RecoveryBootChainList = []bootloader.BootFile{ + bootloader.NewBootFile("", "asset", bootloader.RoleRecovery), + s.recoveryKernelBf, + } + bootloader.Force(mtbl) + + s.AddCleanup(func() { bootloader.Force(nil) }) + + // run kernel + s.runKernelBf = bootloader.NewBootFile("/var/lib/snapd/snap/pc-kernel_500.snap", + "kernel.efi", bootloader.RoleRunMode) + // seed (recovery) kernel + s.recoveryKernelBf = bootloader.NewBootFile("/var/lib/snapd/seed/snaps/pc-kernel_1.snap", + "kernel.efi", bootloader.RoleRecovery) + + c.Assert(os.MkdirAll(filepath.Join(boot.InitramfsUbuntuBootDir, "device"), 0755), IsNil) + + s.readSystemEssentialCalls = 0 + restore = boot.MockSeedReadSystemEssential(func(seedDir, label string, essentialTypes []snap.Type, tm timings.Measurer) (*asserts.Model, []*seed.Snap, error) { + s.readSystemEssentialCalls++ + kernelRev := 1 + systemModel := s.oldUc20dev.Model() + if label == "1234" { + // recovery system for new model + kernelRev = 999 + systemModel = s.newUc20dev.Model() + } + return systemModel, []*seed.Snap{mockKernelSeedSnap(snap.R(kernelRev)), mockGadgetSeedSnap(c, nil)}, nil + }) + s.AddCleanup(restore) +} + +func (s *modelSuite) TestWriteModelToUbuntuBoot(c *C) { + err := boot.WriteModelToUbuntuBoot(s.oldUc20dev.Model()) + c.Assert(err, IsNil) + c.Check(filepath.Join(boot.InitramfsUbuntuBootDir, "device/model"), testutil.FileContains, + "model: my-model-uc20\n") + + // overwrite the file + err = boot.WriteModelToUbuntuBoot(s.newUc20dev.Model()) + c.Assert(err, IsNil) + c.Check(filepath.Join(boot.InitramfsUbuntuBootDir, "device/model"), testutil.FileContains, + "model: my-new-model-uc20\n") + + err = os.RemoveAll(filepath.Join(boot.InitramfsUbuntuBootDir)) + c.Assert(err, IsNil) + // fails when trying to write + err = boot.WriteModelToUbuntuBoot(s.newUc20dev.Model()) + c.Assert(err, ErrorMatches, `open .*/run/mnt/ubuntu-boot/device/model\..*: no such file or directory`) +} + +func (s *modelSuite) TestDeviceChangeHappy(c *C) { + // system is encrypted + s.stampSealedKeys(c, s.rootdir) + + // set up the old model file + err := boot.WriteModelToUbuntuBoot(s.oldUc20dev.Model()) + c.Assert(err, IsNil) + c.Check(filepath.Join(boot.InitramfsUbuntuBootDir, "device/model"), testutil.FileContains, + "model: my-model-uc20\n") + + resealKeysCalls := 0 + restore := boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + resealKeysCalls++ + m, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + currForSealing := boot.ModelUniqueID(m.ModelForSealing()) + tryForSealing := boot.ModelUniqueID(m.TryModelForSealing()) + switch resealKeysCalls { + case 1: + // run key + c.Assert(params.KeyFiles, DeepEquals, []string{ + filepath.Join(boot.InitramfsBootEncryptionKeyDir, "ubuntu-data.sealed-key"), + }) + case 2: // recovery keys + c.Assert(params.KeyFiles, DeepEquals, []string{ + filepath.Join(boot.InitramfsSeedEncryptionKeyDir, "ubuntu-data.recovery.sealed-key"), + filepath.Join(boot.InitramfsSeedEncryptionKeyDir, "ubuntu-save.recovery.sealed-key"), + }) + case 3: + // run key + c.Assert(params.KeyFiles, DeepEquals, []string{ + filepath.Join(boot.InitramfsBootEncryptionKeyDir, "ubuntu-data.sealed-key"), + }) + case 4: // recovery keys + c.Assert(params.KeyFiles, DeepEquals, []string{ + filepath.Join(boot.InitramfsSeedEncryptionKeyDir, "ubuntu-data.recovery.sealed-key"), + filepath.Join(boot.InitramfsSeedEncryptionKeyDir, "ubuntu-save.recovery.sealed-key"), + }) + default: + c.Errorf("unexpected additional call to secboot.ResealKeys (call # %d)", resealKeysCalls) + } + + switch resealKeysCalls { + case 1, 2: + // keys are first resealed for both models + c.Assert(currForSealing, Equals, "canonical/my-model-uc20,dangerous,"+s.keyID) + c.Assert(tryForSealing, Equals, "canonical/my-new-model-uc20,secured,"+s.keyID) + // boot/device/model is still the old file + c.Assert(filepath.Join(boot.InitramfsUbuntuBootDir, "device/model"), testutil.FileContains, + "model: my-model-uc20\n") + c.Assert(filepath.Join(boot.InitramfsUbuntuBootDir, "device/model"), testutil.FileContains, + "grade: dangerous\n") + case 3, 4: + // and finally just for the new model + c.Assert(currForSealing, Equals, "canonical/my-new-model-uc20,secured,"+s.keyID) + c.Assert(tryForSealing, Equals, "/,,") + // boot/device/model is the new model by this time + c.Assert(filepath.Join(boot.InitramfsUbuntuBootDir, "device/model"), testutil.FileContains, + "model: my-new-model-uc20\n") + c.Assert(filepath.Join(boot.InitramfsUbuntuBootDir, "device/model"), testutil.FileContains, + "grade: secured\n") + } + return nil + }) + defer restore() + + u := mockUnlocker{} + + err = boot.DeviceChange(s.oldUc20dev, s.newUc20dev, u.unlocker) + c.Assert(err, IsNil) + c.Assert(resealKeysCalls, Equals, 4) + c.Check(u.unlocked, Equals, 2) + + c.Check(filepath.Join(boot.InitramfsUbuntuBootDir, "device/model"), testutil.FileContains, + "model: my-new-model-uc20\n") + + m, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + currForSealing := boot.ModelUniqueID(m.ModelForSealing()) + tryForSealing := boot.ModelUniqueID(m.TryModelForSealing()) + c.Assert(currForSealing, Equals, "canonical/my-new-model-uc20,secured,"+s.keyID) + // try model has been cleared + c.Assert(tryForSealing, Equals, "/,,") +} + +func (s *modelSuite) TestDeviceChangeUnhappyFirstReseal(c *C) { + // system is encrypted + s.stampSealedKeys(c, s.rootdir) + + // set up the old model file + err := boot.WriteModelToUbuntuBoot(s.oldUc20dev.Model()) + c.Assert(err, IsNil) + c.Check(filepath.Join(boot.InitramfsUbuntuBootDir, "device/model"), testutil.FileContains, + "model: my-model-uc20\n") + + resealKeysCalls := 0 + restore := boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + resealKeysCalls++ + m, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + currForSealing := boot.ModelUniqueID(m.ModelForSealing()) + tryForSealing := boot.ModelUniqueID(m.TryModelForSealing()) + switch resealKeysCalls { + case 1: + // run key + c.Assert(params.KeyFiles, DeepEquals, []string{ + filepath.Join(boot.InitramfsBootEncryptionKeyDir, "ubuntu-data.sealed-key"), + }) + default: + c.Errorf("unexpected additional call to secboot.ResealKeys (call # %d)", resealKeysCalls) + } + + switch resealKeysCalls { + case 1: + // keys are first resealed for both models + c.Assert(currForSealing, Equals, "canonical/my-model-uc20,dangerous,"+s.keyID) + c.Assert(tryForSealing, Equals, "canonical/my-new-model-uc20,secured,"+s.keyID) + // boot/device/model is still the old file + c.Assert(filepath.Join(boot.InitramfsUbuntuBootDir, "device/model"), testutil.FileContains, + "model: my-model-uc20\n") + c.Assert(filepath.Join(boot.InitramfsUbuntuBootDir, "device/model"), testutil.FileContains, + "grade: dangerous\n") + } + return fmt.Errorf("fail on first try") + }) + defer restore() + + err = boot.DeviceChange(s.oldUc20dev, s.newUc20dev, nil) + c.Assert(err, ErrorMatches, "cannot reseal the encryption key: fail on first try") + c.Assert(resealKeysCalls, Equals, 1) + // still the old model file + c.Check(filepath.Join(boot.InitramfsUbuntuBootDir, "device/model"), testutil.FileContains, + "model: my-model-uc20\n") + + m, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + currForSealing := boot.ModelUniqueID(m.ModelForSealing()) + tryForSealing := boot.ModelUniqueID(m.TryModelForSealing()) + c.Assert(currForSealing, Equals, "canonical/my-model-uc20,dangerous,"+s.keyID) + // try model has been cleared + c.Assert(tryForSealing, Equals, "/,,") +} + +func (s *modelSuite) TestDeviceChangeUnhappyFirstSwapModelFile(c *C) { + // system is encrypted + s.stampSealedKeys(c, s.rootdir) + + // set up the old model file + err := boot.WriteModelToUbuntuBoot(s.oldUc20dev.Model()) + c.Assert(err, IsNil) + c.Check(filepath.Join(boot.InitramfsUbuntuBootDir, "device/model"), testutil.FileContains, + "model: my-model-uc20\n") + + resealKeysCalls := 0 + restore := boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + resealKeysCalls++ + m, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + currForSealing := boot.ModelUniqueID(m.ModelForSealing()) + tryForSealing := boot.ModelUniqueID(m.TryModelForSealing()) + switch resealKeysCalls { + case 1: + // run key + c.Assert(params.KeyFiles, DeepEquals, []string{ + filepath.Join(boot.InitramfsBootEncryptionKeyDir, "ubuntu-data.sealed-key"), + }) + case 2: + // recovery keys + c.Assert(params.KeyFiles, DeepEquals, []string{ + filepath.Join(boot.InitramfsSeedEncryptionKeyDir, "ubuntu-data.recovery.sealed-key"), + filepath.Join(boot.InitramfsSeedEncryptionKeyDir, "ubuntu-save.recovery.sealed-key"), + }) + default: + c.Errorf("unexpected additional call to secboot.ResealKeys (call # %d)", resealKeysCalls) + } + + switch resealKeysCalls { + case 1, 2: + // keys are first resealed for both models + c.Assert(currForSealing, Equals, "canonical/my-model-uc20,dangerous,"+s.keyID) + c.Assert(tryForSealing, Equals, "canonical/my-new-model-uc20,secured,"+s.keyID) + // boot/device/model is still the old file + c.Assert(filepath.Join(boot.InitramfsUbuntuBootDir, "device/model"), testutil.FileContains, + "model: my-model-uc20\n") + c.Assert(filepath.Join(boot.InitramfsUbuntuBootDir, "device/model"), testutil.FileContains, + "grade: dangerous\n") + } + + if resealKeysCalls == 2 { + // break writing of the model file + c.Assert(os.RemoveAll(filepath.Join(boot.InitramfsUbuntuBootDir, "device")), IsNil) + } + return nil + }) + defer restore() + + err = boot.DeviceChange(s.oldUc20dev, s.newUc20dev, nil) + c.Assert(err, ErrorMatches, `cannot write new model file: open .*/run/mnt/ubuntu-boot/device/model\..*: no such file or directory`) + c.Assert(resealKeysCalls, Equals, 2) + + m, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + currForSealing := boot.ModelUniqueID(m.ModelForSealing()) + tryForSealing := boot.ModelUniqueID(m.TryModelForSealing()) + c.Assert(currForSealing, Equals, "canonical/my-model-uc20,dangerous,"+s.keyID) + // try model has been cleared + c.Assert(tryForSealing, Equals, "/,,") +} + +func (s *modelSuite) TestDeviceChangeUnhappySecondReseal(c *C) { + // system is encrypted + s.stampSealedKeys(c, s.rootdir) + + // set up the old model file + err := boot.WriteModelToUbuntuBoot(s.oldUc20dev.Model()) + c.Assert(err, IsNil) + c.Check(filepath.Join(boot.InitramfsUbuntuBootDir, "device/model"), testutil.FileContains, + "model: my-model-uc20\n") + + resealKeysCalls := 0 + restore := boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + resealKeysCalls++ + m, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + currForSealing := boot.ModelUniqueID(m.ModelForSealing()) + tryForSealing := boot.ModelUniqueID(m.TryModelForSealing()) + // which keys? + switch resealKeysCalls { + case 1, 3: + // run key + c.Assert(params.KeyFiles, DeepEquals, []string{ + filepath.Join(boot.InitramfsBootEncryptionKeyDir, "ubuntu-data.sealed-key"), + }) + case 2: + // recovery keys + c.Assert(params.KeyFiles, DeepEquals, []string{ + filepath.Join(boot.InitramfsSeedEncryptionKeyDir, "ubuntu-data.recovery.sealed-key"), + filepath.Join(boot.InitramfsSeedEncryptionKeyDir, "ubuntu-save.recovery.sealed-key"), + }) + default: + c.Errorf("unexpected additional call to secboot.ResealKeys (call # %d)", resealKeysCalls) + } + // what's in params? + switch resealKeysCalls { + case 1: + c.Assert(params.ModelParams, HasLen, 2) + c.Assert(params.ModelParams[0].Model.Model(), Equals, "my-model-uc20") + c.Assert(params.ModelParams[1].Model.Model(), Equals, "my-new-model-uc20") + case 2: + // recovery key resealed for current model only + c.Assert(params.ModelParams, HasLen, 1) + c.Assert(params.ModelParams[0].Model.Model(), Equals, "my-model-uc20") + case 3, 4: + // try model has become current + c.Assert(params.ModelParams, HasLen, 1) + c.Assert(params.ModelParams[0].Model.Model(), Equals, "my-new-model-uc20") + } + // what's in modeenv? + switch resealKeysCalls { + case 1, 2: + // keys are first resealed for both models + c.Assert(currForSealing, Equals, "canonical/my-model-uc20,dangerous,"+s.keyID) + c.Assert(tryForSealing, Equals, "canonical/my-new-model-uc20,secured,"+s.keyID) + // boot/device/model is still the old file + c.Assert(filepath.Join(boot.InitramfsUbuntuBootDir, "device/model"), testutil.FileContains, + "model: my-model-uc20\n") + c.Assert(filepath.Join(boot.InitramfsUbuntuBootDir, "device/model"), testutil.FileContains, + "grade: dangerous\n") + case 3: + // and finally just for the new model + c.Assert(currForSealing, Equals, "canonical/my-new-model-uc20,secured,"+s.keyID) + c.Assert(tryForSealing, Equals, "/,,") + // boot/device/model is the new model by this time + c.Assert(filepath.Join(boot.InitramfsUbuntuBootDir, "device/model"), testutil.FileContains, + "model: my-new-model-uc20\n") + c.Assert(filepath.Join(boot.InitramfsUbuntuBootDir, "device/model"), testutil.FileContains, + "grade: secured\n") + } + + if resealKeysCalls == 3 { + return fmt.Errorf("fail on second try") + } + + return nil + }) + defer restore() + + err = boot.DeviceChange(s.oldUc20dev, s.newUc20dev, nil) + c.Assert(err, ErrorMatches, `cannot reseal the encryption key: fail on second try`) + c.Assert(resealKeysCalls, Equals, 3) + // old model file was restored + c.Check(filepath.Join(boot.InitramfsUbuntuBootDir, "device/model"), testutil.FileContains, + "model: my-model-uc20\n") + + m, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + currForSealing := boot.ModelUniqueID(m.ModelForSealing()) + tryForSealing := boot.ModelUniqueID(m.TryModelForSealing()) + c.Assert(currForSealing, Equals, "canonical/my-model-uc20,dangerous,"+s.keyID) + // try model has been cleared + c.Assert(tryForSealing, Equals, "/,,") +} + +func (s *modelSuite) TestDeviceChangeRebootBeforeNewModel(c *C) { + // system is encrypted + s.stampSealedKeys(c, s.rootdir) + + // set up the old model file + err := boot.WriteModelToUbuntuBoot(s.oldUc20dev.Model()) + c.Assert(err, IsNil) + c.Check(filepath.Join(boot.InitramfsUbuntuBootDir, "device/model"), testutil.FileContains, + "model: my-model-uc20\n") + + resealKeysCalls := 0 + restore := boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + resealKeysCalls++ + c.Logf("reseal key call: %v", resealKeysCalls) + m, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + currForSealing := boot.ModelUniqueID(m.ModelForSealing()) + tryForSealing := boot.ModelUniqueID(m.TryModelForSealing()) + // timeline & calls: + // 1 - pre reboot, run & recovery keys, try model set + // 2 - pre reboot, recovery keys, try model set, unexpected reboot is triggered + // (reboot) + // no call for run key, boot chains haven't changes since call 1 + // 3 - recovery key, try model set + // 4, 5 - post reboot, run & recovery keys, after rewriting model file, try model cleared + + // which keys? + switch resealKeysCalls { + case 1, 4: + // run key + c.Assert(params.KeyFiles, DeepEquals, []string{ + filepath.Join(boot.InitramfsBootEncryptionKeyDir, "ubuntu-data.sealed-key"), + }) + case 2, 3, 5: + // recovery keys + c.Assert(params.KeyFiles, DeepEquals, []string{ + filepath.Join(boot.InitramfsSeedEncryptionKeyDir, "ubuntu-data.recovery.sealed-key"), + filepath.Join(boot.InitramfsSeedEncryptionKeyDir, "ubuntu-save.recovery.sealed-key"), + }) + default: + c.Errorf("unexpected additional call to secboot.ResealKeys (call # %d)", resealKeysCalls) + } + // what's in params? + switch resealKeysCalls { + case 1: + c.Assert(params.ModelParams, HasLen, 2) + c.Assert(params.ModelParams[0].Model.Model(), Equals, "my-model-uc20") + c.Assert(params.ModelParams[1].Model.Model(), Equals, "my-new-model-uc20") + case 2: + // attempted reseal of recovery key before clearing try model + c.Assert(params.ModelParams, HasLen, 1) + c.Assert(params.ModelParams[0].Model.Model(), Equals, "my-model-uc20") + case 3: + // recovery keys are resealed only for current system + c.Assert(params.ModelParams, HasLen, 1) + c.Assert(params.ModelParams[0].Model.Model(), Equals, "my-model-uc20") + case 4, 5: + // try model has become current + c.Assert(params.ModelParams, HasLen, 1) + c.Assert(params.ModelParams[0].Model.Model(), Equals, "my-new-model-uc20") + } + // what's in modeenv? + switch resealKeysCalls { + case 1, 2, 3: + // keys are first resealed for both models, which are restored to the modeenv + c.Assert(currForSealing, Equals, "canonical/my-model-uc20,dangerous,"+s.keyID) + c.Assert(tryForSealing, Equals, "canonical/my-new-model-uc20,secured,"+s.keyID) + // boot/device/model is still the old file + c.Assert(filepath.Join(boot.InitramfsUbuntuBootDir, "device/model"), testutil.FileContains, + "model: my-model-uc20\n") + c.Assert(filepath.Join(boot.InitramfsUbuntuBootDir, "device/model"), testutil.FileContains, + "grade: dangerous\n") + case 4, 5: + // and finally just for the new model + c.Assert(currForSealing, Equals, "canonical/my-new-model-uc20,secured,"+s.keyID) + c.Assert(tryForSealing, Equals, "/,,") + // boot/device/model is the new model by this time + c.Assert(filepath.Join(boot.InitramfsUbuntuBootDir, "device/model"), testutil.FileContains, + "model: my-new-model-uc20\n") + c.Assert(filepath.Join(boot.InitramfsUbuntuBootDir, "device/model"), testutil.FileContains, + "grade: secured\n") + } + + if resealKeysCalls == 2 { + panic("mock reboot after first complete reseal") + } + + return nil + }) + defer restore() + + c.Assert(func() { boot.DeviceChange(s.oldUc20dev, s.newUc20dev, nil) }, PanicMatches, + `mock reboot after first complete reseal`) + c.Assert(resealKeysCalls, Equals, 2) + // still old model in place + c.Check(filepath.Join(boot.InitramfsUbuntuBootDir, "device/model"), testutil.FileContains, + "model: my-model-uc20\n") + + m, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + currForSealing := boot.ModelUniqueID(m.ModelForSealing()) + tryForSealing := boot.ModelUniqueID(m.TryModelForSealing()) + c.Assert(currForSealing, Equals, "canonical/my-model-uc20,dangerous,"+s.keyID) + // try model is already set + c.Assert(tryForSealing, Equals, "canonical/my-new-model-uc20,secured,"+s.keyID) + + // let's try again + err = boot.DeviceChange(s.oldUc20dev, s.newUc20dev, nil) + c.Assert(err, IsNil) + c.Assert(resealKeysCalls, Equals, 5) + // got new model now + c.Check(filepath.Join(boot.InitramfsUbuntuBootDir, "device/model"), testutil.FileContains, + "model: my-new-model-uc20\n") + + m, err = boot.ReadModeenv("") + c.Assert(err, IsNil) + currForSealing = boot.ModelUniqueID(m.ModelForSealing()) + tryForSealing = boot.ModelUniqueID(m.TryModelForSealing()) + // new model is current + c.Assert(currForSealing, Equals, "canonical/my-new-model-uc20,secured,"+s.keyID) + // try model has been cleared + c.Assert(tryForSealing, Equals, "/,,") + +} + +func (s *modelSuite) TestDeviceChangeRebootAfterNewModelFileWrite(c *C) { + // system is encrypted + s.stampSealedKeys(c, s.rootdir) + + // set up the old model file + err := boot.WriteModelToUbuntuBoot(s.oldUc20dev.Model()) + c.Assert(err, IsNil) + c.Check(filepath.Join(boot.InitramfsUbuntuBootDir, "device/model"), testutil.FileContains, + "model: my-model-uc20\n") + + resealKeysCalls := 0 + restore := boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + resealKeysCalls++ + c.Logf("reseal key call: %v", resealKeysCalls) + m, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + currForSealing := boot.ModelUniqueID(m.ModelForSealing()) + tryForSealing := boot.ModelUniqueID(m.TryModelForSealing()) + // timeline & calls: + // 1, 2 - pre reboot, run & recovery keys, try model set + // 3 - run key, after model file has been modified, try model cleared, unexpected + // reboot is triggered + // (reboot) + // no reseal - boot chains are identical to what was in calls 1 & 2 which were successful + // 4, 5 - post reboot, run & recovery keys, after rewriting model file, try model cleared + + // which keys? + switch resealKeysCalls { + case 1, 3, 4: + // run key + c.Assert(params.KeyFiles, DeepEquals, []string{ + filepath.Join(boot.InitramfsBootEncryptionKeyDir, "ubuntu-data.sealed-key"), + }) + case 2, 5: + // recovery keys + c.Assert(params.KeyFiles, DeepEquals, []string{ + filepath.Join(boot.InitramfsSeedEncryptionKeyDir, "ubuntu-data.recovery.sealed-key"), + filepath.Join(boot.InitramfsSeedEncryptionKeyDir, "ubuntu-save.recovery.sealed-key"), + }) + default: + c.Errorf("unexpected additional call to secboot.ResealKeys (call # %d)", resealKeysCalls) + } + // what's in params? + switch resealKeysCalls { + case 1: + c.Assert(params.ModelParams, HasLen, 2) + c.Assert(params.ModelParams[0].Model.Model(), Equals, "my-model-uc20") + c.Assert(params.ModelParams[1].Model.Model(), Equals, "my-new-model-uc20") + case 2: + // recovery key resealed for current model only + c.Assert(params.ModelParams, HasLen, 1) + c.Assert(params.ModelParams[0].Model.Model(), Equals, "my-model-uc20") + case 3: + // attempted reseal with of run key after clearing try model + c.Assert(params.ModelParams, HasLen, 1) + c.Assert(params.ModelParams[0].Model.Model(), Equals, "my-new-model-uc20") + case 4, 5: + // try model has become current + c.Assert(params.ModelParams, HasLen, 1) + c.Assert(params.ModelParams[0].Model.Model(), Equals, "my-new-model-uc20") + } + // what's in modeenv? + switch resealKeysCalls { + case 1, 2: + // keys are first resealed for both models + c.Assert(currForSealing, Equals, "canonical/my-model-uc20,dangerous,"+s.keyID) + c.Assert(tryForSealing, Equals, "canonical/my-new-model-uc20,secured,"+s.keyID) + // boot/device/model is still the old file + c.Assert(filepath.Join(boot.InitramfsUbuntuBootDir, "device/model"), testutil.FileContains, + "model: my-model-uc20\n") + case 3, 4, 5: + // and finally just for the new model + c.Assert(currForSealing, Equals, "canonical/my-new-model-uc20,secured,"+s.keyID) + c.Assert(tryForSealing, Equals, "/,,") + // boot/device/model is the new model by this time + c.Assert(filepath.Join(boot.InitramfsUbuntuBootDir, "device/model"), testutil.FileContains, + "model: my-new-model-uc20\n") + c.Assert(filepath.Join(boot.InitramfsUbuntuBootDir, "device/model"), testutil.FileContains, + "grade: secured\n") + } + + if resealKeysCalls == 3 { + panic("mock reboot before second complete reseal") + } + + return nil + }) + defer restore() + + c.Assert(func() { boot.DeviceChange(s.oldUc20dev, s.newUc20dev, nil) }, PanicMatches, + `mock reboot before second complete reseal`) + c.Assert(resealKeysCalls, Equals, 3) + // model file has already been replaced + c.Check(filepath.Join(boot.InitramfsUbuntuBootDir, "device/model"), testutil.FileContains, + "model: my-new-model-uc20\n") + + m, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + currForSealing := boot.ModelUniqueID(m.ModelForSealing()) + tryForSealing := boot.ModelUniqueID(m.TryModelForSealing()) + // as well as modeenv + c.Assert(currForSealing, Equals, "canonical/my-new-model-uc20,secured,"+s.keyID) + c.Assert(tryForSealing, Equals, "/,,") + + // let's try again (post reboot) + err = boot.DeviceChange(s.oldUc20dev, s.newUc20dev, nil) + c.Assert(err, IsNil) + c.Assert(resealKeysCalls, Equals, 5) + // got new model now + c.Check(filepath.Join(boot.InitramfsUbuntuBootDir, "device/model"), testutil.FileContains, + "model: my-new-model-uc20\n") + + m, err = boot.ReadModeenv("") + c.Assert(err, IsNil) + currForSealing = boot.ModelUniqueID(m.ModelForSealing()) + tryForSealing = boot.ModelUniqueID(m.TryModelForSealing()) + // new model is current + c.Assert(currForSealing, Equals, "canonical/my-new-model-uc20,secured,"+s.keyID) + // try model has been cleared + c.Assert(tryForSealing, Equals, "/,,") + +} + +func (s *modelSuite) TestDeviceChangeRebootPostSameModel(c *C) { + // system is encrypted + s.stampSealedKeys(c, s.rootdir) + + // set up the old model file + err := boot.WriteModelToUbuntuBoot(s.oldUc20dev.Model()) + c.Assert(err, IsNil) + c.Check(filepath.Join(boot.InitramfsUbuntuBootDir, "device/model"), testutil.FileContains, + "model: my-model-uc20\n") + + resealKeysCalls := 0 + restore := boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + resealKeysCalls++ + c.Logf("reseal key call: %v", resealKeysCalls) + m, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + currForSealing := boot.ModelUniqueID(m.ModelForSealing()) + tryForSealing := boot.ModelUniqueID(m.TryModelForSealing()) + // timeline & calls: + // 1, 2 - pre reboot, run & recovery keys, try model set + // 3 - run key, after model file has been modified, try model cleared + // 4 - recovery key, model file has been modified, try model cleared, unexpected + // reboot is triggered + // (reboot) + // 5, 6 - run & recovery, try model set, new model also restored + // as 'old' model, params are grouped by model + // 7 - run only (recovery boot chains have not changed since) + + // which keys? + switch resealKeysCalls { + case 1, 3, 5, 7: + // run key + c.Assert(params.KeyFiles, DeepEquals, []string{ + filepath.Join(boot.InitramfsBootEncryptionKeyDir, "ubuntu-data.sealed-key"), + }) + case 2, 4, 6: + // recovery keys + c.Assert(params.KeyFiles, DeepEquals, []string{ + filepath.Join(boot.InitramfsSeedEncryptionKeyDir, "ubuntu-data.recovery.sealed-key"), + filepath.Join(boot.InitramfsSeedEncryptionKeyDir, "ubuntu-save.recovery.sealed-key"), + }) + default: + c.Errorf("unexpected additional call to secboot.ResealKeys (call # %d)", resealKeysCalls) + } + // what's in params? + switch resealKeysCalls { + case 1: + c.Assert(params.ModelParams, HasLen, 2) + c.Assert(params.ModelParams[0].Model.Model(), Equals, "my-model-uc20") + c.Assert(params.ModelParams[1].Model.Model(), Equals, "my-new-model-uc20") + case 2: + // recovery key resealed for current model only + c.Assert(params.ModelParams, HasLen, 1) + c.Assert(params.ModelParams[0].Model.Model(), Equals, "my-model-uc20") + case 3, 4: + // try model has become current + c.Assert(params.ModelParams, HasLen, 1) + c.Assert(params.ModelParams[0].Model.Model(), Equals, "my-new-model-uc20") + case 5, 6, 7: + // + c.Assert(params.ModelParams, HasLen, 1) + c.Assert(params.ModelParams[0].Model.Model(), Equals, "my-new-model-uc20") + } + // what's in modeenv? + switch resealKeysCalls { + case 1, 2: + // keys are first resealed for both models + c.Assert(currForSealing, Equals, "canonical/my-model-uc20,dangerous,"+s.keyID) + c.Assert(tryForSealing, Equals, "canonical/my-new-model-uc20,secured,"+s.keyID) + // boot/device/model is still the old file + c.Assert(filepath.Join(boot.InitramfsUbuntuBootDir, "device/model"), testutil.FileContains, + "model: my-model-uc20\n") + case 3, 4, 7: + // and finally just for the new model + c.Assert(currForSealing, Equals, "canonical/my-new-model-uc20,secured,"+s.keyID) + c.Assert(tryForSealing, Equals, "/,,") + // boot/device/model is the new model by this time + c.Assert(filepath.Join(boot.InitramfsUbuntuBootDir, "device/model"), testutil.FileContains, + "model: my-new-model-uc20\n") + case 5, 6: + // new model passed as old one + c.Assert(currForSealing, Equals, "canonical/my-new-model-uc20,secured,"+s.keyID) + c.Assert(tryForSealing, Equals, "canonical/my-new-model-uc20,secured,"+s.keyID) + // boot/device/model is still the old file + c.Assert(filepath.Join(boot.InitramfsUbuntuBootDir, "device/model"), testutil.FileContains, + "model: my-new-model-uc20\n") + } + + if resealKeysCalls == 4 { + panic("mock reboot before second complete reseal") + } + return nil + }) + defer restore() + + // as if called by device manager in task handler + c.Assert(func() { boot.DeviceChange(s.oldUc20dev, s.newUc20dev, nil) }, PanicMatches, + `mock reboot before second complete reseal`) + c.Assert(resealKeysCalls, Equals, 4) + // model file has already been replaced + c.Check(filepath.Join(boot.InitramfsUbuntuBootDir, "device/model"), testutil.FileContains, + "model: my-new-model-uc20\n") + + // as if called by device manager, after the model has been changed, but + // the set-model task isn't marked as done + err = boot.DeviceChange(s.newUc20dev, s.newUc20dev, nil) + c.Assert(err, IsNil) + c.Assert(resealKeysCalls, Equals, 7) + + c.Check(filepath.Join(boot.InitramfsUbuntuBootDir, "device/model"), testutil.FileContains, + "model: my-new-model-uc20\n") + + m, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + currForSealing := boot.ModelUniqueID(m.ModelForSealing()) + tryForSealing := boot.ModelUniqueID(m.TryModelForSealing()) + c.Assert(currForSealing, Equals, "canonical/my-new-model-uc20,secured,"+s.keyID) + // try model has been cleared + c.Assert(tryForSealing, Equals, "/,,") +} + +type unhappyMockedWriteModelToBootTestCase struct { + breakModeenvAfterFirstWrite bool + modelRestoreFail bool + expectedErr string +} + +func (s *modelSuite) testDeviceChangeUnhappyMockedWriteModelToBoot(c *C, tc unhappyMockedWriteModelToBootTestCase) { + // system is encrypted + s.stampSealedKeys(c, s.rootdir) + + // set up the old model file + err := boot.WriteModelToUbuntuBoot(s.oldUc20dev.Model()) + c.Assert(err, IsNil) + c.Check(filepath.Join(boot.InitramfsUbuntuBootDir, "device/model"), testutil.FileContains, + "model: my-model-uc20\n") + + modeenvDir := filepath.Dir(dirs.SnapModeenvFileUnder(dirs.GlobalRootDir)) + defer os.Chmod(modeenvDir, 0755) + + writeModelToBootCalls := 0 + resealKeysCalls := 0 + restore := boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + resealKeysCalls++ + m, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + currForSealing := boot.ModelUniqueID(m.ModelForSealing()) + tryForSealing := boot.ModelUniqueID(m.TryModelForSealing()) + switch resealKeysCalls { + case 1: + // run key + c.Assert(params.KeyFiles, DeepEquals, []string{ + filepath.Join(boot.InitramfsBootEncryptionKeyDir, "ubuntu-data.sealed-key"), + }) + case 2: + // recovery keys + c.Assert(params.KeyFiles, DeepEquals, []string{ + filepath.Join(boot.InitramfsSeedEncryptionKeyDir, "ubuntu-data.recovery.sealed-key"), + filepath.Join(boot.InitramfsSeedEncryptionKeyDir, "ubuntu-save.recovery.sealed-key"), + }) + default: + c.Errorf("unexpected additional call to secboot.ResealKeys (call # %d)", resealKeysCalls) + } + + switch resealKeysCalls { + case 1: + // keys are first resealed for both models + c.Assert(currForSealing, Equals, "canonical/my-model-uc20,dangerous,"+s.keyID) + c.Assert(tryForSealing, Equals, "canonical/my-new-model-uc20,secured,"+s.keyID) + // no model has been written to ubuntu-boot yet + c.Assert(writeModelToBootCalls, Equals, 0) + } + return nil + }) + defer restore() + + restore = boot.MockWriteModelToUbuntuBoot(func(model *asserts.Model) error { + writeModelToBootCalls++ + c.Assert(model, NotNil) + switch writeModelToBootCalls { + case 1: + // a call to write the new model + c.Check(model.Model(), Equals, "my-new-model-uc20") + // only 2 calls to reseal until now + c.Check(resealKeysCalls, Equals, 2) + if tc.breakModeenvAfterFirstWrite { + c.Assert(os.Chmod(modeenvDir, 0000), IsNil) + return nil + } + case 2: + // a call to restore the old model + c.Check(model.Model(), Equals, "my-model-uc20") + if !tc.breakModeenvAfterFirstWrite { + c.Errorf("unexpected additional call to writeModelToBoot (call # %d)", writeModelToBootCalls) + } + if !tc.modelRestoreFail { + return nil + } + default: + c.Errorf("unexpected additional call to writeModelToBoot (call # %d)", writeModelToBootCalls) + } + return fmt.Errorf("mocked fail in write model to boot") + }) + defer restore() + + err = boot.DeviceChange(s.oldUc20dev, s.newUc20dev, nil) + c.Assert(err, ErrorMatches, tc.expectedErr) + c.Assert(resealKeysCalls, Equals, 2) + if tc.breakModeenvAfterFirstWrite { + // write to boot failed on the second call + c.Assert(writeModelToBootCalls, Equals, 2) + } else { + c.Assert(writeModelToBootCalls, Equals, 1) + } + // still the old model file, all writes were intercepted + c.Check(filepath.Join(boot.InitramfsUbuntuBootDir, "device/model"), testutil.FileContains, + "model: my-model-uc20\n") + + if !tc.breakModeenvAfterFirstWrite { + m, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + currForSealing := boot.ModelUniqueID(m.ModelForSealing()) + tryForSealing := boot.ModelUniqueID(m.TryModelForSealing()) + c.Assert(currForSealing, Equals, "canonical/my-model-uc20,dangerous,"+s.keyID) + // try model has been cleared + c.Assert(tryForSealing, Equals, "/,,") + } +} + +func (s *modelSuite) TestDeviceChangeUnhappyMockedWriteModelToBootBeforeModelSwap(c *C) { + s.testDeviceChangeUnhappyMockedWriteModelToBoot(c, unhappyMockedWriteModelToBootTestCase{ + expectedErr: "cannot write new model file: mocked fail in write model to boot", + }) +} + +func (s *modelSuite) TestDeviceChangeUnhappyMockedWriteModelToBootAfterModelSwapFailingRestore(c *C) { + // writing modeenv after placing new model file on disk fails, and so + // does restoring of the old model + if os.Getuid() == 0 { + // the test is manipulating file permissions, which doesn't + // affect root + c.Skip("test cannot be executed by root") + } + s.testDeviceChangeUnhappyMockedWriteModelToBoot(c, unhappyMockedWriteModelToBootTestCase{ + breakModeenvAfterFirstWrite: true, + modelRestoreFail: true, + + expectedErr: `open .*/var/lib/snapd/modeenv\..*: permission denied \(restoring model failed: mocked fail in write model to boot\)`, + }) +} + +func (s *modelSuite) TestDeviceChangeUnhappyMockedWriteModelToBootAfterModelSwapHappyRestore(c *C) { + // writing modeenv after placing new model file on disk fails, but + // restore is successful + if os.Getuid() == 0 { + // the test is manipulating file permissions, which doesn't + // affect root + c.Skip("test cannot be executed by root") + } + s.testDeviceChangeUnhappyMockedWriteModelToBoot(c, unhappyMockedWriteModelToBootTestCase{ + breakModeenvAfterFirstWrite: true, + modelRestoreFail: false, + + expectedErr: `open .*/var/lib/snapd/modeenv\..*: permission denied$`, + }) +} + +func (s *modelSuite) TestDeviceChangeUnhappyFailReseaWithSwappedModelMockedWriteToBoot(c *C) { + // system is encrypted + s.stampSealedKeys(c, s.rootdir) + + // set up the old model file + err := boot.WriteModelToUbuntuBoot(s.oldUc20dev.Model()) + c.Assert(err, IsNil) + c.Check(filepath.Join(boot.InitramfsUbuntuBootDir, "device/model"), testutil.FileContains, + "model: my-model-uc20\n") + + writeModelToBootCalls := 0 + resealKeysCalls := 0 + restore := boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + resealKeysCalls++ + if resealKeysCalls == 3 { + // we are resealing the run key, the old model has been + // replaced by the new one in modeenv + c.Assert(params.KeyFiles, DeepEquals, []string{ + filepath.Join(boot.InitramfsBootEncryptionKeyDir, "ubuntu-data.sealed-key"), + }) + m, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + currForSealing := boot.ModelUniqueID(m.ModelForSealing()) + tryForSealing := boot.ModelUniqueID(m.TryModelForSealing()) + // keys are first resealed for both models + c.Assert(currForSealing, Equals, "canonical/my-new-model-uc20,secured,"+s.keyID) + c.Assert(tryForSealing, Equals, "/,,") + // an new model has already been written + c.Assert(writeModelToBootCalls, Equals, 1) + return fmt.Errorf("mock reseal failure") + } + + return nil + }) + defer restore() + + restore = boot.MockWriteModelToUbuntuBoot(func(model *asserts.Model) error { + writeModelToBootCalls++ + switch writeModelToBootCalls { + case 1: + c.Assert(model, NotNil) + c.Check(model.Model(), Equals, "my-new-model-uc20") + // only 2 calls to reseal until now + c.Check(resealKeysCalls, Equals, 2) + case 2: + // handling of reseal with new model restores the old one on the disk + c.Check(model.Model(), Equals, "my-model-uc20") + m, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + // and both models are present in the modeenv + currForSealing := boot.ModelUniqueID(m.ModelForSealing()) + tryForSealing := boot.ModelUniqueID(m.TryModelForSealing()) + // keys are first resealed for both models + c.Assert(currForSealing, Equals, "canonical/my-model-uc20,dangerous,"+s.keyID) + c.Assert(tryForSealing, Equals, "canonical/my-new-model-uc20,secured,"+s.keyID) + + default: + c.Errorf("unexpected additional call to writeModelToBoot (call # %d)", writeModelToBootCalls) + } + return nil + }) + defer restore() + + err = boot.DeviceChange(s.oldUc20dev, s.newUc20dev, nil) + c.Assert(err, ErrorMatches, `cannot reseal the encryption key: mock reseal failure`) + c.Assert(resealKeysCalls, Equals, 3) + c.Assert(writeModelToBootCalls, Equals, 2) + // still the old model file, all writes were intercepted + c.Check(filepath.Join(boot.InitramfsUbuntuBootDir, "device/model"), testutil.FileContains, + "model: my-model-uc20\n") + + // finally the try model has been dropped from modeenv + m, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + currForSealing := boot.ModelUniqueID(m.ModelForSealing()) + tryForSealing := boot.ModelUniqueID(m.TryModelForSealing()) + c.Assert(currForSealing, Equals, "canonical/my-model-uc20,dangerous,"+s.keyID) + // try model has been cleared + c.Assert(tryForSealing, Equals, "/,,") +} + +func (s *modelSuite) TestDeviceChangeRebootRestoreModelKeyChangeMockedWriteModel(c *C) { + // system is encrypted + s.stampSealedKeys(c, s.rootdir) + + oldKeyID := "Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + newKeyID := "ZZZ_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + // model can be mocked freely as we will not encode it as we mocked a + // function that writes out the model too + s.oldUc20dev = boottest.MockUC20Device("", boottest.MakeMockUC20Model(map[string]interface{}{ + "model": "my-model-uc20", + "brand-id": "my-brand", + "grade": "dangerous", + "sign-key-sha3-384": oldKeyID, + })) + + s.newUc20dev = boottest.MockUC20Device("", boottest.MakeMockUC20Model(map[string]interface{}{ + "model": "my-model-uc20", + "brand-id": "my-brand", + "grade": "dangerous", + "sign-key-sha3-384": newKeyID, + })) + + resealKeysCalls := 0 + restore := boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + resealKeysCalls++ + c.Logf("reseal key call: %v", resealKeysCalls) + m, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + currForSealing := boot.ModelUniqueID(m.ModelForSealing()) + tryForSealing := boot.ModelUniqueID(m.TryModelForSealing()) + // timeline & calls: + // 1, 2 - pre reboot, run & recovery keys, try model set + // 3 - run key, after model file has been modified, try model cleared + // 4 - recovery key, model file has been modified, try model cleared, + // unexpected reboot is triggered + // (reboot) + // 5 - run with old model & key (since we resealed run key in + // call 3, and recovery has not changed), old model restored in modeenv + // 6 - run with new model and key, old current has been dropped + // 7 - recovery with new model only + + // which keys? + switch resealKeysCalls { + case 1, 3, 5, 6: + // run key + c.Assert(params.KeyFiles, DeepEquals, []string{ + filepath.Join(boot.InitramfsBootEncryptionKeyDir, "ubuntu-data.sealed-key"), + }) + case 2, 4, 7: + // recovery keys + c.Assert(params.KeyFiles, DeepEquals, []string{ + filepath.Join(boot.InitramfsSeedEncryptionKeyDir, "ubuntu-data.recovery.sealed-key"), + filepath.Join(boot.InitramfsSeedEncryptionKeyDir, "ubuntu-save.recovery.sealed-key"), + }) + default: + c.Errorf("unexpected additional call to secboot.ResealKeys (call # %d)", resealKeysCalls) + } + // what's in params? + switch resealKeysCalls { + case 1, 5: + c.Assert(params.ModelParams, HasLen, 2) + c.Assert(params.ModelParams[0].Model.Model(), Equals, "my-model-uc20") + c.Assert(params.ModelParams[0].Model.SignKeyID(), Equals, oldKeyID) + c.Assert(params.ModelParams[1].Model.Model(), Equals, "my-model-uc20") + c.Assert(params.ModelParams[1].Model.SignKeyID(), Equals, newKeyID) + case 2: + // recovery key resealed for current model only + c.Assert(params.ModelParams, HasLen, 1) + c.Assert(params.ModelParams[0].Model.Model(), Equals, "my-model-uc20") + c.Assert(params.ModelParams[0].Model.SignKeyID(), Equals, oldKeyID) + case 3, 4, 6, 7: + // try model has become current + c.Assert(params.ModelParams, HasLen, 1) + c.Assert(params.ModelParams[0].Model.Model(), Equals, "my-model-uc20") + c.Assert(params.ModelParams[0].Model.SignKeyID(), Equals, newKeyID) + } + // what's in modeenv? + switch resealKeysCalls { + case 1, 2, 5: + // keys are first resealed for both models + c.Assert(currForSealing, Equals, "my-brand/my-model-uc20,dangerous,"+oldKeyID) + c.Assert(tryForSealing, Equals, "my-brand/my-model-uc20,dangerous,"+newKeyID) + case 3, 4, 6, 7: + // and finally just for the new model + c.Assert(currForSealing, Equals, "my-brand/my-model-uc20,dangerous,"+newKeyID) + c.Assert(tryForSealing, Equals, "/,,") + } + + if resealKeysCalls == 4 { + panic("mock reboot before second complete reseal") + } + return nil + }) + defer restore() + + writeModelToBootCalls := 0 + restore = boot.MockWriteModelToUbuntuBoot(func(model *asserts.Model) error { + writeModelToBootCalls++ + c.Logf("write model to boot call: %v", writeModelToBootCalls) + switch writeModelToBootCalls { + case 1: + c.Assert(model, NotNil) + c.Check(model.Model(), Equals, "my-model-uc20") + // only 2 calls to reseal until now + c.Check(resealKeysCalls, Equals, 2) + case 2: + // handling of reseal with new model restores the old one on the disk + c.Check(model.Model(), Equals, "my-model-uc20") + m, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + // and both models are present in the modeenv + currForSealing := boot.ModelUniqueID(m.ModelForSealing()) + tryForSealing := boot.ModelUniqueID(m.TryModelForSealing()) + // keys are first resealed for both models + c.Assert(currForSealing, Equals, "my-brand/my-model-uc20,dangerous,"+oldKeyID) + c.Assert(tryForSealing, Equals, "my-brand/my-model-uc20,dangerous,"+newKeyID) + + default: + c.Errorf("unexpected additional call to writeModelToBoot (call # %d)", writeModelToBootCalls) + } + return nil + }) + defer restore() + + // as if called by device manager in task handler + c.Assert(func() { boot.DeviceChange(s.oldUc20dev, s.newUc20dev, nil) }, PanicMatches, + `mock reboot before second complete reseal`) + c.Assert(resealKeysCalls, Equals, 4) + c.Assert(writeModelToBootCalls, Equals, 1) + + err := boot.DeviceChange(s.oldUc20dev, s.newUc20dev, nil) + c.Assert(err, IsNil) + c.Assert(resealKeysCalls, Equals, 7) + c.Assert(writeModelToBootCalls, Equals, 2) + + m, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + currForSealing := boot.ModelUniqueID(m.ModelForSealing()) + tryForSealing := boot.ModelUniqueID(m.TryModelForSealing()) + c.Assert(currForSealing, Equals, "my-brand/my-model-uc20,dangerous,"+newKeyID) + // try model has been cleared + c.Assert(tryForSealing, Equals, "/,,") +} diff --git a/boot/reboot.go b/boot/reboot.go new file mode 100644 index 00000000..1c8b62c7 --- /dev/null +++ b/boot/reboot.go @@ -0,0 +1,125 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package boot + +import ( + "fmt" + "os" + "os/exec" + "time" + + "github.com/snapcore/snapd/bootloader" + "github.com/snapcore/snapd/i18n" + "github.com/snapcore/snapd/osutil" +) + +// rebootArgsPath is used so we can mock the path easily in tests +var rebootArgsPath = "/run/systemd/reboot-param" +var bootloaderFind = bootloader.Find + +type RebootAction int + +const ( + RebootReboot RebootAction = iota + RebootHalt + RebootPoweroff +) + +func (a RebootAction) String() string { + switch a { + case RebootReboot: + return "system reboot" + case RebootHalt: + return "system halt" + case RebootPoweroff: + return "system poweroff" + default: + panic(fmt.Sprintf("unknown reboot action %d", a)) + } +} + +var ( + shutdownMsg = i18n.G("reboot scheduled to update the system") + haltMsg = i18n.G("system halt scheduled") + poweroffMsg = i18n.G("system poweroff scheduled") + + // testingRebootItself is set to true when we want to unit + // test the Reboot function. + testingRebootItself = false +) + +func getRebootArguments(rebootInfo *RebootInfo) (string, error) { + if rebootInfo == nil { + return "", nil + } + + bl, err := bootloaderFind("", rebootInfo.BootloaderOptions) + if err != nil { + return "", fmt.Errorf("cannot resolve bootloader: %v", err) + } + if rbl, ok := bl.(bootloader.RebootBootloader); ok { + return rbl.GetRebootArguments() + } + return "", nil +} + +func Reboot(action RebootAction, rebootDelay time.Duration, rebootInfo *RebootInfo) error { + if osutil.IsTestBinary() && !testingRebootItself { + panic("Reboot must be mocked in tests") + } + + if rebootDelay < 0 { + rebootDelay = 0 + } + mins := int64(rebootDelay / time.Minute) + var arg, msg string + switch action { + case RebootReboot: + arg = "-r" + msg = shutdownMsg + case RebootHalt: + arg = "--halt" + msg = haltMsg + case RebootPoweroff: + arg = "--poweroff" + msg = poweroffMsg + default: + return fmt.Errorf("unknown reboot action: %v", action) + } + + // Use reboot arguments if required by the bootloader + rebArgs, err := getRebootArguments(rebootInfo) + if err != nil { + return err + } + if rebArgs != "" { + if err := osutil.AtomicWriteFile(rebootArgsPath, + []byte(rebArgs+"\n"), 0644, 0); err != nil { + return err + } + } + + cmd := exec.Command("shutdown", arg, fmt.Sprintf("+%d", mins), msg) + if out, err := cmd.CombinedOutput(); err != nil { + os.Remove(rebootArgsPath) + return osutil.OutputErr(out, err) + } + return nil +} diff --git a/boot/reboot_test.go b/boot/reboot_test.go new file mode 100644 index 00000000..21ea9d64 --- /dev/null +++ b/boot/reboot_test.go @@ -0,0 +1,191 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package boot_test + +import ( + "fmt" + "path/filepath" + "time" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/boot" + "github.com/snapcore/snapd/bootloader" + "github.com/snapcore/snapd/bootloader/bootloadertest" + "github.com/snapcore/snapd/testutil" +) + +type rebootSuite struct { + baseBootenvSuite +} + +var _ = Suite(&rebootSuite{}) + +func (s *rebootSuite) SetUpTest(c *C) { + s.baseBootenvSuite.SetUpTest(c) + s.AddCleanup(boot.EnableTestingRebootFunction()) +} + +func (s *rebootSuite) TestRebootActionString(c *C) { + c.Assert(fmt.Sprint(boot.RebootReboot), Equals, "system reboot") + c.Assert(fmt.Sprint(boot.RebootHalt), Equals, "system halt") + c.Assert(fmt.Sprint(boot.RebootPoweroff), Equals, "system poweroff") +} + +func (s *rebootSuite) TestRebootHelper(c *C) { + bl := bootloadertest.Mock("test", "") + bootloader.Force(bl) + s.AddCleanup(func() { bootloader.Force(nil) }) + + cmd := testutil.MockCommand(c, "shutdown", "") + defer cmd.Restore() + + tests := []struct { + delay time.Duration + delayArg string + }{ + {-1, "+0"}, + {0, "+0"}, + {time.Minute, "+1"}, + {10 * time.Minute, "+10"}, + {30 * time.Second, "+0"}, + } + + args := []struct { + a boot.RebootAction + arg string + msg string + }{ + {boot.RebootReboot, "-r", "reboot scheduled to update the system"}, + {boot.RebootHalt, "--halt", "system halt scheduled"}, + {boot.RebootPoweroff, "--poweroff", "system poweroff scheduled"}, + } + + for _, arg := range args { + for _, t := range tests { + err := boot.Reboot(arg.a, t.delay, nil) + c.Assert(err, IsNil) + c.Check(cmd.Calls(), DeepEquals, [][]string{ + {"shutdown", arg.arg, t.delayArg, arg.msg}, + }) + + cmd.ForgetCalls() + } + } +} + +func (s *rebootSuite) TestRebootWithBootloaderError(c *C) { + rbl := bootloadertest.Mock("rebootargs", "") + bootloader.Force(rbl) + s.AddCleanup(func() { bootloader.Force(nil) }) + + r := boot.MockBootloaderFind(func(rootdir string, opts *bootloader.Options) (bootloader.Bootloader, error) { + c.Check(rootdir, Equals, "") + c.Check(opts, IsNil) + return nil, fmt.Errorf("oh no") + }) + defer r() + + cmd := testutil.MockCommand(c, "shutdown", "") + defer cmd.Restore() + + err := boot.Reboot(0, 0, &boot.RebootInfo{ + BootloaderOptions: nil, + }) + c.Assert(err, ErrorMatches, `cannot resolve bootloader: oh no`) + c.Check(cmd.Calls(), HasLen, 0) +} + +func (s *rebootSuite) TestRebootWithBootloader(c *C) { + rbl := bootloadertest.Mock("rebootargs", "") + bootloader.Force(rbl) + s.AddCleanup(func() { bootloader.Force(nil) }) + + // still get the file-path so we can ensure that the file + // has not been written + dir := c.MkDir() + rebArgsPath := filepath.Join(dir, "reboot-param") + restoreRebootArgs := boot.MockRebootArgsPath(rebArgsPath) + defer restoreRebootArgs() + + cmd := testutil.MockCommand(c, "shutdown", "") + defer cmd.Restore() + + err := boot.Reboot(0, 0, &boot.RebootInfo{ + BootloaderOptions: &bootloader.Options{ + Role: bootloader.RoleRunMode, + }, + }) + c.Assert(err, IsNil) + + // ensure the arguments file is absent + c.Assert(rebArgsPath, testutil.FileAbsent) + c.Check(cmd.Calls(), DeepEquals, [][]string{ + {"shutdown", "-r", "+0", "reboot scheduled to update the system"}, + }) +} + +func (s *rebootSuite) TestRebootWithRebootBootloader(c *C) { + rbl := bootloadertest.Mock("rebootargs", "").WithRebootBootloader() + bootloader.Force(rbl) + s.AddCleanup(func() { bootloader.Force(nil) }) + rbl.RebootArgs = "0 tryboot" + dir := c.MkDir() + rebArgsPath := filepath.Join(dir, "reboot-param") + restoreRebootArgs := boot.MockRebootArgsPath(rebArgsPath) + defer restoreRebootArgs() + + cmd := testutil.MockCommand(c, "shutdown", "") + defer cmd.Restore() + + err := boot.Reboot(0, 0, &boot.RebootInfo{ + BootloaderOptions: &bootloader.Options{ + Role: bootloader.RoleRunMode, + }, + }) + c.Assert(err, IsNil) + c.Assert(rebArgsPath, testutil.FileEquals, "0 tryboot\n") + c.Check(cmd.Calls(), DeepEquals, [][]string{ + {"shutdown", "-r", "+0", "reboot scheduled to update the system"}, + }) +} + +func (s *rebootSuite) TestRebootWithRebootBootloaderNoArguments(c *C) { + rbl := bootloadertest.Mock("rebootargs", "").WithRebootBootloader() + bootloader.Force(rbl) + s.AddCleanup(func() { bootloader.Force(nil) }) + rbl.RebootArgs = "" + dir := c.MkDir() + rebArgsPath := filepath.Join(dir, "reboot-param") + restoreRebootArgs := boot.MockRebootArgsPath(rebArgsPath) + defer restoreRebootArgs() + + cmd := testutil.MockCommand(c, "shutdown", "") + defer cmd.Restore() + + err := boot.Reboot(0, 0, nil) + c.Assert(err, IsNil) + + // ensure the arguments file is absent + c.Assert(rebArgsPath, testutil.FileAbsent) + c.Check(cmd.Calls(), DeepEquals, [][]string{ + {"shutdown", "-r", "+0", "reboot scheduled to update the system"}, + }) +} diff --git a/boot/seal.go b/boot/seal.go new file mode 100644 index 00000000..c8911f9f --- /dev/null +++ b/boot/seal.go @@ -0,0 +1,1019 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020-2023 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package boot + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/bootloader" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/gadget/device" + "github.com/snapcore/snapd/kernel/fde" + "github.com/snapcore/snapd/logger" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/secboot" + "github.com/snapcore/snapd/secboot/keys" + "github.com/snapcore/snapd/seed" + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/strutil" + "github.com/snapcore/snapd/timings" +) + +var ( + secbootProvisionTPM = secboot.ProvisionTPM + secbootSealKeys = secboot.SealKeys + secbootSealKeysWithFDESetupHook = secboot.SealKeysWithFDESetupHook + secbootResealKeys = secboot.ResealKeys + secbootPCRHandleOfSealedKey = secboot.PCRHandleOfSealedKey + secbootReleasePCRResourceHandles = secboot.ReleasePCRResourceHandles + + seedReadSystemEssential = seed.ReadSystemEssential +) + +// Hook functions setup by devicestate to support device-specific full +// disk encryption implementations. The state must be locked when these +// functions are called. +var ( + // HasFDESetupHook purpose is to detect if the target kernel has a + // fde-setup-hook. If kernelInfo is nil the current kernel is checked + // assuming it is representative` of the target one. + HasFDESetupHook = func(kernelInfo *snap.Info) (bool, error) { + return false, nil + } + RunFDESetupHook fde.RunSetupHookFunc = func(req *fde.SetupRequest) ([]byte, error) { + return nil, fmt.Errorf("internal error: RunFDESetupHook not set yet") + } +) + +// MockSecbootResealKeys is only useful in testing. Note that this is a very low +// level call and may need significant environment setup. +func MockSecbootResealKeys(f func(params *secboot.ResealKeysParams) error) (restore func()) { + osutil.MustBeTestBinary("secbootResealKeys only can be mocked in tests") + old := secbootResealKeys + secbootResealKeys = f + return func() { + secbootResealKeys = old + } +} + +// MockResealKeyToModeenv is only useful in testing. +func MockResealKeyToModeenv(f func(rootdir string, modeenv *Modeenv, expectReseal bool, unlocker Unlocker) error) (restore func()) { + osutil.MustBeTestBinary("resealKeyToModeenv only can be mocked in tests") + old := resealKeyToModeenv + resealKeyToModeenv = f + return func() { + resealKeyToModeenv = old + } +} + +// MockSealKeyToModeenvFlags is used for testing from other packages. +type MockSealKeyToModeenvFlags = sealKeyToModeenvFlags + +// MockSealKeyToModeenv is used for testing from other packages. +func MockSealKeyToModeenv(f func(key, saveKey keys.EncryptionKey, model *asserts.Model, modeenv *Modeenv, flags MockSealKeyToModeenvFlags) error) (restore func()) { + old := sealKeyToModeenv + sealKeyToModeenv = f + return func() { + sealKeyToModeenv = old + } +} + +func bootChainsFileUnder(rootdir string) string { + return filepath.Join(dirs.SnapFDEDirUnder(rootdir), "boot-chains") +} + +func recoveryBootChainsFileUnder(rootdir string) string { + return filepath.Join(dirs.SnapFDEDirUnder(rootdir), "recovery-boot-chains") +} + +type sealKeyToModeenvFlags struct { + // HasFDESetupHook is true if the kernel has a fde-setup hook to use + HasFDESetupHook bool + // FactoryReset indicates that the sealing is happening during factory + // reset. + FactoryReset bool + // SnapsDir is set to provide a non-default directory to find + // run mode snaps in. + SnapsDir string + // SeedDir is the path where to find mounted seed with + // essential snaps. + SeedDir string + // Unlocker is used unlock the snapd state for long operations + StateUnlocker Unlocker +} + +// sealKeyToModeenvImpl seals the supplied keys to the parameters specified +// in modeenv. +// It assumes to be invoked in install mode. +func sealKeyToModeenvImpl(key, saveKey keys.EncryptionKey, model *asserts.Model, modeenv *Modeenv, flags sealKeyToModeenvFlags) error { + if !isModeeenvLocked() { + return fmt.Errorf("internal error: cannot seal without the modeenv lock") + } + + // make sure relevant locations exist + for _, p := range []string{ + InitramfsSeedEncryptionKeyDir, + InitramfsBootEncryptionKeyDir, + InstallHostFDEDataDir(model), + InstallHostFDESaveDir, + } { + // XXX: should that be 0700 ? + if err := os.MkdirAll(p, 0755); err != nil { + return err + } + } + + if flags.HasFDESetupHook { + return sealKeyToModeenvUsingFDESetupHook(key, saveKey, model, modeenv, flags) + } + + if flags.StateUnlocker != nil { + relock := flags.StateUnlocker() + defer relock() + } + return sealKeyToModeenvUsingSecboot(key, saveKey, model, modeenv, flags) +} + +func runKeySealRequests(key keys.EncryptionKey) []secboot.SealKeyRequest { + return []secboot.SealKeyRequest{ + { + Key: key, + KeyName: "ubuntu-data", + KeyFile: device.DataSealedKeyUnder(InitramfsBootEncryptionKeyDir), + }, + } +} + +func fallbackKeySealRequests(key, saveKey keys.EncryptionKey, factoryReset bool) []secboot.SealKeyRequest { + saveFallbackKey := device.FallbackSaveSealedKeyUnder(InitramfsSeedEncryptionKeyDir) + + if factoryReset { + // factory reset uses alternative sealed key location, such that + // until we boot into the run mode, both sealed keys are present + // on disk + saveFallbackKey = device.FactoryResetFallbackSaveSealedKeyUnder(InitramfsSeedEncryptionKeyDir) + } + return []secboot.SealKeyRequest{ + { + Key: key, + KeyName: "ubuntu-data", + KeyFile: device.FallbackDataSealedKeyUnder(InitramfsSeedEncryptionKeyDir), + }, + { + Key: saveKey, + KeyName: "ubuntu-save", + KeyFile: saveFallbackKey, + }, + } +} + +func sealKeyToModeenvUsingFDESetupHook(key, saveKey keys.EncryptionKey, model *asserts.Model, modeenv *Modeenv, flags sealKeyToModeenvFlags) error { + // XXX: Move the auxKey creation to a more generic place, see + // PR#10123 for a possible way of doing this. However given + // that the equivalent key for the TPM case is also created in + // sealKeyToModeenvUsingTPM more symetric to create the auxKey + // here and when we also move TPM to use the auxKey to move + // the creation of it. + auxKey, err := keys.NewAuxKey() + if err != nil { + return fmt.Errorf("cannot create aux key: %v", err) + } + params := secboot.SealKeysWithFDESetupHookParams{ + Model: modeenv.ModelForSealing(), + AuxKey: auxKey, + AuxKeyFile: filepath.Join(InstallHostFDESaveDir, "aux-key"), + } + factoryReset := flags.FactoryReset + skrs := append(runKeySealRequests(key), fallbackKeySealRequests(key, saveKey, factoryReset)...) + if err := secbootSealKeysWithFDESetupHook(RunFDESetupHook, skrs, ¶ms); err != nil { + return err + } + + if err := device.StampSealedKeys(InstallHostWritableDir(model), "fde-setup-hook"); err != nil { + return err + } + + return nil +} + +func sealKeyToModeenvUsingSecboot(key, saveKey keys.EncryptionKey, model *asserts.Model, modeenv *Modeenv, flags sealKeyToModeenvFlags) error { + // build the recovery mode boot chain + rbl, err := bootloader.Find(InitramfsUbuntuSeedDir, &bootloader.Options{ + Role: bootloader.RoleRecovery, + }) + if err != nil { + return fmt.Errorf("cannot find the recovery bootloader: %v", err) + } + tbl, ok := rbl.(bootloader.TrustedAssetsBootloader) + if !ok { + // TODO:UC20: later the exact kind of bootloaders we expect here might change + return fmt.Errorf("internal error: cannot seal keys without a trusted assets bootloader") + } + + includeTryModel := false + systems := []string{modeenv.RecoverySystem} + modes := map[string][]string{ + // the system we are installing from is considered current and + // tested, hence allow both recover and factory reset modes + modeenv.RecoverySystem: {ModeRecover, ModeFactoryReset}, + } + recoveryBootChains, err := recoveryBootChainsForSystems(systems, modes, tbl, modeenv, includeTryModel, flags.SeedDir) + if err != nil { + return fmt.Errorf("cannot compose recovery boot chains: %v", err) + } + logger.Debugf("recovery bootchain:\n%+v", recoveryBootChains) + + // build the run mode boot chains + bl, err := bootloader.Find(InitramfsUbuntuBootDir, &bootloader.Options{ + Role: bootloader.RoleRunMode, + NoSlashBoot: true, + }) + if err != nil { + return fmt.Errorf("cannot find the bootloader: %v", err) + } + + // kernel command lines are filled during install + cmdlines := modeenv.CurrentKernelCommandLines + runModeBootChains, err := runModeBootChains(rbl, bl, modeenv, cmdlines, flags.SnapsDir) + if err != nil { + return fmt.Errorf("cannot compose run mode boot chains: %v", err) + } + logger.Debugf("run mode bootchain:\n%+v", runModeBootChains) + + pbc := toPredictableBootChains(append(runModeBootChains, recoveryBootChains...)) + + roleToBlName := map[bootloader.Role]string{ + bootloader.RoleRecovery: rbl.Name(), + bootloader.RoleRunMode: bl.Name(), + } + + // the boot chains we seal the fallback object to + rpbc := toPredictableBootChains(recoveryBootChains) + + // gets written to a file by sealRunObjectKeys() + authKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return fmt.Errorf("cannot generate key for signing dynamic authorization policies: %v", err) + } + + runObjectKeyPCRHandle := uint32(secboot.RunObjectPCRPolicyCounterHandle) + fallbackObjectKeyPCRHandle := uint32(secboot.FallbackObjectPCRPolicyCounterHandle) + if flags.FactoryReset { + // during factory reset we may need to rotate the PCR handles, + // seal the new keys using a new set of handles such that the + // old sealed ubuntu-save key is still usable, for this we + // switch between two sets of handles in a round robin fashion, + // first looking at the PCR handle used by the current fallback + // key and then using the other set when sealing the new keys; + // the currently used handles will be released during the first + // boot of a new run system + usesAlt, err := usesAltPCRHandles() + if err != nil { + return err + } + if !usesAlt { + logger.Noticef("using alternative PCR handles") + runObjectKeyPCRHandle = secboot.AltRunObjectPCRPolicyCounterHandle + fallbackObjectKeyPCRHandle = secboot.AltFallbackObjectPCRPolicyCounterHandle + } + } + + // we are preparing a new system, hence the TPM needs to be provisioned + lockoutAuthFile := device.TpmLockoutAuthUnder(InstallHostFDESaveDir) + tpmProvisionMode := secboot.TPMProvisionFull + if flags.FactoryReset { + tpmProvisionMode = secboot.TPMPartialReprovision + } + if err := secbootProvisionTPM(tpmProvisionMode, lockoutAuthFile); err != nil { + return err + } + + if flags.FactoryReset { + // it is possible that we are sealing the keys again, after a + // previously running factory reset was interrupted by a reboot, + // in which case the PCR handles of the new sealed keys might + // have already been used + if err := secbootReleasePCRResourceHandles(runObjectKeyPCRHandle, fallbackObjectKeyPCRHandle); err != nil { + return err + } + } + + // TODO: refactor sealing functions to take a struct instead of so many + // parameters + err = sealRunObjectKeys(key, pbc, authKey, roleToBlName, runObjectKeyPCRHandle) + if err != nil { + return err + } + + err = sealFallbackObjectKeys(key, saveKey, rpbc, authKey, roleToBlName, flags.FactoryReset, + fallbackObjectKeyPCRHandle) + if err != nil { + return err + } + + if err := device.StampSealedKeys(InstallHostWritableDir(model), device.SealingMethodTPM); err != nil { + return err + } + + installBootChainsPath := bootChainsFileUnder(InstallHostWritableDir(model)) + if err := writeBootChains(pbc, installBootChainsPath, 0); err != nil { + return err + } + + installRecoveryBootChainsPath := recoveryBootChainsFileUnder(InstallHostWritableDir(model)) + if err := writeBootChains(rpbc, installRecoveryBootChainsPath, 0); err != nil { + return err + } + + return nil +} + +func usesAltPCRHandles() (bool, error) { + saveFallbackKey := device.FallbackSaveSealedKeyUnder(InitramfsSeedEncryptionKeyDir) + // inspect the PCR handle of the ubuntu-save fallback key + handle, err := secbootPCRHandleOfSealedKey(saveFallbackKey) + if err != nil { + return false, err + } + logger.Noticef("fallback sealed key %v PCR handle: %#x", saveFallbackKey, handle) + return handle == secboot.AltFallbackObjectPCRPolicyCounterHandle, nil +} + +func sealRunObjectKeys(key keys.EncryptionKey, pbc predictableBootChains, authKey *ecdsa.PrivateKey, roleToBlName map[bootloader.Role]string, pcrHandle uint32) error { + modelParams, err := sealKeyModelParams(pbc, roleToBlName) + if err != nil { + return fmt.Errorf("cannot prepare for key sealing: %v", err) + } + + sealKeyParams := &secboot.SealKeysParams{ + ModelParams: modelParams, + TPMPolicyAuthKey: authKey, + TPMPolicyAuthKeyFile: filepath.Join(InstallHostFDESaveDir, "tpm-policy-auth-key"), + PCRPolicyCounterHandle: pcrHandle, + } + + logger.Debugf("sealing run key with PCR handle: %#x", sealKeyParams.PCRPolicyCounterHandle) + // The run object contains only the ubuntu-data key; the ubuntu-save key + // is then stored inside the encrypted data partition, so that the normal run + // path only unseals one object because unsealing is expensive. + // Furthermore, the run object key is stored on ubuntu-boot so that we do not + // need to continually write/read keys from ubuntu-seed. + if err := secbootSealKeys(runKeySealRequests(key), sealKeyParams); err != nil { + return fmt.Errorf("cannot seal the encryption keys: %v", err) + } + + return nil +} + +func sealFallbackObjectKeys(key, saveKey keys.EncryptionKey, pbc predictableBootChains, authKey *ecdsa.PrivateKey, roleToBlName map[bootloader.Role]string, factoryReset bool, pcrHandle uint32) error { + // also seal the keys to the recovery bootchains as a fallback + modelParams, err := sealKeyModelParams(pbc, roleToBlName) + if err != nil { + return fmt.Errorf("cannot prepare for fallback key sealing: %v", err) + } + sealKeyParams := &secboot.SealKeysParams{ + ModelParams: modelParams, + TPMPolicyAuthKey: authKey, + PCRPolicyCounterHandle: pcrHandle, + } + logger.Debugf("sealing fallback key with PCR handle: %#x", sealKeyParams.PCRPolicyCounterHandle) + // The fallback object contains the ubuntu-data and ubuntu-save keys. The + // key files are stored on ubuntu-seed, separate from ubuntu-data so they + // can be used if ubuntu-data and ubuntu-boot are corrupted or unavailable. + + if err := secbootSealKeys(fallbackKeySealRequests(key, saveKey, factoryReset), sealKeyParams); err != nil { + return fmt.Errorf("cannot seal the fallback encryption keys: %v", err) + } + + return nil +} + +var resealKeyToModeenv = resealKeyToModeenvImpl + +// resealKeyToModeenv reseals the existing encryption key to the +// parameters specified in modeenv. +// It is *very intentional* that resealing takes the modeenv and only +// the modeenv as input. modeenv content is well defined and updated +// atomically. In particular we want to avoid resealing against +// transient/in-memory information with the risk that successive +// reseals during in-progress operations produce diverging outcomes. +func resealKeyToModeenvImpl(rootdir string, modeenv *Modeenv, expectReseal bool, unlocker Unlocker) error { + if !isModeeenvLocked() { + return fmt.Errorf("internal error: cannot reseal without the modeenv lock") + } + + method, err := device.SealedKeysMethod(rootdir) + if err == device.ErrNoSealedKeys { + // nothing to do + return nil + } + if err != nil { + return err + } + switch method { + case device.SealingMethodFDESetupHook: + return resealKeyToModeenvUsingFDESetupHook(rootdir, modeenv, expectReseal) + case device.SealingMethodTPM, device.SealingMethodLegacyTPM: + if unlocker != nil { + // unlock/relock global state + defer unlocker()() + } + return resealKeyToModeenvSecboot(rootdir, modeenv, expectReseal) + default: + return fmt.Errorf("unknown key sealing method: %q", method) + } +} + +var resealKeyToModeenvUsingFDESetupHook = resealKeyToModeenvUsingFDESetupHookImpl + +func resealKeyToModeenvUsingFDESetupHookImpl(rootdir string, modeenv *Modeenv, expectReseal bool) error { + // TODO: we need to implement reseal at least in terms of + // rebinding the keys to models on remodeling + + // TODO: If we have situations that do TPM-like full sealing then: + // Implement reseal using the fde-setup hook. This will + // require a helper like "FDEShouldResealUsingSetupHook" + // that will be set by devicestate and returns (bool, + // error). It needs to return "false" during seeding + // because then there is no kernel available yet. It + // can though return true as soon as there's an active + // kernel if seeded is false + // + // It will also need to run HasFDESetupHook internally + // and return an error if the hook goes missing + // (e.g. because a kernel refresh losses the hook by + // accident). It could also run features directly and + // check for "reseal" in features. + return nil +} + +// TODO:UC20: allow more than one model to accommodate the remodel scenario +func resealKeyToModeenvSecboot(rootdir string, modeenv *Modeenv, expectReseal bool) error { + // build the recovery mode boot chain + rbl, err := bootloader.Find(InitramfsUbuntuSeedDir, &bootloader.Options{ + Role: bootloader.RoleRecovery, + }) + if err != nil { + return fmt.Errorf("cannot find the recovery bootloader: %v", err) + } + tbl, ok := rbl.(bootloader.TrustedAssetsBootloader) + if !ok { + // TODO:UC20: later the exact kind of bootloaders we expect here might change + return fmt.Errorf("internal error: sealed keys but not a trusted assets bootloader") + } + // derive the allowed modes for each system mentioned in the modeenv + modes := modesForSystems(modeenv) + + // the recovery boot chains for the run key are generated for all + // recovery systems, including those that are being tried; since this is + // a run key, the boot chains are generated for both models to + // accommodate the dynamics of a remodel + includeTryModel := true + recoveryBootChainsForRunKey, err := recoveryBootChainsForSystems(modeenv.CurrentRecoverySystems, modes, tbl, + modeenv, includeTryModel, dirs.SnapSeedDir) + if err != nil { + return fmt.Errorf("cannot compose recovery boot chains for run key: %v", err) + } + + // the boot chains for recovery keys include only those system that were + // tested and are known to be good + testedRecoverySystems := modeenv.GoodRecoverySystems + if len(testedRecoverySystems) == 0 && len(modeenv.CurrentRecoverySystems) > 0 { + // compatibility for systems where good recovery systems list + // has not been populated yet + testedRecoverySystems = modeenv.CurrentRecoverySystems[:1] + logger.Noticef("no good recovery systems for reseal, fallback to known current system %v", + testedRecoverySystems[0]) + } + // use the current model as the recovery keys are not expected to be + // used during a remodel + includeTryModel = false + recoveryBootChains, err := recoveryBootChainsForSystems(testedRecoverySystems, modes, tbl, modeenv, includeTryModel, dirs.SnapSeedDir) + if err != nil { + return fmt.Errorf("cannot compose recovery boot chains: %v", err) + } + + // build the run mode boot chains + bl, err := bootloader.Find(InitramfsUbuntuBootDir, &bootloader.Options{ + Role: bootloader.RoleRunMode, + NoSlashBoot: true, + }) + if err != nil { + return fmt.Errorf("cannot find the bootloader: %v", err) + } + cmdlines, err := kernelCommandLinesForResealWithFallback(modeenv) + if err != nil { + return err + } + runModeBootChains, err := runModeBootChains(rbl, bl, modeenv, cmdlines, "") + if err != nil { + return fmt.Errorf("cannot compose run mode boot chains: %v", err) + } + + roleToBlName := map[bootloader.Role]string{ + bootloader.RoleRecovery: rbl.Name(), + bootloader.RoleRunMode: bl.Name(), + } + saveFDEDir := dirs.SnapFDEDirUnderSave(dirs.SnapSaveDirUnder(rootdir)) + authKeyFile := filepath.Join(saveFDEDir, "tpm-policy-auth-key") + + // reseal the run object + pbc := toPredictableBootChains(append(runModeBootChains, recoveryBootChainsForRunKey...)) + + needed, nextCount, err := isResealNeeded(pbc, bootChainsFileUnder(rootdir), expectReseal) + if err != nil { + return err + } + if needed { + pbcJSON, _ := json.Marshal(pbc) + logger.Debugf("resealing (%d) to boot chains: %s", nextCount, pbcJSON) + + if err := resealRunObjectKeys(pbc, authKeyFile, roleToBlName); err != nil { + return err + } + logger.Debugf("resealing (%d) succeeded", nextCount) + + bootChainsPath := bootChainsFileUnder(rootdir) + if err := writeBootChains(pbc, bootChainsPath, nextCount); err != nil { + return err + } + } else { + logger.Debugf("reseal not necessary") + } + + // reseal the fallback object + rpbc := toPredictableBootChains(recoveryBootChains) + + var nextFallbackCount int + needed, nextFallbackCount, err = isResealNeeded(rpbc, recoveryBootChainsFileUnder(rootdir), expectReseal) + if err != nil { + return err + } + if needed { + rpbcJSON, _ := json.Marshal(rpbc) + logger.Debugf("resealing (%d) to recovery boot chains: %s", nextFallbackCount, rpbcJSON) + + if err := resealFallbackObjectKeys(rpbc, authKeyFile, roleToBlName); err != nil { + return err + } + logger.Debugf("fallback resealing (%d) succeeded", nextFallbackCount) + + recoveryBootChainsPath := recoveryBootChainsFileUnder(rootdir) + if err := writeBootChains(rpbc, recoveryBootChainsPath, nextFallbackCount); err != nil { + return err + } + } else { + logger.Debugf("fallback reseal not necessary") + } + + return nil +} + +func resealRunObjectKeys(pbc predictableBootChains, authKeyFile string, roleToBlName map[bootloader.Role]string) error { + // get model parameters from bootchains + modelParams, err := sealKeyModelParams(pbc, roleToBlName) + if err != nil { + return fmt.Errorf("cannot prepare for key resealing: %v", err) + } + + // list all the key files to reseal + keyFiles := []string{device.DataSealedKeyUnder(InitramfsBootEncryptionKeyDir)} + + resealKeyParams := &secboot.ResealKeysParams{ + ModelParams: modelParams, + KeyFiles: keyFiles, + TPMPolicyAuthKeyFile: authKeyFile, + } + if err := secbootResealKeys(resealKeyParams); err != nil { + return fmt.Errorf("cannot reseal the encryption key: %v", err) + } + + return nil +} + +func resealFallbackObjectKeys(pbc predictableBootChains, authKeyFile string, roleToBlName map[bootloader.Role]string) error { + // get model parameters from bootchains + modelParams, err := sealKeyModelParams(pbc, roleToBlName) + if err != nil { + return fmt.Errorf("cannot prepare for fallback key resealing: %v", err) + } + + // list all the key files to reseal + keyFiles := []string{ + device.FallbackDataSealedKeyUnder(InitramfsSeedEncryptionKeyDir), + device.FallbackSaveSealedKeyUnder(InitramfsSeedEncryptionKeyDir), + } + + resealKeyParams := &secboot.ResealKeysParams{ + ModelParams: modelParams, + KeyFiles: keyFiles, + TPMPolicyAuthKeyFile: authKeyFile, + } + if err := secbootResealKeys(resealKeyParams); err != nil { + return fmt.Errorf("cannot reseal the fallback encryption keys: %v", err) + } + + return nil +} + +// recoveryModesForSystems returns a map for recovery modes for recovery systems +// mentioned in the modeenv. The returned map contains both tested and candidate +// recovery systems +func modesForSystems(modeenv *Modeenv) map[string][]string { + if len(modeenv.GoodRecoverySystems) == 0 && len(modeenv.CurrentRecoverySystems) == 0 { + return nil + } + + systemToModes := map[string][]string{} + + // first go through tested recovery systems + modesForTestedSystem := []string{ModeRecover, ModeFactoryReset} + // tried systems can only boot to recovery mode + modesForCandidateSystem := []string{ModeRecover} + + // go through current recovery systems which can contain both tried + // systems and candidate ones + for _, sys := range modeenv.CurrentRecoverySystems { + systemToModes[sys] = modesForCandidateSystem + } + // go through recovery systems that were tested and update their modes + for _, sys := range modeenv.GoodRecoverySystems { + systemToModes[sys] = modesForTestedSystem + } + return systemToModes +} + +// TODO:UC20: this needs to take more than one model to accommodate the remodel +// scenario +func recoveryBootChainsForSystems(systems []string, modesForSystems map[string][]string, trbl bootloader.TrustedAssetsBootloader, modeenv *Modeenv, includeTryModel bool, seedDir string) (chains []bootChain, err error) { + trustedAssets, err := trbl.TrustedAssets() + if err != nil { + return nil, err + } + + chainsForModel := func(model secboot.ModelForSealing) error { + modelID := modelUniqueID(model) + for _, system := range systems { + // get kernel and gadget information from seed + perf := timings.New(nil) + seedSystemModel, snaps, err := seedReadSystemEssential(seedDir, system, []snap.Type{snap.TypeKernel, snap.TypeGadget}, perf) + if err != nil { + return fmt.Errorf("cannot read system %q seed: %v", system, err) + } + if len(snaps) != 2 { + return fmt.Errorf("cannot obtain recovery system snaps") + } + seedModelID := modelUniqueID(seedSystemModel) + // TODO: the generated unique ID contains the model's + // sign key ID, consider relaxing this to ignore the key + // ID when matching models, OTOH we would need to + // properly take into account key expiration and + // revocation + if seedModelID != modelID { + // could be an incompatible recovery system that + // is still currently tracked in modeenv + continue + } + seedKernel, seedGadget := snaps[0], snaps[1] + if snaps[0].EssentialType == snap.TypeGadget { + seedKernel, seedGadget = seedGadget, seedKernel + } + + var cmdlines []string + modes, ok := modesForSystems[system] + if !ok { + return fmt.Errorf("internal error: no modes for system %q", system) + } + for _, mode := range modes { + // get the command line for this mode + cmdline, err := composeCommandLine(currentEdition, mode, system, seedGadget.Path, model) + if err != nil { + return fmt.Errorf("cannot obtain kernel command line for mode %q: %v", mode, err) + } + cmdlines = append(cmdlines, cmdline) + } + + var kernelRev string + if seedKernel.SideInfo.Revision.Store() { + kernelRev = seedKernel.SideInfo.Revision.String() + } + + recoveryBootChains, err := trbl.RecoveryBootChains(seedKernel.Path) + if err != nil { + return err + } + + foundChain := false + + // get asset chains + for _, recoveryBootChain := range recoveryBootChains { + assetChain, kbf, err := buildBootAssets(recoveryBootChain, modeenv, trustedAssets) + if err != nil { + return err + } + if assetChain == nil { + // This chain is not used as + // it is not in the modeenv, + // we expect another chain to + // work. + continue + } + + chains = append(chains, bootChain{ + BrandID: model.BrandID(), + Model: model.Model(), + // TODO: test this + Classic: model.Classic(), + Grade: model.Grade(), + ModelSignKeyID: model.SignKeyID(), + AssetChain: assetChain, + Kernel: seedKernel.SnapName(), + KernelRevision: kernelRev, + KernelCmdlines: cmdlines, + kernelBootFile: kbf, + }) + + foundChain = true + } + + if !foundChain { + return fmt.Errorf("could not find any valid chain for this model") + } + } + return nil + } + + if err := chainsForModel(modeenv.ModelForSealing()); err != nil { + return nil, err + } + + if modeenv.TryModel != "" && includeTryModel { + if err := chainsForModel(modeenv.TryModelForSealing()); err != nil { + return nil, err + } + } + + return chains, nil +} + +func runModeBootChains(rbl, bl bootloader.Bootloader, modeenv *Modeenv, cmdlines []string, runSnapsDir string) ([]bootChain, error) { + tbl, ok := rbl.(bootloader.TrustedAssetsBootloader) + if !ok { + return nil, fmt.Errorf("recovery bootloader doesn't support trusted assets") + } + chains := make([]bootChain, 0, len(modeenv.CurrentKernels)) + + trustedAssets, err := tbl.TrustedAssets() + if err != nil { + return nil, err + } + + chainsForModel := func(model secboot.ModelForSealing) error { + for _, k := range modeenv.CurrentKernels { + info, err := snap.ParsePlaceInfoFromSnapFileName(k) + if err != nil { + return err + } + var kernelPath string + if runSnapsDir == "" { + kernelPath = info.MountFile() + } else { + kernelPath = filepath.Join(runSnapsDir, info.Filename()) + } + runModeBootChains, err := tbl.BootChains(bl, kernelPath) + if err != nil { + return err + } + + foundChain := false + + for _, runModeBootChain := range runModeBootChains { + // get asset chains + assetChain, kbf, err := buildBootAssets(runModeBootChain, modeenv, trustedAssets) + if err != nil { + return err + } + if assetChain == nil { + // This chain is not used as + // it is not in the modeenv, + // we expect another chain to + // work. + continue + } + var kernelRev string + if info.SnapRevision().Store() { + kernelRev = info.SnapRevision().String() + } + chains = append(chains, bootChain{ + BrandID: model.BrandID(), + Model: model.Model(), + // TODO: test this + Classic: model.Classic(), + Grade: model.Grade(), + ModelSignKeyID: model.SignKeyID(), + AssetChain: assetChain, + Kernel: info.SnapName(), + KernelRevision: kernelRev, + KernelCmdlines: cmdlines, + kernelBootFile: kbf, + }) + foundChain = true + } + + if !foundChain { + return fmt.Errorf("could not find any valid chain for this model") + } + } + return nil + } + if err := chainsForModel(modeenv.ModelForSealing()); err != nil { + return nil, err + } + + if modeenv.TryModel != "" { + if err := chainsForModel(modeenv.TryModelForSealing()); err != nil { + return nil, err + } + } + return chains, nil +} + +// buildBootAssets takes the BootFiles of a bootloader boot chain and +// produces corresponding bootAssets with the matching current asset +// hashes from modeenv plus it returns separately the last BootFile +// which is for the kernel. +func buildBootAssets(bootFiles []bootloader.BootFile, modeenv *Modeenv, trustedAssets map[string]string) (assets []bootAsset, kernel bootloader.BootFile, err error) { + if len(bootFiles) == 0 { + // useful in testing, when mocking is insufficient + return nil, bootloader.BootFile{}, fmt.Errorf("internal error: cannot build boot assets without boot files") + } + assets = make([]bootAsset, len(bootFiles)-1) + + // the last element is the kernel which is not a boot asset + for i, bf := range bootFiles[:len(bootFiles)-1] { + path := bf.Path + name, ok := trustedAssets[path] + if !ok { + return nil, kernel, fmt.Errorf("internal error: asset '%s' is not considered a trusted asset for the bootloader", path) + } + var hashes []string + if bf.Role == bootloader.RoleRecovery { + hashes, ok = modeenv.CurrentTrustedRecoveryBootAssets[name] + } else { + hashes, ok = modeenv.CurrentTrustedBootAssets[name] + } + if !ok { + // We have not found an asset for this + // chain. There are chains expected to not + // exist. So we return without error. + // recoveryBootChainsForSystems and + // runModeBootChains will fail if no chain is + // found + return nil, kernel, nil + } + assets[i] = bootAsset{ + Role: bf.Role, + Name: name, + Hashes: hashes, + } + } + + return assets, bootFiles[len(bootFiles)-1], nil +} + +func sealKeyModelParams(pbc predictableBootChains, roleToBlName map[bootloader.Role]string) ([]*secboot.SealKeyModelParams, error) { + // seal parameters keyed by unique model ID + modelToParams := map[string]*secboot.SealKeyModelParams{} + modelParams := make([]*secboot.SealKeyModelParams, 0, len(pbc)) + + for _, bc := range pbc { + modelForSealing := bc.modelForSealing() + modelID := modelUniqueID(modelForSealing) + const expectNew = false + loadChains, err := bootAssetsToLoadChains(bc.AssetChain, bc.kernelBootFile, roleToBlName, expectNew) + if err != nil { + return nil, fmt.Errorf("cannot build load chains with current boot assets: %s", err) + } + + // group parameters by model, reuse an existing SealKeyModelParams + // if the model is the same. + if params, ok := modelToParams[modelID]; ok { + params.KernelCmdlines = strutil.SortedListsUniqueMerge(params.KernelCmdlines, bc.KernelCmdlines) + params.EFILoadChains = append(params.EFILoadChains, loadChains...) + } else { + param := &secboot.SealKeyModelParams{ + Model: modelForSealing, + KernelCmdlines: bc.KernelCmdlines, + EFILoadChains: loadChains, + } + modelParams = append(modelParams, param) + modelToParams[modelID] = param + } + } + + return modelParams, nil +} + +// isResealNeeded returns true when the predictable boot chains provided as +// input do not match the cached boot chains on disk under rootdir. +// It also returns the next value for the reseal count that is saved +// together with the boot chains. +// A hint expectReseal can be provided, it is used when the matching +// is ambigous because the boot chains contain unrevisioned kernels. +func isResealNeeded(pbc predictableBootChains, bootChainsFile string, expectReseal bool) (ok bool, nextCount int, err error) { + previousPbc, c, err := readBootChains(bootChainsFile) + if err != nil { + return false, 0, err + } + + switch predictableBootChainsEqualForReseal(pbc, previousPbc) { + case bootChainEquivalent: + return false, c + 1, nil + case bootChainUnrevisioned: + return expectReseal, c + 1, nil + case bootChainDifferent: + } + return true, c + 1, nil +} + +func postFactoryResetCleanupSecboot() error { + // we are inspecting a key which was generated during factory reset, in + // the simplest case the sealed key generated previously used the main + // handles, while the current key uses alt handles, hence we need to + // release the main handles corresponding to the old key + handles := []uint32{secboot.RunObjectPCRPolicyCounterHandle, secboot.FallbackObjectPCRPolicyCounterHandle} + usesAlt, err := usesAltPCRHandles() + if err != nil { + return fmt.Errorf("cannot inspect fallback key: %v", err) + } + if !usesAlt { + // current fallback key using the main handles, which is + // possible of there were subsequent factory reset steps, + // release the alt handles associated with the old key + handles = []uint32{secboot.AltRunObjectPCRPolicyCounterHandle, secboot.AltFallbackObjectPCRPolicyCounterHandle} + } + return secbootReleasePCRResourceHandles(handles...) +} + +func postFactoryResetCleanup() error { + hasHook, err := HasFDESetupHook(nil) + if err != nil { + return fmt.Errorf("cannot check for fde-setup hook %v", err) + } + + saveFallbackKeyFactory := device.FactoryResetFallbackSaveSealedKeyUnder(InitramfsSeedEncryptionKeyDir) + saveFallbackKey := device.FallbackSaveSealedKeyUnder(InitramfsSeedEncryptionKeyDir) + if err := os.Rename(saveFallbackKeyFactory, saveFallbackKey); err != nil { + // it is possible that the key file was already renamed if we + // came back here after an unexpected reboot + if !os.IsNotExist(err) { + return fmt.Errorf("cannot rotate fallback key: %v", err) + } + } + + if hasHook { + // TODO: do we need to invoke FDE hook? + return nil + } + + if err := postFactoryResetCleanupSecboot(); err != nil { + return fmt.Errorf("cannot cleanup secboot state: %v", err) + } + + return nil +} + +// resealExpectedByModeenvChange returns true if resealing is expected +// due to modeenv changes, false otherwise. Reseal might not be needed +// if the only change in modeenv is the gadget (if the boot assets +// change that is detected in resealKeyToModeenv() and reseal will +// happen anyway) +func resealExpectedByModeenvChange(m1, m2 *Modeenv) bool { + auxModeenv := *m2 + auxModeenv.Gadget = m1.Gadget + return !auxModeenv.deepEqual(m1) +} diff --git a/boot/seal_test.go b/boot/seal_test.go new file mode 100644 index 00000000..052f228c --- /dev/null +++ b/boot/seal_test.go @@ -0,0 +1,2740 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package boot_test + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "strconv" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/arch/archtest" + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/boot" + "github.com/snapcore/snapd/boot/boottest" + "github.com/snapcore/snapd/bootloader" + "github.com/snapcore/snapd/bootloader/assets" + "github.com/snapcore/snapd/bootloader/bootloadertest" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/kernel/fde" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/secboot" + "github.com/snapcore/snapd/secboot/keys" + "github.com/snapcore/snapd/seed" + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/snap/snaptest" + "github.com/snapcore/snapd/testutil" + "github.com/snapcore/snapd/timings" +) + +type sealSuite struct { + testutil.BaseTest +} + +var _ = Suite(&sealSuite{}) + +func (s *sealSuite) SetUpTest(c *C) { + s.BaseTest.SetUpTest(c) + + rootdir := c.MkDir() + dirs.SetRootDir(rootdir) + s.AddCleanup(func() { dirs.SetRootDir("/") }) + s.AddCleanup(archtest.MockArchitecture("amd64")) + snippets := []assets.ForEditions{ + {FirstEdition: 1, Snippet: []byte("console=ttyS0 console=tty1 panic=-1")}, + } + s.AddCleanup(assets.MockSnippetsForEdition("grub.cfg:static-cmdline", snippets)) + s.AddCleanup(assets.MockSnippetsForEdition("grub-recovery.cfg:static-cmdline", snippets)) +} + +func mockKernelSeedSnap(rev snap.Revision) *seed.Snap { + return mockNamedKernelSeedSnap(rev, "pc-kernel") +} + +func mockNamedKernelSeedSnap(rev snap.Revision, name string) *seed.Snap { + revAsString := rev.String() + if rev.Unset() { + revAsString = "unset" + } + return &seed.Snap{ + Path: fmt.Sprintf("/var/lib/snapd/seed/snaps/%v_%v.snap", name, revAsString), + SideInfo: &snap.SideInfo{ + RealName: name, + Revision: rev, + }, + EssentialType: snap.TypeKernel, + } +} + +func mockGadgetSeedSnap(c *C, files [][]string) *seed.Snap { + mockGadgetYaml := ` +volumes: + volumename: + bootloader: grub +` + + hasGadgetYaml := false + for _, entry := range files { + if entry[0] == "meta/gadget.yaml" { + hasGadgetYaml = true + } + } + if !hasGadgetYaml { + files = append(files, []string{"meta/gadget.yaml", mockGadgetYaml}) + } + + gadgetSnapFile := snaptest.MakeTestSnapWithFiles(c, gadgetSnapYaml, files) + return &seed.Snap{ + Path: gadgetSnapFile, + SideInfo: &snap.SideInfo{ + RealName: "gadget", + Revision: snap.R(1), + }, + EssentialType: snap.TypeGadget, + } +} + +func (s *sealSuite) TestSealKeyToModeenv(c *C) { + defer boot.MockModeenvLocked()() + + for idx, tc := range []struct { + sealErr error + provisionErr error + factoryReset bool + pcrHandleOfKey uint32 + pcrHandleOfKeyErr error + shimId string + grubId string + runGrubId string + expErr string + expProvisionCalls int + expSealCalls int + expReleasePCRHandleCalls int + expPCRHandleOfKeyCalls int + }{ + { + sealErr: nil, expErr: "", + expProvisionCalls: 1, expSealCalls: 2, + }, { + sealErr: nil, + // old boot assets + shimId: "bootx64.efi", grubId: "grubx64.efi", + expErr: "", + expProvisionCalls: 1, expSealCalls: 2, + }, { + sealErr: nil, factoryReset: true, pcrHandleOfKey: secboot.FallbackObjectPCRPolicyCounterHandle, + expProvisionCalls: 1, expSealCalls: 2, expPCRHandleOfKeyCalls: 1, expReleasePCRHandleCalls: 1, + }, { + sealErr: nil, factoryReset: true, pcrHandleOfKey: secboot.AltFallbackObjectPCRPolicyCounterHandle, + expProvisionCalls: 1, expSealCalls: 2, expPCRHandleOfKeyCalls: 1, expReleasePCRHandleCalls: 1, + }, { + sealErr: nil, factoryReset: true, pcrHandleOfKeyErr: errors.New("PCR handle error"), + expErr: "PCR handle error", + expPCRHandleOfKeyCalls: 1, + }, { + sealErr: errors.New("seal error"), expErr: "cannot seal the encryption keys: seal error", + expProvisionCalls: 1, expSealCalls: 1, + }, { + provisionErr: errors.New("provision error"), sealErr: errors.New("unexpected call"), + expErr: "provision error", + expProvisionCalls: 1, + }, + } { + c.Logf("tc %v", idx) + rootdir := c.MkDir() + dirs.SetRootDir(rootdir) + defer dirs.SetRootDir("") + + shimId := tc.shimId + if shimId == "" { + shimId = "ubuntu:shimx64.efi" + } + grubId := tc.grubId + if grubId == "" { + grubId = "ubuntu:grubx64.efi" + } + runGrubId := tc.runGrubId + if runGrubId == "" { + runGrubId = "grubx64.efi" + } + + err := createMockGrubCfg(filepath.Join(rootdir, "run/mnt/ubuntu-seed")) + c.Assert(err, IsNil) + + err = createMockGrubCfg(filepath.Join(rootdir, "run/mnt/ubuntu-boot")) + c.Assert(err, IsNil) + + model := boottest.MakeMockUC20Model() + + modeenv := &boot.Modeenv{ + RecoverySystem: "20200825", + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + grubId: []string{"grub-hash-1"}, + shimId: []string{"shim-hash-1"}, + }, + + CurrentTrustedBootAssets: boot.BootAssetsMap{ + runGrubId: []string{"run-grub-hash-1"}, + }, + + CurrentKernels: []string{"pc-kernel_500.snap"}, + + CurrentKernelCommandLines: boot.BootCommandLines{ + "snapd_recovery_mode=run console=ttyS0 console=tty1 panic=-1", + }, + Model: model.Model(), + BrandID: model.BrandID(), + Grade: string(model.Grade()), + ModelSignKeyID: model.SignKeyID(), + } + + // mock asset cache + mockAssetsCache(c, rootdir, "grub", []string{ + fmt.Sprintf("%s-shim-hash-1", shimId), + fmt.Sprintf("%s-grub-hash-1", grubId), + fmt.Sprintf("%s-run-grub-hash-1", runGrubId), + }) + + // set encryption key + myKey := keys.EncryptionKey{} + myKey2 := keys.EncryptionKey{} + for i := range myKey { + myKey[i] = byte(i) + myKey2[i] = byte(128 + i) + } + + // set a mock recovery kernel + readSystemEssentialCalls := 0 + restore := boot.MockSeedReadSystemEssential(func(seedDir, label string, essentialTypes []snap.Type, tm timings.Measurer) (*asserts.Model, []*seed.Snap, error) { + readSystemEssentialCalls++ + return model, []*seed.Snap{mockKernelSeedSnap(snap.R(1)), mockGadgetSeedSnap(c, nil)}, nil + }) + defer restore() + + provisionCalls := 0 + restore = boot.MockSecbootProvisionTPM(func(mode secboot.TPMProvisionMode, lockoutAuthFile string) error { + provisionCalls++ + c.Check(lockoutAuthFile, Equals, filepath.Join(boot.InstallHostFDESaveDir, "tpm-lockout-auth")) + if tc.factoryReset { + c.Check(mode, Equals, secboot.TPMPartialReprovision) + } else { + c.Check(mode, Equals, secboot.TPMProvisionFull) + } + return tc.provisionErr + }) + defer restore() + + pcrHandleOfKeyCalls := 0 + restore = boot.MockSecbootPCRHandleOfSealedKey(func(p string) (uint32, error) { + pcrHandleOfKeyCalls++ + c.Check(provisionCalls, Equals, 0) + c.Check(p, Equals, filepath.Join(rootdir, "/run/mnt/ubuntu-seed/device/fde/ubuntu-save.recovery.sealed-key")) + return tc.pcrHandleOfKey, tc.pcrHandleOfKeyErr + }) + defer restore() + + releasePCRHandleCalls := 0 + restore = boot.MockSecbootReleasePCRResourceHandles(func(handles ...uint32) error { + c.Check(tc.factoryReset, Equals, true) + releasePCRHandleCalls++ + if tc.pcrHandleOfKey == secboot.FallbackObjectPCRPolicyCounterHandle { + c.Check(handles, DeepEquals, []uint32{ + secboot.AltRunObjectPCRPolicyCounterHandle, + secboot.AltFallbackObjectPCRPolicyCounterHandle, + }) + } else { + c.Check(handles, DeepEquals, []uint32{ + secboot.RunObjectPCRPolicyCounterHandle, + secboot.FallbackObjectPCRPolicyCounterHandle, + }) + } + return nil + }) + defer restore() + + // set mock key sealing + sealKeysCalls := 0 + restore = boot.MockSecbootSealKeys(func(keys []secboot.SealKeyRequest, params *secboot.SealKeysParams) error { + c.Assert(provisionCalls, Equals, 1, Commentf("TPM must have been provisioned before")) + sealKeysCalls++ + switch sealKeysCalls { + case 1: + // the run object seals only the ubuntu-data key + c.Check(params.TPMPolicyAuthKeyFile, Equals, filepath.Join(boot.InstallHostFDESaveDir, "tpm-policy-auth-key")) + + dataKeyFile := filepath.Join(rootdir, "/run/mnt/ubuntu-boot/device/fde/ubuntu-data.sealed-key") + c.Check(keys, DeepEquals, []secboot.SealKeyRequest{{Key: myKey, KeyName: "ubuntu-data", KeyFile: dataKeyFile}}) + if tc.pcrHandleOfKey == secboot.FallbackObjectPCRPolicyCounterHandle { + c.Check(params.PCRPolicyCounterHandle, Equals, secboot.AltRunObjectPCRPolicyCounterHandle) + } else { + c.Check(params.PCRPolicyCounterHandle, Equals, secboot.RunObjectPCRPolicyCounterHandle) + } + case 2: + // the fallback object seals the ubuntu-data and the ubuntu-save keys + c.Check(params.TPMPolicyAuthKeyFile, Equals, "") + + dataKeyFile := filepath.Join(rootdir, "/run/mnt/ubuntu-seed/device/fde/ubuntu-data.recovery.sealed-key") + saveKeyFile := filepath.Join(rootdir, "/run/mnt/ubuntu-seed/device/fde/ubuntu-save.recovery.sealed-key") + if tc.factoryReset { + // during factory reset we use a different key location + saveKeyFile = filepath.Join(rootdir, "/run/mnt/ubuntu-seed/device/fde/ubuntu-save.recovery.sealed-key.factory-reset") + } + c.Check(keys, DeepEquals, []secboot.SealKeyRequest{{Key: myKey, KeyName: "ubuntu-data", KeyFile: dataKeyFile}, {Key: myKey2, KeyName: "ubuntu-save", KeyFile: saveKeyFile}}) + if tc.pcrHandleOfKey == secboot.FallbackObjectPCRPolicyCounterHandle { + c.Check(params.PCRPolicyCounterHandle, Equals, secboot.AltFallbackObjectPCRPolicyCounterHandle) + } else { + c.Check(params.PCRPolicyCounterHandle, Equals, secboot.FallbackObjectPCRPolicyCounterHandle) + } + default: + c.Errorf("unexpected additional call to secboot.SealKeys (call # %d)", sealKeysCalls) + } + c.Assert(params.ModelParams, HasLen, 1) + for _, d := range []string{boot.InitramfsSeedEncryptionKeyDir, filepath.Join(dirs.GlobalRootDir, "/run/mnt/ubuntu-data/system-data/var/lib/snapd/device/fde")} { + ex, isdir, _ := osutil.DirExists(d) + c.Check(ex && isdir, Equals, true, Commentf("location %q does not exist or is not a directory", d)) + } + + shim := bootloader.NewBootFile("", filepath.Join(rootdir, fmt.Sprintf("var/lib/snapd/boot-assets/grub/%s-shim-hash-1", shimId)), bootloader.RoleRecovery) + grub := bootloader.NewBootFile("", filepath.Join(rootdir, fmt.Sprintf("var/lib/snapd/boot-assets/grub/%s-grub-hash-1", grubId)), bootloader.RoleRecovery) + runGrub := bootloader.NewBootFile("", filepath.Join(rootdir, fmt.Sprintf("var/lib/snapd/boot-assets/grub/%s-run-grub-hash-1", runGrubId)), bootloader.RoleRunMode) + kernel := bootloader.NewBootFile("/var/lib/snapd/seed/snaps/pc-kernel_1.snap", "kernel.efi", bootloader.RoleRecovery) + runKernel := bootloader.NewBootFile(filepath.Join(rootdir, "var/lib/snapd/snaps/pc-kernel_500.snap"), "kernel.efi", bootloader.RoleRunMode) + + switch sealKeysCalls { + case 1: + c.Assert(params.ModelParams[0].EFILoadChains, DeepEquals, []*secboot.LoadChain{ + secboot.NewLoadChain(shim, + secboot.NewLoadChain(grub, + secboot.NewLoadChain(kernel))), + secboot.NewLoadChain(shim, + secboot.NewLoadChain(grub, + secboot.NewLoadChain(runGrub, + secboot.NewLoadChain(runKernel)))), + }) + c.Assert(params.ModelParams[0].KernelCmdlines, DeepEquals, []string{ + "snapd_recovery_mode=factory-reset snapd_recovery_system=20200825 console=ttyS0 console=tty1 panic=-1", + "snapd_recovery_mode=recover snapd_recovery_system=20200825 console=ttyS0 console=tty1 panic=-1", + "snapd_recovery_mode=run console=ttyS0 console=tty1 panic=-1", + }) + case 2: + c.Assert(params.ModelParams[0].EFILoadChains, DeepEquals, []*secboot.LoadChain{ + secboot.NewLoadChain(shim, + secboot.NewLoadChain(grub, + secboot.NewLoadChain(kernel))), + }) + c.Assert(params.ModelParams[0].KernelCmdlines, DeepEquals, []string{ + "snapd_recovery_mode=factory-reset snapd_recovery_system=20200825 console=ttyS0 console=tty1 panic=-1", + "snapd_recovery_mode=recover snapd_recovery_system=20200825 console=ttyS0 console=tty1 panic=-1", + }) + default: + c.Errorf("unexpected additional call to secboot.SealKeys (call # %d)", sealKeysCalls) + } + c.Assert(params.ModelParams[0].Model.Model(), Equals, "my-model-uc20") + + return tc.sealErr + }) + defer restore() + + u := mockUnlocker{} + err = boot.SealKeyToModeenv(myKey, myKey2, model, modeenv, boot.MockSealKeyToModeenvFlags{ + FactoryReset: tc.factoryReset, + StateUnlocker: u.unlocker, + }) + c.Check(u.unlocked, Equals, 1) + c.Check(pcrHandleOfKeyCalls, Equals, tc.expPCRHandleOfKeyCalls) + c.Check(provisionCalls, Equals, tc.expProvisionCalls) + c.Check(sealKeysCalls, Equals, tc.expSealCalls) + c.Check(releasePCRHandleCalls, Equals, tc.expReleasePCRHandleCalls) + if tc.expErr == "" { + c.Assert(err, IsNil) + } else { + c.Assert(err, ErrorMatches, tc.expErr) + continue + } + + // verify the boot chains data file + pbc, cnt, err := boot.ReadBootChains(filepath.Join(dirs.SnapFDEDirUnder(filepath.Join(dirs.GlobalRootDir, "/run/mnt/ubuntu-data/system-data")), "boot-chains")) + c.Assert(err, IsNil) + c.Check(cnt, Equals, 0) + c.Check(pbc, DeepEquals, boot.PredictableBootChains{ + boot.BootChain{ + BrandID: "my-brand", + Model: "my-model-uc20", + Grade: "dangerous", + ModelSignKeyID: "Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij", + AssetChain: []boot.BootAsset{ + { + Role: "recovery", + Name: shimId, + Hashes: []string{"shim-hash-1"}, + }, + { + Role: "recovery", + Name: grubId, + Hashes: []string{"grub-hash-1"}, + }, + }, + Kernel: "pc-kernel", + KernelRevision: "1", + KernelCmdlines: []string{ + "snapd_recovery_mode=factory-reset snapd_recovery_system=20200825 console=ttyS0 console=tty1 panic=-1", + "snapd_recovery_mode=recover snapd_recovery_system=20200825 console=ttyS0 console=tty1 panic=-1", + }, + }, + boot.BootChain{ + BrandID: "my-brand", + Model: "my-model-uc20", + Grade: "dangerous", + ModelSignKeyID: "Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij", + AssetChain: []boot.BootAsset{ + { + Role: "recovery", + Name: shimId, + Hashes: []string{"shim-hash-1"}, + }, + { + Role: "recovery", + Name: grubId, + Hashes: []string{"grub-hash-1"}, + }, + { + Role: "run-mode", + Name: runGrubId, + Hashes: []string{"run-grub-hash-1"}, + }, + }, + Kernel: "pc-kernel", + KernelRevision: "500", + KernelCmdlines: []string{ + "snapd_recovery_mode=run console=ttyS0 console=tty1 panic=-1", + }, + }, + }) + + // verify the recovery boot chains + pbc, cnt, err = boot.ReadBootChains(filepath.Join(dirs.SnapFDEDirUnder(filepath.Join(dirs.GlobalRootDir, "/run/mnt/ubuntu-data/system-data")), "recovery-boot-chains")) + c.Assert(err, IsNil) + c.Check(cnt, Equals, 0) + c.Check(pbc, DeepEquals, boot.PredictableBootChains{ + boot.BootChain{ + BrandID: "my-brand", + Model: "my-model-uc20", + Grade: "dangerous", + ModelSignKeyID: "Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij", + AssetChain: []boot.BootAsset{ + { + Role: "recovery", + Name: shimId, + Hashes: []string{"shim-hash-1"}, + }, + { + Role: "recovery", + Name: grubId, + Hashes: []string{"grub-hash-1"}, + }, + }, + Kernel: "pc-kernel", + KernelRevision: "1", + KernelCmdlines: []string{ + "snapd_recovery_mode=factory-reset snapd_recovery_system=20200825 console=ttyS0 console=tty1 panic=-1", + "snapd_recovery_mode=recover snapd_recovery_system=20200825 console=ttyS0 console=tty1 panic=-1", + }, + }, + }) + + // marker + marker := filepath.Join(dirs.SnapFDEDirUnder(filepath.Join(dirs.GlobalRootDir, "/run/mnt/ubuntu-data/system-data")), "sealed-keys") + c.Check(marker, testutil.FileEquals, "tpm") + } +} + +type mockUnlocker struct { + unlocked int +} + +func (u *mockUnlocker) unlocker() func() { + return func() { + u.unlocked += 1 + } +} + +func isChainPresent(allowed []*secboot.LoadChain, files []bootloader.BootFile) bool { + if len(files) == 0 { + return len(allowed) == 0 + } + + current := files[0] + for _, c := range allowed { + if current.Path == c.Path && current.Snap == c.Snap && current.Role == c.Role { + if isChainPresent(c.Next, files[1:]) { + return true + } + } + } + + return false +} + +type containsChainChecker struct { + *CheckerInfo +} + +var ContainsChain Checker = &containsChainChecker{ + &CheckerInfo{Name: "ContainsChain", Params: []string{"chainscontainer", "chain"}}, +} + +func (c *containsChainChecker) Check(params []interface{}, names []string) (result bool, error string) { + allowed, ok := params[0].([]*secboot.LoadChain) + if !ok { + return false, "Wrong type for chain container" + } + bootFiles, ok := params[1].([]bootloader.BootFile) + if !ok { + return false, "Wrong type for boot file chain" + } + result = isChainPresent(allowed, bootFiles) + if !result { + error = fmt.Sprintf("Chain %v is not present in allowed boot chains", bootFiles) + } + return result, error +} + +// TODO:UC20: also test fallback reseal +func (s *sealSuite) TestResealKeyToModeenvWithSystemFallback(c *C) { + var prevPbc boot.PredictableBootChains + var prevRecoveryPbc boot.PredictableBootChains + + defer boot.MockModeenvLocked()() + + for idx, tc := range []struct { + sealedKeys bool + reuseRunPbc bool + reuseRecoveryPbc bool + resealErr error + shimId string + shimId2 string + noShim2 bool + grubId string + grubId2 string + noGrub2 bool + runGrubId string + err string + }{ + {sealedKeys: false, shimId: "bootx64.efi", grubId: "grubx64.efi", resealErr: nil, err: ""}, + {sealedKeys: true, shimId: "bootx64.efi", grubId: "grubx64.efi", resealErr: nil, err: ""}, + {sealedKeys: false, shimId: "bootx64.efi", grubId: "grubx64.efi", resealErr: nil, err: ""}, + {sealedKeys: true, shimId: "bootx64.efi", grubId: "grubx64.efi", resealErr: nil, err: ""}, + {sealedKeys: false, shimId2: "bootx64.efi", grubId2: "grubx64.efi", resealErr: nil, err: ""}, + {sealedKeys: true, shimId2: "bootx64.efi", grubId2: "grubx64.efi", resealErr: nil, err: ""}, + {sealedKeys: false, shimId: "bootx64.efi", grubId: "grubx64.efi", shimId2: "ubuntu:shimx64.efi", grubId2: "ubuntu:grubx64.efi", resealErr: nil, err: ""}, + {sealedKeys: true, shimId: "bootx64.efi", grubId: "grubx64.efi", shimId2: "ubuntu:shimx64.efi", grubId2: "ubuntu:grubx64.efi", resealErr: nil, err: ""}, + {sealedKeys: false, noGrub2: true, resealErr: nil, err: ""}, + {sealedKeys: true, noGrub2: true, resealErr: nil, err: ""}, + {sealedKeys: false, noShim2: true, resealErr: nil, err: ""}, + {sealedKeys: true, noShim2: true, resealErr: nil, err: ""}, + {sealedKeys: false, noShim2: true, noGrub2: true, resealErr: nil, err: ""}, + {sealedKeys: true, noShim2: true, noGrub2: true, resealErr: nil, err: ""}, + {sealedKeys: false, resealErr: nil, err: ""}, + {sealedKeys: true, resealErr: nil, err: ""}, + {sealedKeys: true, resealErr: errors.New("reseal error"), err: "cannot reseal the encryption key: reseal error"}, + {reuseRunPbc: true, reuseRecoveryPbc: true, sealedKeys: true, resealErr: nil, err: ""}, + // recovery boot chain is unchanged + {reuseRunPbc: false, reuseRecoveryPbc: true, sealedKeys: true, resealErr: nil, err: ""}, + // run boot chain is unchanged + {reuseRunPbc: true, reuseRecoveryPbc: false, sealedKeys: true, resealErr: nil, err: ""}, + } { + c.Logf("tc: %v", idx) + rootdir := c.MkDir() + dirs.SetRootDir(rootdir) + defer dirs.SetRootDir("") + + shimId := tc.shimId + if shimId == "" { + shimId = "ubuntu:shimx64.efi" + } + shimId2 := tc.shimId2 + if shimId2 == "" && !tc.noShim2 { + shimId2 = shimId + } + grubId := tc.grubId + if grubId == "" { + grubId = "ubuntu:grubx64.efi" + } + grubId2 := tc.grubId2 + if grubId2 == "" && !tc.noGrub2 { + grubId2 = grubId + } + runGrubId := tc.runGrubId + if runGrubId == "" { + runGrubId = "grubx64.efi" + } + + if tc.sealedKeys { + c.Assert(os.MkdirAll(dirs.SnapFDEDir, 0755), IsNil) + err := os.WriteFile(filepath.Join(dirs.SnapFDEDir, "sealed-keys"), nil, 0644) + c.Assert(err, IsNil) + + } + + err := createMockGrubCfg(filepath.Join(rootdir, "run/mnt/ubuntu-seed")) + c.Assert(err, IsNil) + + err = createMockGrubCfg(filepath.Join(rootdir, "run/mnt/ubuntu-boot")) + c.Assert(err, IsNil) + + model := boottest.MakeMockUC20Model() + + recoveryBootAssets := boot.BootAssetsMap{} + var expectedCache []string + recoveryBootAssets[shimId] = append(recoveryBootAssets[shimId], "shim-hash-1") + expectedCache = append(expectedCache, fmt.Sprintf("%s-shim-hash-1", shimId)) + if shimId2 != "" { + recoveryBootAssets[shimId2] = append(recoveryBootAssets[shimId2], "shim-hash-2") + expectedCache = append(expectedCache, fmt.Sprintf("%s-shim-hash-2", shimId2)) + } + recoveryBootAssets[grubId] = append(recoveryBootAssets[grubId], "grub-hash-1") + expectedCache = append(expectedCache, fmt.Sprintf("%s-grub-hash-1", grubId)) + if grubId2 != "" { + recoveryBootAssets[grubId2] = append(recoveryBootAssets[grubId2], "grub-hash-2") + expectedCache = append(expectedCache, fmt.Sprintf("%s-grub-hash-2", grubId2)) + } + + expectedCache = append(expectedCache, + fmt.Sprintf("%s-run-grub-hash-1", runGrubId), + fmt.Sprintf("%s-run-grub-hash-2", runGrubId), + ) + + modeenv := &boot.Modeenv{ + CurrentRecoverySystems: []string{"20200825"}, + CurrentTrustedRecoveryBootAssets: recoveryBootAssets, + CurrentTrustedBootAssets: boot.BootAssetsMap{ + runGrubId: []string{"run-grub-hash-1", "run-grub-hash-2"}, + }, + + CurrentKernels: []string{"pc-kernel_500.snap", "pc-kernel_600.snap"}, + + CurrentKernelCommandLines: boot.BootCommandLines{ + "snapd_recovery_mode=run console=ttyS0 console=tty1 panic=-1", + }, + Model: model.Model(), + BrandID: model.BrandID(), + Grade: string(model.Grade()), + ModelSignKeyID: model.SignKeyID(), + } + + if tc.reuseRunPbc { + err := boot.WriteBootChains(prevPbc, filepath.Join(dirs.SnapFDEDir, "boot-chains"), 9) + c.Assert(err, IsNil) + } + if tc.reuseRecoveryPbc { + err = boot.WriteBootChains(prevRecoveryPbc, filepath.Join(dirs.SnapFDEDir, "recovery-boot-chains"), 9) + c.Assert(err, IsNil) + } + + // mock asset cache + mockAssetsCache(c, rootdir, "grub", expectedCache) + + // set a mock recovery kernel + readSystemEssentialCalls := 0 + restore := boot.MockSeedReadSystemEssential(func(seedDir, label string, essentialTypes []snap.Type, tm timings.Measurer) (*asserts.Model, []*seed.Snap, error) { + readSystemEssentialCalls++ + return model, []*seed.Snap{mockKernelSeedSnap(snap.R(1)), mockGadgetSeedSnap(c, nil)}, nil + }) + defer restore() + + // set mock key resealing + resealKeysCalls := 0 + restore = boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + c.Check(params.TPMPolicyAuthKeyFile, Equals, filepath.Join(dirs.SnapSaveDir, "device/fde", "tpm-policy-auth-key")) + + resealKeysCalls++ + c.Assert(params.ModelParams, HasLen, 1) + + // shared parameters + c.Assert(params.ModelParams[0].Model.Model(), Equals, "my-model-uc20") + + // recovery parameters + shim := bootloader.NewBootFile("", filepath.Join(rootdir, fmt.Sprintf("var/lib/snapd/boot-assets/grub/%s-shim-hash-1", shimId)), bootloader.RoleRecovery) + shim2 := bootloader.NewBootFile("", filepath.Join(rootdir, fmt.Sprintf("var/lib/snapd/boot-assets/grub/%s-shim-hash-2", shimId2)), bootloader.RoleRecovery) + grub := bootloader.NewBootFile("", filepath.Join(rootdir, fmt.Sprintf("var/lib/snapd/boot-assets/grub/%s-grub-hash-1", grubId)), bootloader.RoleRecovery) + grub2 := bootloader.NewBootFile("", filepath.Join(rootdir, fmt.Sprintf("var/lib/snapd/boot-assets/grub/%s-grub-hash-2", grubId2)), bootloader.RoleRecovery) + kernel := bootloader.NewBootFile("/var/lib/snapd/seed/snaps/pc-kernel_1.snap", "kernel.efi", bootloader.RoleRecovery) + // run mode parameters + runGrub := bootloader.NewBootFile("", filepath.Join(rootdir, fmt.Sprintf("var/lib/snapd/boot-assets/grub/%s-run-grub-hash-1", runGrubId)), bootloader.RoleRunMode) + runGrub2 := bootloader.NewBootFile("", filepath.Join(rootdir, fmt.Sprintf("var/lib/snapd/boot-assets/grub/%s-run-grub-hash-2", runGrubId)), bootloader.RoleRunMode) + runKernel := bootloader.NewBootFile(filepath.Join(rootdir, "var/lib/snapd/snaps/pc-kernel_500.snap"), "kernel.efi", bootloader.RoleRunMode) + runKernel2 := bootloader.NewBootFile(filepath.Join(rootdir, "var/lib/snapd/snaps/pc-kernel_600.snap"), "kernel.efi", bootloader.RoleRunMode) + + var possibleChains [][]bootloader.BootFile + for _, possibleRunKernel := range []bootloader.BootFile{runKernel, runKernel2} { + possibleChains = append(possibleChains, []bootloader.BootFile{ + shim, + grub, + runGrub, + possibleRunKernel, + }) + possibleChains = append(possibleChains, []bootloader.BootFile{ + shim, + grub, + runGrub2, + possibleRunKernel, + }) + if grubId2 != "" { + if shimId2 == shimId { + // We keep the same boot chain so, shim -> grub2 is possible. + possibleChains = append(possibleChains, []bootloader.BootFile{ + shim, + grub2, + runGrub2, + possibleRunKernel, + }) + } + if shimId2 != "" { + possibleChains = append(possibleChains, []bootloader.BootFile{ + shim2, + grub2, + runGrub2, + possibleRunKernel, + }) + } + } else if shimId2 != "" { + // We should not test the case where we half update, to a completely new bootchain. + c.Assert(shimId, Equals, shimId2) + + possibleChains = append(possibleChains, []bootloader.BootFile{ + shim2, + grub, + runGrub2, + possibleRunKernel, + }) + } + } + + var possibleRecoveryChains [][]bootloader.BootFile + possibleRecoveryChains = append(possibleRecoveryChains, []bootloader.BootFile{ + shim, + grub, + kernel, + }) + if grubId2 != "" { + if shimId2 == shimId { + // We keep the same boot chain so, shim -> grub2 is possible. + possibleRecoveryChains = append(possibleRecoveryChains, []bootloader.BootFile{ + shim, + grub2, + kernel, + }) + } + if shimId2 != "" { + possibleRecoveryChains = append(possibleRecoveryChains, []bootloader.BootFile{ + shim2, + grub2, + kernel, + }) + } + } else if shimId2 != "" { + // We should not test the case where we half update, to a completely new bootchain. + c.Assert(shimId, Equals, shimId2) + + possibleRecoveryChains = append(possibleRecoveryChains, []bootloader.BootFile{ + shim2, + grub, + kernel, + }) + } + + checkRunParams := func() { + c.Check(params.KeyFiles, DeepEquals, []string{ + filepath.Join(boot.InitramfsBootEncryptionKeyDir, "ubuntu-data.sealed-key"), + }) + c.Check(params.ModelParams[0].KernelCmdlines, DeepEquals, []string{ + "snapd_recovery_mode=recover snapd_recovery_system=20200825 console=ttyS0 console=tty1 panic=-1", + "snapd_recovery_mode=run console=ttyS0 console=tty1 panic=-1", + }) + + for _, chain := range possibleChains { + c.Check(params.ModelParams[0].EFILoadChains, ContainsChain, chain) + } + for _, chain := range possibleRecoveryChains { + c.Check(params.ModelParams[0].EFILoadChains, ContainsChain, chain) + } + } + + checkRecoveryParams := func() { + c.Check(params.KeyFiles, DeepEquals, []string{ + filepath.Join(boot.InitramfsSeedEncryptionKeyDir, "ubuntu-data.recovery.sealed-key"), + filepath.Join(boot.InitramfsSeedEncryptionKeyDir, "ubuntu-save.recovery.sealed-key"), + }) + c.Check(params.ModelParams[0].KernelCmdlines, DeepEquals, []string{ + "snapd_recovery_mode=recover snapd_recovery_system=20200825 console=ttyS0 console=tty1 panic=-1", + }) + for _, chain := range possibleRecoveryChains { + c.Check(params.ModelParams[0].EFILoadChains, ContainsChain, chain) + } + } + + switch resealKeysCalls { + case 1: + if !tc.reuseRunPbc { + checkRunParams() + } else if !tc.reuseRecoveryPbc { + checkRecoveryParams() + } else { + c.Errorf("unexpected call to secboot.ResealKeys (call # %d)", resealKeysCalls) + } + case 2: + if !tc.reuseRecoveryPbc { + checkRecoveryParams() + } else { + c.Errorf("unexpected call to secboot.ResealKeys (call # %d)", resealKeysCalls) + } + default: + c.Errorf("unexpected additional call to secboot.ResealKeys (call # %d)", resealKeysCalls) + } + + return tc.resealErr + }) + defer restore() + + u := mockUnlocker{} + + // here we don't have unasserted kernels so just set + // expectReseal to false as it doesn't matter; + // the behavior with unasserted kernel is tested in + // boot_test.go specific tests + const expectReseal = false + err = boot.ResealKeyToModeenv(rootdir, modeenv, expectReseal, u.unlocker) + if !tc.sealedKeys || (tc.reuseRunPbc && tc.reuseRecoveryPbc) { + // did nothing + c.Assert(err, IsNil) + c.Assert(resealKeysCalls, Equals, 0) + continue + } + c.Check(u.unlocked, Equals, 1) + if tc.err == "" { + c.Assert(err, IsNil) + } else { + c.Assert(err, ErrorMatches, tc.err) + } + if tc.resealErr != nil { + // mocked error is returned on first reseal + c.Assert(resealKeysCalls, Equals, 1) + } else if !tc.reuseRecoveryPbc && !tc.reuseRunPbc { + // none of the boot chains is reused, so 2 reseals are + // observed + c.Assert(resealKeysCalls, Equals, 2) + } else { + // one of the boot chains is reused, only one reseal + c.Assert(resealKeysCalls, Equals, 1) + } + if tc.err != "" { + continue + } + + // verify the boot chains data file + pbc, cnt, err := boot.ReadBootChains(filepath.Join(dirs.SnapFDEDir, "boot-chains")) + c.Assert(err, IsNil) + if tc.reuseRunPbc { + c.Assert(cnt, Equals, 9) + } else { + c.Assert(cnt, Equals, 1) + } + + var expectedRecoveryBootChains []boot.BootChain + var expectedRunBootChains []boot.BootChain + var shimHashes []string + shimHashes = append(shimHashes, "shim-hash-1") + if shimId2 != "" && shimId2 == shimId { + shimHashes = append(shimHashes, "shim-hash-2") + } + var grubHashes []string + grubHashes = append(grubHashes, "grub-hash-1") + if grubId2 != "" && grubId2 == grubId { + grubHashes = append(grubHashes, "grub-hash-2") + } + // recovery boot chains + expectedRecoveryBootChains = append(expectedRecoveryBootChains, + boot.BootChain{ + BrandID: "my-brand", + Model: "my-model-uc20", + Grade: "dangerous", + ModelSignKeyID: "Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij", + AssetChain: []boot.BootAsset{ + { + Role: "recovery", + Name: shimId, + Hashes: shimHashes, + }, + { + Role: "recovery", + Name: grubId, + Hashes: grubHashes, + }, + }, + Kernel: "pc-kernel", + KernelRevision: "1", + KernelCmdlines: []string{ + "snapd_recovery_mode=recover snapd_recovery_system=20200825 console=ttyS0 console=tty1 panic=-1", + }, + }, + ) + expectedRunBootChains = append(expectedRunBootChains, + boot.BootChain{ + BrandID: "my-brand", + Model: "my-model-uc20", + Grade: "dangerous", + ModelSignKeyID: "Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij", + AssetChain: []boot.BootAsset{ + { + Role: "recovery", + Name: shimId, + Hashes: shimHashes, + }, + { + Role: "recovery", + Name: grubId, + Hashes: grubHashes, + }, + { + Role: "run-mode", + Name: runGrubId, + Hashes: []string{"run-grub-hash-1", "run-grub-hash-2"}, + }, + }, + Kernel: "pc-kernel", + KernelRevision: "500", + KernelCmdlines: []string{ + "snapd_recovery_mode=run console=ttyS0 console=tty1 panic=-1", + }, + }, + boot.BootChain{ + BrandID: "my-brand", + Model: "my-model-uc20", + Grade: "dangerous", + ModelSignKeyID: "Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij", + AssetChain: []boot.BootAsset{ + { + Role: "recovery", + Name: shimId, + Hashes: shimHashes, + }, + { + Role: "recovery", + Name: grubId, + Hashes: grubHashes, + }, + { + Role: "run-mode", + Name: runGrubId, + Hashes: []string{"run-grub-hash-1", "run-grub-hash-2"}, + }, + }, + Kernel: "pc-kernel", + KernelRevision: "600", + KernelCmdlines: []string{ + "snapd_recovery_mode=run console=ttyS0 console=tty1 panic=-1", + }, + }, + ) + if shimId2 != "" && shimId2 != shimId && grubId2 != "" && grubId2 != grubId { + expectedExtraRecoveryBootChains := []boot.BootChain{ + { + BrandID: "my-brand", + Model: "my-model-uc20", + Grade: "dangerous", + ModelSignKeyID: "Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij", + AssetChain: []boot.BootAsset{ + { + Role: "recovery", + Name: shimId2, + Hashes: []string{"shim-hash-2"}, + }, + { + Role: "recovery", + Name: grubId2, + Hashes: []string{"grub-hash-2"}, + }, + }, + Kernel: "pc-kernel", + KernelRevision: "1", + KernelCmdlines: []string{ + "snapd_recovery_mode=recover snapd_recovery_system=20200825 console=ttyS0 console=tty1 panic=-1", + }, + }, + } + expectedExtraBootChains := []boot.BootChain{ + { + BrandID: "my-brand", + Model: "my-model-uc20", + Grade: "dangerous", + ModelSignKeyID: "Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij", + AssetChain: []boot.BootAsset{ + { + Role: "recovery", + Name: shimId2, + Hashes: []string{"shim-hash-2"}, + }, + { + Role: "recovery", + Name: grubId2, + Hashes: []string{"grub-hash-2"}, + }, + { + Role: "run-mode", + Name: runGrubId, + Hashes: []string{"run-grub-hash-1", "run-grub-hash-2"}, + }, + }, + Kernel: "pc-kernel", + KernelRevision: "500", + KernelCmdlines: []string{ + "snapd_recovery_mode=run console=ttyS0 console=tty1 panic=-1", + }, + }, + { + BrandID: "my-brand", + Model: "my-model-uc20", + Grade: "dangerous", + ModelSignKeyID: "Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij", + AssetChain: []boot.BootAsset{ + { + Role: "recovery", + Name: shimId2, + Hashes: []string{"shim-hash-2"}, + }, + { + Role: "recovery", + Name: grubId2, + Hashes: []string{"grub-hash-2"}, + }, + { + Role: "run-mode", + Name: runGrubId, + Hashes: []string{"run-grub-hash-1", "run-grub-hash-2"}, + }, + }, + Kernel: "pc-kernel", + KernelRevision: "600", + KernelCmdlines: []string{ + "snapd_recovery_mode=run console=ttyS0 console=tty1 panic=-1", + }, + }, + } + + if shimId == "bootx64.efi" { + // the possible chains are ordered from bootloader.Grub. So it is always old bootchain to new bootchain + expectedRecoveryBootChains = append(expectedRecoveryBootChains, expectedExtraRecoveryBootChains...) + expectedRunBootChains = append(expectedRunBootChains, expectedExtraBootChains...) + } else { + expectedRecoveryBootChains = append(expectedExtraRecoveryBootChains, expectedRecoveryBootChains...) + expectedRunBootChains = append(expectedExtraBootChains, expectedRunBootChains...) + } + } + c.Check(pbc, DeepEquals, boot.PredictableBootChains(append(expectedRecoveryBootChains, expectedRunBootChains...))) + prevPbc = pbc + recoveryPbc, cnt, err := boot.ReadBootChains(filepath.Join(dirs.SnapFDEDir, "recovery-boot-chains")) + c.Assert(err, IsNil) + if tc.reuseRecoveryPbc { + c.Check(cnt, Equals, 9) + } else { + c.Check(cnt, Equals, 1) + } + prevRecoveryPbc = recoveryPbc + } +} + +func (s *sealSuite) TestResealKeyToModeenvRecoveryKeysForGoodSystemsOnly(c *C) { + rootdir := c.MkDir() + dirs.SetRootDir(rootdir) + defer dirs.SetRootDir("") + + c.Assert(os.MkdirAll(dirs.SnapFDEDir, 0755), IsNil) + err := os.WriteFile(filepath.Join(dirs.SnapFDEDir, "sealed-keys"), nil, 0644) + c.Assert(err, IsNil) + + err = createMockGrubCfg(filepath.Join(rootdir, "run/mnt/ubuntu-seed")) + c.Assert(err, IsNil) + + err = createMockGrubCfg(filepath.Join(rootdir, "run/mnt/ubuntu-boot")) + c.Assert(err, IsNil) + + model := boottest.MakeMockUC20Model() + + modeenv := &boot.Modeenv{ + // where 1234 is being tried + CurrentRecoverySystems: []string{"20200825", "1234"}, + // 20200825 has known to be good + GoodRecoverySystems: []string{"20200825"}, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "grubx64.efi": []string{"grub-hash"}, + "bootx64.efi": []string{"shim-hash"}, + }, + + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "grubx64.efi": []string{"run-grub-hash"}, + }, + + CurrentKernels: []string{"pc-kernel_500.snap"}, + + CurrentKernelCommandLines: boot.BootCommandLines{ + "snapd_recovery_mode=run console=ttyS0 console=tty1 panic=-1", + }, + Model: model.Model(), + BrandID: model.BrandID(), + Grade: string(model.Grade()), + ModelSignKeyID: model.SignKeyID(), + } + + // mock asset cache + mockAssetsCache(c, rootdir, "grub", []string{ + "bootx64.efi-shim-hash", + "grubx64.efi-grub-hash", + "grubx64.efi-run-grub-hash", + }) + + // set a mock recovery kernel + readSystemEssentialCalls := 0 + restore := boot.MockSeedReadSystemEssential(func(seedDir, label string, essentialTypes []snap.Type, tm timings.Measurer) (*asserts.Model, []*seed.Snap, error) { + readSystemEssentialCalls++ + kernelRev := 1 + if label == "1234" { + kernelRev = 999 + } + return model, []*seed.Snap{mockKernelSeedSnap(snap.R(kernelRev)), mockGadgetSeedSnap(c, nil)}, nil + }) + defer restore() + + defer boot.MockModeenvLocked()() + + // set mock key resealing + resealKeysCalls := 0 + restore = boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + c.Check(params.TPMPolicyAuthKeyFile, Equals, filepath.Join(dirs.SnapSaveDir, "device/fde", "tpm-policy-auth-key")) + + resealKeysCalls++ + c.Assert(params.ModelParams, HasLen, 1) + + // shared parameters + c.Assert(params.ModelParams[0].Model.Model(), Equals, "my-model-uc20") + c.Logf("got:") + for _, ch := range params.ModelParams[0].EFILoadChains { + printChain(c, ch, "-") + } + switch resealKeysCalls { + case 1: // run key + c.Assert(params.KeyFiles, DeepEquals, []string{ + filepath.Join(boot.InitramfsBootEncryptionKeyDir, "ubuntu-data.sealed-key"), + }) + c.Assert(params.ModelParams[0].KernelCmdlines, DeepEquals, []string{ + "snapd_recovery_mode=factory-reset snapd_recovery_system=20200825 console=ttyS0 console=tty1 panic=-1", + "snapd_recovery_mode=recover snapd_recovery_system=1234 console=ttyS0 console=tty1 panic=-1", + "snapd_recovery_mode=recover snapd_recovery_system=20200825 console=ttyS0 console=tty1 panic=-1", + "snapd_recovery_mode=run console=ttyS0 console=tty1 panic=-1", + }) + // load chains + c.Assert(params.ModelParams[0].EFILoadChains, HasLen, 3) + case 2: // recovery keys + c.Assert(params.KeyFiles, DeepEquals, []string{ + filepath.Join(boot.InitramfsSeedEncryptionKeyDir, "ubuntu-data.recovery.sealed-key"), + filepath.Join(boot.InitramfsSeedEncryptionKeyDir, "ubuntu-save.recovery.sealed-key"), + }) + c.Assert(params.ModelParams[0].KernelCmdlines, DeepEquals, []string{ + "snapd_recovery_mode=factory-reset snapd_recovery_system=20200825 console=ttyS0 console=tty1 panic=-1", + "snapd_recovery_mode=recover snapd_recovery_system=20200825 console=ttyS0 console=tty1 panic=-1", + }) + // load chains + c.Assert(params.ModelParams[0].EFILoadChains, HasLen, 1) + default: + c.Errorf("unexpected additional call to secboot.ResealKeys (call # %d)", resealKeysCalls) + } + + // recovery parameters + shim := bootloader.NewBootFile("", filepath.Join(rootdir, "var/lib/snapd/boot-assets/grub/bootx64.efi-shim-hash"), bootloader.RoleRecovery) + grub := bootloader.NewBootFile("", filepath.Join(rootdir, "var/lib/snapd/boot-assets/grub/grubx64.efi-grub-hash"), bootloader.RoleRecovery) + kernelGoodRecovery := bootloader.NewBootFile("/var/lib/snapd/seed/snaps/pc-kernel_1.snap", "kernel.efi", bootloader.RoleRecovery) + // kernel from a tried recovery system + kernelTriedRecovery := bootloader.NewBootFile("/var/lib/snapd/seed/snaps/pc-kernel_999.snap", "kernel.efi", bootloader.RoleRecovery) + // run mode parameters + runGrub := bootloader.NewBootFile("", filepath.Join(rootdir, "var/lib/snapd/boot-assets/grub/grubx64.efi-run-grub-hash"), bootloader.RoleRunMode) + runKernel := bootloader.NewBootFile(filepath.Join(rootdir, "var/lib/snapd/snaps/pc-kernel_500.snap"), "kernel.efi", bootloader.RoleRunMode) + + switch resealKeysCalls { + case 1: // run load chain + c.Assert(params.ModelParams[0].EFILoadChains, DeepEquals, []*secboot.LoadChain{ + secboot.NewLoadChain(shim, + secboot.NewLoadChain(grub, + secboot.NewLoadChain(kernelGoodRecovery), + )), + secboot.NewLoadChain(shim, + secboot.NewLoadChain(grub, + secboot.NewLoadChain(kernelTriedRecovery), + )), + secboot.NewLoadChain(shim, + secboot.NewLoadChain(grub, + secboot.NewLoadChain(runGrub, + secboot.NewLoadChain(runKernel)), + )), + }) + case 2: // recovery load chains + c.Assert(params.ModelParams[0].EFILoadChains, DeepEquals, []*secboot.LoadChain{ + secboot.NewLoadChain(shim, + secboot.NewLoadChain(grub, + secboot.NewLoadChain(kernelGoodRecovery), + )), + }) + } + + return nil + }) + defer restore() + + // here we don't have unasserted kernels so just set + // expectReseal to false as it doesn't matter; + // the behavior with unasserted kernel is tested in + // boot_test.go specific tests + const expectReseal = false + err = boot.ResealKeyToModeenv(rootdir, modeenv, expectReseal, nil) + c.Assert(err, IsNil) + c.Assert(resealKeysCalls, Equals, 2) + + // verify the boot chains data file for run key + runPbc, cnt, err := boot.ReadBootChains(filepath.Join(dirs.SnapFDEDir, "boot-chains")) + c.Assert(err, IsNil) + c.Assert(cnt, Equals, 1) + c.Check(runPbc, DeepEquals, boot.PredictableBootChains{ + boot.BootChain{ + BrandID: "my-brand", + Model: "my-model-uc20", + Grade: "dangerous", + ModelSignKeyID: "Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij", + AssetChain: []boot.BootAsset{ + { + Role: "recovery", + Name: "bootx64.efi", + Hashes: []string{"shim-hash"}, + }, + { + Role: "recovery", + Name: "grubx64.efi", + Hashes: []string{"grub-hash"}, + }, + }, + Kernel: "pc-kernel", + KernelRevision: "1", + KernelCmdlines: []string{ + "snapd_recovery_mode=factory-reset snapd_recovery_system=20200825 console=ttyS0 console=tty1 panic=-1", + "snapd_recovery_mode=recover snapd_recovery_system=20200825 console=ttyS0 console=tty1 panic=-1", + }, + }, + // includes the tried system + boot.BootChain{ + BrandID: "my-brand", + Model: "my-model-uc20", + Grade: "dangerous", + ModelSignKeyID: "Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij", + AssetChain: []boot.BootAsset{ + { + Role: "recovery", + Name: "bootx64.efi", + Hashes: []string{"shim-hash"}, + }, + { + Role: "recovery", + Name: "grubx64.efi", + Hashes: []string{"grub-hash"}, + }, + }, + Kernel: "pc-kernel", + KernelRevision: "999", + KernelCmdlines: []string{ + // but only the recover mode + "snapd_recovery_mode=recover snapd_recovery_system=1234 console=ttyS0 console=tty1 panic=-1", + }, + }, + boot.BootChain{ + BrandID: "my-brand", + Model: "my-model-uc20", + Grade: "dangerous", + ModelSignKeyID: "Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij", + AssetChain: []boot.BootAsset{ + { + Role: "recovery", + Name: "bootx64.efi", + Hashes: []string{"shim-hash"}, + }, + { + Role: "recovery", + Name: "grubx64.efi", + Hashes: []string{"grub-hash"}, + }, + { + Role: "run-mode", + Name: "grubx64.efi", + Hashes: []string{"run-grub-hash"}, + }, + }, + Kernel: "pc-kernel", + KernelRevision: "500", + KernelCmdlines: []string{ + "snapd_recovery_mode=run console=ttyS0 console=tty1 panic=-1", + }, + }, + }) + // recovery boot chains + recoveryPbc, cnt, err := boot.ReadBootChains(filepath.Join(dirs.SnapFDEDir, "recovery-boot-chains")) + c.Assert(err, IsNil) + c.Assert(cnt, Equals, 1) + c.Check(recoveryPbc, DeepEquals, boot.PredictableBootChains{ + // only one entry for a recovery system that is known to be good + boot.BootChain{ + BrandID: "my-brand", + Model: "my-model-uc20", + Grade: "dangerous", + ModelSignKeyID: "Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij", + AssetChain: []boot.BootAsset{ + { + Role: "recovery", + Name: "bootx64.efi", + Hashes: []string{"shim-hash"}, + }, + { + Role: "recovery", + Name: "grubx64.efi", + Hashes: []string{"grub-hash"}, + }, + }, + Kernel: "pc-kernel", + KernelRevision: "1", + KernelCmdlines: []string{ + "snapd_recovery_mode=factory-reset snapd_recovery_system=20200825 console=ttyS0 console=tty1 panic=-1", + "snapd_recovery_mode=recover snapd_recovery_system=20200825 console=ttyS0 console=tty1 panic=-1", + }, + }, + }) +} + +func (s *sealSuite) TestResealKeyToModeenvFallbackCmdline(c *C) { + rootdir := c.MkDir() + dirs.SetRootDir(rootdir) + defer dirs.SetRootDir("") + + model := boottest.MakeMockUC20Model() + + c.Assert(os.MkdirAll(dirs.SnapFDEDir, 0755), IsNil) + err := os.WriteFile(filepath.Join(dirs.SnapFDEDir, "sealed-keys"), nil, 0644) + c.Assert(err, IsNil) + + modeenv := &boot.Modeenv{ + CurrentRecoverySystems: []string{"20200825"}, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": []string{"asset-hash-1"}, + }, + + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": []string{"asset-hash-1"}, + }, + + CurrentKernels: []string{"pc-kernel_500.snap"}, + + // as if it is unset yet + CurrentKernelCommandLines: nil, + + Model: model.Model(), + BrandID: model.BrandID(), + Grade: string(model.Grade()), + ModelSignKeyID: model.SignKeyID(), + } + + err = boot.WriteBootChains(nil, filepath.Join(dirs.SnapFDEDir, "boot-chains"), 9) + c.Assert(err, IsNil) + // mock asset cache + mockAssetsCache(c, rootdir, "trusted", []string{ + "asset-asset-hash-1", + }) + + // match one of current kernels + runKernelBf := bootloader.NewBootFile("/var/lib/snapd/snap/pc-kernel_500.snap", "kernel.efi", bootloader.RoleRunMode) + // match the seed kernel + recoveryKernelBf := bootloader.NewBootFile("/var/lib/snapd/seed/snaps/pc-kernel_1.snap", "kernel.efi", bootloader.RoleRecovery) + + bootdir := c.MkDir() + mtbl := bootloadertest.Mock("trusted", bootdir).WithTrustedAssets() + mtbl.TrustedAssetsMap = map[string]string{"asset": "asset"} + mtbl.StaticCommandLine = "static cmdline" + mtbl.BootChainList = []bootloader.BootFile{ + bootloader.NewBootFile("", "asset", bootloader.RoleRunMode), + runKernelBf, + } + mtbl.RecoveryBootChainList = []bootloader.BootFile{ + bootloader.NewBootFile("", "asset", bootloader.RoleRecovery), + recoveryKernelBf, + } + bootloader.Force(mtbl) + defer bootloader.Force(nil) + + // set a mock recovery kernel + readSystemEssentialCalls := 0 + restore := boot.MockSeedReadSystemEssential(func(seedDir, label string, essentialTypes []snap.Type, tm timings.Measurer) (*asserts.Model, []*seed.Snap, error) { + readSystemEssentialCalls++ + return model, []*seed.Snap{mockKernelSeedSnap(snap.R(1)), mockGadgetSeedSnap(c, nil)}, nil + }) + defer restore() + + defer boot.MockModeenvLocked()() + + // set mock key resealing + resealKeysCalls := 0 + restore = boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + resealKeysCalls++ + c.Assert(params.ModelParams, HasLen, 1) + c.Logf("reseal: %+v", params) + switch resealKeysCalls { + case 1: + c.Assert(params.ModelParams[0].KernelCmdlines, DeepEquals, []string{ + "snapd_recovery_mode=recover snapd_recovery_system=20200825 static cmdline", + "snapd_recovery_mode=run static cmdline", + }) + case 2: + c.Assert(params.ModelParams[0].KernelCmdlines, DeepEquals, []string{ + "snapd_recovery_mode=recover snapd_recovery_system=20200825 static cmdline", + }) + default: + c.Fatalf("unexpected number of reseal calls, %v", params) + } + return nil + }) + defer restore() + + const expectReseal = false + err = boot.ResealKeyToModeenv(rootdir, modeenv, expectReseal, nil) + c.Assert(err, IsNil) + c.Assert(resealKeysCalls, Equals, 2) + + // verify the boot chains data file + pbc, cnt, err := boot.ReadBootChains(filepath.Join(dirs.SnapFDEDir, "boot-chains")) + c.Assert(err, IsNil) + c.Assert(cnt, Equals, 10) + c.Check(pbc, DeepEquals, boot.PredictableBootChains{ + boot.BootChain{ + BrandID: "my-brand", + Model: "my-model-uc20", + Grade: "dangerous", + ModelSignKeyID: "Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij", + AssetChain: []boot.BootAsset{ + { + Role: "recovery", + Name: "asset", + Hashes: []string{"asset-hash-1"}, + }, + }, + Kernel: "pc-kernel", + KernelRevision: "1", + KernelCmdlines: []string{ + "snapd_recovery_mode=recover snapd_recovery_system=20200825 static cmdline", + }, + }, + boot.BootChain{ + BrandID: "my-brand", + Model: "my-model-uc20", + Grade: "dangerous", + ModelSignKeyID: "Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij", + AssetChain: []boot.BootAsset{ + { + Role: "run-mode", + Name: "asset", + Hashes: []string{"asset-hash-1"}, + }, + }, + Kernel: "pc-kernel", + KernelRevision: "500", + KernelCmdlines: []string{ + "snapd_recovery_mode=run static cmdline", + }, + }, + }) +} + +func (s *sealSuite) TestRunModeBootChains(c *C) { + for _, tc := range []struct { + desc string + cmdlines []string + recoveryAssetsMap boot.BootAssetsMap + runAssetsMap boot.BootAssetsMap + currentKernels []string + expectedCmdlines [][]string + expectedAssets [][]boot.BootAsset + expectedKernelRevs []int + expectedErr string + }{ + { + desc: "Old chain", + cmdlines: []string{"testline"}, + recoveryAssetsMap: boot.BootAssetsMap{ + "grubx64.efi": []string{"grub-hash-1"}, + "bootx64.efi": []string{"shim-hash-1"}, + }, + runAssetsMap: boot.BootAssetsMap{ + "grubx64.efi": []string{"grub-hash-2", "grub-hash-3"}, + }, + currentKernels: []string{"pc-kernel_500.snap"}, + expectedKernelRevs: []int{500, 500}, + expectedCmdlines: [][]string{ + {"testline"}, + {"testline"}, + }, + expectedAssets: [][]boot.BootAsset{ + { + {Role: bootloader.RoleRecovery, Name: "bootx64.efi", Hashes: []string{"shim-hash-1"}}, + {Role: bootloader.RoleRecovery, Name: "grubx64.efi", Hashes: []string{"grub-hash-1"}}, + {Role: bootloader.RoleRunMode, Name: "grubx64.efi", Hashes: []string{"grub-hash-2", "grub-hash-3"}}, + }, + }, + }, + { + desc: "New chain", + cmdlines: []string{"testline"}, + recoveryAssetsMap: boot.BootAssetsMap{ + "ubuntu:grubx64.efi": []string{"grub-hash-1"}, + "ubuntu:shimx64.efi": []string{"shim-hash-1"}, + }, + runAssetsMap: boot.BootAssetsMap{ + "grubx64.efi": []string{"grub-hash-2", "grub-hash-3"}, + }, + currentKernels: []string{"pc-kernel_500.snap"}, + expectedKernelRevs: []int{500, 500}, + expectedCmdlines: [][]string{ + {"testline"}, + {"testline"}, + }, + expectedAssets: [][]boot.BootAsset{ + { + {Role: bootloader.RoleRecovery, Name: "ubuntu:shimx64.efi", Hashes: []string{"shim-hash-1"}}, + {Role: bootloader.RoleRecovery, Name: "ubuntu:grubx64.efi", Hashes: []string{"grub-hash-1"}}, + {Role: bootloader.RoleRunMode, Name: "grubx64.efi", Hashes: []string{"grub-hash-2", "grub-hash-3"}}, + }, + }, + }, + { + desc: "Both old and new chains", + cmdlines: []string{"testline"}, + recoveryAssetsMap: boot.BootAssetsMap{ + "grubx64.efi": []string{"grub-hash-1"}, + "bootx64.efi": []string{"shim-hash-1"}, + "ubuntu:grubx64.efi": []string{"grub-hash-3"}, + "ubuntu:shimx64.efi": []string{"shim-hash-3"}, + }, + runAssetsMap: boot.BootAssetsMap{ + "grubx64.efi": []string{"grub-hash-2", "grub-hash-3"}, + }, + currentKernels: []string{"pc-kernel_500.snap"}, + expectedKernelRevs: []int{500, 500}, + expectedCmdlines: [][]string{ + {"testline"}, + {"testline"}, + }, + expectedAssets: [][]boot.BootAsset{ + { + {Role: bootloader.RoleRecovery, Name: "bootx64.efi", Hashes: []string{"shim-hash-1"}}, + {Role: bootloader.RoleRecovery, Name: "grubx64.efi", Hashes: []string{"grub-hash-1"}}, + {Role: bootloader.RoleRunMode, Name: "grubx64.efi", Hashes: []string{"grub-hash-2", "grub-hash-3"}}, + }, + { + {Role: bootloader.RoleRecovery, Name: "ubuntu:shimx64.efi", Hashes: []string{"shim-hash-3"}}, + {Role: bootloader.RoleRecovery, Name: "ubuntu:grubx64.efi", Hashes: []string{"grub-hash-3"}}, + {Role: bootloader.RoleRunMode, Name: "grubx64.efi", Hashes: []string{"grub-hash-2", "grub-hash-3"}}, + }, + }, + }, + } { + c.Logf("tc: %q", tc.desc) + rootdir := c.MkDir() + dirs.SetRootDir(rootdir) + defer dirs.SetRootDir("") + + model := boottest.MakeMockUC20Model() + + modeenv := &boot.Modeenv{ + CurrentTrustedRecoveryBootAssets: tc.recoveryAssetsMap, + CurrentTrustedBootAssets: tc.runAssetsMap, + CurrentKernels: tc.currentKernels, + + BrandID: model.BrandID(), + Model: model.Model(), + ModelSignKeyID: model.SignKeyID(), + Grade: string(model.Grade()), + } + + grubDir := filepath.Join(rootdir, "run/mnt/ubuntu-seed") + err := createMockGrubCfg(grubDir) + c.Assert(err, IsNil) + + runGrubDir := filepath.Join(rootdir, "run/mnt/ubuntu-boot") + err = createMockGrubCfg(runGrubDir) + c.Assert(err, IsNil) + + rbl, err := bootloader.Find(grubDir, &bootloader.Options{ + Role: bootloader.RoleRecovery, + NoSlashBoot: true, + }) + c.Assert(err, IsNil) + bl, err := bootloader.Find(runGrubDir, &bootloader.Options{ + Role: bootloader.RoleRunMode, + NoSlashBoot: true, + }) + c.Assert(err, IsNil) + + bootChains, err := boot.RunModeBootChains(rbl, bl, modeenv, tc.cmdlines, "/snaps") + if tc.expectedErr == "" { + c.Assert(err, IsNil) + + foundChains := make(map[int]bool) + for i, chain := range bootChains { + foundChain := false + c.Logf("For chain: %v", chain.AssetChain) + for j, expectedAssets := range tc.expectedAssets { + c.Logf("Comparing with: %v", expectedAssets) + if reflect.DeepEqual(chain.AssetChain, expectedAssets) { + foundChains[j] = true + foundChain = true + continue + } + } + c.Assert(foundChain, Equals, true) + c.Assert(chain.Kernel, Equals, "pc-kernel") + expectedKernelRev := tc.expectedKernelRevs[i] + c.Assert(chain.KernelRevision, Equals, fmt.Sprintf("%d", expectedKernelRev)) + c.Assert(chain.KernelBootFile(), DeepEquals, bootloader.BootFile{ + Snap: fmt.Sprintf("/snaps/pc-kernel_%d.snap", expectedKernelRev), + Path: "kernel.efi", + Role: bootloader.RoleRunMode, + }) + c.Assert(chain.KernelCmdlines, DeepEquals, tc.expectedCmdlines[i]) + } + for j := range tc.expectedAssets { + c.Assert(foundChains[j], Equals, true) + } + } else { + c.Assert(err, ErrorMatches, tc.expectedErr) + } + } +} + +func (s *sealSuite) TestRecoveryBootChainsForSystems(c *C) { + for _, tc := range []struct { + desc string + assetsMap boot.BootAssetsMap + recoverySystems []string + modesForSystems map[string][]string + undefinedKernel bool + gadgetFilesForSystem map[string][][]string + expectedAssets [][]boot.BootAsset + expectedKernelRevs []int + expectedBootChainsCount int + // in the order of boot chains + expectedCmdlines [][]string + err string + }{ + { + desc: "transition sequences", + recoverySystems: []string{"20200825"}, + modesForSystems: map[string][]string{"20200825": {boot.ModeRecover, boot.ModeFactoryReset}}, + assetsMap: boot.BootAssetsMap{ + "grubx64.efi": []string{"grub-hash-1", "grub-hash-2"}, + "bootx64.efi": []string{"shim-hash-1"}, + }, + expectedAssets: [][]boot.BootAsset{{ + {Role: bootloader.RoleRecovery, Name: "bootx64.efi", Hashes: []string{"shim-hash-1"}}, + {Role: bootloader.RoleRecovery, Name: "grubx64.efi", Hashes: []string{"grub-hash-1", "grub-hash-2"}}, + }}, + expectedKernelRevs: []int{1}, + expectedCmdlines: [][]string{{ + "snapd_recovery_mode=recover snapd_recovery_system=20200825 console=ttyS0 console=tty1 panic=-1", + "snapd_recovery_mode=factory-reset snapd_recovery_system=20200825 console=ttyS0 console=tty1 panic=-1", + }}, + }, + { + desc: "two systems", + recoverySystems: []string{"20200825", "20200831"}, + modesForSystems: map[string][]string{ + "20200825": {boot.ModeRecover, boot.ModeFactoryReset}, + "20200831": {boot.ModeRecover, boot.ModeFactoryReset}, + }, + assetsMap: boot.BootAssetsMap{ + "grubx64.efi": []string{"grub-hash-1", "grub-hash-2"}, + "bootx64.efi": []string{"shim-hash-1"}, + }, + expectedAssets: [][]boot.BootAsset{{ + {Role: bootloader.RoleRecovery, Name: "bootx64.efi", Hashes: []string{"shim-hash-1"}}, + {Role: bootloader.RoleRecovery, Name: "grubx64.efi", Hashes: []string{"grub-hash-1", "grub-hash-2"}}, + }}, + expectedKernelRevs: []int{1, 3}, + expectedCmdlines: [][]string{{ + "snapd_recovery_mode=recover snapd_recovery_system=20200825 console=ttyS0 console=tty1 panic=-1", + "snapd_recovery_mode=factory-reset snapd_recovery_system=20200825 console=ttyS0 console=tty1 panic=-1", + }, { + "snapd_recovery_mode=recover snapd_recovery_system=20200831 console=ttyS0 console=tty1 panic=-1", + "snapd_recovery_mode=factory-reset snapd_recovery_system=20200831 console=ttyS0 console=tty1 panic=-1", + }}, + }, + { + desc: "non transition sequence", + recoverySystems: []string{"20200825"}, + modesForSystems: map[string][]string{"20200825": {boot.ModeRecover, boot.ModeFactoryReset}}, + assetsMap: boot.BootAssetsMap{ + "grubx64.efi": []string{"grub-hash-1"}, + "bootx64.efi": []string{"shim-hash-1"}, + }, + expectedAssets: [][]boot.BootAsset{{ + {Role: bootloader.RoleRecovery, Name: "bootx64.efi", Hashes: []string{"shim-hash-1"}}, + {Role: bootloader.RoleRecovery, Name: "grubx64.efi", Hashes: []string{"grub-hash-1"}}, + }}, + expectedKernelRevs: []int{1}, + expectedCmdlines: [][]string{{ + "snapd_recovery_mode=recover snapd_recovery_system=20200825 console=ttyS0 console=tty1 panic=-1", + "snapd_recovery_mode=factory-reset snapd_recovery_system=20200825 console=ttyS0 console=tty1 panic=-1", + }}, + }, + { + desc: "two systems with command lines", + recoverySystems: []string{"20200825", "20200831"}, + modesForSystems: map[string][]string{ + "20200825": {boot.ModeRecover, boot.ModeFactoryReset}, + "20200831": {boot.ModeRecover, boot.ModeFactoryReset}, + }, + assetsMap: boot.BootAssetsMap{ + "grubx64.efi": []string{"grub-hash-1", "grub-hash-2"}, + "bootx64.efi": []string{"shim-hash-1"}, + }, + expectedAssets: [][]boot.BootAsset{{ + {Role: bootloader.RoleRecovery, Name: "bootx64.efi", Hashes: []string{"shim-hash-1"}}, + {Role: bootloader.RoleRecovery, Name: "grubx64.efi", Hashes: []string{"grub-hash-1", "grub-hash-2"}}, + }}, + gadgetFilesForSystem: map[string][][]string{ + "20200825": { + {"cmdline.extra", "extra for 20200825"}, + }, + "20200831": { + // TODO: make it a cmdline.full + {"cmdline.extra", "some-extra-for-20200831"}, + }, + }, + expectedKernelRevs: []int{1, 3}, + expectedCmdlines: [][]string{{ + "snapd_recovery_mode=recover snapd_recovery_system=20200825 console=ttyS0 console=tty1 panic=-1 extra for 20200825", + "snapd_recovery_mode=factory-reset snapd_recovery_system=20200825 console=ttyS0 console=tty1 panic=-1 extra for 20200825", + }, { + "snapd_recovery_mode=recover snapd_recovery_system=20200831 console=ttyS0 console=tty1 panic=-1 some-extra-for-20200831", + "snapd_recovery_mode=factory-reset snapd_recovery_system=20200831 console=ttyS0 console=tty1 panic=-1 some-extra-for-20200831", + }}, + }, + { + desc: "three systems, one with different model", + recoverySystems: []string{"20200825", "20200831", "off-model"}, + modesForSystems: map[string][]string{ + "20200825": {boot.ModeRecover, boot.ModeFactoryReset}, + "20200831": {boot.ModeRecover, boot.ModeFactoryReset}, + "off-model": {boot.ModeRecover, boot.ModeFactoryReset}, + }, + assetsMap: boot.BootAssetsMap{ + "grubx64.efi": []string{"grub-hash-1", "grub-hash-2"}, + "bootx64.efi": []string{"shim-hash-1"}, + }, + expectedAssets: [][]boot.BootAsset{{ + {Role: bootloader.RoleRecovery, Name: "bootx64.efi", Hashes: []string{"shim-hash-1"}}, + {Role: bootloader.RoleRecovery, Name: "grubx64.efi", Hashes: []string{"grub-hash-1", "grub-hash-2"}}, + }}, + expectedKernelRevs: []int{1, 3}, + expectedCmdlines: [][]string{{ + "snapd_recovery_mode=recover snapd_recovery_system=20200825 console=ttyS0 console=tty1 panic=-1", + "snapd_recovery_mode=factory-reset snapd_recovery_system=20200825 console=ttyS0 console=tty1 panic=-1", + }, { + "snapd_recovery_mode=recover snapd_recovery_system=20200831 console=ttyS0 console=tty1 panic=-1", + "snapd_recovery_mode=factory-reset snapd_recovery_system=20200831 console=ttyS0 console=tty1 panic=-1", + }}, + expectedBootChainsCount: 2, + }, + { + desc: "two systems, one with different modes", + recoverySystems: []string{"20200825", "20200831"}, + modesForSystems: map[string][]string{ + "20200825": {boot.ModeRecover, boot.ModeFactoryReset}, + "20200831": {boot.ModeRecover}, + }, + assetsMap: boot.BootAssetsMap{ + "grubx64.efi": []string{"grub-hash-1", "grub-hash-2"}, + "bootx64.efi": []string{"shim-hash-1"}, + }, + expectedAssets: [][]boot.BootAsset{{ + {Role: bootloader.RoleRecovery, Name: "bootx64.efi", Hashes: []string{"shim-hash-1"}}, + {Role: bootloader.RoleRecovery, Name: "grubx64.efi", Hashes: []string{"grub-hash-1", "grub-hash-2"}}, + }}, + expectedKernelRevs: []int{1, 3}, + expectedCmdlines: [][]string{{ + "snapd_recovery_mode=recover snapd_recovery_system=20200825 console=ttyS0 console=tty1 panic=-1", + "snapd_recovery_mode=factory-reset snapd_recovery_system=20200825 console=ttyS0 console=tty1 panic=-1", + }, { + "snapd_recovery_mode=recover snapd_recovery_system=20200831 console=ttyS0 console=tty1 panic=-1", + }}, + expectedBootChainsCount: 2, + }, + { + desc: "invalid recovery system label", + recoverySystems: []string{"0"}, + modesForSystems: map[string][]string{"0": {boot.ModeRecover}}, + err: `cannot read system "0" seed: invalid system seed`, + }, + { + desc: "missing modes for a system", + recoverySystems: []string{"20200825"}, + modesForSystems: map[string][]string{"other": {boot.ModeRecover}}, + err: `internal error: no modes for system "20200825"`, + }, + { + desc: "no matching boot chains", + recoverySystems: []string{"20200825"}, + modesForSystems: map[string][]string{"20200825": {boot.ModeRecover, boot.ModeFactoryReset}}, + assetsMap: boot.BootAssetsMap{ + "grubx64.efi": []string{"grub-hash-1", "grub-hash-2"}, + "shimx64.efi": []string{"shim-hash-1"}, // it should be bootx64.efi + }, + err: `could not find any valid chain for this model`, + }, + { + desc: "udpate to new layout", + recoverySystems: []string{"20200825"}, + modesForSystems: map[string][]string{"20200825": {boot.ModeRecover, boot.ModeFactoryReset}}, + assetsMap: boot.BootAssetsMap{ + "grubx64.efi": []string{"grub-hash-1"}, + "bootx64.efi": []string{"shim-hash-1"}, + "ubuntu:grubx64.efi": []string{"grub-hash-2"}, + "ubuntu:shimx64.efi": []string{"shim-hash-2"}, + }, + expectedAssets: [][]boot.BootAsset{{ + {Role: bootloader.RoleRecovery, Name: "bootx64.efi", Hashes: []string{"shim-hash-1"}}, + {Role: bootloader.RoleRecovery, Name: "grubx64.efi", Hashes: []string{"grub-hash-1"}}, + }, { + {Role: bootloader.RoleRecovery, Name: "ubuntu:shimx64.efi", Hashes: []string{"shim-hash-2"}}, + {Role: bootloader.RoleRecovery, Name: "ubuntu:grubx64.efi", Hashes: []string{"grub-hash-2"}}, + }}, + expectedKernelRevs: []int{1, 1}, + expectedCmdlines: [][]string{{ + "snapd_recovery_mode=recover snapd_recovery_system=20200825 console=ttyS0 console=tty1 panic=-1", + "snapd_recovery_mode=factory-reset snapd_recovery_system=20200825 console=ttyS0 console=tty1 panic=-1", + }, { + "snapd_recovery_mode=recover snapd_recovery_system=20200825 console=ttyS0 console=tty1 panic=-1", + "snapd_recovery_mode=factory-reset snapd_recovery_system=20200825 console=ttyS0 console=tty1 panic=-1", + }}, + expectedBootChainsCount: 2, + }, + } { + c.Logf("tc: %q", tc.desc) + rootdir := c.MkDir() + dirs.SetRootDir(rootdir) + defer dirs.SetRootDir("") + + model := boottest.MakeMockUC20Model() + + // set recovery kernel + restore := boot.MockSeedReadSystemEssential(func(seedDir, label string, essentialTypes []snap.Type, tm timings.Measurer) (*asserts.Model, []*seed.Snap, error) { + systemModel := model + kernelRev := 1 + switch label { + case "20200825": + // nothing special + case "20200831": + kernelRev = 3 + case "off-model": + systemModel = boottest.MakeMockUC20Model(map[string]interface{}{ + "model": "model-mismatch-uc20", + }) + default: + return nil, nil, fmt.Errorf("invalid system seed") + } + return systemModel, []*seed.Snap{mockKernelSeedSnap(snap.R(kernelRev)), mockGadgetSeedSnap(c, tc.gadgetFilesForSystem[label])}, nil + }) + defer restore() + + grubDir := filepath.Join(rootdir, "run/mnt/ubuntu-seed") + err := createMockGrubCfg(grubDir) + c.Assert(err, IsNil) + + bl, err := bootloader.Find(grubDir, &bootloader.Options{Role: bootloader.RoleRecovery}) + c.Assert(err, IsNil) + tbl, ok := bl.(bootloader.TrustedAssetsBootloader) + c.Assert(ok, Equals, true) + + modeenv := &boot.Modeenv{ + CurrentTrustedRecoveryBootAssets: tc.assetsMap, + + BrandID: model.BrandID(), + Model: model.Model(), + ModelSignKeyID: model.SignKeyID(), + Grade: string(model.Grade()), + } + + includeTryModel := false + bc, err := boot.RecoveryBootChainsForSystems(tc.recoverySystems, tc.modesForSystems, tbl, modeenv, includeTryModel, dirs.SnapSeedDir) + if tc.err == "" { + c.Assert(err, IsNil) + if tc.expectedBootChainsCount == 0 { + // usually there is a boot chain for each recovery system + c.Assert(bc, HasLen, len(tc.recoverySystems)) + } else { + c.Assert(bc, HasLen, tc.expectedBootChainsCount) + } + c.Assert(tc.expectedCmdlines, HasLen, len(bc), Commentf("broken test, expected command lines must be of the same length as recovery systems and recovery boot chains")) + foundChains := make(map[int]bool) + for i, chain := range bc { + foundChain := false + for j, expectedAssets := range tc.expectedAssets { + if reflect.DeepEqual(chain.AssetChain, expectedAssets) { + foundChains[j] = true + foundChain = true + continue + } + } + c.Assert(foundChain, Equals, true) + c.Assert(chain.Kernel, Equals, "pc-kernel") + expectedKernelRev := tc.expectedKernelRevs[i] + c.Assert(chain.KernelRevision, Equals, fmt.Sprintf("%d", expectedKernelRev)) + c.Assert(chain.KernelBootFile(), DeepEquals, bootloader.BootFile{ + Snap: fmt.Sprintf("/var/lib/snapd/seed/snaps/pc-kernel_%d.snap", expectedKernelRev), + Path: "kernel.efi", + Role: bootloader.RoleRecovery, + }) + c.Assert(chain.KernelCmdlines, DeepEquals, tc.expectedCmdlines[i]) + } + for j := range tc.expectedAssets { + c.Assert(foundChains[j], Equals, true) + } + } else { + c.Assert(err, ErrorMatches, tc.err) + } + + } + +} + +func createMockGrubCfg(baseDir string) error { + cfg := filepath.Join(baseDir, "EFI/ubuntu/grub.cfg") + if err := os.MkdirAll(filepath.Dir(cfg), 0755); err != nil { + return err + } + return os.WriteFile(cfg, []byte("# Snapd-Boot-Config-Edition: 1\n"), 0644) +} + +func (s *sealSuite) TestSealKeyModelParams(c *C) { + rootdir := c.MkDir() + dirs.SetRootDir(rootdir) + defer dirs.SetRootDir("") + + model := boottest.MakeMockUC20Model() + + roleToBlName := map[bootloader.Role]string{ + bootloader.RoleRecovery: "grub", + bootloader.RoleRunMode: "grub", + } + // mock asset cache + mockAssetsCache(c, rootdir, "grub", []string{ + "shim-shim-hash", + "loader-loader-hash1", + "loader-loader-hash2", + }) + + oldmodel := boottest.MakeMockUC20Model(map[string]interface{}{ + "model": "old-model-uc20", + "timestamp": "2019-10-01T08:00:00+00:00", + }) + + // old recovery + oldrc := boot.BootChain{ + BrandID: oldmodel.BrandID(), + Model: oldmodel.Model(), + Grade: oldmodel.Grade(), + ModelSignKeyID: oldmodel.SignKeyID(), + AssetChain: []boot.BootAsset{ + {Name: "shim", Role: bootloader.RoleRecovery, Hashes: []string{"shim-hash"}}, + {Name: "loader", Role: bootloader.RoleRecovery, Hashes: []string{"loader-hash1"}}, + }, + KernelCmdlines: []string{"panic=1", "oldrc"}, + } + oldkbf := bootloader.BootFile{Snap: "pc-kernel_1.snap"} + oldrc.SetKernelBootFile(oldkbf) + + // recovery + rc1 := boot.BootChain{ + BrandID: model.BrandID(), + Model: model.Model(), + Grade: model.Grade(), + ModelSignKeyID: model.SignKeyID(), + AssetChain: []boot.BootAsset{ + {Name: "shim", Role: bootloader.RoleRecovery, Hashes: []string{"shim-hash"}}, + {Name: "loader", Role: bootloader.RoleRecovery, Hashes: []string{"loader-hash1"}}, + }, + KernelCmdlines: []string{"panic=1", "rc1"}, + } + rc1kbf := bootloader.BootFile{Snap: "pc-kernel_10.snap"} + rc1.SetKernelBootFile(rc1kbf) + + // run system + runc1 := boot.BootChain{ + BrandID: model.BrandID(), + Model: model.Model(), + Grade: model.Grade(), + ModelSignKeyID: model.SignKeyID(), + AssetChain: []boot.BootAsset{ + {Name: "shim", Role: bootloader.RoleRecovery, Hashes: []string{"shim-hash"}}, + {Name: "loader", Role: bootloader.RoleRecovery, Hashes: []string{"loader-hash1"}}, + {Name: "loader", Role: bootloader.RoleRunMode, Hashes: []string{"loader-hash2"}}, + }, + KernelCmdlines: []string{"panic=1", "runc1"}, + } + runc1kbf := bootloader.BootFile{Snap: "pc-kernel_50.snap"} + runc1.SetKernelBootFile(runc1kbf) + + pbc := boot.ToPredictableBootChains([]boot.BootChain{rc1, runc1, oldrc}) + + shim := bootloader.NewBootFile("", filepath.Join(rootdir, "var/lib/snapd/boot-assets/grub/shim-shim-hash"), bootloader.RoleRecovery) + loader1 := bootloader.NewBootFile("", filepath.Join(rootdir, "var/lib/snapd/boot-assets/grub/loader-loader-hash1"), bootloader.RoleRecovery) + loader2 := bootloader.NewBootFile("", filepath.Join(rootdir, "var/lib/snapd/boot-assets/grub/loader-loader-hash2"), bootloader.RoleRunMode) + + params, err := boot.SealKeyModelParams(pbc, roleToBlName) + c.Assert(err, IsNil) + c.Check(params, HasLen, 2) + c.Check(params[0].Model.Model(), Equals, model.Model()) + // NB: merging of lists makes panic=1 appear once + c.Check(params[0].KernelCmdlines, DeepEquals, []string{"panic=1", "rc1", "runc1"}) + + c.Check(params[0].EFILoadChains, DeepEquals, []*secboot.LoadChain{ + secboot.NewLoadChain(shim, + secboot.NewLoadChain(loader1, + secboot.NewLoadChain(rc1kbf))), + secboot.NewLoadChain(shim, + secboot.NewLoadChain(loader1, + secboot.NewLoadChain(loader2, + secboot.NewLoadChain(runc1kbf)))), + }) + + c.Check(params[1].Model.Model(), Equals, oldmodel.Model()) + c.Check(params[1].KernelCmdlines, DeepEquals, []string{"oldrc", "panic=1"}) + c.Check(params[1].EFILoadChains, DeepEquals, []*secboot.LoadChain{ + secboot.NewLoadChain(shim, + secboot.NewLoadChain(loader1, + secboot.NewLoadChain(oldkbf))), + }) +} + +func (s *sealSuite) TestIsResealNeeded(c *C) { + if os.Geteuid() == 0 { + c.Skip("the test cannot be run by the root user") + } + + chains := []boot.BootChain{ + { + BrandID: "mybrand", + Model: "foo", + Grade: "signed", + ModelSignKeyID: "my-key-id", + AssetChain: []boot.BootAsset{ + // hashes will be sorted + {Role: bootloader.RoleRecovery, Name: "shim", Hashes: []string{"x", "y"}}, + {Role: bootloader.RoleRecovery, Name: "loader", Hashes: []string{"c", "d"}}, + {Role: bootloader.RoleRunMode, Name: "loader", Hashes: []string{"z", "x"}}, + }, + Kernel: "pc-kernel-other", + KernelRevision: "2345", + KernelCmdlines: []string{`snapd_recovery_mode=run foo`}, + }, { + BrandID: "mybrand", + Model: "foo", + Grade: "dangerous", + ModelSignKeyID: "my-key-id", + AssetChain: []boot.BootAsset{ + // hashes will be sorted + {Role: bootloader.RoleRecovery, Name: "shim", Hashes: []string{"y", "x"}}, + {Role: bootloader.RoleRecovery, Name: "loader", Hashes: []string{"c", "d"}}, + }, + Kernel: "pc-kernel-recovery", + KernelRevision: "1234", + KernelCmdlines: []string{`snapd_recovery_mode=recover foo`}, + }, + } + + pbc := boot.ToPredictableBootChains(chains) + + rootdir := c.MkDir() + err := boot.WriteBootChains(pbc, filepath.Join(dirs.SnapFDEDirUnder(rootdir), "boot-chains"), 2) + c.Assert(err, IsNil) + + needed, _, err := boot.IsResealNeeded(pbc, filepath.Join(dirs.SnapFDEDirUnder(rootdir), "boot-chains"), false) + c.Assert(err, IsNil) + c.Check(needed, Equals, false) + + otherchain := []boot.BootChain{pbc[0]} + needed, cnt, err := boot.IsResealNeeded(otherchain, filepath.Join(dirs.SnapFDEDirUnder(rootdir), "boot-chains"), false) + c.Assert(err, IsNil) + // chains are different + c.Check(needed, Equals, true) + c.Check(cnt, Equals, 3) + + // boot-chains does not exist, we cannot compare so advise to reseal + otherRootdir := c.MkDir() + needed, cnt, err = boot.IsResealNeeded(otherchain, filepath.Join(dirs.SnapFDEDirUnder(otherRootdir), "boot-chains"), false) + c.Assert(err, IsNil) + c.Check(needed, Equals, true) + c.Check(cnt, Equals, 1) + + // exists but cannot be read + c.Assert(os.Chmod(filepath.Join(dirs.SnapFDEDirUnder(rootdir), "boot-chains"), 0000), IsNil) + defer os.Chmod(filepath.Join(dirs.SnapFDEDirUnder(rootdir), "boot-chains"), 0755) + needed, _, err = boot.IsResealNeeded(otherchain, filepath.Join(dirs.SnapFDEDirUnder(rootdir), "boot-chains"), false) + c.Assert(err, ErrorMatches, "cannot open existing boot chains data file: open .*/boot-chains: permission denied") + c.Check(needed, Equals, false) + + // unrevisioned kernel chain + unrevchain := []boot.BootChain{pbc[0], pbc[1]} + unrevchain[1].KernelRevision = "" + // write on disk + bootChainsFile := filepath.Join(dirs.SnapFDEDirUnder(rootdir), "boot-chains") + err = boot.WriteBootChains(unrevchain, bootChainsFile, 2) + c.Assert(err, IsNil) + + needed, cnt, err = boot.IsResealNeeded(pbc, bootChainsFile, false) + c.Assert(err, IsNil) + c.Check(needed, Equals, true) + c.Check(cnt, Equals, 3) + + // cases falling back to expectReseal + needed, _, err = boot.IsResealNeeded(unrevchain, bootChainsFile, false) + c.Assert(err, IsNil) + c.Check(needed, Equals, false) + + needed, cnt, err = boot.IsResealNeeded(unrevchain, bootChainsFile, true) + c.Assert(err, IsNil) + c.Check(needed, Equals, true) + c.Check(cnt, Equals, 3) +} + +func (s *sealSuite) TestSealToModeenvWithFdeHookHappy(c *C) { + rootdir := c.MkDir() + dirs.SetRootDir(rootdir) + defer dirs.SetRootDir("") + model := boottest.MakeMockUC20Model() + + n := 0 + var runFDESetupHookReqs []*fde.SetupRequest + restore := boot.MockRunFDESetupHook(func(req *fde.SetupRequest) ([]byte, error) { + n++ + runFDESetupHookReqs = append(runFDESetupHookReqs, req) + + key := []byte(fmt.Sprintf("key-%v", strconv.Itoa(n))) + return key, nil + }) + defer restore() + keyToSave := make(map[string][]byte) + restore = boot.MockSecbootSealKeysWithFDESetupHook(func(runHook fde.RunSetupHookFunc, skrs []secboot.SealKeyRequest, params *secboot.SealKeysWithFDESetupHookParams) error { + c.Check(params.Model.Model(), Equals, model.Model()) + c.Check(params.AuxKeyFile, Equals, filepath.Join(boot.InstallHostFDESaveDir, "aux-key")) + for _, skr := range skrs { + out, err := runHook(&fde.SetupRequest{ + Key: skr.Key, + KeyName: skr.KeyName, + }) + c.Assert(err, IsNil) + keyToSave[skr.KeyFile] = out + } + return nil + }) + defer restore() + + modeenv := &boot.Modeenv{ + RecoverySystem: "20200825", + Model: model.Model(), + BrandID: model.BrandID(), + Grade: string(model.Grade()), + ModelSignKeyID: model.SignKeyID(), + } + key := keys.EncryptionKey{1, 2, 3, 4} + saveKey := keys.EncryptionKey{5, 6, 7, 8} + + defer boot.MockModeenvLocked()() + + err := boot.SealKeyToModeenv(key, saveKey, model, modeenv, boot.MockSealKeyToModeenvFlags{HasFDESetupHook: true}) + c.Assert(err, IsNil) + // check that runFDESetupHook was called the expected way + c.Check(runFDESetupHookReqs, DeepEquals, []*fde.SetupRequest{ + {Key: key, KeyName: "ubuntu-data"}, + {Key: key, KeyName: "ubuntu-data"}, + {Key: saveKey, KeyName: "ubuntu-save"}, + }) + // check that the sealed keys got written to the expected places + for i, p := range []string{ + filepath.Join(boot.InitramfsBootEncryptionKeyDir, "ubuntu-data.sealed-key"), + filepath.Join(boot.InitramfsSeedEncryptionKeyDir, "ubuntu-data.recovery.sealed-key"), + filepath.Join(boot.InitramfsSeedEncryptionKeyDir, "ubuntu-save.recovery.sealed-key"), + } { + // Check for a valid platform handle, encrypted payload (base64) + mockedSealedKey := []byte(fmt.Sprintf("key-%v", strconv.Itoa(i+1))) + c.Check(keyToSave[p], DeepEquals, mockedSealedKey) + } + + marker := filepath.Join(dirs.SnapFDEDirUnder(filepath.Join(dirs.GlobalRootDir, "/run/mnt/ubuntu-data/system-data")), "sealed-keys") + c.Check(marker, testutil.FileEquals, "fde-setup-hook") +} + +func (s *sealSuite) TestSealToModeenvWithFdeHookSad(c *C) { + rootdir := c.MkDir() + dirs.SetRootDir(rootdir) + defer dirs.SetRootDir("") + + restore := boot.MockSecbootSealKeysWithFDESetupHook(func(fde.RunSetupHookFunc, []secboot.SealKeyRequest, *secboot.SealKeysWithFDESetupHookParams) error { + return fmt.Errorf("hook failed") + }) + defer restore() + + modeenv := &boot.Modeenv{ + RecoverySystem: "20200825", + } + key := keys.EncryptionKey{1, 2, 3, 4} + saveKey := keys.EncryptionKey{5, 6, 7, 8} + + defer boot.MockModeenvLocked()() + + model := boottest.MakeMockUC20Model() + err := boot.SealKeyToModeenv(key, saveKey, model, modeenv, boot.MockSealKeyToModeenvFlags{HasFDESetupHook: true}) + c.Assert(err, ErrorMatches, "hook failed") + marker := filepath.Join(dirs.SnapFDEDirUnder(filepath.Join(dirs.GlobalRootDir, "/run/mnt/ubuntu-data/system-data")), "sealed-keys") + c.Check(marker, testutil.FileAbsent) +} + +func (s *sealSuite) TestResealKeyToModeenvWithFdeHookCalled(c *C) { + rootdir := c.MkDir() + dirs.SetRootDir(rootdir) + defer dirs.SetRootDir("") + + resealKeyToModeenvUsingFDESetupHookCalled := 0 + restore := boot.MockResealKeyToModeenvUsingFDESetupHook(func(string, *boot.Modeenv, bool) error { + resealKeyToModeenvUsingFDESetupHookCalled++ + return nil + }) + defer restore() + + // TODO: this simulates that the hook is not available yet + // because of e.g. seeding. Longer term there will be + // more, see TODO in resealKeyToModeenvUsingFDESetupHookImpl + restore = boot.MockHasFDESetupHook(func(kernel *snap.Info) (bool, error) { + return false, fmt.Errorf("hook not available yet because e.g. seeding") + }) + defer restore() + + marker := filepath.Join(dirs.SnapFDEDirUnder(rootdir), "sealed-keys") + err := os.MkdirAll(filepath.Dir(marker), 0755) + c.Assert(err, IsNil) + err = os.WriteFile(marker, []byte("fde-setup-hook"), 0644) + c.Assert(err, IsNil) + + defer boot.MockModeenvLocked()() + + model := boottest.MakeMockUC20Model() + modeenv := &boot.Modeenv{ + RecoverySystem: "20200825", + Model: model.Model(), + BrandID: model.BrandID(), + Grade: string(model.Grade()), + ModelSignKeyID: model.SignKeyID(), + } + expectReseal := false + err = boot.ResealKeyToModeenv(rootdir, modeenv, expectReseal, nil) + c.Assert(err, IsNil) + c.Check(resealKeyToModeenvUsingFDESetupHookCalled, Equals, 1) +} + +func (s *sealSuite) TestResealKeyToModeenvWithFdeHookVerySad(c *C) { + rootdir := c.MkDir() + dirs.SetRootDir(rootdir) + defer dirs.SetRootDir("") + + resealKeyToModeenvUsingFDESetupHookCalled := 0 + restore := boot.MockResealKeyToModeenvUsingFDESetupHook(func(string, *boot.Modeenv, bool) error { + resealKeyToModeenvUsingFDESetupHookCalled++ + return fmt.Errorf("fde setup hook failed") + }) + defer restore() + + marker := filepath.Join(dirs.SnapFDEDirUnder(rootdir), "sealed-keys") + err := os.MkdirAll(filepath.Dir(marker), 0755) + c.Assert(err, IsNil) + err = os.WriteFile(marker, []byte("fde-setup-hook"), 0644) + c.Assert(err, IsNil) + + defer boot.MockModeenvLocked()() + + model := boottest.MakeMockUC20Model() + modeenv := &boot.Modeenv{ + RecoverySystem: "20200825", + Model: model.Model(), + BrandID: model.BrandID(), + Grade: string(model.Grade()), + ModelSignKeyID: model.SignKeyID(), + } + expectReseal := false + err = boot.ResealKeyToModeenv(rootdir, modeenv, expectReseal, nil) + c.Assert(err, ErrorMatches, "fde setup hook failed") + c.Check(resealKeyToModeenvUsingFDESetupHookCalled, Equals, 1) +} + +func (s *sealSuite) testResealKeyToModeenvWithTryModel(c *C, shimId, grubId string) { + rootdir := c.MkDir() + dirs.SetRootDir(rootdir) + defer dirs.SetRootDir("") + + c.Assert(os.MkdirAll(dirs.SnapFDEDir, 0755), IsNil) + err := os.WriteFile(filepath.Join(dirs.SnapFDEDir, "sealed-keys"), nil, 0644) + c.Assert(err, IsNil) + + err = createMockGrubCfg(filepath.Join(rootdir, "run/mnt/ubuntu-seed")) + c.Assert(err, IsNil) + + err = createMockGrubCfg(filepath.Join(rootdir, "run/mnt/ubuntu-boot")) + c.Assert(err, IsNil) + + model := boottest.MakeMockUC20Model() + // a try model which would normally only appear during remodel + tryModel := boottest.MakeMockUC20Model(map[string]interface{}{ + "model": "try-my-model-uc20", + "grade": "secured", + }) + + modeenv := &boot.Modeenv{ + // recovery system set up like during a remodel, right before a + // set-device is called, the recovery system of the new model + // has been tested + CurrentRecoverySystems: []string{"20200825", "1234", "off-model"}, + GoodRecoverySystems: []string{"20200825", "1234"}, + + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + grubId: []string{"grub-hash"}, + shimId: []string{"shim-hash"}, + }, + + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "grubx64.efi": []string{"run-grub-hash"}, + }, + + CurrentKernels: []string{"pc-kernel_500.snap"}, + + CurrentKernelCommandLines: boot.BootCommandLines{ + "snapd_recovery_mode=run console=ttyS0 console=tty1 panic=-1", + }, + // the current model + Model: model.Model(), + BrandID: model.BrandID(), + Grade: string(model.Grade()), + ModelSignKeyID: model.SignKeyID(), + // the try model + TryModel: tryModel.Model(), + TryBrandID: tryModel.BrandID(), + TryGrade: string(tryModel.Grade()), + TryModelSignKeyID: tryModel.SignKeyID(), + } + + // mock asset cache + mockAssetsCache(c, rootdir, "grub", []string{ + fmt.Sprintf("%s-shim-hash", shimId), + fmt.Sprintf("%s-grub-hash", grubId), + "grubx64.efi-run-grub-hash", + }) + + // set a mock recovery kernel + readSystemEssentialCalls := 0 + restore := boot.MockSeedReadSystemEssential(func(seedDir, label string, essentialTypes []snap.Type, tm timings.Measurer) (*asserts.Model, []*seed.Snap, error) { + readSystemEssentialCalls++ + kernelRev := 1 + systemModel := model + if label == "1234" { + // recovery system for new model + kernelRev = 999 + systemModel = tryModel + } + if label == "off-model" { + // a model that matches neither current not try models + systemModel = boottest.MakeMockUC20Model(map[string]interface{}{ + "model": "different-model-uc20", + "grade": "secured", + }) + } + return systemModel, []*seed.Snap{mockKernelSeedSnap(snap.R(kernelRev)), mockGadgetSeedSnap(c, nil)}, nil + }) + defer restore() + + defer boot.MockModeenvLocked()() + + // set mock key resealing + resealKeysCalls := 0 + restore = boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + c.Check(params.TPMPolicyAuthKeyFile, Equals, filepath.Join(dirs.SnapSaveDir, "device/fde", "tpm-policy-auth-key")) + c.Logf("got:") + for _, mp := range params.ModelParams { + c.Logf("model: %v", mp.Model.Model()) + for _, ch := range mp.EFILoadChains { + printChain(c, ch, "-") + } + } + + resealKeysCalls++ + + switch resealKeysCalls { + case 1: // run key + c.Assert(params.KeyFiles, DeepEquals, []string{ + filepath.Join(boot.InitramfsBootEncryptionKeyDir, "ubuntu-data.sealed-key"), + }) + // 2 models, one current and one try model + c.Assert(params.ModelParams, HasLen, 2) + // shared parameters + c.Assert(params.ModelParams[0].Model.Model(), Equals, "my-model-uc20") + c.Assert(params.ModelParams[0].KernelCmdlines, DeepEquals, []string{ + "snapd_recovery_mode=factory-reset snapd_recovery_system=20200825 console=ttyS0 console=tty1 panic=-1", + "snapd_recovery_mode=recover snapd_recovery_system=20200825 console=ttyS0 console=tty1 panic=-1", + "snapd_recovery_mode=run console=ttyS0 console=tty1 panic=-1", + }) + // 2 load chains (bootloader + run kernel, bootloader + recovery kernel) + c.Assert(params.ModelParams[0].EFILoadChains, HasLen, 2) + + c.Assert(params.ModelParams[1].Model.Model(), Equals, "try-my-model-uc20") + c.Assert(params.ModelParams[1].KernelCmdlines, DeepEquals, []string{ + "snapd_recovery_mode=factory-reset snapd_recovery_system=1234 console=ttyS0 console=tty1 panic=-1", + "snapd_recovery_mode=recover snapd_recovery_system=1234 console=ttyS0 console=tty1 panic=-1", + "snapd_recovery_mode=run console=ttyS0 console=tty1 panic=-1", + }) + // 2 load chains (bootloader + run kernel, bootloader + recovery kernel) + c.Assert(params.ModelParams[1].EFILoadChains, HasLen, 2) + case 2: // recovery keys + c.Assert(params.KeyFiles, DeepEquals, []string{ + filepath.Join(boot.InitramfsSeedEncryptionKeyDir, "ubuntu-data.recovery.sealed-key"), + filepath.Join(boot.InitramfsSeedEncryptionKeyDir, "ubuntu-save.recovery.sealed-key"), + }) + // only the current model + c.Assert(params.ModelParams, HasLen, 1) + // shared parameters + c.Assert(params.ModelParams[0].Model.Model(), Equals, "my-model-uc20") + for _, mp := range params.ModelParams { + c.Assert(mp.KernelCmdlines, DeepEquals, []string{ + "snapd_recovery_mode=factory-reset snapd_recovery_system=20200825 console=ttyS0 console=tty1 panic=-1", + "snapd_recovery_mode=recover snapd_recovery_system=20200825 console=ttyS0 console=tty1 panic=-1", + }) + // load chains + c.Assert(mp.EFILoadChains, HasLen, 1) + } + default: + c.Errorf("unexpected additional call to secboot.ResealKeys (call # %d)", resealKeysCalls) + } + + // recovery parameters + shim := bootloader.NewBootFile("", filepath.Join(rootdir, fmt.Sprintf("var/lib/snapd/boot-assets/grub/%s-shim-hash", shimId)), bootloader.RoleRecovery) + grub := bootloader.NewBootFile("", filepath.Join(rootdir, fmt.Sprintf("var/lib/snapd/boot-assets/grub/%s-grub-hash", grubId)), bootloader.RoleRecovery) + kernelOldRecovery := bootloader.NewBootFile("/var/lib/snapd/seed/snaps/pc-kernel_1.snap", "kernel.efi", bootloader.RoleRecovery) + // kernel from a tried recovery system + kernelNewRecovery := bootloader.NewBootFile("/var/lib/snapd/seed/snaps/pc-kernel_999.snap", "kernel.efi", bootloader.RoleRecovery) + // run mode parameters + runGrub := bootloader.NewBootFile("", filepath.Join(rootdir, "var/lib/snapd/boot-assets/grub/grubx64.efi-run-grub-hash"), bootloader.RoleRunMode) + runKernel := bootloader.NewBootFile(filepath.Join(rootdir, "var/lib/snapd/snaps/pc-kernel_500.snap"), "kernel.efi", bootloader.RoleRunMode) + + // verify the load chains, which are identical for both models + switch resealKeysCalls { + case 1: // run load chain for 2 models, current and a try model + c.Assert(params.ModelParams, HasLen, 2) + // each load chain has either the run kernel (shared for + // both), or the kernel of the respective recovery + // system + c.Assert(params.ModelParams[0].EFILoadChains, DeepEquals, []*secboot.LoadChain{ + secboot.NewLoadChain(shim, + secboot.NewLoadChain(grub, + secboot.NewLoadChain(kernelOldRecovery), + )), + secboot.NewLoadChain(shim, + secboot.NewLoadChain(grub, + secboot.NewLoadChain(runGrub, + secboot.NewLoadChain(runKernel)), + )), + }) + c.Assert(params.ModelParams[1].EFILoadChains, DeepEquals, []*secboot.LoadChain{ + secboot.NewLoadChain(shim, + secboot.NewLoadChain(grub, + secboot.NewLoadChain(kernelNewRecovery), + )), + secboot.NewLoadChain(shim, + secboot.NewLoadChain(grub, + secboot.NewLoadChain(runGrub, + secboot.NewLoadChain(runKernel)), + )), + }) + case 2: // recovery load chains, only for the current model + c.Assert(params.ModelParams, HasLen, 1) + // load chain with a kernel from a recovery system that + // matches the current model only + c.Assert(params.ModelParams[0].EFILoadChains, DeepEquals, []*secboot.LoadChain{ + secboot.NewLoadChain(shim, + secboot.NewLoadChain(grub, + secboot.NewLoadChain(kernelOldRecovery), + )), + }) + } + + return nil + }) + defer restore() + + // here we don't have unasserted kernels so just set + // expectReseal to false as it doesn't matter; + // the behavior with unasserted kernel is tested in + // boot_test.go specific tests + const expectReseal = false + err = boot.ResealKeyToModeenv(rootdir, modeenv, expectReseal, nil) + c.Assert(err, IsNil) + c.Assert(resealKeysCalls, Equals, 2) + + // verify the boot chains data file for run key + + recoveryAssetChain := []boot.BootAsset{{ + Role: "recovery", + Name: shimId, + Hashes: []string{"shim-hash"}, + }, { + Role: "recovery", + Name: grubId, + Hashes: []string{"grub-hash"}, + }} + runAssetChain := []boot.BootAsset{{ + Role: "recovery", + Name: shimId, + Hashes: []string{"shim-hash"}, + }, { + Role: "recovery", + Name: grubId, + Hashes: []string{"grub-hash"}, + }, { + Role: "run-mode", + Name: "grubx64.efi", + Hashes: []string{"run-grub-hash"}, + }} + runPbc, cnt, err := boot.ReadBootChains(filepath.Join(dirs.SnapFDEDir, "boot-chains")) + c.Assert(err, IsNil) + c.Assert(cnt, Equals, 1) + c.Check(runPbc, DeepEquals, boot.PredictableBootChains{ + // the current model + boot.BootChain{ + BrandID: "my-brand", + Model: "my-model-uc20", + Grade: "dangerous", + ModelSignKeyID: "Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij", + AssetChain: recoveryAssetChain, + Kernel: "pc-kernel", + KernelRevision: "1", + KernelCmdlines: []string{ + "snapd_recovery_mode=factory-reset snapd_recovery_system=20200825 console=ttyS0 console=tty1 panic=-1", + "snapd_recovery_mode=recover snapd_recovery_system=20200825 console=ttyS0 console=tty1 panic=-1", + }, + }, + boot.BootChain{ + BrandID: "my-brand", + Model: "my-model-uc20", + Grade: "dangerous", + ModelSignKeyID: "Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij", + AssetChain: runAssetChain, + Kernel: "pc-kernel", + KernelRevision: "500", + KernelCmdlines: []string{ + "snapd_recovery_mode=run console=ttyS0 console=tty1 panic=-1", + }, + }, + // the try model + boot.BootChain{ + BrandID: "my-brand", + Model: "try-my-model-uc20", + Grade: "secured", + ModelSignKeyID: "Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij", + AssetChain: recoveryAssetChain, + Kernel: "pc-kernel", + KernelRevision: "999", + KernelCmdlines: []string{ + "snapd_recovery_mode=factory-reset snapd_recovery_system=1234 console=ttyS0 console=tty1 panic=-1", + "snapd_recovery_mode=recover snapd_recovery_system=1234 console=ttyS0 console=tty1 panic=-1", + }, + }, + boot.BootChain{ + BrandID: "my-brand", + Model: "try-my-model-uc20", + Grade: "secured", + ModelSignKeyID: "Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij", + AssetChain: runAssetChain, + Kernel: "pc-kernel", + KernelRevision: "500", + KernelCmdlines: []string{ + "snapd_recovery_mode=run console=ttyS0 console=tty1 panic=-1", + }, + }, + }) + // recovery boot chains + recoveryPbc, cnt, err := boot.ReadBootChains(filepath.Join(dirs.SnapFDEDir, "recovery-boot-chains")) + c.Assert(err, IsNil) + c.Assert(cnt, Equals, 1) + c.Check(recoveryPbc, DeepEquals, boot.PredictableBootChains{ + // recovery keys are sealed to current model only + boot.BootChain{ + BrandID: "my-brand", + Model: "my-model-uc20", + Grade: "dangerous", + ModelSignKeyID: "Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij", + AssetChain: recoveryAssetChain, + Kernel: "pc-kernel", + KernelRevision: "1", + KernelCmdlines: []string{ + "snapd_recovery_mode=factory-reset snapd_recovery_system=20200825 console=ttyS0 console=tty1 panic=-1", + "snapd_recovery_mode=recover snapd_recovery_system=20200825 console=ttyS0 console=tty1 panic=-1", + }, + }, + }) +} + +func (s *sealSuite) TestResealKeyToModeenvWithTryModelOldBootChain(c *C) { + s.testResealKeyToModeenvWithTryModel(c, "bootx64.efi", "grubx64.efi") +} + +func (s *sealSuite) TestResealKeyToModeenvWithTryModelNewBootChain(c *C) { + s.testResealKeyToModeenvWithTryModel(c, "ubuntu:shimx64.efi", "ubuntu:grubx64.efi") +} + +func (s *sealSuite) TestMarkFactoryResetComplete(c *C) { + + for i, tc := range []struct { + encrypted bool + factoryKeyAlreadyMigrated bool + pcrHandleOfKey uint32 + pcrHandleOfKeyErr error + pcrHandleOfKeyCalls int + releasePCRHandlesErr error + releasePCRHandleCalls int + hasFDEHook bool + err string + }{ + { + // unencrypted is a nop + encrypted: false, + }, { + // the old fallback key uses the main handle + encrypted: true, pcrHandleOfKey: secboot.FallbackObjectPCRPolicyCounterHandle, + factoryKeyAlreadyMigrated: true, pcrHandleOfKeyCalls: 1, releasePCRHandleCalls: 1, + }, { + // the old fallback key uses the alt handle + encrypted: true, pcrHandleOfKey: secboot.AltFallbackObjectPCRPolicyCounterHandle, + factoryKeyAlreadyMigrated: true, pcrHandleOfKeyCalls: 1, releasePCRHandleCalls: 1, + }, { + // unexpected reboot, the key file was already moved + encrypted: true, pcrHandleOfKey: secboot.AltFallbackObjectPCRPolicyCounterHandle, + pcrHandleOfKeyCalls: 1, releasePCRHandleCalls: 1, + }, { + // do nothing if we have the FDE hook + encrypted: true, pcrHandleOfKeyErr: errors.New("unexpected call"), + hasFDEHook: true, + }, + // error cases + { + encrypted: true, pcrHandleOfKey: secboot.FallbackObjectPCRPolicyCounterHandle, + factoryKeyAlreadyMigrated: true, + pcrHandleOfKeyCalls: 1, + pcrHandleOfKeyErr: errors.New("handle error"), + err: "cannot perform post factory reset boot cleanup: cannot cleanup secboot state: cannot inspect fallback key: handle error", + }, { + encrypted: true, pcrHandleOfKey: secboot.FallbackObjectPCRPolicyCounterHandle, + factoryKeyAlreadyMigrated: true, + pcrHandleOfKeyCalls: 1, releasePCRHandleCalls: 1, + releasePCRHandlesErr: errors.New("release error"), + err: "cannot perform post factory reset boot cleanup: cannot cleanup secboot state: release error", + }, + } { + c.Logf("tc %v", i) + + saveSealedKey := filepath.Join(boot.InitramfsSeedEncryptionKeyDir, "ubuntu-save.recovery.sealed-key") + saveSealedKeyByFactoryReset := filepath.Join(boot.InitramfsSeedEncryptionKeyDir, "ubuntu-save.recovery.sealed-key.factory-reset") + + if tc.encrypted { + c.Assert(os.MkdirAll(boot.InitramfsSeedEncryptionKeyDir, 0755), IsNil) + if tc.factoryKeyAlreadyMigrated { + c.Assert(os.WriteFile(saveSealedKey, []byte{'o', 'l', 'd'}, 0644), IsNil) + c.Assert(os.WriteFile(saveSealedKeyByFactoryReset, []byte{'n', 'e', 'w'}, 0644), IsNil) + } else { + c.Assert(os.WriteFile(saveSealedKey, []byte{'n', 'e', 'w'}, 0644), IsNil) + } + } + + restore := boot.MockHasFDESetupHook(func(kernel *snap.Info) (bool, error) { + c.Check(kernel, IsNil) + return tc.hasFDEHook, nil + }) + defer restore() + + pcrHandleOfKeyCalls := 0 + restore = boot.MockSecbootPCRHandleOfSealedKey(func(p string) (uint32, error) { + pcrHandleOfKeyCalls++ + // XXX we're inspecting the current key after it got rotated + c.Check(p, Equals, filepath.Join(dirs.GlobalRootDir, "/run/mnt/ubuntu-seed/device/fde/ubuntu-save.recovery.sealed-key")) + return tc.pcrHandleOfKey, tc.pcrHandleOfKeyErr + }) + defer restore() + + releasePCRHandleCalls := 0 + restore = boot.MockSecbootReleasePCRResourceHandles(func(handles ...uint32) error { + releasePCRHandleCalls++ + if tc.pcrHandleOfKey == secboot.FallbackObjectPCRPolicyCounterHandle { + c.Check(handles, DeepEquals, []uint32{ + secboot.AltRunObjectPCRPolicyCounterHandle, + secboot.AltFallbackObjectPCRPolicyCounterHandle, + }) + } else { + c.Check(handles, DeepEquals, []uint32{ + secboot.RunObjectPCRPolicyCounterHandle, + secboot.FallbackObjectPCRPolicyCounterHandle, + }) + } + return tc.releasePCRHandlesErr + }) + defer restore() + + err := boot.MarkFactoryResetComplete(tc.encrypted) + if tc.err != "" { + c.Assert(err, ErrorMatches, tc.err) + } else { + c.Assert(err, IsNil) + } + c.Check(pcrHandleOfKeyCalls, Equals, tc.pcrHandleOfKeyCalls) + c.Check(releasePCRHandleCalls, Equals, tc.releasePCRHandleCalls) + if tc.encrypted { + c.Check(filepath.Join(boot.InitramfsSeedEncryptionKeyDir, "ubuntu-save.recovery.sealed-key"), + testutil.FileEquals, []byte{'n', 'e', 'w'}) + c.Check(filepath.Join(boot.InitramfsSeedEncryptionKeyDir, "ubuntu-save.recovery.sealed-key.factory-reset"), + testutil.FileAbsent) + } + } + +} diff --git a/boot/systems.go b/boot/systems.go new file mode 100644 index 00000000..4b48ffaa --- /dev/null +++ b/boot/systems.go @@ -0,0 +1,554 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package boot + +import ( + "fmt" + "strings" + + "github.com/snapcore/snapd/bootloader" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/strutil" +) + +func dropFromRecoverySystemsList(systemsList []string, systemLabel string) (newList []string, found bool) { + for idx, sys := range systemsList { + if sys == systemLabel { + return append(systemsList[:idx], systemsList[idx+1:]...), true + } + } + return systemsList, false +} + +// ClearTryRecoverySystem removes a given candidate recovery system and clears +// the try model in the modeenv state file, then reseals and clears related +// bootloader variables. An empty system label can be passed when the boot +// variables state is inconsistent. +func ClearTryRecoverySystem(dev snap.Device, systemLabel string) error { + if !dev.HasModeenv() { + return fmt.Errorf("internal error: recovery systems can only be used on UC20+") + } + modeenvLock() + defer modeenvUnlock() + + return clearTryRecoverySystem(dev, systemLabel) +} + +func clearTryRecoverySystem(dev snap.Device, systemLabel string) error { + m, err := loadModeenv() + if err != nil { + return err + } + opts := &bootloader.Options{ + // setup the recovery bootloader + Role: bootloader.RoleRecovery, + } + bl, err := bootloader.Find(InitramfsUbuntuSeedDir, opts) + if err != nil { + return err + } + + modified := false + // we may be repeating the cleanup, in which case the system was already + // removed from the modeenv and we don't need to rewrite the modeenv + if updated, found := dropFromRecoverySystemsList(m.CurrentRecoverySystems, systemLabel); found { + m.CurrentRecoverySystems = updated + modified = true + } + if m.TryModel != "" { + // recovery system is tried with a matching models + m.clearTryModel() + modified = true + } + if modified { + if err := m.Write(); err != nil { + return err + } + } + + // clear both variables, no matter the values they hold + vars := map[string]string{ + "try_recovery_system": "", + "recovery_system_status": "", + } + // try to clear regardless of reseal failing + blErr := bl.SetBootVars(vars) + + // but we still want to reseal, in case the cleanup did not reach this + // point before + const expectReseal = true + resealErr := resealKeyToModeenv(dirs.GlobalRootDir, m, expectReseal, nil) + + if resealErr != nil { + return resealErr + } + return blErr +} + +// SetTryRecoverySystem sets up the boot environment for trying out a recovery +// system with given label in the context of the provided device. The call adds +// the new system to the list of current recovery systems in the modeenv, and +// optionally sets a try model, if the device model is different from the +// current one, which typically can happen during a remodel. Once done, the +// caller should request switching to the given recovery system. +func SetTryRecoverySystem(dev snap.Device, systemLabel string) (err error) { + if !dev.HasModeenv() { + return fmt.Errorf("internal error: recovery systems can only be used on UC20+") + } + modeenvLock() + defer modeenvUnlock() + + m, err := loadModeenv() + if err != nil { + return err + } + opts := &bootloader.Options{ + // setup the recovery bootloader + Role: bootloader.RoleRecovery, + } + // TODO:UC20: seed may need to be switched to RW + bl, err := bootloader.Find(InitramfsUbuntuSeedDir, opts) + if err != nil { + return err + } + + modified := false + // we could have rebooted before resealing the keys + if !strutil.ListContains(m.CurrentRecoverySystems, systemLabel) { + m.CurrentRecoverySystems = append(m.CurrentRecoverySystems, systemLabel) + modified = true + + } + // we either have the current device context, in which case the model + // will match the current model in the modeenv, or a remodel device + // context carrying a new model, for which we may need to set the try + // model in the modeenv + model := dev.Model() + if modelUniqueID(model) != modelUniqueID(m.ModelForSealing()) { + // recovery system is tried with a matching model + m.setTryModel(model) + modified = true + } + if modified { + if err := m.Write(); err != nil { + return err + } + } + + defer func() { + if err == nil { + return + } + if cleanupErr := clearTryRecoverySystem(dev, systemLabel); cleanupErr != nil { + err = fmt.Errorf("%v (cleanup failed: %v)", err, cleanupErr) + } + }() + + // even when we unexpectedly reboot after updating the bootenv here, we + // should not boot into the tried system, as the caller must explicitly + // request that by other means + vars := map[string]string{ + "try_recovery_system": systemLabel, + "recovery_system_status": "try", + } + if err := bl.SetBootVars(vars); err != nil { + return err + } + + // until the keys are resealed, even if we unexpectedly boot into the + // tried system, data will still be inaccessible and the system will be + // considered as nonoperational + const expectReseal = true + return resealKeyToModeenv(dirs.GlobalRootDir, m, expectReseal, nil) +} + +type errInconsistentRecoverySystemState struct { + why string +} + +func (e *errInconsistentRecoverySystemState) Error() string { return e.why } +func IsInconsistentRecoverySystemState(err error) bool { + _, ok := err.(*errInconsistentRecoverySystemState) + return ok +} + +// InitramfsIsTryingRecoverySystem, typically called while in initramfs of +// recovery mode system, checks whether the boot variables indicate that the +// given recovery system is only being tried. When the state of boot variables +// is inconsistent, eg. status indicates that a recovery system is to be tried, +// but the label is unset, a specific error which can be tested with +// IsInconsystemRecoverySystemState() is returned. +func InitramfsIsTryingRecoverySystem(currentSystemLabel string) (bool, error) { + opts := &bootloader.Options{ + // setup the recovery bootloader + Role: bootloader.RoleRecovery, + } + bl, err := bootloader.Find(InitramfsUbuntuSeedDir, opts) + if err != nil { + return false, err + } + + vars, err := bl.GetBootVars("try_recovery_system", "recovery_system_status") + if err != nil { + return false, err + } + + status := vars["recovery_system_status"] + switch status { + case "": + // not trying any recovery systems right now + return false, nil + case "try", "tried": + // both are valid options, where tried may indicate there was an + // unexpected reboot somewhere along the path of getting back to + // the run system + default: + return false, &errInconsistentRecoverySystemState{ + why: fmt.Sprintf("unexpected recovery system status %q", status), + } + } + + trySystem := vars["try_recovery_system"] + if trySystem == "" { + // XXX: could we end up with one variable set and the other not? + return false, &errInconsistentRecoverySystemState{ + why: fmt.Sprintf("try recovery system is unset but status is %q", status), + } + } + + if trySystem == currentSystemLabel { + // we are running a recovery system indicated in the boot + // variables, which may or may not be considered good at this + // point, nonetheless we are in recover mode and thus consider + // the system as being tried + + // note, with status set to 'tried', we may be back to the + // tried system again, most likely due to an unexpected reboot + // when coming back to run mode + return true, nil + } + // we may still be running an actual recovery system if such mode was + // requested + return false, nil +} + +type TryRecoverySystemOutcome int + +const ( + TryRecoverySystemOutcomeFailure TryRecoverySystemOutcome = iota + TryRecoverySystemOutcomeSuccess + // TryRecoverySystemOutcomeInconsistent indicates that the booted try + // recovery system state was incorrect and corresponding boot variables + // need to be cleared + TryRecoverySystemOutcomeInconsistent + // TryRecoverySystemOutcomeNoneTried indicates a state in which no + // recovery system has been tried + TryRecoverySystemOutcomeNoneTried +) + +// EnsureNextBootToRunModeWithTryRecoverySystemOutcome, typically called while +// in initramfs, updates the boot environment to indicate an outcome of trying +// out a recovery system and sets the system up to boot into run mode. It is up +// to the caller to ensure the status is updated for the right recovery system, +// typically by calling InitramfsIsTryingRecoverySystem beforehand. +func EnsureNextBootToRunModeWithTryRecoverySystemOutcome(outcome TryRecoverySystemOutcome) error { + opts := &bootloader.Options{ + // setup the recovery bootloader + Role: bootloader.RoleRecovery, + } + // TODO:UC20: seed may need to be switched to RW + bl, err := bootloader.Find(InitramfsUbuntuSeedDir, opts) + if err != nil { + return err + } + vars := map[string]string{ + // always going to back to run mode + "snapd_recovery_mode": "run", + "snapd_recovery_system": "", + "recovery_system_status": "try", + } + switch outcome { + case TryRecoverySystemOutcomeFailure: + // already set up for this scenario + case TryRecoverySystemOutcomeSuccess: + vars["recovery_system_status"] = "tried" + case TryRecoverySystemOutcomeInconsistent: + // there may be an unexpected status, or the tried system label + // is unset, in either case, clear the status + vars["recovery_system_status"] = "" + } + return bl.SetBootVars(vars) +} + +func observeSuccessfulSystems(m *Modeenv) (*Modeenv, error) { + // updates happen in run mode only + if m.Mode != "run" { + return m, nil + } + + // compatibility scenario, no good systems are tracked in modeenv yet, + // and there is a single entry in the current systems list + if len(m.GoodRecoverySystems) == 0 && len(m.CurrentRecoverySystems) == 1 { + newM, err := m.Copy() + if err != nil { + return nil, err + } + newM.GoodRecoverySystems = []string{m.CurrentRecoverySystems[0]} + return newM, nil + } + return m, nil +} + +// InspectTryRecoverySystemOutcome obtains a tried recovery system status. When +// no recovery system has been tried, the outcome will be +// TryRecoverySystemOutcomeNoneTried. The caller is responsible for clearing the +// bootenv once the status bas been properly acted on. +func InspectTryRecoverySystemOutcome(dev snap.Device) (outcome TryRecoverySystemOutcome, label string, err error) { + modeenvLock() + defer modeenvUnlock() + + opts := &bootloader.Options{ + // setup the recovery bootloader + Role: bootloader.RoleRecovery, + } + // TODO:UC20: seed may need to be switched to RW + bl, err := bootloader.Find(InitramfsUbuntuSeedDir, opts) + if err != nil { + return TryRecoverySystemOutcomeFailure, "", err + } + + vars, err := bl.GetBootVars("try_recovery_system", "recovery_system_status") + if err != nil { + return TryRecoverySystemOutcomeFailure, "", err + } + status := vars["recovery_system_status"] + trySystem := vars["try_recovery_system"] + + outcome = TryRecoverySystemOutcomeFailure + switch { + case status == "" && trySystem == "": + // simplest case, not trying a system + return TryRecoverySystemOutcomeNoneTried, "", nil + case status != "try" && status != "tried": + // system label is set, but the status is unexpected status + return TryRecoverySystemOutcomeInconsistent, "", &errInconsistentRecoverySystemState{ + why: fmt.Sprintf("unexpected recovery system status %q", status), + } + case trySystem == "": + // no system set, but we have status + return TryRecoverySystemOutcomeInconsistent, "", &errInconsistentRecoverySystemState{ + why: fmt.Sprintf("try recovery system is unset but status is %q", status), + } + case status == "tried": + // check that try_recovery_system ended up in the modeenv's + // CurrentRecoverySystems + m, err := loadModeenv() + if err != nil { + return TryRecoverySystemOutcomeFailure, trySystem, err + } + + found := false + for _, sys := range m.CurrentRecoverySystems { + if sys == trySystem { + found = true + } + } + if !found { + return TryRecoverySystemOutcomeFailure, trySystem, &errInconsistentRecoverySystemState{ + why: fmt.Sprintf("recovery system %q was tried, but is not present in the modeenv CurrentRecoverySystems", trySystem), + } + } + + outcome = TryRecoverySystemOutcomeSuccess + } + + return outcome, trySystem, nil +} + +// PromoteTriedRecoverySystem promotes the provided recovery system to be +// recognized as a good one, and ensures that the system is present in the list +// of good recovery systems and current recovery systems in modeenv. The +// provided list of tried systems should contain the system in question. If the +// system uses encryption, the keys will updated state. If resealing fails, an +// attempt to restore the previous state is made +func PromoteTriedRecoverySystem(dev snap.Device, systemLabel string, triedSystems []string) (err error) { + if !dev.HasModeenv() { + return fmt.Errorf("internal error: recovery systems can only be used on UC20+") + } + modeenvLock() + defer modeenvUnlock() + + if !strutil.ListContains(triedSystems, systemLabel) { + // system is not among the tried systems + return fmt.Errorf("system has not been successfully tried") + } + + m, err := loadModeenv() + if err != nil { + return err + } + rewriteModeenv := false + if !strutil.ListContains(m.CurrentRecoverySystems, systemLabel) { + m.CurrentRecoverySystems = append(m.CurrentRecoverySystems, systemLabel) + rewriteModeenv = true + } + if !strutil.ListContains(m.GoodRecoverySystems, systemLabel) { + m.GoodRecoverySystems = append(m.GoodRecoverySystems, systemLabel) + rewriteModeenv = true + } + if rewriteModeenv { + if err := m.Write(); err != nil { + return err + } + } + + const expectReseal = true + if err := resealKeyToModeenv(dirs.GlobalRootDir, m, expectReseal, nil); err != nil { + if cleanupErr := dropRecoverySystem(dev, systemLabel); cleanupErr != nil { + err = fmt.Errorf("%v (cleanup failed: %v)", err, cleanupErr) + } + return err + } + return nil +} + +// DropRecoverySystem drops a provided system from the list of good and current +// recovery systems, updates the modeenv and reseals the keys a needed. Note, +// this call *DOES NOT* clear the boot environment variables. +func DropRecoverySystem(dev snap.Device, systemLabel string) error { + if !dev.HasModeenv() { + return fmt.Errorf("internal error: recovery systems can only be used on UC20+") + } + modeenvLock() + defer modeenvUnlock() + return dropRecoverySystem(dev, systemLabel) +} + +func dropRecoverySystem(dev snap.Device, systemLabel string) error { + m, err := loadModeenv() + if err != nil { + return err + } + + rewriteModeenv := false + if updatedGood, found := dropFromRecoverySystemsList(m.GoodRecoverySystems, systemLabel); found { + m.GoodRecoverySystems = updatedGood + rewriteModeenv = true + } + if updatedCurrent, found := dropFromRecoverySystemsList(m.CurrentRecoverySystems, systemLabel); found { + m.CurrentRecoverySystems = updatedCurrent + rewriteModeenv = true + } + if rewriteModeenv { + if err := m.Write(); err != nil { + return err + } + } + + const expectReseal = true + return resealKeyToModeenv(dirs.GlobalRootDir, m, expectReseal, nil) +} + +// MarkRecoveryCapableSystem records a given system as one that we can recover +// from. +func MarkRecoveryCapableSystem(systemLabel string) error { + opts := &bootloader.Options{ + // setup the recovery bootloader + Role: bootloader.RoleRecovery, + } + // TODO:UC20: seed may need to be switched to RW + bl, err := bootloader.Find(InitramfsUbuntuSeedDir, opts) + if err != nil { + return err + } + rbl, ok := bl.(bootloader.RecoveryAwareBootloader) + if !ok { + return nil + } + vars, err := rbl.GetBootVars("snapd_good_recovery_systems") + if err != nil { + return err + } + var systems []string + if vars["snapd_good_recovery_systems"] != "" { + systems = strings.Split(vars["snapd_good_recovery_systems"], ",") + } + // to be consistent with how modeeenv treats good recovery systems, we + // append the system, also make sure that the system appears last, such + // that the bootloader may pick the last entry and have a good default + foundPos := -1 + for idx, sys := range systems { + if sys == systemLabel { + foundPos = idx + break + } + } + if foundPos == -1 { + // not found in the list + systems = append(systems, systemLabel) + } else if foundPos < len(systems)-1 { + // not a last entry in the list of systems + systems = append(systems[0:foundPos], systems[foundPos+1:]...) + systems = append(systems, systemLabel) + } + + systemsForEnv := strings.Join(systems, ",") + return rbl.SetBootVars(map[string]string{ + "snapd_good_recovery_systems": systemsForEnv, + }) +} + +// UnmarkRecoveryCapableSystem removes a given system from the list of systems +// that we can recover from. +func UnmarkRecoveryCapableSystem(systemLabel string) error { + opts := &bootloader.Options{ + Role: bootloader.RoleRecovery, + } + + bl, err := bootloader.Find(InitramfsUbuntuSeedDir, opts) + if err != nil { + return err + } + rbl, ok := bl.(bootloader.RecoveryAwareBootloader) + if !ok { + return nil + } + vars, err := rbl.GetBootVars("snapd_good_recovery_systems") + if err != nil { + return err + } + var systems []string + if vars["snapd_good_recovery_systems"] != "" { + systems = strings.Split(vars["snapd_good_recovery_systems"], ",") + } + + for idx, sys := range systems { + if sys == systemLabel { + systems = append(systems[:idx], systems[idx+1:]...) + break + } + } + + systemsForEnv := strings.Join(systems, ",") + return rbl.SetBootVars(map[string]string{ + "snapd_good_recovery_systems": systemsForEnv, + }) +} diff --git a/boot/systems_test.go b/boot/systems_test.go new file mode 100644 index 00000000..af86235f --- /dev/null +++ b/boot/systems_test.go @@ -0,0 +1,2113 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2021 Canonical Ltd +* + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * +*/ + +package boot_test + +import ( + "fmt" + "os" + "path/filepath" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/boot" + "github.com/snapcore/snapd/boot/boottest" + "github.com/snapcore/snapd/bootloader" + "github.com/snapcore/snapd/bootloader/bootloadertest" + "github.com/snapcore/snapd/secboot" + "github.com/snapcore/snapd/seed" + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/testutil" + "github.com/snapcore/snapd/timings" +) + +type baseSystemsSuite struct { + baseBootenvSuite +} + +func (s *baseSystemsSuite) SetUpTest(c *C) { + s.baseBootenvSuite.SetUpTest(c) + c.Assert(os.MkdirAll(boot.InitramfsUbuntuBootDir, 0755), IsNil) + c.Assert(os.MkdirAll(boot.InitramfsUbuntuSeedDir, 0755), IsNil) +} + +type systemsSuite struct { + baseSystemsSuite + + uc20dev snap.Device + + runKernelBf bootloader.BootFile + recoveryKernelBf bootloader.BootFile + seedKernelSnap *seed.Snap + seedGadgetSnap *seed.Snap +} + +var _ = Suite(&systemsSuite{}) + +func (s *systemsSuite) mockTrustedBootloaderWithAssetAndChains(c *C, runKernelBf, recoveryKernelBf bootloader.BootFile) *bootloadertest.MockTrustedAssetsBootloader { + mockAssetsCache(c, s.rootdir, "trusted", []string{ + "asset-asset-hash-1", + }) + + mtbl := bootloadertest.Mock("trusted", s.bootdir).WithTrustedAssets() + mtbl.TrustedAssetsMap = map[string]string{"asset": "asset"} + mtbl.StaticCommandLine = "static cmdline" + mtbl.BootChainList = []bootloader.BootFile{ + bootloader.NewBootFile("", "asset", bootloader.RoleRunMode), + runKernelBf, + } + mtbl.RecoveryBootChainList = []bootloader.BootFile{ + bootloader.NewBootFile("", "asset", bootloader.RoleRecovery), + recoveryKernelBf, + } + bootloader.Force(mtbl) + return mtbl +} + +func (s *systemsSuite) SetUpTest(c *C) { + s.baseBootenvSuite.SetUpTest(c) + + restore := boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { return nil }) + s.AddCleanup(restore) + + s.uc20dev = boottest.MockUC20Device("", nil) + + // run kernel + s.runKernelBf = bootloader.NewBootFile("/var/lib/snapd/snap/pc-kernel_500.snap", + "kernel.efi", bootloader.RoleRunMode) + // seed (recovery) kernel + s.recoveryKernelBf = bootloader.NewBootFile("/var/lib/snapd/seed/snaps/pc-kernel_1.snap", + "kernel.efi", bootloader.RoleRecovery) + + s.seedKernelSnap = mockKernelSeedSnap(snap.R(1)) + s.seedGadgetSnap = mockGadgetSeedSnap(c, nil) +} + +func (s *systemsSuite) TestSetTryRecoverySystemEncrypted(c *C) { + mockAssetsCache(c, s.rootdir, "trusted", []string{ + "asset-asset-hash-1", + }) + + mtbl := s.mockTrustedBootloaderWithAssetAndChains(c, s.runKernelBf, s.recoveryKernelBf) + bootloader.Force(mtbl) + defer bootloader.Force(nil) + + // system is encrypted + s.stampSealedKeys(c, s.rootdir) + + model := s.uc20dev.Model() + + modeenv := &boot.Modeenv{ + Mode: "run", + // keep this comment to make old gofmt happy + CurrentRecoverySystems: []string{"20200825"}, + GoodRecoverySystems: []string{"20200825"}, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": []string{"asset-hash-1"}, + }, + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": []string{"asset-hash-1"}, + }, + CurrentKernels: []string{"pc-kernel_500.snap"}, + + Model: model.Model(), + BrandID: model.BrandID(), + Grade: string(model.Grade()), + ModelSignKeyID: model.SignKeyID(), + } + c.Assert(modeenv.WriteTo(""), IsNil) + + var readSeedSeenLabels []string + restore := boot.MockSeedReadSystemEssential(func(seedDir, label string, essentialTypes []snap.Type, tm timings.Measurer) (*asserts.Model, []*seed.Snap, error) { + // the mock bootloader can only mock a single recovery boot + // chain, so pretend both seeds use the same kernel, but keep track of the labels + readSeedSeenLabels = append(readSeedSeenLabels, label) + return model, []*seed.Snap{s.seedKernelSnap, s.seedGadgetSnap}, nil + }) + defer restore() + + resealCalls := 0 + restore = boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + resealCalls++ + // bootloader variables have already been modified + c.Check(mtbl.SetBootVarsCalls, Equals, 1) + c.Assert(params, NotNil) + c.Assert(params.ModelParams, HasLen, 1) + switch resealCalls { + case 1: + c.Check(params.KeyFiles, DeepEquals, []string{ + filepath.Join(boot.InitramfsBootEncryptionKeyDir, "ubuntu-data.sealed-key"), + }) + c.Assert(params.ModelParams[0].KernelCmdlines, DeepEquals, []string{ + "snapd_recovery_mode=factory-reset snapd_recovery_system=20200825 static cmdline", + "snapd_recovery_mode=recover snapd_recovery_system=1234 static cmdline", + "snapd_recovery_mode=recover snapd_recovery_system=20200825 static cmdline", + "snapd_recovery_mode=run static cmdline", + }) + return nil + case 2: + c.Check(params.KeyFiles, DeepEquals, []string{ + filepath.Join(boot.InitramfsSeedEncryptionKeyDir, "ubuntu-data.recovery.sealed-key"), + filepath.Join(boot.InitramfsSeedEncryptionKeyDir, "ubuntu-save.recovery.sealed-key"), + }) + c.Assert(params.ModelParams[0].KernelCmdlines, DeepEquals, []string{ + "snapd_recovery_mode=factory-reset snapd_recovery_system=20200825 static cmdline", + "snapd_recovery_mode=recover snapd_recovery_system=20200825 static cmdline", + }) + return nil + default: + c.Errorf("unexpected call to secboot.ResealKeys with count %v", resealCalls) + return fmt.Errorf("unexpected call") + } + }) + defer restore() + + err := boot.SetTryRecoverySystem(s.uc20dev, "1234") + c.Assert(err, IsNil) + + vars, err := mtbl.GetBootVars("try_recovery_system", "recovery_system_status") + c.Assert(err, IsNil) + c.Check(vars, DeepEquals, map[string]string{ + "try_recovery_system": "1234", + "recovery_system_status": "try", + }) + // run and recovery keys + c.Check(resealCalls, Equals, 2) + c.Check(readSeedSeenLabels, DeepEquals, []string{ + "20200825", "1234", // current recovery systems for run key + "20200825", // good recovery systems for recovery keys + }) + + modeenvRead, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Check(modeenvRead.DeepEqual(&boot.Modeenv{ + Mode: "run", + // keep this comment to make old gofmt happy + CurrentRecoverySystems: []string{"20200825", "1234"}, + GoodRecoverySystems: []string{"20200825"}, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": []string{"asset-hash-1"}, + }, + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": []string{"asset-hash-1"}, + }, + CurrentKernels: []string{"pc-kernel_500.snap"}, + + Model: model.Model(), + BrandID: model.BrandID(), + Grade: string(model.Grade()), + ModelSignKeyID: model.SignKeyID(), + }), Equals, true) +} + +func (s *systemsSuite) TestSetTryRecoverySystemRemodelEncrypted(c *C) { + mockAssetsCache(c, s.rootdir, "trusted", []string{ + "asset-asset-hash-1", + }) + + mtbl := s.mockTrustedBootloaderWithAssetAndChains(c, s.runKernelBf, s.recoveryKernelBf) + bootloader.Force(mtbl) + defer bootloader.Force(nil) + + // system is encrypted + s.stampSealedKeys(c, s.rootdir) + + model := s.uc20dev.Model() + newModel := boottest.MakeMockUC20Model(map[string]interface{}{ + "model": "my-new-model", + }) + + modeenv := &boot.Modeenv{ + Mode: "run", + // keep this comment to make old gofmt happy + CurrentRecoverySystems: []string{"20200825"}, + GoodRecoverySystems: []string{"20200825"}, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": []string{"asset-hash-1"}, + }, + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": []string{"asset-hash-1"}, + }, + CurrentKernels: []string{"pc-kernel_500.snap"}, + + Model: model.Model(), + BrandID: model.BrandID(), + Grade: string(model.Grade()), + ModelSignKeyID: model.SignKeyID(), + } + c.Assert(modeenv.WriteTo(""), IsNil) + + var readSeedSeenLabels []string + restore := boot.MockSeedReadSystemEssential(func(seedDir, label string, essentialTypes []snap.Type, tm timings.Measurer) (*asserts.Model, []*seed.Snap, error) { + // the mock bootloader can only mock a single recovery boot + // chain, so pretend both seeds use the same kernel, but keep track of the labels + readSeedSeenLabels = append(readSeedSeenLabels, label) + systemModel := model + if label == "1234" { + systemModel = newModel + } + return systemModel, []*seed.Snap{s.seedKernelSnap, s.seedGadgetSnap}, nil + }) + defer restore() + + resealCalls := 0 + restore = boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + resealCalls++ + // bootloader variables have already been modified + c.Check(mtbl.SetBootVarsCalls, Equals, 1) + c.Assert(params, NotNil) + switch resealCalls { + case 1: + c.Assert(params.ModelParams, HasLen, 2) + c.Check(params.KeyFiles, DeepEquals, []string{ + filepath.Join(boot.InitramfsBootEncryptionKeyDir, "ubuntu-data.sealed-key"), + }) + c.Assert(params.ModelParams[0].KernelCmdlines, DeepEquals, []string{ + "snapd_recovery_mode=factory-reset snapd_recovery_system=20200825 static cmdline", + "snapd_recovery_mode=recover snapd_recovery_system=20200825 static cmdline", + "snapd_recovery_mode=run static cmdline", + }) + c.Assert(params.ModelParams[1].KernelCmdlines, DeepEquals, []string{ + "snapd_recovery_mode=recover snapd_recovery_system=1234 static cmdline", + "snapd_recovery_mode=run static cmdline", + }) + return nil + case 2: + c.Assert(params.ModelParams, HasLen, 1) + c.Check(params.KeyFiles, DeepEquals, []string{ + filepath.Join(boot.InitramfsSeedEncryptionKeyDir, "ubuntu-data.recovery.sealed-key"), + filepath.Join(boot.InitramfsSeedEncryptionKeyDir, "ubuntu-save.recovery.sealed-key"), + }) + c.Assert(params.ModelParams[0].KernelCmdlines, DeepEquals, []string{ + "snapd_recovery_mode=factory-reset snapd_recovery_system=20200825 static cmdline", + "snapd_recovery_mode=recover snapd_recovery_system=20200825 static cmdline", + }) + return nil + default: + c.Errorf("unexpected call to secboot.ResealKeys with count %v", resealCalls) + return fmt.Errorf("unexpected call") + } + }) + defer restore() + + // a remodel will pass the new device + newUC20Device := boottest.MockUC20Device("run", newModel) + err := boot.SetTryRecoverySystem(newUC20Device, "1234") + c.Assert(err, IsNil) + + vars, err := mtbl.GetBootVars("try_recovery_system", "recovery_system_status") + c.Assert(err, IsNil) + c.Check(vars, DeepEquals, map[string]string{ + "try_recovery_system": "1234", + "recovery_system_status": "try", + }) + // run and recovery keys + c.Check(resealCalls, Equals, 2) + c.Check(readSeedSeenLabels, DeepEquals, []string{ + "20200825", "1234", // current recovery systems for run key and current model from modeenv + "20200825", "1234", // current recovery systems for run key and try model from modeenv + "20200825", // good recovery systems for recovery keys + }) + + modeenvRead, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Check(modeenvRead, testutil.JsonEquals, boot.Modeenv{ + Mode: "run", + // keep this comment to make old gofmt happy + CurrentRecoverySystems: []string{"20200825", "1234"}, + GoodRecoverySystems: []string{"20200825"}, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": []string{"asset-hash-1"}, + }, + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": []string{"asset-hash-1"}, + }, + CurrentKernels: []string{"pc-kernel_500.snap"}, + + Model: model.Model(), + BrandID: model.BrandID(), + Grade: string(model.Grade()), + ModelSignKeyID: model.SignKeyID(), + + TryModel: newModel.Model(), + TryBrandID: newModel.BrandID(), + TryGrade: string(newModel.Grade()), + TryModelSignKeyID: newModel.SignKeyID(), + }) +} + +func (s *systemsSuite) TestSetTryRecoverySystemSimple(c *C) { + mtbl := bootloadertest.Mock("trusted", s.bootdir).WithTrustedAssets() + bootloader.Force(mtbl) + defer bootloader.Force(nil) + + model := s.uc20dev.Model() + modeenv := &boot.Modeenv{ + Mode: "run", + // keep this comment to make old gofmt happy + CurrentRecoverySystems: []string{"20200825"}, + + Model: model.Model(), + BrandID: model.BrandID(), + Grade: string(model.Grade()), + ModelSignKeyID: model.SignKeyID(), + } + c.Assert(modeenv.WriteTo(""), IsNil) + + restore := boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + return fmt.Errorf("unexpected call") + }) + s.AddCleanup(restore) + + err := boot.SetTryRecoverySystem(s.uc20dev, "1234") + c.Assert(err, IsNil) + + vars, err := mtbl.GetBootVars("try_recovery_system", "recovery_system_status") + c.Assert(err, IsNil) + c.Check(vars, DeepEquals, map[string]string{ + "try_recovery_system": "1234", + "recovery_system_status": "try", + }) + + modeenvRead, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Check(modeenvRead, testutil.JsonEquals, boot.Modeenv{ + Mode: "run", + // keep this comment to make old gofmt happy + CurrentRecoverySystems: []string{"20200825", "1234"}, + + Model: model.Model(), + BrandID: model.BrandID(), + Grade: string(model.Grade()), + ModelSignKeyID: model.SignKeyID(), + }) +} + +func (s *systemsSuite) TestSetTryRecoverySystemSetBootVarsErr(c *C) { + mtbl := bootloadertest.Mock("trusted", s.bootdir).WithTrustedAssets() + bootloader.Force(mtbl) + defer bootloader.Force(nil) + + modeenv := &boot.Modeenv{ + Mode: "run", + // keep this comment to make old gofmt happy + CurrentRecoverySystems: []string{"20200825"}, + } + c.Assert(modeenv.WriteTo(""), IsNil) + + restore := boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + return fmt.Errorf("unexpected call") + }) + s.AddCleanup(restore) + + mtbl.BootVars = map[string]string{ + "try_recovery_system": "mock", + "recovery_system_status": "mock", + } + mtbl.SetErrFunc = func() error { + switch mtbl.SetBootVarsCalls { + case 1: + return fmt.Errorf("set boot vars fails") + case 2: + // called during cleanup + return nil + default: + return fmt.Errorf("unexpected call") + } + } + + err := boot.SetTryRecoverySystem(s.uc20dev, "1234") + c.Assert(err, ErrorMatches, "set boot vars fails") + + // cleared + vars, err := mtbl.GetBootVars("try_recovery_system", "recovery_system_status") + c.Assert(err, IsNil) + c.Check(vars, DeepEquals, map[string]string{ + "try_recovery_system": "", + "recovery_system_status": "", + }) + + modeenvRead, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + // modeenv is unchanged + c.Check(modeenvRead.DeepEqual(modeenv), Equals, true) +} + +func (s *systemsSuite) TestSetTryRecoverySystemCleanupOnErrorBeforeReseal(c *C) { + mockAssetsCache(c, s.rootdir, "trusted", []string{ + "asset-asset-hash-1", + }) + + mtbl := s.mockTrustedBootloaderWithAssetAndChains(c, s.runKernelBf, s.recoveryKernelBf) + defer bootloader.Force(nil) + + // system is encrypted + s.stampSealedKeys(c, s.rootdir) + + model := s.uc20dev.Model() + + modeenv := &boot.Modeenv{ + Mode: "run", + // keep this comment to make old gofmt happy + CurrentRecoverySystems: []string{"20200825"}, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": []string{"asset-hash-1"}, + }, + + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": []string{"asset-hash-1"}, + }, + + Model: model.Model(), + BrandID: model.BrandID(), + Grade: string(model.Grade()), + ModelSignKeyID: model.SignKeyID(), + } + c.Assert(modeenv.WriteTo(""), IsNil) + + readSeedCalls := 0 + cleanupTriggered := false + restore := boot.MockSeedReadSystemEssential(func(seedDir, label string, essentialTypes []snap.Type, tm timings.Measurer) (*asserts.Model, []*seed.Snap, error) { + readSeedCalls++ + // this is the reseal cleanup path + switch readSeedCalls { + case 1: + // called for the first system + c.Assert(label, Equals, "20200825") + c.Check(mtbl.SetBootVarsCalls, Equals, 1) + return model, []*seed.Snap{s.seedKernelSnap, s.seedGadgetSnap}, nil + case 2: + // called for the 'try' system + c.Assert(label, Equals, "1234") + // modeenv is updated first + modeenvRead, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Check(modeenvRead.CurrentRecoverySystems, DeepEquals, []string{ + "20200825", "1234", + }) + c.Check(mtbl.SetBootVarsCalls, Equals, 1) + // we are triggering the cleanup by returning an error now + cleanupTriggered = true + return nil, nil, fmt.Errorf("seed read essential fails") + case 3: + // (cleanup) recovery boot chains for run key, called + // for the first system only + fallthrough + case 4: + // (cleanup) recovery boot chains for recovery keys + c.Assert(label, Equals, "20200825") + // boot variables already updated + c.Check(mtbl.SetBootVarsCalls, Equals, 2) + return model, []*seed.Snap{s.seedKernelSnap, s.seedGadgetSnap}, nil + default: + return nil, nil, fmt.Errorf("unexpected call %v", readSeedCalls) + } + }) + defer restore() + + resealCalls := 0 + restore = boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + resealCalls++ + if cleanupTriggered { + return nil + } + return fmt.Errorf("unexpected call") + }) + defer restore() + + err := boot.SetTryRecoverySystem(s.uc20dev, "1234") + c.Assert(err, ErrorMatches, ".*: seed read essential fails") + + // failed after the call to read the 'try' system seed + c.Check(readSeedCalls, Equals, 4) + // called twice during cleanup for run and recovery keys + c.Check(resealCalls, Equals, 2) + + modeenvRead, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + // modeenv is unchanged + c.Check(modeenvRead.DeepEqual(modeenv), Equals, true) + // bootloader variables have been cleared + vars, err := mtbl.GetBootVars("try_recovery_system", "recovery_system_status") + c.Assert(err, IsNil) + c.Check(vars, DeepEquals, map[string]string{ + "try_recovery_system": "", + "recovery_system_status": "", + }) +} + +func (s *systemsSuite) TestSetTryRecoverySystemCleanupOnErrorAfterReseal(c *C) { + mockAssetsCache(c, s.rootdir, "trusted", []string{ + "asset-asset-hash-1", + }) + + mtbl := s.mockTrustedBootloaderWithAssetAndChains(c, s.runKernelBf, s.recoveryKernelBf) + bootloader.Force(mtbl) + defer bootloader.Force(nil) + + // system is encrypted + s.stampSealedKeys(c, s.rootdir) + + model := s.uc20dev.Model() + + modeenv := &boot.Modeenv{ + Mode: "run", + // keep this comment to make old gofmt happy + CurrentRecoverySystems: []string{"20200825"}, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": []string{"asset-hash-1"}, + }, + + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": []string{"asset-hash-1"}, + }, + + Model: model.Model(), + BrandID: model.BrandID(), + Grade: string(model.Grade()), + ModelSignKeyID: model.SignKeyID(), + } + c.Assert(modeenv.WriteTo(""), IsNil) + + readSeedCalls := 0 + restore := boot.MockSeedReadSystemEssential(func(seedDir, label string, essentialTypes []snap.Type, tm timings.Measurer) (*asserts.Model, []*seed.Snap, error) { + readSeedCalls++ + // this is the reseal cleanup path + + switch readSeedCalls { + case 1: + // called for the first system + c.Assert(label, Equals, "20200825") + c.Check(mtbl.SetBootVarsCalls, Equals, 1) + return model, []*seed.Snap{s.seedKernelSnap, s.seedGadgetSnap}, nil + case 2: + // called for the 'try' system + c.Assert(label, Equals, "1234") + // modeenv is updated first + modeenvRead, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Check(modeenvRead.CurrentRecoverySystems, DeepEquals, []string{ + "20200825", "1234", + }) + c.Check(mtbl.SetBootVarsCalls, Equals, 1) + // still good + return model, []*seed.Snap{s.seedKernelSnap, s.seedGadgetSnap}, nil + case 3: + // recovery boot chains for a good recovery system + c.Check(mtbl.SetBootVarsCalls, Equals, 1) + fallthrough + case 4: + // (cleanup) recovery boot chains for run key, called + // for the first system only + fallthrough + case 5: + // (cleanup) recovery boot chains for recovery keys + c.Assert(label, Equals, "20200825") + // boot variables already updated + if readSeedCalls >= 4 { + c.Check(mtbl.SetBootVarsCalls, Equals, 2) + } + return model, []*seed.Snap{s.seedKernelSnap, s.seedGadgetSnap}, nil + default: + return nil, nil, fmt.Errorf("unexpected call %v", readSeedCalls) + } + }) + defer restore() + + resealCalls := 0 + restore = boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + resealCalls++ + switch resealCalls { + case 1: + // attempt to reseal the run key + return fmt.Errorf("reseal fails") + case 2, 3: + // reseal of run and recovery keys + return nil + default: + return fmt.Errorf("unexpected call") + + } + }) + defer restore() + + err := boot.SetTryRecoverySystem(s.uc20dev, "1234") + c.Assert(err, ErrorMatches, "cannot reseal the encryption key: reseal fails") + + // failed after the call to read the 'try' system seed + c.Check(readSeedCalls, Equals, 5) + // called 3 times, once when mocked failure occurs, twice during cleanup + // for run and recovery keys + c.Check(resealCalls, Equals, 3) + + modeenvRead, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + // modeenv is unchanged + c.Check(modeenvRead.DeepEqual(modeenv), Equals, true) + // bootloader variables have been cleared + vars, err := mtbl.GetBootVars("try_recovery_system", "recovery_system_status") + c.Assert(err, IsNil) + c.Check(vars, DeepEquals, map[string]string{ + "try_recovery_system": "", + "recovery_system_status": "", + }) +} + +func (s *systemsSuite) TestSetTryRecoverySystemCleanupError(c *C) { + mockAssetsCache(c, s.rootdir, "trusted", []string{ + "asset-asset-hash-1", + }) + + mtbl := s.mockTrustedBootloaderWithAssetAndChains(c, s.runKernelBf, s.recoveryKernelBf) + bootloader.Force(mtbl) + defer bootloader.Force(nil) + + // system is encrypted + s.stampSealedKeys(c, s.rootdir) + + model := s.uc20dev.Model() + modeenv := &boot.Modeenv{ + Mode: "run", + // keep this comment to make old gofmt happy + CurrentRecoverySystems: []string{"20200825"}, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": []string{"asset-hash-1"}, + }, + + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": []string{"asset-hash-1"}, + }, + + Model: model.Model(), + BrandID: model.BrandID(), + Grade: string(model.Grade()), + ModelSignKeyID: model.SignKeyID(), + } + c.Assert(modeenv.WriteTo(""), IsNil) + + readSeedCalls := 0 + restore := boot.MockSeedReadSystemEssential(func(seedDir, label string, essentialTypes []snap.Type, tm timings.Measurer) (*asserts.Model, []*seed.Snap, error) { + readSeedCalls++ + // this is the reseal cleanup path + c.Logf("call %v label %v", readSeedCalls, label) + switch readSeedCalls { + case 1: + // called for the first system + c.Assert(label, Equals, "20200825") + return s.uc20dev.Model(), []*seed.Snap{s.seedKernelSnap, s.seedGadgetSnap}, nil + case 2: + // called for the 'try' system + c.Assert(label, Equals, "1234") + // still good + return s.uc20dev.Model(), []*seed.Snap{s.seedKernelSnap, s.seedGadgetSnap}, nil + case 3: + // recovery boot chains for a good recovery system + fallthrough + case 4: + // (cleanup) recovery boot chains for run key, called + // for the first system only + fallthrough + case 5: + // (cleanup) recovery boot chains for recovery keys + c.Check(label, Equals, "20200825") + return s.uc20dev.Model(), []*seed.Snap{s.seedKernelSnap, s.seedGadgetSnap}, nil + default: + return nil, nil, fmt.Errorf("unexpected call %v", readSeedCalls) + } + }) + defer restore() + + resealCalls := 0 + restore = boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + resealCalls++ + switch resealCalls { + case 1: + return fmt.Errorf("reseal fails") + case 2, 3: + // reseal of run and recovery keys + return fmt.Errorf("reseal in cleanup fails too") + default: + return fmt.Errorf("unexpected call") + + } + }) + defer restore() + + err := boot.SetTryRecoverySystem(s.uc20dev, "1234") + c.Assert(err, ErrorMatches, `cannot reseal the encryption key: reseal fails \(cleanup failed: cannot reseal the encryption key: reseal in cleanup fails too\)`) + + // failed after the call to read the 'try' system seed + c.Check(readSeedCalls, Equals, 5) + // called twice, once when enabling the try system, once on cleanup + c.Check(resealCalls, Equals, 2) + + modeenvRead, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + // modeenv is unchanged + c.Check(modeenvRead.DeepEqual(modeenv), Equals, true) + // bootloader variables have been cleared regardless of reseal failing + vars, err := mtbl.GetBootVars("try_recovery_system", "recovery_system_status") + c.Assert(err, IsNil) + c.Check(vars, DeepEquals, map[string]string{ + "try_recovery_system": "", + "recovery_system_status": "", + }) +} + +func (s *systemsSuite) testInspectRecoverySystemOutcomeHappy(c *C, mtbl *bootloadertest.MockTrustedAssetsBootloader, expectedOutcome boot.TryRecoverySystemOutcome, expectedErr string) { + bootloader.Force(mtbl) + defer bootloader.Force(nil) + + // system is encrypted + s.stampSealedKeys(c, s.rootdir) + + restore := boot.MockSeedReadSystemEssential(func(seedDir, label string, essentialTypes []snap.Type, tm timings.Measurer) (*asserts.Model, []*seed.Snap, error) { + return nil, nil, fmt.Errorf("unexpected call") + }) + defer restore() + + restore = boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + return fmt.Errorf("unexpected call") + }) + defer restore() + + outcome, label, err := boot.InspectTryRecoverySystemOutcome(s.uc20dev) + if expectedErr == "" { + c.Assert(err, IsNil) + } else { + c.Assert(err, ErrorMatches, expectedErr) + } + c.Check(outcome, Equals, expectedOutcome) + switch outcome { + case boot.TryRecoverySystemOutcomeSuccess, boot.TryRecoverySystemOutcomeFailure: + c.Check(label, Equals, "1234") + default: + c.Check(label, Equals, "") + } +} + +func (s *systemsSuite) TestInspectRecoverySystemOutcomeHappySuccess(c *C) { + triedVars := map[string]string{ + "recovery_system_status": "tried", + "try_recovery_system": "1234", + } + mtbl := s.mockTrustedBootloaderWithAssetAndChains(c, s.runKernelBf, s.recoveryKernelBf) + err := mtbl.SetBootVars(triedVars) + c.Assert(err, IsNil) + + m := boot.Modeenv{ + Mode: boot.ModeRun, + // keep this comment to make old gofmt happy + CurrentRecoverySystems: []string{"29112019", "1234"}, + } + err = m.WriteTo("") + c.Assert(err, IsNil) + + s.testInspectRecoverySystemOutcomeHappy(c, mtbl, boot.TryRecoverySystemOutcomeSuccess, "") + + vars, err := mtbl.GetBootVars("try_recovery_system", "recovery_system_status") + c.Assert(err, IsNil) + c.Check(vars, DeepEquals, triedVars) +} + +func (s *systemsSuite) TestInspectRecoverySystemOutcomeFailureMissingSystemInModeenv(c *C) { + triedVars := map[string]string{ + "recovery_system_status": "tried", + "try_recovery_system": "1234", + } + mtbl := s.mockTrustedBootloaderWithAssetAndChains(c, s.runKernelBf, s.recoveryKernelBf) + err := mtbl.SetBootVars(triedVars) + c.Assert(err, IsNil) + + m := boot.Modeenv{ + Mode: boot.ModeRun, + // we don't have the tried recovery system in the modeenv + CurrentRecoverySystems: []string{"29112019"}, + } + err = m.WriteTo("") + c.Assert(err, IsNil) + + s.testInspectRecoverySystemOutcomeHappy(c, mtbl, boot.TryRecoverySystemOutcomeFailure, `recovery system "1234" was tried, but is not present in the modeenv CurrentRecoverySystems`) + + vars, err := mtbl.GetBootVars("try_recovery_system", "recovery_system_status") + c.Assert(err, IsNil) + c.Check(vars, DeepEquals, triedVars) +} + +func (s *systemsSuite) TestInspectRecoverySystemOutcomeHappyFailure(c *C) { + tryVars := map[string]string{ + "recovery_system_status": "try", + "try_recovery_system": "1234", + } + mtbl := s.mockTrustedBootloaderWithAssetAndChains(c, s.runKernelBf, s.recoveryKernelBf) + err := mtbl.SetBootVars(tryVars) + c.Assert(err, IsNil) + s.testInspectRecoverySystemOutcomeHappy(c, mtbl, boot.TryRecoverySystemOutcomeFailure, "") + + vars, err := mtbl.GetBootVars("try_recovery_system", "recovery_system_status") + c.Assert(err, IsNil) + c.Check(vars, DeepEquals, tryVars) +} + +func (s *systemsSuite) TestInspectRecoverySystemOutcomeNotTried(c *C) { + notTriedVars := map[string]string{ + "recovery_system_status": "", + "try_recovery_system": "", + } + mtbl := s.mockTrustedBootloaderWithAssetAndChains(c, s.runKernelBf, s.recoveryKernelBf) + err := mtbl.SetBootVars(notTriedVars) + c.Assert(err, IsNil) + s.testInspectRecoverySystemOutcomeHappy(c, mtbl, boot.TryRecoverySystemOutcomeNoneTried, "") +} + +func (s *systemsSuite) TestInspectRecoverySystemOutcomeInconsistentBogusStatus(c *C) { + badVars := map[string]string{ + "recovery_system_status": "foo", + "try_recovery_system": "1234", + } + mtbl := s.mockTrustedBootloaderWithAssetAndChains(c, s.runKernelBf, s.recoveryKernelBf) + err := mtbl.SetBootVars(badVars) + c.Assert(err, IsNil) + s.testInspectRecoverySystemOutcomeHappy(c, mtbl, boot.TryRecoverySystemOutcomeInconsistent, `unexpected recovery system status "foo"`) + vars, err := mtbl.GetBootVars("try_recovery_system", "recovery_system_status") + c.Assert(err, IsNil) + c.Check(vars, DeepEquals, badVars) +} + +func (s *systemsSuite) TestInspectRecoverySystemOutcomeInconsistentBadLabel(c *C) { + badVars := map[string]string{ + "recovery_system_status": "tried", + "try_recovery_system": "", + } + mtbl := s.mockTrustedBootloaderWithAssetAndChains(c, s.runKernelBf, s.recoveryKernelBf) + err := mtbl.SetBootVars(badVars) + c.Assert(err, IsNil) + s.testInspectRecoverySystemOutcomeHappy(c, mtbl, boot.TryRecoverySystemOutcomeInconsistent, `try recovery system is unset but status is "tried"`) + vars, err := mtbl.GetBootVars("try_recovery_system", "recovery_system_status") + c.Assert(err, IsNil) + c.Check(vars, DeepEquals, badVars) +} + +func (s *systemsSuite) TestInspectRecoverySystemOutcomeInconsistentUnexpectedLabel(c *C) { + badVars := map[string]string{ + "recovery_system_status": "", + "try_recovery_system": "1234", + } + mtbl := s.mockTrustedBootloaderWithAssetAndChains(c, s.runKernelBf, s.recoveryKernelBf) + err := mtbl.SetBootVars(badVars) + c.Assert(err, IsNil) + s.testInspectRecoverySystemOutcomeHappy(c, mtbl, boot.TryRecoverySystemOutcomeInconsistent, `unexpected recovery system status ""`) + vars, err := mtbl.GetBootVars("try_recovery_system", "recovery_system_status") + c.Assert(err, IsNil) + c.Check(vars, DeepEquals, badVars) +} + +type clearRecoverySystemTestCase struct { + systemLabel string + tryModel *asserts.Model + resealErr error + expectedErr string +} + +func (s *systemsSuite) testClearRecoverySystem(c *C, mtbl *bootloadertest.MockTrustedAssetsBootloader, tc clearRecoverySystemTestCase) { + mockAssetsCache(c, s.rootdir, "trusted", []string{ + "asset-asset-hash-1", + }) + + bootloader.Force(mtbl) + defer bootloader.Force(nil) + + // system is encrypted + s.stampSealedKeys(c, s.rootdir) + + model := s.uc20dev.Model() + + modeenv := &boot.Modeenv{ + Mode: "run", + // keep this comment to make old gofmt happy + CurrentRecoverySystems: []string{"20200825"}, + GoodRecoverySystems: []string{"20200825"}, + CurrentKernels: []string{}, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": []string{"asset-hash-1"}, + }, + + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": []string{"asset-hash-1"}, + }, + + Model: model.Model(), + BrandID: model.BrandID(), + Grade: string(model.Grade()), + ModelSignKeyID: model.SignKeyID(), + } + if tc.systemLabel != "" { + modeenv.CurrentRecoverySystems = append(modeenv.CurrentRecoverySystems, tc.systemLabel) + } + if tc.tryModel != nil { + modeenv.TryModel = tc.tryModel.Model() + modeenv.TryBrandID = tc.tryModel.BrandID() + modeenv.TryGrade = string(tc.tryModel.Grade()) + modeenv.TryModelSignKeyID = tc.tryModel.SignKeyID() + } + c.Assert(modeenv.WriteTo(""), IsNil) + + var readSeedSeenLabels []string + restore := boot.MockSeedReadSystemEssential(func(seedDir, label string, essentialTypes []snap.Type, tm timings.Measurer) (*asserts.Model, []*seed.Snap, error) { + // the mock bootloader can only mock a single recovery boot + // chain, so pretend both seeds use the same kernel, but keep track of the labels + readSeedSeenLabels = append(readSeedSeenLabels, label) + return model, []*seed.Snap{s.seedKernelSnap, s.seedGadgetSnap}, nil + }) + defer restore() + + resealCalls := 0 + restore = boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + resealCalls++ + c.Assert(params, NotNil) + c.Assert(params.ModelParams, HasLen, 1) + switch resealCalls { + case 1: + c.Check(params.KeyFiles, DeepEquals, []string{ + filepath.Join(boot.InitramfsBootEncryptionKeyDir, "ubuntu-data.sealed-key"), + }) + return tc.resealErr + case 2: + c.Check(params.KeyFiles, DeepEquals, []string{ + filepath.Join(boot.InitramfsSeedEncryptionKeyDir, "ubuntu-data.recovery.sealed-key"), + filepath.Join(boot.InitramfsSeedEncryptionKeyDir, "ubuntu-save.recovery.sealed-key"), + }) + c.Assert(params.ModelParams[0].KernelCmdlines, DeepEquals, []string{ + "snapd_recovery_mode=factory-reset snapd_recovery_system=20200825 static cmdline", + "snapd_recovery_mode=recover snapd_recovery_system=20200825 static cmdline", + }) + return nil + default: + c.Errorf("unexpected call to secboot.ResealKeys with count %v", resealCalls) + return fmt.Errorf("unexpected call") + } + }) + defer restore() + + err := boot.ClearTryRecoverySystem(s.uc20dev, tc.systemLabel) + if tc.expectedErr == "" { + c.Assert(err, IsNil) + } else { + c.Assert(err, ErrorMatches, tc.expectedErr) + } + + // only one seed system accessed + c.Check(readSeedSeenLabels, DeepEquals, []string{"20200825", "20200825"}) + if tc.resealErr == nil { + // called twice, for run and recovery keys + c.Check(resealCalls, Equals, 2) + } else { + // fails on run key + c.Check(resealCalls, Equals, 1) + } + + modeenvRead, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + // modeenv systems list has one entry only + c.Check(modeenvRead, testutil.JsonEquals, boot.Modeenv{ + Mode: "run", + // keep this comment to make old gofmt happy + CurrentRecoverySystems: []string{"20200825"}, + GoodRecoverySystems: []string{"20200825"}, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": []string{"asset-hash-1"}, + }, + + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": []string{"asset-hash-1"}, + }, + + Model: model.Model(), + BrandID: model.BrandID(), + Grade: string(model.Grade()), + ModelSignKeyID: model.SignKeyID(), + + // try model if set, has been cleared + }) +} + +func (s *systemsSuite) TestClearRecoverySystemHappy(c *C) { + setVars := map[string]string{ + "recovery_system_status": "try", + "try_recovery_system": "1234", + } + mtbl := s.mockTrustedBootloaderWithAssetAndChains(c, s.runKernelBf, s.recoveryKernelBf) + err := mtbl.SetBootVars(setVars) + c.Assert(err, IsNil) + + s.testClearRecoverySystem(c, mtbl, clearRecoverySystemTestCase{systemLabel: "1234"}) + // bootloader variables have been cleared + vars, err := mtbl.GetBootVars("try_recovery_system", "recovery_system_status") + c.Assert(err, IsNil) + c.Check(vars, DeepEquals, map[string]string{ + "try_recovery_system": "", + "recovery_system_status": "", + }) +} + +func (s *systemsSuite) TestClearRecoverySystemTriedHappy(c *C) { + setVars := map[string]string{ + "recovery_system_status": "tried", + "try_recovery_system": "1234", + } + mtbl := s.mockTrustedBootloaderWithAssetAndChains(c, s.runKernelBf, s.recoveryKernelBf) + err := mtbl.SetBootVars(setVars) + c.Assert(err, IsNil) + + s.testClearRecoverySystem(c, mtbl, clearRecoverySystemTestCase{systemLabel: "1234"}) + // bootloader variables have been cleared + vars, err := mtbl.GetBootVars("try_recovery_system", "recovery_system_status") + c.Assert(err, IsNil) + c.Check(vars, DeepEquals, map[string]string{ + "try_recovery_system": "", + "recovery_system_status": "", + }) +} + +func (s *systemsSuite) TestClearRecoverySystemInconsistentStateHappy(c *C) { + setVars := map[string]string{ + "recovery_system_status": "foo", + "try_recovery_system": "", + } + mtbl := s.mockTrustedBootloaderWithAssetAndChains(c, s.runKernelBf, s.recoveryKernelBf) + err := mtbl.SetBootVars(setVars) + c.Assert(err, IsNil) + + s.testClearRecoverySystem(c, mtbl, clearRecoverySystemTestCase{systemLabel: "1234"}) + // bootloader variables have been cleared + vars, err := mtbl.GetBootVars("try_recovery_system", "recovery_system_status") + c.Assert(err, IsNil) + c.Check(vars, DeepEquals, map[string]string{ + "try_recovery_system": "", + "recovery_system_status": "", + }) +} + +func (s *systemsSuite) TestClearRecoverySystemInconsistentNoLabelHappy(c *C) { + setVars := map[string]string{ + "recovery_system_status": "this-will-be-gone", + "try_recovery_system": "this-too", + } + mtbl := s.mockTrustedBootloaderWithAssetAndChains(c, s.runKernelBf, s.recoveryKernelBf) + err := mtbl.SetBootVars(setVars) + c.Assert(err, IsNil) + + // clear without passing the system label, just clears the relevant boot + // variables + const noLabel = "" + s.testClearRecoverySystem(c, mtbl, clearRecoverySystemTestCase{systemLabel: noLabel}) + // bootloader variables have been cleared + vars, err := mtbl.GetBootVars("try_recovery_system", "recovery_system_status") + c.Assert(err, IsNil) + c.Check(vars, DeepEquals, map[string]string{ + "try_recovery_system": "", + "recovery_system_status": "", + }) +} + +func (s *systemsSuite) TestClearRecoverySystemRemodelHappy(c *C) { + setVars := map[string]string{ + "recovery_system_status": "try", + "try_recovery_system": "1234", + } + mtbl := s.mockTrustedBootloaderWithAssetAndChains(c, s.runKernelBf, s.recoveryKernelBf) + err := mtbl.SetBootVars(setVars) + c.Assert(err, IsNil) + + s.testClearRecoverySystem(c, mtbl, clearRecoverySystemTestCase{ + systemLabel: "1234", + tryModel: boottest.MakeMockUC20Model(map[string]interface{}{ + "tryModelodel": "my-new-model", + }), + }) + // bootloader variables have been cleared + vars, err := mtbl.GetBootVars("try_recovery_system", "recovery_system_status") + c.Assert(err, IsNil) + c.Check(vars, DeepEquals, map[string]string{ + "try_recovery_system": "", + "recovery_system_status": "", + }) +} + +func (s *systemsSuite) TestClearRecoverySystemResealFails(c *C) { + setVars := map[string]string{ + "recovery_system_status": "try", + "try_recovery_system": "1234", + } + mtbl := s.mockTrustedBootloaderWithAssetAndChains(c, s.runKernelBf, s.recoveryKernelBf) + err := mtbl.SetBootVars(setVars) + c.Assert(err, IsNil) + + s.testClearRecoverySystem(c, mtbl, clearRecoverySystemTestCase{ + systemLabel: "1234", + resealErr: fmt.Errorf("reseal fails"), + expectedErr: "cannot reseal the encryption key: reseal fails", + }) + // bootloader variables have been cleared + vars, err := mtbl.GetBootVars("try_recovery_system", "recovery_system_status") + c.Assert(err, IsNil) + // variables were cleared + c.Check(vars, DeepEquals, map[string]string{ + "try_recovery_system": "", + "recovery_system_status": "", + }) +} + +func (s *systemsSuite) TestClearRecoverySystemSetBootVarsFails(c *C) { + setVars := map[string]string{ + "recovery_system_status": "try", + "try_recovery_system": "1234", + } + mtbl := s.mockTrustedBootloaderWithAssetAndChains(c, s.runKernelBf, s.recoveryKernelBf) + err := mtbl.SetBootVars(setVars) + c.Assert(err, IsNil) + mtbl.SetErr = fmt.Errorf("set boot vars fails") + + s.testClearRecoverySystem(c, mtbl, clearRecoverySystemTestCase{ + systemLabel: "1234", + expectedErr: "set boot vars fails", + }) +} + +func (s *systemsSuite) TestClearRecoverySystemReboot(c *C) { + setVars := map[string]string{ + "recovery_system_status": "try", + "try_recovery_system": "1234", + } + mtbl := s.mockTrustedBootloaderWithAssetAndChains(c, s.runKernelBf, s.recoveryKernelBf) + err := mtbl.SetBootVars(setVars) + c.Assert(err, IsNil) + + mockAssetsCache(c, s.rootdir, "trusted", []string{ + "asset-asset-hash-1", + }) + + bootloader.Force(mtbl) + defer bootloader.Force(nil) + + // system is encrypted + s.stampSealedKeys(c, s.rootdir) + + model := s.uc20dev.Model() + + modeenv := &boot.Modeenv{ + Mode: "run", + // keep this comment to make old gofmt happy + CurrentRecoverySystems: []string{"20200825", "1234"}, + CurrentKernels: []string{}, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": []string{"asset-hash-1"}, + }, + + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": []string{"asset-hash-1"}, + }, + + Model: model.Model(), + BrandID: model.BrandID(), + Grade: string(model.Grade()), + ModelSignKeyID: model.SignKeyID(), + } + c.Assert(modeenv.WriteTo(""), IsNil) + + var readSeedSeenLabels []string + restore := boot.MockSeedReadSystemEssential(func(seedDir, label string, essentialTypes []snap.Type, tm timings.Measurer) (*asserts.Model, []*seed.Snap, error) { + // the mock bootloader can only mock a single recovery boot + // chain, so pretend both seeds use the same kernel, but keep track of the labels + readSeedSeenLabels = append(readSeedSeenLabels, label) + return model, []*seed.Snap{s.seedKernelSnap, s.seedGadgetSnap}, nil + }) + defer restore() + + resealCalls := 0 + restore = boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + resealCalls++ + c.Assert(params, NotNil) + c.Assert(params.ModelParams, HasLen, 1) + switch resealCalls { + case 1: + c.Check(params.KeyFiles, DeepEquals, []string{ + filepath.Join(boot.InitramfsBootEncryptionKeyDir, "ubuntu-data.sealed-key"), + }) + panic("reseal panic") + case 2: + c.Check(params.KeyFiles, DeepEquals, []string{ + filepath.Join(boot.InitramfsBootEncryptionKeyDir, "ubuntu-data.sealed-key"), + }) + return nil + case 3: + c.Check(params.KeyFiles, DeepEquals, []string{ + filepath.Join(boot.InitramfsSeedEncryptionKeyDir, "ubuntu-data.recovery.sealed-key"), + filepath.Join(boot.InitramfsSeedEncryptionKeyDir, "ubuntu-save.recovery.sealed-key"), + }) + c.Assert(params.ModelParams[0].KernelCmdlines, DeepEquals, []string{ + "snapd_recovery_mode=recover snapd_recovery_system=20200825 static cmdline", + }) + return nil + default: + c.Errorf("unexpected call to secboot.ResealKeys with count %v", resealCalls) + return fmt.Errorf("unexpected call") + + } + }) + defer restore() + + checkGoodState := func() { + // modeenv was already written + modeenvRead, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + // modeenv systems list has one entry only + c.Check(modeenvRead.CurrentRecoverySystems, DeepEquals, []string{ + "20200825", + }) + // bootloader variables have been cleared already + vars, err := mtbl.GetBootVars("try_recovery_system", "recovery_system_status") + c.Assert(err, IsNil) + // variables were cleared + c.Check(vars, DeepEquals, map[string]string{ + "try_recovery_system": "", + "recovery_system_status": "", + }) + } + + c.Assert(func() { + boot.ClearTryRecoverySystem(s.uc20dev, "1234") + }, PanicMatches, "reseal panic") + // only one seed system accessed + c.Check(readSeedSeenLabels, DeepEquals, []string{"20200825", "20200825"}) + // panicked on run key + c.Check(resealCalls, Equals, 1) + checkGoodState() + + mtbl.SetErrFunc = func() error { + panic("set boot vars panic") + } + c.Assert(func() { + boot.ClearTryRecoverySystem(s.uc20dev, "1234") + }, PanicMatches, "set boot vars panic") + // we did not reach resealing yet + c.Check(resealCalls, Equals, 1) + checkGoodState() + + mtbl.SetErrFunc = nil + err = boot.ClearTryRecoverySystem(s.uc20dev, "1234") + c.Assert(err, IsNil) + checkGoodState() +} + +type recoverySystemGoodTestCase struct { + systemLabelAddToCurrent bool + systemLabelAddToGood bool + triedSystems []string + + resealRecoveryKeyErr error + resealRecoveryKeyDuringCleanupErr error + resealCalls int + expectedErr string + + readSeedSystems []string + expectedCurrentSystemsList []string + expectedGoodSystemsList []string +} + +func (s *systemsSuite) testPromoteTriedRecoverySystem(c *C, systemLabel string, tc recoverySystemGoodTestCase) { + mtbl := s.mockTrustedBootloaderWithAssetAndChains(c, s.runKernelBf, s.recoveryKernelBf) + mockAssetsCache(c, s.rootdir, "trusted", []string{ + "asset-asset-hash-1", + }) + + bootloader.Force(mtbl) + defer bootloader.Force(nil) + + // system is encrypted + s.stampSealedKeys(c, s.rootdir) + + model := s.uc20dev.Model() + + modeenv := &boot.Modeenv{ + Mode: "run", + // keep this comment to make old gofmt happy + CurrentRecoverySystems: []string{"20200825"}, + GoodRecoverySystems: []string{"20200825"}, + CurrentKernels: []string{}, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": []string{"asset-hash-1"}, + }, + + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": []string{"asset-hash-1"}, + }, + + Model: model.Model(), + BrandID: model.BrandID(), + Grade: string(model.Grade()), + ModelSignKeyID: model.SignKeyID(), + } + if tc.systemLabelAddToCurrent { + modeenv.CurrentRecoverySystems = append(modeenv.CurrentRecoverySystems, systemLabel) + } + if tc.systemLabelAddToGood { + modeenv.GoodRecoverySystems = append(modeenv.GoodRecoverySystems, systemLabel) + } + + c.Assert(modeenv.WriteTo(""), IsNil) + + var readSeedSeenLabels []string + restore := boot.MockSeedReadSystemEssential(func(seedDir, label string, essentialTypes []snap.Type, tm timings.Measurer) (*asserts.Model, []*seed.Snap, error) { + // the mock bootloader can only mock a single recovery boot + // chain, so pretend both seeds use the same kernel, but keep track of the labels + readSeedSeenLabels = append(readSeedSeenLabels, label) + return model, []*seed.Snap{s.seedKernelSnap, s.seedGadgetSnap}, nil + }) + defer restore() + + resealCalls := 0 + restore = boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + resealCalls++ + c.Assert(params, NotNil) + c.Assert(params.ModelParams, HasLen, 1) + switch resealCalls { + case 1: + c.Check(params.KeyFiles, DeepEquals, []string{ + filepath.Join(boot.InitramfsBootEncryptionKeyDir, "ubuntu-data.sealed-key"), + }) + c.Assert(params.ModelParams[0].KernelCmdlines, DeepEquals, []string{ + fmt.Sprintf("snapd_recovery_mode=factory-reset snapd_recovery_system=%s static cmdline", systemLabel), + "snapd_recovery_mode=factory-reset snapd_recovery_system=20200825 static cmdline", + fmt.Sprintf("snapd_recovery_mode=recover snapd_recovery_system=%s static cmdline", systemLabel), + "snapd_recovery_mode=recover snapd_recovery_system=20200825 static cmdline", + }) + return nil + case 2: + c.Check(params.KeyFiles, DeepEquals, []string{ + filepath.Join(boot.InitramfsSeedEncryptionKeyDir, "ubuntu-data.recovery.sealed-key"), + filepath.Join(boot.InitramfsSeedEncryptionKeyDir, "ubuntu-save.recovery.sealed-key"), + }) + c.Assert(params.ModelParams[0].KernelCmdlines, DeepEquals, []string{ + fmt.Sprintf("snapd_recovery_mode=factory-reset snapd_recovery_system=%s static cmdline", systemLabel), + "snapd_recovery_mode=factory-reset snapd_recovery_system=20200825 static cmdline", + fmt.Sprintf("snapd_recovery_mode=recover snapd_recovery_system=%s static cmdline", systemLabel), + "snapd_recovery_mode=recover snapd_recovery_system=20200825 static cmdline", + }) + return tc.resealRecoveryKeyErr + case 3: + // run key boot chain is unchanged, so only recovery key boot chain is resealed + if tc.resealRecoveryKeyErr == nil { + c.Errorf("unexpected call to secboot.ResealKeys with count %v", resealCalls) + return fmt.Errorf("unexpected call") + } + c.Check(params.KeyFiles, DeepEquals, []string{ + filepath.Join(boot.InitramfsBootEncryptionKeyDir, "ubuntu-data.sealed-key"), + }) + c.Assert(params.ModelParams[0].KernelCmdlines, DeepEquals, []string{ + "snapd_recovery_mode=factory-reset snapd_recovery_system=20200825 static cmdline", + "snapd_recovery_mode=recover snapd_recovery_system=20200825 static cmdline", + }) + return nil + case 4: + if tc.resealRecoveryKeyErr == nil { + c.Errorf("unexpected call to secboot.ResealKeys with count %v", resealCalls) + return fmt.Errorf("unexpected call") + } + c.Check(params.KeyFiles, DeepEquals, []string{ + filepath.Join(boot.InitramfsSeedEncryptionKeyDir, "ubuntu-data.recovery.sealed-key"), + filepath.Join(boot.InitramfsSeedEncryptionKeyDir, "ubuntu-save.recovery.sealed-key"), + }) + c.Assert(params.ModelParams[0].KernelCmdlines, DeepEquals, []string{ + "snapd_recovery_mode=factory-reset snapd_recovery_system=20200825 static cmdline", + "snapd_recovery_mode=recover snapd_recovery_system=20200825 static cmdline", + }) + return tc.resealRecoveryKeyDuringCleanupErr + default: + c.Errorf("unexpected call to secboot.ResealKeys with count %v", resealCalls) + return fmt.Errorf("unexpected call") + } + }) + defer restore() + + err := boot.PromoteTriedRecoverySystem(s.uc20dev, systemLabel, tc.triedSystems) + if tc.expectedErr == "" { + c.Assert(err, IsNil) + } else { + c.Assert(err, ErrorMatches, tc.expectedErr) + } + c.Check(readSeedSeenLabels, DeepEquals, tc.readSeedSystems) + c.Check(resealCalls, Equals, tc.resealCalls) + + modeenvRead, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + c.Check(modeenvRead.GoodRecoverySystems, DeepEquals, tc.expectedGoodSystemsList) + c.Check(modeenvRead.CurrentRecoverySystems, DeepEquals, tc.expectedCurrentSystemsList) +} + +func (s *systemsSuite) TestPromoteTriedRecoverySystemHappy(c *C) { + s.testPromoteTriedRecoverySystem(c, "1234", recoverySystemGoodTestCase{ + triedSystems: []string{"1234"}, + + resealCalls: 2, + + readSeedSystems: []string{ + // run key + "20200825", "1234", + // recovery keys + "20200825", "1234", + }, + + expectedCurrentSystemsList: []string{"20200825", "1234"}, + expectedGoodSystemsList: []string{"20200825", "1234"}, + }) +} + +func (s *systemsSuite) TestPromoteTriedRecoverySystemInCurrent(c *C) { + s.testPromoteTriedRecoverySystem(c, "1234", recoverySystemGoodTestCase{ + triedSystems: []string{"1234"}, + systemLabelAddToCurrent: true, + resealCalls: 2, + + readSeedSystems: []string{ + // run key + "20200825", "1234", + // recovery keys + "20200825", "1234", + }, + expectedCurrentSystemsList: []string{"20200825", "1234"}, + expectedGoodSystemsList: []string{"20200825", "1234"}, + }) +} + +func (s *systemsSuite) TestPromoteTriedRecoverySystemPresentEverywhere(c *C) { + s.testPromoteTriedRecoverySystem(c, "1234", recoverySystemGoodTestCase{ + triedSystems: []string{"1234"}, + systemLabelAddToCurrent: true, + systemLabelAddToGood: true, + + resealCalls: 2, + + readSeedSystems: []string{ + // run key + "20200825", "1234", + // recovery keys + "20200825", "1234", + }, + expectedCurrentSystemsList: []string{"20200825", "1234"}, + expectedGoodSystemsList: []string{"20200825", "1234"}, + }) +} + +func (s *systemsSuite) TestPromoteTriedRecoverySystemResealFails(c *C) { + s.testPromoteTriedRecoverySystem(c, "1234", recoverySystemGoodTestCase{ + triedSystems: []string{"1234"}, + resealRecoveryKeyErr: fmt.Errorf("recovery key reseal mock failure"), + // no failure during cleanup + resealRecoveryKeyDuringCleanupErr: nil, + + resealCalls: 4, + + expectedErr: `cannot reseal the fallback encryption keys: recovery key reseal mock failure`, + + readSeedSystems: []string{ + // run key + "20200825", "1234", + // recovery keys + "20200825", "1234", + // cleanup run key reseal (the seed system is still in + // current-recovery-systems) + "20200825", + // cleanup recovery keys + "20200825", + }, + expectedCurrentSystemsList: []string{"20200825"}, + expectedGoodSystemsList: []string{"20200825"}, + }) +} + +func (s *systemsSuite) TestPromoteTriedRecoverySystemResealUndoFails(c *C) { + s.testPromoteTriedRecoverySystem(c, "1234", recoverySystemGoodTestCase{ + triedSystems: []string{"1234"}, + resealRecoveryKeyErr: fmt.Errorf("recovery key reseal mock failure"), + resealRecoveryKeyDuringCleanupErr: fmt.Errorf("recovery key reseal mock fail in cleanup"), + + resealCalls: 4, + + expectedErr: `cannot reseal the fallback encryption keys: recovery key reseal mock failure \(cleanup failed: cannot reseal the fallback encryption keys: recovery key reseal mock fail in cleanup\)`, + + readSeedSystems: []string{ + // run key + "20200825", "1234", + // recovery keys + "20200825", "1234", + // cleanup run key reseal (the seed system is still in + // current-recovery-systems) + // cleanup run key + "20200825", + // cleanup recovery keys + "20200825", + }, + expectedCurrentSystemsList: []string{"20200825"}, + expectedGoodSystemsList: []string{"20200825"}, + }) +} + +func (s *systemsSuite) TestPromoteTriedRecoverySystemNotTried(c *C) { + s.testPromoteTriedRecoverySystem(c, "1234", recoverySystemGoodTestCase{ + triedSystems: []string{"not-here"}, + + expectedErr: `system has not been successfully tried`, + + expectedCurrentSystemsList: []string{"20200825"}, + expectedGoodSystemsList: []string{"20200825"}, + }) + + // also works if tried systems list is nil + s.testPromoteTriedRecoverySystem(c, "1234", recoverySystemGoodTestCase{ + triedSystems: nil, + + expectedErr: `system has not been successfully tried`, + + expectedCurrentSystemsList: []string{"20200825"}, + expectedGoodSystemsList: []string{"20200825"}, + }) +} + +type recoverySystemDropTestCase struct { + systemLabelAddToCurrent bool + systemLabelAddToGood bool + + resealRecoveryKeyErr error + resealCalls int + expectedErr string + + expectedCurrentSystemsList []string + expectedGoodSystemsList []string +} + +func (s *systemsSuite) testDropRecoverySystem(c *C, systemLabel string, tc recoverySystemDropTestCase) { + mtbl := s.mockTrustedBootloaderWithAssetAndChains(c, s.runKernelBf, s.recoveryKernelBf) + mockAssetsCache(c, s.rootdir, "trusted", []string{ + "asset-asset-hash-1", + }) + + bootloader.Force(mtbl) + defer bootloader.Force(nil) + + // system is encrypted + s.stampSealedKeys(c, s.rootdir) + + model := s.uc20dev.Model() + + modeenv := &boot.Modeenv{ + Mode: "run", + // keep this comment to make old gofmt happy + CurrentRecoverySystems: []string{"20200825"}, + GoodRecoverySystems: []string{"20200825"}, + CurrentKernels: []string{}, + CurrentTrustedRecoveryBootAssets: boot.BootAssetsMap{ + "asset": []string{"asset-hash-1"}, + }, + + CurrentTrustedBootAssets: boot.BootAssetsMap{ + "asset": []string{"asset-hash-1"}, + }, + + Model: model.Model(), + BrandID: model.BrandID(), + Grade: string(model.Grade()), + ModelSignKeyID: model.SignKeyID(), + } + if tc.systemLabelAddToCurrent { + modeenv.CurrentRecoverySystems = append(modeenv.CurrentRecoverySystems, systemLabel) + } + if tc.systemLabelAddToGood { + modeenv.GoodRecoverySystems = append(modeenv.GoodRecoverySystems, systemLabel) + } + + c.Assert(modeenv.WriteTo(""), IsNil) + + var readSeedSeenLabels []string + restore := boot.MockSeedReadSystemEssential(func(seedDir, label string, essentialTypes []snap.Type, tm timings.Measurer) (*asserts.Model, []*seed.Snap, error) { + // the mock bootloader can only mock a single recovery boot + // chain, so pretend both seeds use the same kernel, but keep track of the labels + readSeedSeenLabels = append(readSeedSeenLabels, label) + return model, []*seed.Snap{s.seedKernelSnap, s.seedGadgetSnap}, nil + }) + defer restore() + + resealCalls := 0 + restore = boot.MockSecbootResealKeys(func(params *secboot.ResealKeysParams) error { + resealCalls++ + c.Assert(params, NotNil) + c.Assert(params.ModelParams, HasLen, 1) + switch resealCalls { + case 1: + c.Check(params.KeyFiles, DeepEquals, []string{ + filepath.Join(boot.InitramfsBootEncryptionKeyDir, "ubuntu-data.sealed-key"), + }) + c.Assert(params.ModelParams[0].KernelCmdlines, DeepEquals, []string{ + "snapd_recovery_mode=factory-reset snapd_recovery_system=20200825 static cmdline", + "snapd_recovery_mode=recover snapd_recovery_system=20200825 static cmdline", + }) + return nil + case 2: + c.Check(params.KeyFiles, DeepEquals, []string{ + filepath.Join(boot.InitramfsSeedEncryptionKeyDir, "ubuntu-data.recovery.sealed-key"), + filepath.Join(boot.InitramfsSeedEncryptionKeyDir, "ubuntu-save.recovery.sealed-key"), + }) + c.Assert(params.ModelParams[0].KernelCmdlines, DeepEquals, []string{ + "snapd_recovery_mode=factory-reset snapd_recovery_system=20200825 static cmdline", + "snapd_recovery_mode=recover snapd_recovery_system=20200825 static cmdline", + }) + return tc.resealRecoveryKeyErr + default: + c.Errorf("unexpected call to secboot.ResealKeys with count %v", resealCalls) + return fmt.Errorf("unexpected call") + } + }) + defer restore() + + err := boot.DropRecoverySystem(s.uc20dev, systemLabel) + if tc.expectedErr == "" { + c.Assert(err, IsNil) + } else { + c.Assert(err, ErrorMatches, tc.expectedErr) + } + c.Check(readSeedSeenLabels, DeepEquals, []string{"20200825", "20200825"}) + c.Check(resealCalls, Equals, tc.resealCalls) + + modeenvRead, err := boot.ReadModeenv("") + c.Assert(err, IsNil) + // current is unchanged + c.Check(modeenvRead.GoodRecoverySystems, DeepEquals, tc.expectedCurrentSystemsList) + c.Check(modeenvRead.CurrentRecoverySystems, DeepEquals, tc.expectedGoodSystemsList) +} + +func (s *systemsSuite) TestDropRecoverySystemHappy(c *C) { + s.testDropRecoverySystem(c, "1234", recoverySystemDropTestCase{ + systemLabelAddToCurrent: true, + systemLabelAddToGood: true, + resealCalls: 2, + + expectedGoodSystemsList: []string{"20200825"}, + expectedCurrentSystemsList: []string{"20200825"}, + }) +} + +func (s *systemsSuite) TestDropRecoverySystemAlreadyGoneFromBoth(c *C) { + s.testDropRecoverySystem(c, "1234", recoverySystemDropTestCase{ + systemLabelAddToCurrent: false, + systemLabelAddToGood: false, + resealCalls: 2, + + expectedGoodSystemsList: []string{"20200825"}, + expectedCurrentSystemsList: []string{"20200825"}, + }) +} + +func (s *systemsSuite) TestDropRecoverySystemAlreadyGoneOne(c *C) { + s.testDropRecoverySystem(c, "1234", recoverySystemDropTestCase{ + systemLabelAddToCurrent: true, + systemLabelAddToGood: false, + resealCalls: 2, + + expectedGoodSystemsList: []string{"20200825"}, + expectedCurrentSystemsList: []string{"20200825"}, + }) +} + +func (s *systemsSuite) TestDropRecoverySystemResealErr(c *C) { + s.testDropRecoverySystem(c, "1234", recoverySystemDropTestCase{ + systemLabelAddToCurrent: true, + systemLabelAddToGood: false, + resealCalls: 2, + resealRecoveryKeyErr: fmt.Errorf("mocked error"), + expectedErr: `cannot reseal the fallback encryption keys: mocked error`, + + expectedGoodSystemsList: []string{"20200825"}, + expectedCurrentSystemsList: []string{"20200825"}, + }) +} + +func (s *systemsSuite) TestMarkRecoveryCapableSystemHappy(c *C) { + rbl := bootloadertest.Mock("recovery", c.MkDir()).RecoveryAware() + bootloader.Force(rbl) + + err := boot.MarkRecoveryCapableSystem("1234") + c.Assert(err, IsNil) + vars, err := rbl.GetBootVars("snapd_good_recovery_systems") + c.Assert(err, IsNil) + c.Check(vars, DeepEquals, map[string]string{ + "snapd_good_recovery_systems": "1234", + }) + // try the same system again + err = boot.MarkRecoveryCapableSystem("1234") + c.Assert(err, IsNil) + vars, err = rbl.GetBootVars("snapd_good_recovery_systems") + c.Assert(err, IsNil) + c.Check(vars, DeepEquals, map[string]string{ + // still a single entry + "snapd_good_recovery_systems": "1234", + }) + + // try something new + err = boot.MarkRecoveryCapableSystem("4567") + c.Assert(err, IsNil) + vars, err = rbl.GetBootVars("snapd_good_recovery_systems") + c.Assert(err, IsNil) + c.Check(vars, DeepEquals, map[string]string{ + // entry added + "snapd_good_recovery_systems": "1234,4567", + }) + + // try adding the old one again + err = boot.MarkRecoveryCapableSystem("1234") + c.Assert(err, IsNil) + vars, err = rbl.GetBootVars("snapd_good_recovery_systems") + c.Assert(err, IsNil) + c.Check(vars, DeepEquals, map[string]string{ + // system got moved to the end of the list + "snapd_good_recovery_systems": "4567,1234", + }) + + // and the new one again + err = boot.MarkRecoveryCapableSystem("4567") + c.Assert(err, IsNil) + vars, err = rbl.GetBootVars("snapd_good_recovery_systems") + c.Assert(err, IsNil) + c.Check(vars, DeepEquals, map[string]string{ + // and it became the last entry + "snapd_good_recovery_systems": "1234,4567", + }) +} + +func (s *systemsSuite) TestMarkRecoveryCapableSystemAlwaysLast(c *C) { + rbl := bootloadertest.Mock("recovery", c.MkDir()).RecoveryAware() + bootloader.Force(rbl) + + err := rbl.SetBootVars(map[string]string{ + "snapd_good_recovery_systems": "1234,2222", + }) + c.Assert(err, IsNil) + + err = boot.MarkRecoveryCapableSystem("1234") + c.Assert(err, IsNil) + vars, err := rbl.GetBootVars("snapd_good_recovery_systems") + c.Assert(err, IsNil) + c.Check(vars, DeepEquals, map[string]string{ + "snapd_good_recovery_systems": "2222,1234", + }) + + err = rbl.SetBootVars(map[string]string{ + "snapd_good_recovery_systems": "1111,1234,2222", + }) + c.Assert(err, IsNil) + err = boot.MarkRecoveryCapableSystem("1234") + c.Assert(err, IsNil) + vars, err = rbl.GetBootVars("snapd_good_recovery_systems") + c.Assert(err, IsNil) + c.Check(vars, DeepEquals, map[string]string{ + "snapd_good_recovery_systems": "1111,2222,1234", + }) + + err = rbl.SetBootVars(map[string]string{ + "snapd_good_recovery_systems": "1111,2222", + }) + c.Assert(err, IsNil) + err = boot.MarkRecoveryCapableSystem("1234") + c.Assert(err, IsNil) + vars, err = rbl.GetBootVars("snapd_good_recovery_systems") + c.Assert(err, IsNil) + c.Check(vars, DeepEquals, map[string]string{ + "snapd_good_recovery_systems": "1111,2222,1234", + }) +} + +func (s *systemsSuite) TestMarkRecoveryCapableSystemErr(c *C) { + rbl := bootloadertest.Mock("recovery", c.MkDir()).RecoveryAware() + bootloader.Force(rbl) + + err := boot.MarkRecoveryCapableSystem("1234") + c.Assert(err, IsNil) + vars, err := rbl.GetBootVars("snapd_good_recovery_systems") + c.Assert(err, IsNil) + c.Check(vars, DeepEquals, map[string]string{ + "snapd_good_recovery_systems": "1234", + }) + + rbl.SetErr = fmt.Errorf("mocked error") + err = boot.MarkRecoveryCapableSystem("4567") + c.Assert(err, ErrorMatches, "mocked error") + vars, err = rbl.GetBootVars("snapd_good_recovery_systems") + c.Assert(err, IsNil) + c.Check(vars, DeepEquals, map[string]string{ + // mocked error is returned after variable is set + "snapd_good_recovery_systems": "1234,4567", + }) + + // but mocked panic happens earlier + rbl.SetMockToPanic("SetBootVars") + c.Assert(func() { boot.MarkRecoveryCapableSystem("9999") }, + PanicMatches, "mocked reboot panic in SetBootVars") + vars, err = rbl.GetBootVars("snapd_good_recovery_systems") + c.Assert(err, IsNil) + c.Check(vars, DeepEquals, map[string]string{ + "snapd_good_recovery_systems": "1234,4567", + }) + +} + +func (s *systemsSuite) TestMarkRecoveryCapableSystemNonRecoveryAware(c *C) { + bl := bootloadertest.Mock("recovery", c.MkDir()) + bootloader.Force(bl) + + err := boot.MarkRecoveryCapableSystem("1234") + c.Assert(err, IsNil) + c.Check(bl.SetBootVarsCalls, Equals, 0) +} + +type initramfsMarkTryRecoverySystemSuite struct { + baseSystemsSuite + + bl *bootloadertest.MockBootloader +} + +var _ = Suite(&initramfsMarkTryRecoverySystemSuite{}) + +func (s *initramfsMarkTryRecoverySystemSuite) SetUpTest(c *C) { + s.baseSystemsSuite.SetUpTest(c) + + s.bl = bootloadertest.Mock("bootloader", s.bootdir) + bootloader.Force(s.bl) + s.AddCleanup(func() { bootloader.Force(nil) }) +} + +func (s *initramfsMarkTryRecoverySystemSuite) testMarkRecoverySystemForRun(c *C, outcome boot.TryRecoverySystemOutcome, expectingStatus string) { + err := s.bl.SetBootVars(map[string]string{ + "recovery_system_status": "try", + "try_recovery_system": "1234", + }) + c.Assert(err, IsNil) + err = boot.EnsureNextBootToRunModeWithTryRecoverySystemOutcome(outcome) + c.Assert(err, IsNil) + + expectedVars := map[string]string{ + "snapd_recovery_mode": "run", + "snapd_recovery_system": "", + + "recovery_system_status": expectingStatus, + "try_recovery_system": "1234", + } + + vars, err := s.bl.GetBootVars("snapd_recovery_mode", "snapd_recovery_system", + "recovery_system_status", "try_recovery_system") + c.Assert(err, IsNil) + c.Check(vars, DeepEquals, expectedVars) + + err = s.bl.SetBootVars(map[string]string{ + // the status is overwritten, even if it's completely bogus + "recovery_system_status": "foobar", + "try_recovery_system": "1234", + }) + c.Assert(err, IsNil) + + err = boot.EnsureNextBootToRunModeWithTryRecoverySystemOutcome(outcome) + c.Assert(err, IsNil) + + vars, err = s.bl.GetBootVars("snapd_recovery_mode", "snapd_recovery_system", + "recovery_system_status", "try_recovery_system") + c.Assert(err, IsNil) + c.Check(vars, DeepEquals, expectedVars) +} + +func (s *initramfsMarkTryRecoverySystemSuite) TestMarkTryRecoverySystemSuccess(c *C) { + s.testMarkRecoverySystemForRun(c, boot.TryRecoverySystemOutcomeSuccess, "tried") +} + +func (s *initramfsMarkTryRecoverySystemSuite) TestMarkRecoverySystemFailure(c *C) { + s.testMarkRecoverySystemForRun(c, boot.TryRecoverySystemOutcomeFailure, "try") +} + +func (s *initramfsMarkTryRecoverySystemSuite) TestMarkRecoverySystemBogus(c *C) { + s.testMarkRecoverySystemForRun(c, boot.TryRecoverySystemOutcomeInconsistent, "") +} + +func (s *initramfsMarkTryRecoverySystemSuite) TestMarkRecoverySystemErr(c *C) { + s.bl.SetErr = fmt.Errorf("set fails") + err := boot.EnsureNextBootToRunModeWithTryRecoverySystemOutcome(boot.TryRecoverySystemOutcomeSuccess) + c.Assert(err, ErrorMatches, "set fails") + err = boot.EnsureNextBootToRunModeWithTryRecoverySystemOutcome(boot.TryRecoverySystemOutcomeFailure) + c.Assert(err, ErrorMatches, "set fails") + err = boot.EnsureNextBootToRunModeWithTryRecoverySystemOutcome(boot.TryRecoverySystemOutcomeInconsistent) + c.Assert(err, ErrorMatches, "set fails") +} + +func (s *initramfsMarkTryRecoverySystemSuite) TestTryingRecoverySystemUnset(c *C) { + err := s.bl.SetBootVars(map[string]string{ + "recovery_system_status": "try", + // system is unset + "try_recovery_system": "", + }) + c.Assert(err, IsNil) + isTry, err := boot.InitramfsIsTryingRecoverySystem("1234") + c.Assert(err, ErrorMatches, `try recovery system is unset but status is "try"`) + c.Check(boot.IsInconsistentRecoverySystemState(err), Equals, true) + c.Check(isTry, Equals, false) +} + +func (s *initramfsMarkTryRecoverySystemSuite) TestTryingRecoverySystemBogus(c *C) { + err := s.bl.SetBootVars(map[string]string{ + "recovery_system_status": "foobar", + "try_recovery_system": "1234", + }) + c.Assert(err, IsNil) + isTry, err := boot.InitramfsIsTryingRecoverySystem("1234") + c.Assert(err, ErrorMatches, `unexpected recovery system status "foobar"`) + c.Check(boot.IsInconsistentRecoverySystemState(err), Equals, true) + c.Check(isTry, Equals, false) + + // errors out even if try recovery system label is unset + err = s.bl.SetBootVars(map[string]string{ + "recovery_system_status": "no-label", + "try_recovery_system": "", + }) + c.Assert(err, IsNil) + isTry, err = boot.InitramfsIsTryingRecoverySystem("1234") + c.Assert(err, ErrorMatches, `unexpected recovery system status "no-label"`) + c.Check(boot.IsInconsistentRecoverySystemState(err), Equals, true) + c.Check(isTry, Equals, false) +} + +func (s *initramfsMarkTryRecoverySystemSuite) TestTryingRecoverySystemNoTryingStatus(c *C) { + err := s.bl.SetBootVars(map[string]string{ + "recovery_system_status": "", + "try_recovery_system": "", + }) + c.Assert(err, IsNil) + isTry, err := boot.InitramfsIsTryingRecoverySystem("1234") + c.Assert(err, IsNil) + c.Check(isTry, Equals, false) + + err = s.bl.SetBootVars(map[string]string{ + // status is checked first + "recovery_system_status": "", + "try_recovery_system": "1234", + }) + c.Assert(err, IsNil) + isTry, err = boot.InitramfsIsTryingRecoverySystem("1234") + c.Assert(err, IsNil) + c.Check(isTry, Equals, false) +} + +func (s *initramfsMarkTryRecoverySystemSuite) TestTryingRecoverySystemSameSystem(c *C) { + // the usual scenario + err := s.bl.SetBootVars(map[string]string{ + "recovery_system_status": "try", + "try_recovery_system": "1234", + }) + c.Assert(err, IsNil) + isTry, err := boot.InitramfsIsTryingRecoverySystem("1234") + c.Assert(err, IsNil) + c.Check(isTry, Equals, true) + + // pretend the system has already been tried + err = s.bl.SetBootVars(map[string]string{ + "recovery_system_status": "tried", + "try_recovery_system": "1234", + }) + c.Assert(err, IsNil) + isTry, err = boot.InitramfsIsTryingRecoverySystem("1234") + c.Assert(err, IsNil) + c.Check(isTry, Equals, true) +} + +func (s *initramfsMarkTryRecoverySystemSuite) TestRecoverySystemSuccessDifferent(c *C) { + // other system + err := s.bl.SetBootVars(map[string]string{ + "recovery_system_status": "try", + "try_recovery_system": "9999", + }) + c.Assert(err, IsNil) + isTry, err := boot.InitramfsIsTryingRecoverySystem("1234") + c.Assert(err, IsNil) + c.Check(isTry, Equals, false) + + // same when the other system has already been tried + err = s.bl.SetBootVars(map[string]string{ + "recovery_system_status": "tried", + "try_recovery_system": "9999", + }) + c.Assert(err, IsNil) + isTry, err = boot.InitramfsIsTryingRecoverySystem("1234") + c.Assert(err, IsNil) + c.Check(isTry, Equals, false) +} + +func (s *systemsSuite) TestUnmarkRecoveryCapableSystemHappy(c *C) { + rbl := bootloadertest.Mock("recovery", c.MkDir()).RecoveryAware() + bootloader.Force(rbl) + + // mark system + err := boot.MarkRecoveryCapableSystem("1234") + c.Assert(err, IsNil) + vars, err := rbl.GetBootVars("snapd_good_recovery_systems") + c.Assert(err, IsNil) + c.Check(vars, DeepEquals, map[string]string{ + "snapd_good_recovery_systems": "1234", + }) + + // mark system + err = boot.MarkRecoveryCapableSystem("4567") + c.Assert(err, IsNil) + vars, err = rbl.GetBootVars("snapd_good_recovery_systems") + c.Assert(err, IsNil) + c.Check(vars, DeepEquals, map[string]string{ + "snapd_good_recovery_systems": "1234,4567", + }) + + // unmark system that is not present, function is idempotent + err = boot.UnmarkRecoveryCapableSystem("not-here") + c.Assert(err, IsNil) + vars, err = rbl.GetBootVars("snapd_good_recovery_systems") + c.Assert(err, IsNil) + c.Check(vars, DeepEquals, map[string]string{ + "snapd_good_recovery_systems": "1234,4567", + }) + + // unmark system + err = boot.UnmarkRecoveryCapableSystem("1234") + c.Assert(err, IsNil) + vars, err = rbl.GetBootVars("snapd_good_recovery_systems") + c.Assert(err, IsNil) + c.Check(vars, DeepEquals, map[string]string{ + "snapd_good_recovery_systems": "4567", + }) + + // unmark system + err = boot.UnmarkRecoveryCapableSystem("4567") + c.Assert(err, IsNil) + vars, err = rbl.GetBootVars("snapd_good_recovery_systems") + c.Assert(err, IsNil) + c.Check(vars, DeepEquals, map[string]string{ + "snapd_good_recovery_systems": "", + }) +} diff --git a/bootloader/androidboot.go b/bootloader/androidboot.go new file mode 100644 index 00000000..5d859731 --- /dev/null +++ b/bootloader/androidboot.go @@ -0,0 +1,98 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package bootloader + +import ( + "os" + "path/filepath" + + "github.com/snapcore/snapd/bootloader/androidbootenv" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/snap" +) + +type androidboot struct { + rootdir string +} + +// newAndroidboot creates a new Androidboot bootloader object +func newAndroidBoot(rootdir string, _ *Options) Bootloader { + a := &androidboot{rootdir: rootdir} + return a +} + +func (a *androidboot) Name() string { + return "androidboot" +} + +func (a *androidboot) dir() string { + if a.rootdir == "" { + panic("internal error: unset rootdir") + } + return filepath.Join(a.rootdir, "/boot/androidboot") +} + +func (a *androidboot) InstallBootConfig(gadgetDir string, opts *Options) error { + gadgetFile := filepath.Join(gadgetDir, a.Name()+".conf") + systemFile := a.configFile() + return genericInstallBootConfig(gadgetFile, systemFile) +} + +func (a *androidboot) Present() (bool, error) { + return osutil.FileExists(a.configFile()), nil +} + +func (a *androidboot) configFile() string { + return filepath.Join(a.dir(), "androidboot.env") +} + +func (a *androidboot) GetBootVars(names ...string) (map[string]string, error) { + env := androidbootenv.NewEnv(a.configFile()) + if err := env.Load(); err != nil { + return nil, err + } + + out := make(map[string]string, len(names)) + for _, name := range names { + out[name] = env.Get(name) + } + + return out, nil +} + +func (a *androidboot) SetBootVars(values map[string]string) error { + env := androidbootenv.NewEnv(a.configFile()) + if err := env.Load(); err != nil && !os.IsNotExist(err) { + return err + } + for k, v := range values { + env.Set(k, v) + } + return env.Save() +} + +func (a *androidboot) ExtractKernelAssets(s snap.PlaceInfo, snapf snap.Container) error { + return nil + +} + +func (a *androidboot) RemoveKernelAssets(s snap.PlaceInfo) error { + return nil +} diff --git a/bootloader/androidboot_test.go b/bootloader/androidboot_test.go new file mode 100644 index 00000000..52f58970 --- /dev/null +++ b/bootloader/androidboot_test.go @@ -0,0 +1,105 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package bootloader_test + +import ( + "os" + "path/filepath" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/boot" + "github.com/snapcore/snapd/bootloader" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/snap/snapfile" + "github.com/snapcore/snapd/snap/snaptest" +) + +type androidBootTestSuite struct { + baseBootenvTestSuite +} + +var _ = Suite(&androidBootTestSuite{}) + +func (s *androidBootTestSuite) SetUpTest(c *C) { + s.baseBootenvTestSuite.SetUpTest(c) + + // the file needs to exist for androidboot object to be created + bootloader.MockAndroidBootFile(c, s.rootdir, 0644) +} + +func (s *androidBootTestSuite) TestNewAndroidboot(c *C) { + // no files means bl is not present, but we can still create the bl object + c.Assert(os.RemoveAll(s.rootdir), IsNil) + a := bootloader.NewAndroidBoot(s.rootdir) + c.Assert(a, NotNil) + c.Assert(a.Name(), Equals, "androidboot") + + present, err := a.Present() + c.Assert(err, IsNil) + c.Assert(present, Equals, false) + + // now with files present, the bl is present + bootloader.MockAndroidBootFile(c, s.rootdir, 0644) + present, err = a.Present() + c.Assert(err, IsNil) + c.Assert(present, Equals, true) +} + +func (s *androidBootTestSuite) TestSetGetBootVar(c *C) { + a := bootloader.NewAndroidBoot(s.rootdir) + bootVars := map[string]string{"snap_mode": boot.TryStatus} + a.SetBootVars(bootVars) + + v, err := a.GetBootVars("snap_mode") + c.Assert(err, IsNil) + c.Check(v, HasLen, 1) + c.Check(v["snap_mode"], Equals, boot.TryStatus) +} + +func (s *androidBootTestSuite) TestExtractKernelAssetsNoUnpacksKernel(c *C) { + a := bootloader.NewAndroidBoot(s.rootdir) + + c.Assert(a, NotNil) + + files := [][]string{ + {"kernel.img", "I'm a kernel"}, + {"initrd.img", "...and I'm an initrd"}, + {"meta/kernel.yaml", "version: 4.2"}, + } + si := &snap.SideInfo{ + RealName: "ubuntu-kernel", + Revision: snap.R(42), + } + fn := snaptest.MakeTestSnapWithFiles(c, packageKernel, files) + snapf, err := snapfile.Open(fn) + c.Assert(err, IsNil) + + info, err := snap.ReadInfoFromSnapFile(snapf, si) + c.Assert(err, IsNil) + + err = a.ExtractKernelAssets(info, snapf) + c.Assert(err, IsNil) + + // kernel is *not* here + kernimg := filepath.Join(s.rootdir, "boot", "androidboot", "ubuntu-kernel_42.snap", "kernel.img") + c.Assert(osutil.FileExists(kernimg), Equals, false) +} diff --git a/bootloader/androidbootenv/androidbootenv.go b/bootloader/androidbootenv/androidbootenv.go new file mode 100644 index 00000000..2e9d7a19 --- /dev/null +++ b/bootloader/androidbootenv/androidbootenv.go @@ -0,0 +1,90 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package androidbootenv + +import ( + "bufio" + "bytes" + "fmt" + "os" + "strings" + + "github.com/snapcore/snapd/logger" + "github.com/snapcore/snapd/osutil" +) + +type Env struct { + // Map with key-value strings + env map[string]string + // File for environment storage + path string +} + +func NewEnv(path string) *Env { + return &Env{ + env: make(map[string]string), + path: path, + } +} + +func (a *Env) Get(name string) string { + return a.env[name] +} + +func (a *Env) Set(key, value string) { + a.env[key] = value +} + +func (a *Env) Load() error { + file, err := os.Open(a.path) + if err != nil { + return err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + l := strings.SplitN(scanner.Text(), "=", 2) + // be liberal in what you accept + if len(l) < 2 { + logger.Noticef("WARNING: bad value while parsing %v (line: %q)", + a.path, scanner.Text()) + continue + } + a.env[l[0]] = l[1] + } + if err := scanner.Err(); err != nil { + return err + } + + return nil +} + +func (a *Env) Save() error { + var w bytes.Buffer + + for k, v := range a.env { + if _, err := fmt.Fprintf(&w, "%s=%s\n", k, v); err != nil { + return err + } + } + + return osutil.AtomicWriteFile(a.path, w.Bytes(), 0644, 0) +} diff --git a/bootloader/androidbootenv/androidbootenv_test.go b/bootloader/androidbootenv/androidbootenv_test.go new file mode 100644 index 00000000..fba87f06 --- /dev/null +++ b/bootloader/androidbootenv/androidbootenv_test.go @@ -0,0 +1,69 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package androidbootenv_test + +import ( + "path/filepath" + "testing" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/bootloader/androidbootenv" +) + +// Hook up check.v1 into the "go test" runner +func Test(t *testing.T) { TestingT(t) } + +type androidbootenvTestSuite struct { + envPath string + env *androidbootenv.Env +} + +var _ = Suite(&androidbootenvTestSuite{}) + +func (a *androidbootenvTestSuite) SetUpTest(c *C) { + a.envPath = filepath.Join(c.MkDir(), "androidbootenv") + a.env = androidbootenv.NewEnv(a.envPath) + c.Assert(a.env, NotNil) +} + +func (a *androidbootenvTestSuite) TestSet(c *C) { + a.env.Set("key", "value") + c.Check(a.env.Get("key"), Equals, "value") +} + +func (a *androidbootenvTestSuite) TestSaveAndLoad(c *C) { + a.env.Set("key1", "value1") + a.env.Set("key2", "") + a.env.Set("key3", "value3") + + err := a.env.Save() + c.Assert(err, IsNil) + + env2 := androidbootenv.NewEnv(a.envPath) + c.Check(env2, NotNil) + + err = env2.Load() + c.Assert(err, IsNil) + + c.Assert(env2.Get("key1"), Equals, "value1") + c.Assert(env2.Get("key2"), Equals, "") + c.Assert(env2.Get("key3"), Equals, "value3") +} diff --git a/bootloader/asset.go b/bootloader/asset.go new file mode 100644 index 00000000..d4c36ba3 --- /dev/null +++ b/bootloader/asset.go @@ -0,0 +1,113 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package bootloader + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "io" + "os" + "strconv" + "strings" + + "github.com/snapcore/snapd/bootloader/assets" +) + +var errNoEdition = errors.New("no edition") + +// editionFromDiskConfigAsset extracts the edition information from a boot +// config asset on disk. +func editionFromDiskConfigAsset(p string) (uint, error) { + f, err := os.Open(p) + if err != nil { + if os.IsNotExist(err) { + return 0, errNoEdition + } + return 0, fmt.Errorf("cannot load existing config asset: %v", err) + } + defer f.Close() + return editionFromConfigAsset(f) +} + +const editionHeader = "# Snapd-Boot-Config-Edition: " + +// editionFromConfigAsset extracts edition information from boot config asset. +func editionFromConfigAsset(asset io.Reader) (uint, error) { + scanner := bufio.NewScanner(asset) + if !scanner.Scan() { + err := fmt.Errorf("cannot read config asset: unexpected EOF") + if sErr := scanner.Err(); sErr != nil { + err = fmt.Errorf("cannot read config asset: %v", err) + } + return 0, err + } + + line := scanner.Text() + if !strings.HasPrefix(line, editionHeader) { + return 0, errNoEdition + } + + editionStr := line[len(editionHeader):] + editionStr = strings.TrimSpace(editionStr) + edition, err := strconv.ParseUint(editionStr, 10, 32) + if err != nil { + return 0, fmt.Errorf("cannot parse asset edition: %v", err) + } + return uint(edition), nil +} + +// editionFromInternalConfigAsset extracts edition information from a named +// internal boot config asset. +func editionFromInternalConfigAsset(assetName string) (uint, error) { + data := assets.Internal(assetName) + if data == nil { + return 0, fmt.Errorf("internal error: no boot asset for %q", assetName) + } + return editionFromConfigAsset(bytes.NewReader(data)) +} + +// configAsset is a boot config asset, such as text script, used by grub or +// u-boot. +type configAsset struct { + body []byte + parsedEdition uint +} + +func (g *configAsset) Edition() uint { + return g.parsedEdition +} + +func (g *configAsset) Raw() []byte { + return g.body +} + +func configAssetFrom(data []byte) (*configAsset, error) { + edition, err := editionFromConfigAsset(bytes.NewReader(data)) + if err != nil && err != errNoEdition { + return nil, err + } + gbs := &configAsset{ + body: data, + parsedEdition: edition, + } + return gbs, nil +} diff --git a/bootloader/asset_test.go b/bootloader/asset_test.go new file mode 100644 index 00000000..ae63472e --- /dev/null +++ b/bootloader/asset_test.go @@ -0,0 +1,136 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2014-2015 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package bootloader_test + +import ( + "bytes" + "os" + "path/filepath" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/bootloader" + "github.com/snapcore/snapd/bootloader/assets" +) + +type configAssetTestSuite struct { + baseBootenvTestSuite +} + +var _ = Suite(&configAssetTestSuite{}) + +func (s *configAssetTestSuite) TestTrivialFromConfigAssert(c *C) { + e, err := bootloader.EditionFromConfigAsset(bytes.NewBufferString(`# Snapd-Boot-Config-Edition: 321 +next line +one after that`)) + c.Assert(err, IsNil) + c.Assert(e, Equals, uint(321)) + + e, err = bootloader.EditionFromConfigAsset(bytes.NewBufferString(`# Snapd-Boot-Config-Edition: 932 +# Snapd-Boot-Config-Edition: 321 +one after that`)) + c.Assert(err, IsNil) + c.Assert(e, Equals, uint(932)) + + e, err = bootloader.EditionFromConfigAsset(bytes.NewBufferString(`# Snapd-Boot-Config-Edition: 1234 +one after that +# Snapd-Boot-Config-Edition: 321 +`)) + c.Assert(err, IsNil) + c.Assert(e, Equals, uint(1234)) +} + +func (s *configAssetTestSuite) TestTrivialFromFile(c *C) { + d := c.MkDir() + p := filepath.Join(d, "foo") + os.WriteFile(p, []byte(`# Snapd-Boot-Config-Edition: 123 +this is some +this too`), 0644) + e, err := bootloader.EditionFromDiskConfigAsset(p) + c.Assert(err, IsNil) + c.Assert(e, Equals, uint(123)) +} + +func (s *configAssetTestSuite) TestRealConfig(c *C) { + grubConfig := assets.Internal("grub.cfg") + c.Assert(grubConfig, NotNil) + e, err := bootloader.EditionFromConfigAsset(bytes.NewReader(grubConfig)) + c.Assert(err, IsNil) + c.Assert(e, Equals, uint(3)) +} + +func (s *configAssetTestSuite) TestRealRecoveryConfig(c *C) { + grubRecoveryConfig := assets.Internal("grub-recovery.cfg") + c.Assert(grubRecoveryConfig, NotNil) + e, err := bootloader.EditionFromConfigAsset(bytes.NewReader(grubRecoveryConfig)) + c.Assert(err, IsNil) + c.Assert(e, Equals, uint(2)) +} + +func (s *configAssetTestSuite) TestNoConfig(c *C) { + _, err := bootloader.EditionFromConfigAsset(bytes.NewReader(nil)) + c.Assert(err, ErrorMatches, "cannot read config asset: unexpected EOF") +} + +func (s *configAssetTestSuite) TestNoFile(c *C) { + d := c.MkDir() + p := filepath.Join(d, "foo") + // file does not exist + _, err := bootloader.EditionFromDiskConfigAsset(p) + c.Assert(err, ErrorMatches, "no edition") +} + +func (s *configAssetTestSuite) TestUnreadableFile(c *C) { + // root has DAC override + if os.Geteuid() == 0 { + c.Skip("test case cannot be correctly executed by root") + } + d := c.MkDir() + p := filepath.Join(d, "foo") + err := os.WriteFile(p, []byte("foo"), 0000) + c.Assert(err, IsNil) + _, err = bootloader.EditionFromDiskConfigAsset(p) + c.Assert(err, ErrorMatches, "cannot load existing config asset: .*/foo: permission denied") +} + +func (s *configAssetTestSuite) TestNoEdition(c *C) { + _, err := bootloader.EditionFromConfigAsset(bytes.NewReader([]byte(`this is some script +without edition header +`))) + c.Assert(err, ErrorMatches, "no edition") +} + +func (s *configAssetTestSuite) TestBadEdition(c *C) { + _, err := bootloader.EditionFromConfigAsset(bytes.NewReader([]byte(`# Snapd-Boot-Config-Edition: random +data follows +`))) + c.Assert(err, ErrorMatches, `cannot parse asset edition: .* parsing "random": invalid syntax`) +} + +func (s *configAssetTestSuite) TestConfigAssetFrom(c *C) { + script := []byte(`# Snapd-Boot-Config-Edition: 123 +data follows +`) + bs, err := bootloader.ConfigAssetFrom(script) + c.Assert(err, IsNil) + c.Assert(bs, NotNil) + c.Assert(bs.Edition(), Equals, uint(123)) + c.Assert(bs.Raw(), DeepEquals, script) +} diff --git a/bootloader/assets/assets.go b/bootloader/assets/assets.go new file mode 100644 index 00000000..9bc98f6b --- /dev/null +++ b/bootloader/assets/assets.go @@ -0,0 +1,143 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package assets + +import ( + "fmt" + "sort" + + "github.com/snapcore/snapd/osutil" +) + +var registeredAssets = map[string][]byte{} + +// ForEditions wraps a snippet that is used in editions starting with +// FirstEdition. +type ForEditions struct { + // First edition this snippet is used in + FirstEdition uint + // Snippet data + Snippet []byte +} + +var registeredEditionSnippets = map[string][]ForEditions{} + +// registerInternal registers an internal asset under the given name. +func registerInternal(name string, data []byte) { + if _, ok := registeredAssets[name]; ok { + panic(fmt.Sprintf("asset %q is already registered", name)) + } + registeredAssets[name] = data +} + +// Internal returns the content of an internal asset registered under the given +// name, or nil when none was found. +func Internal(name string) []byte { + return registeredAssets[name] +} + +type byFirstEdition []ForEditions + +func (b byFirstEdition) Len() int { return len(b) } +func (b byFirstEdition) Swap(i, j int) { b[i], b[j] = b[j], b[i] } +func (b byFirstEdition) Less(i, j int) bool { return b[i].FirstEdition < b[j].FirstEdition } + +func sanitizeSnippets(snippets []ForEditions) error { + if !sort.IsSorted(byFirstEdition(snippets)) { + return fmt.Errorf("snippets must be sorted in ascending edition number order") + } + for i := range snippets { + if i == 0 { + continue + } + if snippets[i-1].FirstEdition == snippets[i].FirstEdition { + return fmt.Errorf(`first edition %v repeated`, snippets[i].FirstEdition) + } + } + return nil +} + +// registerSnippetForEditions register a set of snippets, each carrying the +// first edition number it applies to, under a given key. +func registerSnippetForEditions(name string, snippets []ForEditions) { + if _, ok := registeredEditionSnippets[name]; ok { + panic(fmt.Sprintf("edition snippets %q are already registered", name)) + } + + if err := sanitizeSnippets(snippets); err != nil { + panic(fmt.Errorf("cannot validate snippets %q: %v", name, err)) + } + registeredEditionSnippets[name] = snippets +} + +// SnippetForEdition returns a snippet registered under given name, +// applicable for the provided edition number. +func SnippetForEdition(name string, edition uint) []byte { + snippets := registeredEditionSnippets[name] + if snippets == nil { + return nil + } + var current []byte + // snippets are sorted by ascending edition number when adding + for _, snip := range snippets { + if edition >= snip.FirstEdition { + current = snip.Snippet + } else { + break + } + } + return current +} + +// MockInternal mocks the contents of an internal asset for use in testing. +func MockInternal(name string, data []byte) (restore func()) { + osutil.MustBeTestBinary("mocking can be done only in tests") + + old, ok := registeredAssets[name] + registeredAssets[name] = data + return func() { + if ok { + registeredAssets[name] = old + } else { + delete(registeredAssets, name) + } + } +} + +// MockSnippetsForEdition mocks the contents of per-edition snippets. +func MockSnippetsForEdition(name string, snippets []ForEditions) (restore func()) { + osutil.MustBeTestBinary("mocking can be done only in tests") + + old, ok := registeredEditionSnippets[name] + snippetsCopy := make([]ForEditions, len(snippets)) + copy(snippetsCopy, snippets) + if ok { + delete(registeredEditionSnippets, name) + } + registerSnippetForEditions(name, snippetsCopy) + + return func() { + if ok { + registeredEditionSnippets[name] = old + } else { + delete(registeredAssets, name) + } + } +} diff --git a/bootloader/assets/assets_test.go b/bootloader/assets/assets_test.go new file mode 100644 index 00000000..3913b15a --- /dev/null +++ b/bootloader/assets/assets_test.go @@ -0,0 +1,156 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package assets_test + +import ( + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/bootloader/assets" + "github.com/snapcore/snapd/testutil" +) + +type assetsTestSuite struct { + testutil.BaseTest +} + +var _ = Suite(&assetsTestSuite{}) + +func (s *assetsTestSuite) SetUpTest(c *C) { + s.BaseTest.SetUpTest(c) + s.AddCleanup(assets.MockCleanState()) +} + +func (s *assetsTestSuite) TestRegisterInternalSimple(c *C) { + assets.RegisterInternal("foo", []byte("bar")) + data := assets.Internal("foo") + c.Check(data, DeepEquals, []byte("bar")) + + complexData := `this is "some +complex binary " data +` + assets.RegisterInternal("complex-data", []byte(complexData)) + complex := assets.Internal("complex-data") + c.Check(complex, DeepEquals, []byte(complexData)) + + nodata := assets.Internal("no data") + c.Check(nodata, IsNil) +} + +func (s *assetsTestSuite) TestRegisterDoublePanics(c *C) { + assets.RegisterInternal("foo", []byte("foo")) + // panics with the same key, no matter the data used + c.Assert(func() { assets.RegisterInternal("foo", []byte("bar")) }, + PanicMatches, `asset "foo" is already registered`) + c.Assert(func() { assets.RegisterInternal("foo", []byte("foo")) }, + PanicMatches, `asset "foo" is already registered`) +} + +func (s *assetsTestSuite) TestRegisterSnippetPanics(c *C) { + assets.RegisterSnippetForEditions("foo", []assets.ForEditions{ + {FirstEdition: 1, Snippet: []byte("foo")}, + }) + // panics with the same key + c.Assert(func() { + assets.RegisterSnippetForEditions("foo", []assets.ForEditions{ + {FirstEdition: 2, Snippet: []byte("bar")}, + }) + }, PanicMatches, `edition snippets "foo" are already registered`) + // panics when snippets aren't sorted + c.Assert(func() { + assets.RegisterSnippetForEditions("unsorted", []assets.ForEditions{ + {FirstEdition: 2, Snippet: []byte("two")}, + {FirstEdition: 1, Snippet: []byte("one")}, + }) + }, PanicMatches, `cannot validate snippets "unsorted": snippets must be sorted in ascending edition number order`) + // panics when edition is repeated + c.Assert(func() { + assets.RegisterSnippetForEditions("doubled edition", []assets.ForEditions{ + {FirstEdition: 1, Snippet: []byte("one")}, + {FirstEdition: 2, Snippet: []byte("two")}, + {FirstEdition: 3, Snippet: []byte("three")}, + {FirstEdition: 3, Snippet: []byte("more tree")}, + {FirstEdition: 4, Snippet: []byte("four")}, + }) + }, PanicMatches, `cannot validate snippets "doubled edition": first edition 3 repeated`) + // mix unsorted with duplicate edition + c.Assert(func() { + assets.RegisterSnippetForEditions("unsorted and doubled edition", []assets.ForEditions{ + {FirstEdition: 1, Snippet: []byte("one")}, + {FirstEdition: 2, Snippet: []byte("two")}, + {FirstEdition: 1, Snippet: []byte("one again")}, + {FirstEdition: 3, Snippet: []byte("more tree")}, + {FirstEdition: 4, Snippet: []byte("four")}, + }) + }, PanicMatches, `cannot validate snippets "unsorted and doubled edition": snippets must be sorted in ascending edition number order`) +} + +func (s *assetsTestSuite) TestEditionSnippets(c *C) { + assets.RegisterSnippetForEditions("foo", []assets.ForEditions{ + {FirstEdition: 1, Snippet: []byte("one")}, + {FirstEdition: 2, Snippet: []byte("two")}, + {FirstEdition: 3, Snippet: []byte("three")}, + {FirstEdition: 4, Snippet: []byte("four")}, + {FirstEdition: 10, Snippet: []byte("ten")}, + {FirstEdition: 20, Snippet: []byte("twenty")}, + }) + assets.RegisterSnippetForEditions("bar", []assets.ForEditions{ + {FirstEdition: 1, Snippet: []byte("bar one")}, + {FirstEdition: 3, Snippet: []byte("bar three")}, + // same as 3 + {FirstEdition: 5, Snippet: []byte("bar three")}, + }) + assets.RegisterSnippetForEditions("just-one", []assets.ForEditions{ + {FirstEdition: 2, Snippet: []byte("just one")}, + }) + + for _, tc := range []struct { + asset string + edition uint + exp []byte + }{ + {"foo", 1, []byte("one")}, + {"foo", 4, []byte("four")}, + {"foo", 10, []byte("ten")}, + // still using snipped from edition 4 + {"foo", 9, []byte("four")}, + // still using snipped from edition 10 + {"foo", 11, []byte("ten")}, + {"foo", 30, []byte("twenty")}, + // different asset + {"bar", 1, []byte("bar one")}, + {"bar", 2, []byte("bar one")}, + {"bar", 3, []byte("bar three")}, + {"bar", 4, []byte("bar three")}, + {"bar", 5, []byte("bar three")}, + {"bar", 6, []byte("bar three")}, + // nothing registered for edition 0 + {"bar", 0, nil}, + // a single snippet under this key + {"just-one", 2, []byte("just one")}, + {"just-one", 1, nil}, + // asset not registered + {"no asset", 1, nil}, + {"no asset", 100, nil}, + } { + c.Logf("%q edition %v", tc.asset, tc.edition) + snippet := assets.SnippetForEdition(tc.asset, tc.edition) + c.Check(snippet, DeepEquals, tc.exp) + } +} diff --git a/bootloader/assets/assetstesting.go b/bootloader/assets/assetstesting.go new file mode 100644 index 00000000..cdcbcd0a --- /dev/null +++ b/bootloader/assets/assetstesting.go @@ -0,0 +1,45 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- +//go:build withbootassetstesting + +/* + * Copyright (C) 2021 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package assets + +import ( + "github.com/snapcore/snapd/logger" +) + +// InjectInternal injects an internal asset under the given name. +func InjectInternal(name string, data []byte) { + logger.Noticef("injecting bootloader asset %q", name) + registeredAssets[name] = data +} + +func SnippetsForEditions(name string) []ForEditions { + return registeredEditionSnippets[name] +} + +// InjectSnippetForEditions injects a set of snippets under a given key. +func InjectSnippetsForEditions(name string, snippets []ForEditions) { + logger.Noticef("injecting bootloader asset edition snippets for %q", name) + + if err := sanitizeSnippets(snippets); err != nil { + panic(err) + } + registeredEditionSnippets[name] = snippets +} diff --git a/bootloader/assets/data/README.grub b/bootloader/assets/data/README.grub new file mode 100644 index 00000000..67ca86af --- /dev/null +++ b/bootloader/assets/data/README.grub @@ -0,0 +1,10 @@ +Edition 1 of grub.cfg and grub-recovery.cfg imported from https://github.com/snapcore/pc-amd64-gadget, commit: + +commit e4d63119322691f14a3f9dfa36a3a075e941ec9d (HEAD -> 20, origin/HEAD, origin/20) +Merge: b70d2ae d113aca +Author: Dimitri John Ledkov +Date: Thu May 7 19:30:00 2020 +0100 + + Merge pull request #47 from xnox/production-keys + + gadget: bump edition to 2, using production signing keys for everything. diff --git a/bootloader/assets/data/grub-recovery.cfg b/bootloader/assets/data/grub-recovery.cfg new file mode 100644 index 00000000..8c297d8c --- /dev/null +++ b/bootloader/assets/data/grub-recovery.cfg @@ -0,0 +1,83 @@ +# Snapd-Boot-Config-Edition: 2 + +set default=0 +set timeout=3 +set timeout_style=hidden + +if [ -e /EFI/ubuntu/grubenv ]; then + load_env --file /EFI/ubuntu/grubenv snapd_recovery_mode snapd_recovery_system +fi + +# standard cmdline params +set snapd_static_cmdline_args='panic=-1' + +# if no default boot mode set, pick one +if [ -z "$snapd_recovery_mode" ]; then + set snapd_recovery_mode=install +fi + +if [ "$snapd_recovery_mode" = "run" ]; then + default="run" +elif [ -n "$snapd_recovery_system" ]; then + default=$snapd_recovery_mode-$snapd_recovery_system +fi + +search --no-floppy --set=boot_fs --label ubuntu-boot + +if [ "$grub_cpu" = "x86_64" ]; then + set snapd_static_cmdline_args='console=ttyS0 console=tty1 panic=-1' + grub_binary="grubx64.efi" +elif [ "$grub_cpu" = "arm64" ]; then + grub_binary="grubaa64.efi" +else + echo "$grub_cpu" "is not supported" + grub_binary="none" +fi + +if [ -n "$boot_fs" ]; then + menuentry "Continue to run mode" --hotkey=n --id=run { + set root=($boot_fs) + chainloader ($boot_fs)/EFI/boot/"$grub_binary" + } +fi + +# globbing in grub does not sort +for label in /systems/*; do + # match the system labels generated by snapd, which are usually just + # numbers. eg. 20210706, but can be hyphen separated numbers and letters + if ! regexp --set 1:label "/([a-z0-9](-?[a-z0-9])*)\$" "$label"; then + continue + fi + # yes, you need to backslash that less-than + if [ -z "$best" -o "$label" \< "$best" ]; then + set best="$label" + fi + # if grubenv did not pick mode-system, use best one + if [ -z "$snapd_recovery_system" ]; then + default=$snapd_recovery_mode-$best + fi + set snapd_recovery_kernel= + load_env --file /systems/$label/grubenv snapd_recovery_kernel snapd_extra_cmdline_args snapd_full_cmdline_args + set cmdline_args="$snapd_static_cmdline_args $snapd_extra_cmdline_args" + if [ -n "$snapd_full_cmdline_args" ]; then + set cmdline_args="$snapd_full_cmdline_args" + fi + + # We could "source /systems/$snapd_recovery_system/grub.cfg" here as well + menuentry "Recover using $label" --hotkey=r --id=recover-$label $snapd_recovery_kernel recover $label { + loopback loop $2 + chainloader (loop)/kernel.efi snapd_recovery_mode=$3 snapd_recovery_system=$4 $cmdline_args + } + menuentry "Install using $label" --hotkey=i --id=install-$label $snapd_recovery_kernel install $label { + loopback loop $2 + chainloader (loop)/kernel.efi snapd_recovery_mode=$3 snapd_recovery_system=$4 $cmdline_args + } + menuentry "Factory reset using $label" --hotkey=i --id=factory-reset-$label $snapd_recovery_kernel factory-reset $label { + loopback loop $2 + chainloader (loop)/kernel.efi snapd_recovery_mode=$3 snapd_recovery_system=$4 $cmdline_args + } +done + +menuentry 'UEFI Firmware Settings' --hotkey=f 'uefi-firmware' { + fwsetup +} diff --git a/bootloader/assets/data/grub.cfg b/bootloader/assets/data/grub.cfg new file mode 100644 index 00000000..298bfc0c --- /dev/null +++ b/bootloader/assets/data/grub.cfg @@ -0,0 +1,57 @@ +# Snapd-Boot-Config-Edition: 3 + +set default=0 +set timeout=3 +set timeout_style=hidden + +# load only kernel_status and kernel command line variables set by snapd from +# the bootenv +load_env --file /EFI/ubuntu/grubenv kernel_status snapd_extra_cmdline_args snapd_full_cmdline_args + +set snapd_static_cmdline_args='panic=-1' +if [ "$grub_cpu" = "x86_64" ]; then + set snapd_static_cmdline_args='console=ttyS0,115200n8 console=tty1 panic=-1' +fi +set cmdline_args="$snapd_static_cmdline_args $snapd_extra_cmdline_args" +if [ -n "$snapd_full_cmdline_args" ]; then + set cmdline_args="$snapd_full_cmdline_args" +fi + +set kernel=kernel.efi + +if [ "$kernel_status" = "try" ]; then + # a new kernel got installed + set kernel_status="trying" + save_env kernel_status + # run fallback (menu entry #1) if we cannot start the kernel + set fallback=1 + + # use try-kernel.efi + set kernel=try-kernel.efi +elif [ "$kernel_status" = "trying" ]; then + # nothing cleared the "trying snap" so the boot failed + # we clear the mode and boot normally + set kernel_status="" + save_env kernel_status +elif [ -n "$kernel_status" ]; then + # ERROR invalid kernel_status state, reset to empty + echo "invalid kernel_status!!!" + echo "resetting to empty" + set kernel_status="" + save_env kernel_status +fi + +menuentry "Run Ubuntu Core" { + # use $prefix because the symlink manipulation at runtime for kernel snap + # upgrades, etc. should only need the /boot/grub/ directory, not the + # /EFI/ubuntu/ directory + chainloader $prefix/$kernel snapd_recovery_mode=run $cmdline_args +} +menuentry "Fallback on failed update" { + # kernel_status has already been set to "trying", rebooting now + # will fail the pending kernel update. Note that we cannot simply + # chainload the fallback kernel as TPM measurements need to be + # cleaned-up to be able to unseal the key. + echo "Cannot start new kernel - booting previous one" + reboot +} diff --git a/bootloader/assets/export_test.go b/bootloader/assets/export_test.go new file mode 100644 index 00000000..229ea5a8 --- /dev/null +++ b/bootloader/assets/export_test.go @@ -0,0 +1,37 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package assets + +var ( + RegisterInternal = registerInternal + RegisterSnippetForEditions = registerSnippetForEditions + RegisterGrubSnippets = registerGrubSnippets +) + +func MockCleanState() (restore func()) { + oldRegisteredAssets := registeredAssets + oldRegisteredEditionAssets := registeredEditionSnippets + registeredAssets = map[string][]byte{} + registeredEditionSnippets = map[string][]ForEditions{} + return func() { + registeredAssets = oldRegisteredAssets + registeredEditionSnippets = oldRegisteredEditionAssets + } +} diff --git a/bootloader/assets/genasset/export_test.go b/bootloader/assets/genasset/export_test.go new file mode 100644 index 00000000..08f12731 --- /dev/null +++ b/bootloader/assets/genasset/export_test.go @@ -0,0 +1,32 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +var ( + ParseArgs = parseArgs + Run = run + FormatLines = formatLines +) + +func ResetArgs() { + *inFile = "" + *outFile = "" + *assetName = "" +} diff --git a/bootloader/assets/genasset/main.go b/bootloader/assets/genasset/main.go new file mode 100644 index 00000000..e609f5c2 --- /dev/null +++ b/bootloader/assets/genasset/main.go @@ -0,0 +1,160 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "bytes" + "flag" + "fmt" + "io" + "os" + "strconv" + "text/template" + "time" + + "github.com/snapcore/snapd/osutil" +) + +var assetTemplateText = `// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package assets + +// Code generated from {{ .InputFileName }} DO NOT EDIT + +func init() { + registerInternal("{{ .AssetName }}", []byte{ +{{ range .AssetDataLines }} {{ . }} +{{ end }} }) +} +` + +var inFile = flag.String("in", "", "asset input file") +var outFile = flag.String("out", "", "asset output file") +var assetName = flag.String("name", "", "asset name") +var assetTemplate = template.Must(template.New("asset").Parse(assetTemplateText)) + +// formatLines generates a list of strings, each carrying a line containing hex +// encoded data +func formatLines(data []byte) []string { + const lineBreak = 16 + + l := len(data) + lines := make([]string, 0, l/lineBreak+1) + for i := 0; i < l; i = i + lineBreak { + end := i + lineBreak + start := i + if end > l { + end = l + } + var line bytes.Buffer + forLine := data[start:end] + for idx, val := range forLine { + line.WriteString(fmt.Sprintf("0x%02x,", val)) + if idx != len(forLine)-1 { + line.WriteRune(' ') + } + } + lines = append(lines, line.String()) + } + return lines +} + +func run(assetName, inputFile, outputFile string) error { + inf, err := os.Open(inputFile) + if err != nil { + return fmt.Errorf("cannot open input file: %v", err) + } + defer inf.Close() + + var inData bytes.Buffer + if _, err := io.Copy(&inData, inf); err != nil { + return fmt.Errorf("cannot copy input data: %v", err) + } + + outf, err := osutil.NewAtomicFile(outputFile, 0644, 0, osutil.NoChown, osutil.NoChown) + if err != nil { + return fmt.Errorf("cannot open output file: %v", err) + } + defer outf.Cancel() + + templateData := struct { + Comment string + InputFileName string + AssetName string + AssetDataLines []string + Year string + }{ + InputFileName: inputFile, + // dealing with precise formatting in template is annoying thus + // we use a preformatted lines carrying asset data + AssetDataLines: formatLines(inData.Bytes()), + AssetName: assetName, + // XXX: The year is currently not used because it leads + // to spurious changes every year. Once we use something + // like real build-system we can re-enable this + Year: strconv.Itoa(time.Now().Year()), + } + if err := assetTemplate.Execute(outf, &templateData); err != nil { + return fmt.Errorf("cannot generate content: %v", err) + } + return outf.Commit() +} + +func parseArgs() error { + flag.Parse() + if *inFile == "" { + return fmt.Errorf("input file not provided") + } + if *outFile == "" { + return fmt.Errorf("output file not provided") + } + if *assetName == "" { + return fmt.Errorf("asset name not provided") + } + return nil +} + +func main() { + if err := parseArgs(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + if err := run(*assetName, *inFile, *outFile); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } +} diff --git a/bootloader/assets/genasset/main_test.go b/bootloader/assets/genasset/main_test.go new file mode 100644 index 00000000..99027f51 --- /dev/null +++ b/bootloader/assets/genasset/main_test.go @@ -0,0 +1,174 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" + + . "gopkg.in/check.v1" + + generate "github.com/snapcore/snapd/bootloader/assets/genasset" + "github.com/snapcore/snapd/testutil" +) + +// Hook up check.v1 into the "go test" runner +func Test(t *testing.T) { TestingT(t) } + +type generateAssetsTestSuite struct { + testutil.BaseTest +} + +var _ = Suite(&generateAssetsTestSuite{}) + +func (s *generateAssetsTestSuite) SetUpTest(c *C) { + s.BaseTest.SetUpTest(c) +} + +func mockArgs(args []string) (restore func()) { + old := os.Args + os.Args = args + return func() { + os.Args = old + } +} + +func (s *generateAssetsTestSuite) TestArgs(c *C) { + generate.ResetArgs() + restore := mockArgs([]string{"self", "-in", "ok", "-out", "ok", "-name", "assetname"}) + defer restore() + c.Assert(generate.ParseArgs(), IsNil) + // no input file + generate.ResetArgs() + restore = mockArgs([]string{"self", "-out", "ok", "-name", "assetname"}) + defer restore() + c.Assert(generate.ParseArgs(), ErrorMatches, "input file not provided") + // no output file + restore = mockArgs([]string{"self", "-in", "in", "-name", "assetname"}) + defer restore() + generate.ResetArgs() + c.Assert(generate.ParseArgs(), ErrorMatches, "output file not provided") + // no name + generate.ResetArgs() + restore = mockArgs([]string{"self", "-in", "in", "-out", "out"}) + defer restore() + c.Assert(generate.ParseArgs(), ErrorMatches, "asset name not provided") +} + +func (s *generateAssetsTestSuite) TestSimpleAsset(c *C) { + d := c.MkDir() + err := os.WriteFile(filepath.Join(d, "in"), []byte("this is a\n"+ + "multiline asset \"'``\nwith chars\n"), 0644) + c.Assert(err, IsNil) + err = generate.Run("asset-name", filepath.Join(d, "in"), filepath.Join(d, "out")) + c.Assert(err, IsNil) + data, err := os.ReadFile(filepath.Join(d, "out")) + c.Assert(err, IsNil) + + const exp = `// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package assets + +// Code generated from %s DO NOT EDIT + +func init() { + registerInternal("asset-name", []byte{ + 0x74, 0x68, 0x69, 0x73, 0x20, 0x69, 0x73, 0x20, 0x61, 0x0a, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x6c, + 0x69, 0x6e, 0x65, 0x20, 0x61, 0x73, 0x73, 0x65, 0x74, 0x20, 0x22, 0x27, 0x60, 0x60, 0x0a, 0x77, + 0x69, 0x74, 0x68, 0x20, 0x63, 0x68, 0x61, 0x72, 0x73, 0x0a, + }) +} +` + c.Check(string(data), Equals, fmt.Sprintf(exp, filepath.Join(d, "in"))) +} + +func (s *generateAssetsTestSuite) TestGoFmtClean(c *C) { + _, err := exec.LookPath("gofmt") + if err != nil { + c.Skip("gofmt is missing") + } + + d := c.MkDir() + err = os.WriteFile(filepath.Join(d, "in"), []byte("this is a\n"+ + "multiline asset \"'``\nuneven chars\n"), 0644) + c.Assert(err, IsNil) + err = generate.Run("asset-name", filepath.Join(d, "in"), filepath.Join(d, "out")) + c.Assert(err, IsNil) + + cmd := exec.Command("gofmt", "-l", "-d", filepath.Join(d, "out")) + out, err := cmd.CombinedOutput() + c.Assert(err, IsNil) + c.Assert(out, HasLen, 0, Commentf("output file is not gofmt clean: %s", string(out))) +} + +func (s *generateAssetsTestSuite) TestRunErrors(c *C) { + d := c.MkDir() + err := generate.Run("asset-name", filepath.Join(d, "missing"), filepath.Join(d, "out")) + c.Assert(err, ErrorMatches, "cannot open input file: open .*/missing: no such file or directory") + + err = os.WriteFile(filepath.Join(d, "in"), []byte("this is a\n"+ + "multiline asset \"'``\nuneven chars\n"), 0644) + c.Assert(err, IsNil) + + err = generate.Run("asset-name", filepath.Join(d, "in"), filepath.Join(d, "does-not-exist", "out")) + c.Assert(err, ErrorMatches, `cannot open output file: open .*/does-not-exist/out\..*: no such file or directory`) + +} + +func (s *generateAssetsTestSuite) TestFormatLines(c *C) { + out := generate.FormatLines(bytes.Repeat([]byte{1}, 12)) + c.Check(out, DeepEquals, []string{ + "0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01,", + }) + out = generate.FormatLines(bytes.Repeat([]byte{1}, 16)) + c.Check(out, DeepEquals, []string{ + "0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01,", + }) + out = generate.FormatLines(bytes.Repeat([]byte{1}, 17)) + c.Check(out, DeepEquals, []string{ + "0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01,", + "0x01,", + }) + out = generate.FormatLines(bytes.Repeat([]byte{1}, 1)) + c.Check(out, DeepEquals, []string{ + "0x01,", + }) +} diff --git a/bootloader/assets/generate.go b/bootloader/assets/generate.go new file mode 100644 index 00000000..825bbfa1 --- /dev/null +++ b/bootloader/assets/generate.go @@ -0,0 +1,23 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package assets + +//go:generate go run $GOINVOKEFLAGS ./genasset/main.go -name grub.cfg -in ./data/grub.cfg -out ./grub_cfg_asset.go +//go:generate go run $GOINVOKEFLAGS ./genasset/main.go -name grub-recovery.cfg -in ./data/grub-recovery.cfg -out ./grub_recovery_cfg_asset.go diff --git a/bootloader/assets/grub.go b/bootloader/assets/grub.go new file mode 100644 index 00000000..54888fdc --- /dev/null +++ b/bootloader/assets/grub.go @@ -0,0 +1,48 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package assets + +import ( + "github.com/snapcore/snapd/arch" +) + +var cmdlineForArch = map[string][]ForEditions{ + "amd64": { + {FirstEdition: 1, Snippet: []byte("console=ttyS0 console=tty1 panic=-1")}, + {FirstEdition: 3, Snippet: []byte("console=ttyS0,115200n8 console=tty1 panic=-1")}, + }, + "arm64": { + {FirstEdition: 1, Snippet: []byte("panic=-1")}, + }, + "i386": { + {FirstEdition: 1, Snippet: []byte("console=ttyS0 console=tty1 panic=-1")}, + {FirstEdition: 3, Snippet: []byte("console=ttyS0,115200n8 console=tty1 panic=-1")}, + }, +} + +func registerGrubSnippets() { + snippets := cmdlineForArch[arch.DpkgArchitecture()] + registerSnippetForEditions("grub.cfg:static-cmdline", snippets) + registerSnippetForEditions("grub-recovery.cfg:static-cmdline", snippets) +} + +func init() { + registerGrubSnippets() +} diff --git a/bootloader/assets/grub_cfg_asset.go b/bootloader/assets/grub_cfg_asset.go new file mode 100644 index 00000000..58386325 --- /dev/null +++ b/bootloader/assets/grub_cfg_asset.go @@ -0,0 +1,149 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package assets + +// Code generated from ./data/grub.cfg DO NOT EDIT + +func init() { + registerInternal("grub.cfg", []byte{ + 0x23, 0x20, 0x53, 0x6e, 0x61, 0x70, 0x64, 0x2d, 0x42, 0x6f, 0x6f, 0x74, 0x2d, 0x43, 0x6f, 0x6e, + 0x66, 0x69, 0x67, 0x2d, 0x45, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x3a, 0x20, 0x33, 0x0a, 0x0a, + 0x73, 0x65, 0x74, 0x20, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x3d, 0x30, 0x0a, 0x73, 0x65, + 0x74, 0x20, 0x74, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x3d, 0x33, 0x0a, 0x73, 0x65, 0x74, 0x20, + 0x74, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x5f, 0x73, 0x74, 0x79, 0x6c, 0x65, 0x3d, 0x68, 0x69, + 0x64, 0x64, 0x65, 0x6e, 0x0a, 0x0a, 0x23, 0x20, 0x6c, 0x6f, 0x61, 0x64, 0x20, 0x6f, 0x6e, 0x6c, + 0x79, 0x20, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x20, + 0x61, 0x6e, 0x64, 0x20, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x20, 0x63, 0x6f, 0x6d, 0x6d, 0x61, + 0x6e, 0x64, 0x20, 0x6c, 0x69, 0x6e, 0x65, 0x20, 0x76, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, + 0x73, 0x20, 0x73, 0x65, 0x74, 0x20, 0x62, 0x79, 0x20, 0x73, 0x6e, 0x61, 0x70, 0x64, 0x20, 0x66, + 0x72, 0x6f, 0x6d, 0x0a, 0x23, 0x20, 0x74, 0x68, 0x65, 0x20, 0x62, 0x6f, 0x6f, 0x74, 0x65, 0x6e, + 0x76, 0x0a, 0x6c, 0x6f, 0x61, 0x64, 0x5f, 0x65, 0x6e, 0x76, 0x20, 0x2d, 0x2d, 0x66, 0x69, 0x6c, + 0x65, 0x20, 0x2f, 0x45, 0x46, 0x49, 0x2f, 0x75, 0x62, 0x75, 0x6e, 0x74, 0x75, 0x2f, 0x67, 0x72, + 0x75, 0x62, 0x65, 0x6e, 0x76, 0x20, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x5f, 0x73, 0x74, 0x61, + 0x74, 0x75, 0x73, 0x20, 0x73, 0x6e, 0x61, 0x70, 0x64, 0x5f, 0x65, 0x78, 0x74, 0x72, 0x61, 0x5f, + 0x63, 0x6d, 0x64, 0x6c, 0x69, 0x6e, 0x65, 0x5f, 0x61, 0x72, 0x67, 0x73, 0x20, 0x73, 0x6e, 0x61, + 0x70, 0x64, 0x5f, 0x66, 0x75, 0x6c, 0x6c, 0x5f, 0x63, 0x6d, 0x64, 0x6c, 0x69, 0x6e, 0x65, 0x5f, + 0x61, 0x72, 0x67, 0x73, 0x0a, 0x0a, 0x73, 0x65, 0x74, 0x20, 0x73, 0x6e, 0x61, 0x70, 0x64, 0x5f, + 0x73, 0x74, 0x61, 0x74, 0x69, 0x63, 0x5f, 0x63, 0x6d, 0x64, 0x6c, 0x69, 0x6e, 0x65, 0x5f, 0x61, + 0x72, 0x67, 0x73, 0x3d, 0x27, 0x70, 0x61, 0x6e, 0x69, 0x63, 0x3d, 0x2d, 0x31, 0x27, 0x0a, 0x69, + 0x66, 0x20, 0x5b, 0x20, 0x22, 0x24, 0x67, 0x72, 0x75, 0x62, 0x5f, 0x63, 0x70, 0x75, 0x22, 0x20, + 0x3d, 0x20, 0x22, 0x78, 0x38, 0x36, 0x5f, 0x36, 0x34, 0x22, 0x20, 0x5d, 0x3b, 0x20, 0x74, 0x68, + 0x65, 0x6e, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x73, 0x65, 0x74, 0x20, 0x73, 0x6e, 0x61, 0x70, 0x64, + 0x5f, 0x73, 0x74, 0x61, 0x74, 0x69, 0x63, 0x5f, 0x63, 0x6d, 0x64, 0x6c, 0x69, 0x6e, 0x65, 0x5f, + 0x61, 0x72, 0x67, 0x73, 0x3d, 0x27, 0x63, 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x65, 0x3d, 0x74, 0x74, + 0x79, 0x53, 0x30, 0x2c, 0x31, 0x31, 0x35, 0x32, 0x30, 0x30, 0x6e, 0x38, 0x20, 0x63, 0x6f, 0x6e, + 0x73, 0x6f, 0x6c, 0x65, 0x3d, 0x74, 0x74, 0x79, 0x31, 0x20, 0x70, 0x61, 0x6e, 0x69, 0x63, 0x3d, + 0x2d, 0x31, 0x27, 0x0a, 0x66, 0x69, 0x0a, 0x73, 0x65, 0x74, 0x20, 0x63, 0x6d, 0x64, 0x6c, 0x69, + 0x6e, 0x65, 0x5f, 0x61, 0x72, 0x67, 0x73, 0x3d, 0x22, 0x24, 0x73, 0x6e, 0x61, 0x70, 0x64, 0x5f, + 0x73, 0x74, 0x61, 0x74, 0x69, 0x63, 0x5f, 0x63, 0x6d, 0x64, 0x6c, 0x69, 0x6e, 0x65, 0x5f, 0x61, + 0x72, 0x67, 0x73, 0x20, 0x24, 0x73, 0x6e, 0x61, 0x70, 0x64, 0x5f, 0x65, 0x78, 0x74, 0x72, 0x61, + 0x5f, 0x63, 0x6d, 0x64, 0x6c, 0x69, 0x6e, 0x65, 0x5f, 0x61, 0x72, 0x67, 0x73, 0x22, 0x0a, 0x69, + 0x66, 0x20, 0x5b, 0x20, 0x2d, 0x6e, 0x20, 0x22, 0x24, 0x73, 0x6e, 0x61, 0x70, 0x64, 0x5f, 0x66, + 0x75, 0x6c, 0x6c, 0x5f, 0x63, 0x6d, 0x64, 0x6c, 0x69, 0x6e, 0x65, 0x5f, 0x61, 0x72, 0x67, 0x73, + 0x22, 0x20, 0x5d, 0x3b, 0x20, 0x74, 0x68, 0x65, 0x6e, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x73, 0x65, + 0x74, 0x20, 0x63, 0x6d, 0x64, 0x6c, 0x69, 0x6e, 0x65, 0x5f, 0x61, 0x72, 0x67, 0x73, 0x3d, 0x22, + 0x24, 0x73, 0x6e, 0x61, 0x70, 0x64, 0x5f, 0x66, 0x75, 0x6c, 0x6c, 0x5f, 0x63, 0x6d, 0x64, 0x6c, + 0x69, 0x6e, 0x65, 0x5f, 0x61, 0x72, 0x67, 0x73, 0x22, 0x0a, 0x66, 0x69, 0x0a, 0x0a, 0x73, 0x65, + 0x74, 0x20, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x3d, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x2e, + 0x65, 0x66, 0x69, 0x0a, 0x0a, 0x69, 0x66, 0x20, 0x5b, 0x20, 0x22, 0x24, 0x6b, 0x65, 0x72, 0x6e, + 0x65, 0x6c, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0x20, 0x3d, 0x20, 0x22, 0x74, 0x72, + 0x79, 0x22, 0x20, 0x5d, 0x3b, 0x20, 0x74, 0x68, 0x65, 0x6e, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x23, + 0x20, 0x61, 0x20, 0x6e, 0x65, 0x77, 0x20, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x20, 0x67, 0x6f, + 0x74, 0x20, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6c, 0x6c, 0x65, 0x64, 0x0a, 0x20, 0x20, 0x20, 0x20, + 0x73, 0x65, 0x74, 0x20, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, + 0x73, 0x3d, 0x22, 0x74, 0x72, 0x79, 0x69, 0x6e, 0x67, 0x22, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x73, + 0x61, 0x76, 0x65, 0x5f, 0x65, 0x6e, 0x76, 0x20, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x5f, 0x73, + 0x74, 0x61, 0x74, 0x75, 0x73, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x23, 0x20, 0x72, 0x75, 0x6e, 0x20, + 0x66, 0x61, 0x6c, 0x6c, 0x62, 0x61, 0x63, 0x6b, 0x20, 0x28, 0x6d, 0x65, 0x6e, 0x75, 0x20, 0x65, + 0x6e, 0x74, 0x72, 0x79, 0x20, 0x23, 0x31, 0x29, 0x20, 0x69, 0x66, 0x20, 0x77, 0x65, 0x20, 0x63, + 0x61, 0x6e, 0x6e, 0x6f, 0x74, 0x20, 0x73, 0x74, 0x61, 0x72, 0x74, 0x20, 0x74, 0x68, 0x65, 0x20, + 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x73, 0x65, 0x74, 0x20, 0x66, + 0x61, 0x6c, 0x6c, 0x62, 0x61, 0x63, 0x6b, 0x3d, 0x31, 0x0a, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x23, + 0x20, 0x75, 0x73, 0x65, 0x20, 0x74, 0x72, 0x79, 0x2d, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x2e, + 0x65, 0x66, 0x69, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x73, 0x65, 0x74, 0x20, 0x6b, 0x65, 0x72, 0x6e, + 0x65, 0x6c, 0x3d, 0x74, 0x72, 0x79, 0x2d, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x2e, 0x65, 0x66, + 0x69, 0x0a, 0x65, 0x6c, 0x69, 0x66, 0x20, 0x5b, 0x20, 0x22, 0x24, 0x6b, 0x65, 0x72, 0x6e, 0x65, + 0x6c, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0x20, 0x3d, 0x20, 0x22, 0x74, 0x72, 0x79, + 0x69, 0x6e, 0x67, 0x22, 0x20, 0x5d, 0x3b, 0x20, 0x74, 0x68, 0x65, 0x6e, 0x0a, 0x20, 0x20, 0x20, + 0x20, 0x23, 0x20, 0x6e, 0x6f, 0x74, 0x68, 0x69, 0x6e, 0x67, 0x20, 0x63, 0x6c, 0x65, 0x61, 0x72, + 0x65, 0x64, 0x20, 0x74, 0x68, 0x65, 0x20, 0x22, 0x74, 0x72, 0x79, 0x69, 0x6e, 0x67, 0x20, 0x73, + 0x6e, 0x61, 0x70, 0x22, 0x20, 0x73, 0x6f, 0x20, 0x74, 0x68, 0x65, 0x20, 0x62, 0x6f, 0x6f, 0x74, + 0x20, 0x66, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x23, 0x20, 0x77, 0x65, + 0x20, 0x63, 0x6c, 0x65, 0x61, 0x72, 0x20, 0x74, 0x68, 0x65, 0x20, 0x6d, 0x6f, 0x64, 0x65, 0x20, + 0x61, 0x6e, 0x64, 0x20, 0x62, 0x6f, 0x6f, 0x74, 0x20, 0x6e, 0x6f, 0x72, 0x6d, 0x61, 0x6c, 0x6c, + 0x79, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x73, 0x65, 0x74, 0x20, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, + 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x3d, 0x22, 0x22, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x73, + 0x61, 0x76, 0x65, 0x5f, 0x65, 0x6e, 0x76, 0x20, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x5f, 0x73, + 0x74, 0x61, 0x74, 0x75, 0x73, 0x0a, 0x65, 0x6c, 0x69, 0x66, 0x20, 0x5b, 0x20, 0x2d, 0x6e, 0x20, + 0x22, 0x24, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, + 0x20, 0x5d, 0x3b, 0x20, 0x74, 0x68, 0x65, 0x6e, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x23, 0x20, 0x45, + 0x52, 0x52, 0x4f, 0x52, 0x20, 0x69, 0x6e, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x20, 0x6b, 0x65, 0x72, + 0x6e, 0x65, 0x6c, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x20, 0x73, 0x74, 0x61, 0x74, 0x65, + 0x2c, 0x20, 0x72, 0x65, 0x73, 0x65, 0x74, 0x20, 0x74, 0x6f, 0x20, 0x65, 0x6d, 0x70, 0x74, 0x79, + 0x0a, 0x20, 0x20, 0x20, 0x20, 0x65, 0x63, 0x68, 0x6f, 0x20, 0x22, 0x69, 0x6e, 0x76, 0x61, 0x6c, + 0x69, 0x64, 0x20, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, + 0x21, 0x21, 0x21, 0x22, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x65, 0x63, 0x68, 0x6f, 0x20, 0x22, 0x72, + 0x65, 0x73, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x20, 0x74, 0x6f, 0x20, 0x65, 0x6d, 0x70, 0x74, + 0x79, 0x22, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x73, 0x65, 0x74, 0x20, 0x6b, 0x65, 0x72, 0x6e, 0x65, + 0x6c, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x3d, 0x22, 0x22, 0x0a, 0x20, 0x20, 0x20, 0x20, + 0x73, 0x61, 0x76, 0x65, 0x5f, 0x65, 0x6e, 0x76, 0x20, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x5f, + 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x0a, 0x66, 0x69, 0x0a, 0x0a, 0x6d, 0x65, 0x6e, 0x75, 0x65, + 0x6e, 0x74, 0x72, 0x79, 0x20, 0x22, 0x52, 0x75, 0x6e, 0x20, 0x55, 0x62, 0x75, 0x6e, 0x74, 0x75, + 0x20, 0x43, 0x6f, 0x72, 0x65, 0x22, 0x20, 0x7b, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x23, 0x20, 0x75, + 0x73, 0x65, 0x20, 0x24, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x20, 0x62, 0x65, 0x63, 0x61, 0x75, + 0x73, 0x65, 0x20, 0x74, 0x68, 0x65, 0x20, 0x73, 0x79, 0x6d, 0x6c, 0x69, 0x6e, 0x6b, 0x20, 0x6d, + 0x61, 0x6e, 0x69, 0x70, 0x75, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x20, 0x61, 0x74, 0x20, 0x72, + 0x75, 0x6e, 0x74, 0x69, 0x6d, 0x65, 0x20, 0x66, 0x6f, 0x72, 0x20, 0x6b, 0x65, 0x72, 0x6e, 0x65, + 0x6c, 0x20, 0x73, 0x6e, 0x61, 0x70, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x23, 0x20, 0x75, 0x70, 0x67, + 0x72, 0x61, 0x64, 0x65, 0x73, 0x2c, 0x20, 0x65, 0x74, 0x63, 0x2e, 0x20, 0x73, 0x68, 0x6f, 0x75, + 0x6c, 0x64, 0x20, 0x6f, 0x6e, 0x6c, 0x79, 0x20, 0x6e, 0x65, 0x65, 0x64, 0x20, 0x74, 0x68, 0x65, + 0x20, 0x2f, 0x62, 0x6f, 0x6f, 0x74, 0x2f, 0x67, 0x72, 0x75, 0x62, 0x2f, 0x20, 0x64, 0x69, 0x72, + 0x65, 0x63, 0x74, 0x6f, 0x72, 0x79, 0x2c, 0x20, 0x6e, 0x6f, 0x74, 0x20, 0x74, 0x68, 0x65, 0x0a, + 0x20, 0x20, 0x20, 0x20, 0x23, 0x20, 0x2f, 0x45, 0x46, 0x49, 0x2f, 0x75, 0x62, 0x75, 0x6e, 0x74, + 0x75, 0x2f, 0x20, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x79, 0x0a, 0x20, 0x20, 0x20, + 0x20, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x6c, 0x6f, 0x61, 0x64, 0x65, 0x72, 0x20, 0x24, 0x70, 0x72, + 0x65, 0x66, 0x69, 0x78, 0x2f, 0x24, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x20, 0x73, 0x6e, 0x61, + 0x70, 0x64, 0x5f, 0x72, 0x65, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x79, 0x5f, 0x6d, 0x6f, 0x64, 0x65, + 0x3d, 0x72, 0x75, 0x6e, 0x20, 0x24, 0x63, 0x6d, 0x64, 0x6c, 0x69, 0x6e, 0x65, 0x5f, 0x61, 0x72, + 0x67, 0x73, 0x0a, 0x7d, 0x0a, 0x6d, 0x65, 0x6e, 0x75, 0x65, 0x6e, 0x74, 0x72, 0x79, 0x20, 0x22, + 0x46, 0x61, 0x6c, 0x6c, 0x62, 0x61, 0x63, 0x6b, 0x20, 0x6f, 0x6e, 0x20, 0x66, 0x61, 0x69, 0x6c, + 0x65, 0x64, 0x20, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x22, 0x20, 0x7b, 0x0a, 0x20, 0x20, 0x20, + 0x20, 0x23, 0x20, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, + 0x20, 0x68, 0x61, 0x73, 0x20, 0x61, 0x6c, 0x72, 0x65, 0x61, 0x64, 0x79, 0x20, 0x62, 0x65, 0x65, + 0x6e, 0x20, 0x73, 0x65, 0x74, 0x20, 0x74, 0x6f, 0x20, 0x22, 0x74, 0x72, 0x79, 0x69, 0x6e, 0x67, + 0x22, 0x2c, 0x20, 0x72, 0x65, 0x62, 0x6f, 0x6f, 0x74, 0x69, 0x6e, 0x67, 0x20, 0x6e, 0x6f, 0x77, + 0x0a, 0x20, 0x20, 0x20, 0x20, 0x23, 0x20, 0x77, 0x69, 0x6c, 0x6c, 0x20, 0x66, 0x61, 0x69, 0x6c, + 0x20, 0x74, 0x68, 0x65, 0x20, 0x70, 0x65, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x20, 0x6b, 0x65, 0x72, + 0x6e, 0x65, 0x6c, 0x20, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x2e, 0x20, 0x4e, 0x6f, 0x74, 0x65, + 0x20, 0x74, 0x68, 0x61, 0x74, 0x20, 0x77, 0x65, 0x20, 0x63, 0x61, 0x6e, 0x6e, 0x6f, 0x74, 0x20, + 0x73, 0x69, 0x6d, 0x70, 0x6c, 0x79, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x23, 0x20, 0x63, 0x68, 0x61, + 0x69, 0x6e, 0x6c, 0x6f, 0x61, 0x64, 0x20, 0x74, 0x68, 0x65, 0x20, 0x66, 0x61, 0x6c, 0x6c, 0x62, + 0x61, 0x63, 0x6b, 0x20, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x20, 0x61, 0x73, 0x20, 0x54, 0x50, + 0x4d, 0x20, 0x6d, 0x65, 0x61, 0x73, 0x75, 0x72, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x20, 0x6e, + 0x65, 0x65, 0x64, 0x20, 0x74, 0x6f, 0x20, 0x62, 0x65, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x23, 0x20, + 0x63, 0x6c, 0x65, 0x61, 0x6e, 0x65, 0x64, 0x2d, 0x75, 0x70, 0x20, 0x74, 0x6f, 0x20, 0x62, 0x65, + 0x20, 0x61, 0x62, 0x6c, 0x65, 0x20, 0x74, 0x6f, 0x20, 0x75, 0x6e, 0x73, 0x65, 0x61, 0x6c, 0x20, + 0x74, 0x68, 0x65, 0x20, 0x6b, 0x65, 0x79, 0x2e, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x65, 0x63, 0x68, + 0x6f, 0x20, 0x22, 0x43, 0x61, 0x6e, 0x6e, 0x6f, 0x74, 0x20, 0x73, 0x74, 0x61, 0x72, 0x74, 0x20, + 0x6e, 0x65, 0x77, 0x20, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x20, 0x2d, 0x20, 0x62, 0x6f, 0x6f, + 0x74, 0x69, 0x6e, 0x67, 0x20, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x20, 0x6f, 0x6e, + 0x65, 0x22, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x72, 0x65, 0x62, 0x6f, 0x6f, 0x74, 0x0a, 0x7d, 0x0a, + }) +} diff --git a/bootloader/assets/grub_recovery_cfg_asset.go b/bootloader/assets/grub_recovery_cfg_asset.go new file mode 100644 index 00000000..81ab668b --- /dev/null +++ b/bootloader/assets/grub_recovery_cfg_asset.go @@ -0,0 +1,208 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package assets + +// Code generated from ./data/grub-recovery.cfg DO NOT EDIT + +func init() { + registerInternal("grub-recovery.cfg", []byte{ + 0x23, 0x20, 0x53, 0x6e, 0x61, 0x70, 0x64, 0x2d, 0x42, 0x6f, 0x6f, 0x74, 0x2d, 0x43, 0x6f, 0x6e, + 0x66, 0x69, 0x67, 0x2d, 0x45, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x3a, 0x20, 0x32, 0x0a, 0x0a, + 0x73, 0x65, 0x74, 0x20, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x3d, 0x30, 0x0a, 0x73, 0x65, + 0x74, 0x20, 0x74, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x3d, 0x33, 0x0a, 0x73, 0x65, 0x74, 0x20, + 0x74, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x5f, 0x73, 0x74, 0x79, 0x6c, 0x65, 0x3d, 0x68, 0x69, + 0x64, 0x64, 0x65, 0x6e, 0x0a, 0x0a, 0x69, 0x66, 0x20, 0x5b, 0x20, 0x2d, 0x65, 0x20, 0x2f, 0x45, + 0x46, 0x49, 0x2f, 0x75, 0x62, 0x75, 0x6e, 0x74, 0x75, 0x2f, 0x67, 0x72, 0x75, 0x62, 0x65, 0x6e, + 0x76, 0x20, 0x5d, 0x3b, 0x20, 0x74, 0x68, 0x65, 0x6e, 0x0a, 0x20, 0x20, 0x20, 0x6c, 0x6f, 0x61, + 0x64, 0x5f, 0x65, 0x6e, 0x76, 0x20, 0x2d, 0x2d, 0x66, 0x69, 0x6c, 0x65, 0x20, 0x2f, 0x45, 0x46, + 0x49, 0x2f, 0x75, 0x62, 0x75, 0x6e, 0x74, 0x75, 0x2f, 0x67, 0x72, 0x75, 0x62, 0x65, 0x6e, 0x76, + 0x20, 0x73, 0x6e, 0x61, 0x70, 0x64, 0x5f, 0x72, 0x65, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x79, 0x5f, + 0x6d, 0x6f, 0x64, 0x65, 0x20, 0x73, 0x6e, 0x61, 0x70, 0x64, 0x5f, 0x72, 0x65, 0x63, 0x6f, 0x76, + 0x65, 0x72, 0x79, 0x5f, 0x73, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x0a, 0x66, 0x69, 0x0a, 0x0a, 0x23, + 0x20, 0x73, 0x74, 0x61, 0x6e, 0x64, 0x61, 0x72, 0x64, 0x20, 0x63, 0x6d, 0x64, 0x6c, 0x69, 0x6e, + 0x65, 0x20, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x73, 0x0a, 0x73, 0x65, 0x74, 0x20, 0x73, 0x6e, 0x61, + 0x70, 0x64, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x69, 0x63, 0x5f, 0x63, 0x6d, 0x64, 0x6c, 0x69, 0x6e, + 0x65, 0x5f, 0x61, 0x72, 0x67, 0x73, 0x3d, 0x27, 0x70, 0x61, 0x6e, 0x69, 0x63, 0x3d, 0x2d, 0x31, + 0x27, 0x0a, 0x0a, 0x23, 0x20, 0x69, 0x66, 0x20, 0x6e, 0x6f, 0x20, 0x64, 0x65, 0x66, 0x61, 0x75, + 0x6c, 0x74, 0x20, 0x62, 0x6f, 0x6f, 0x74, 0x20, 0x6d, 0x6f, 0x64, 0x65, 0x20, 0x73, 0x65, 0x74, + 0x2c, 0x20, 0x70, 0x69, 0x63, 0x6b, 0x20, 0x6f, 0x6e, 0x65, 0x0a, 0x69, 0x66, 0x20, 0x5b, 0x20, + 0x2d, 0x7a, 0x20, 0x22, 0x24, 0x73, 0x6e, 0x61, 0x70, 0x64, 0x5f, 0x72, 0x65, 0x63, 0x6f, 0x76, + 0x65, 0x72, 0x79, 0x5f, 0x6d, 0x6f, 0x64, 0x65, 0x22, 0x20, 0x5d, 0x3b, 0x20, 0x74, 0x68, 0x65, + 0x6e, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x73, 0x65, 0x74, 0x20, 0x73, 0x6e, 0x61, 0x70, 0x64, 0x5f, + 0x72, 0x65, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x79, 0x5f, 0x6d, 0x6f, 0x64, 0x65, 0x3d, 0x69, 0x6e, + 0x73, 0x74, 0x61, 0x6c, 0x6c, 0x0a, 0x66, 0x69, 0x0a, 0x0a, 0x69, 0x66, 0x20, 0x5b, 0x20, 0x22, + 0x24, 0x73, 0x6e, 0x61, 0x70, 0x64, 0x5f, 0x72, 0x65, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x79, 0x5f, + 0x6d, 0x6f, 0x64, 0x65, 0x22, 0x20, 0x3d, 0x20, 0x22, 0x72, 0x75, 0x6e, 0x22, 0x20, 0x5d, 0x3b, + 0x20, 0x74, 0x68, 0x65, 0x6e, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, + 0x74, 0x3d, 0x22, 0x72, 0x75, 0x6e, 0x22, 0x0a, 0x65, 0x6c, 0x69, 0x66, 0x20, 0x5b, 0x20, 0x2d, + 0x6e, 0x20, 0x22, 0x24, 0x73, 0x6e, 0x61, 0x70, 0x64, 0x5f, 0x72, 0x65, 0x63, 0x6f, 0x76, 0x65, + 0x72, 0x79, 0x5f, 0x73, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x22, 0x20, 0x5d, 0x3b, 0x20, 0x74, 0x68, + 0x65, 0x6e, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x3d, 0x24, + 0x73, 0x6e, 0x61, 0x70, 0x64, 0x5f, 0x72, 0x65, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x79, 0x5f, 0x6d, + 0x6f, 0x64, 0x65, 0x2d, 0x24, 0x73, 0x6e, 0x61, 0x70, 0x64, 0x5f, 0x72, 0x65, 0x63, 0x6f, 0x76, + 0x65, 0x72, 0x79, 0x5f, 0x73, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x0a, 0x66, 0x69, 0x0a, 0x0a, 0x73, + 0x65, 0x61, 0x72, 0x63, 0x68, 0x20, 0x2d, 0x2d, 0x6e, 0x6f, 0x2d, 0x66, 0x6c, 0x6f, 0x70, 0x70, + 0x79, 0x20, 0x2d, 0x2d, 0x73, 0x65, 0x74, 0x3d, 0x62, 0x6f, 0x6f, 0x74, 0x5f, 0x66, 0x73, 0x20, + 0x2d, 0x2d, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x20, 0x75, 0x62, 0x75, 0x6e, 0x74, 0x75, 0x2d, 0x62, + 0x6f, 0x6f, 0x74, 0x0a, 0x0a, 0x69, 0x66, 0x20, 0x5b, 0x20, 0x22, 0x24, 0x67, 0x72, 0x75, 0x62, + 0x5f, 0x63, 0x70, 0x75, 0x22, 0x20, 0x3d, 0x20, 0x22, 0x78, 0x38, 0x36, 0x5f, 0x36, 0x34, 0x22, + 0x20, 0x5d, 0x3b, 0x20, 0x74, 0x68, 0x65, 0x6e, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x73, 0x65, 0x74, + 0x20, 0x73, 0x6e, 0x61, 0x70, 0x64, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x69, 0x63, 0x5f, 0x63, 0x6d, + 0x64, 0x6c, 0x69, 0x6e, 0x65, 0x5f, 0x61, 0x72, 0x67, 0x73, 0x3d, 0x27, 0x63, 0x6f, 0x6e, 0x73, + 0x6f, 0x6c, 0x65, 0x3d, 0x74, 0x74, 0x79, 0x53, 0x30, 0x20, 0x63, 0x6f, 0x6e, 0x73, 0x6f, 0x6c, + 0x65, 0x3d, 0x74, 0x74, 0x79, 0x31, 0x20, 0x70, 0x61, 0x6e, 0x69, 0x63, 0x3d, 0x2d, 0x31, 0x27, + 0x0a, 0x20, 0x20, 0x20, 0x20, 0x67, 0x72, 0x75, 0x62, 0x5f, 0x62, 0x69, 0x6e, 0x61, 0x72, 0x79, + 0x3d, 0x22, 0x67, 0x72, 0x75, 0x62, 0x78, 0x36, 0x34, 0x2e, 0x65, 0x66, 0x69, 0x22, 0x0a, 0x65, + 0x6c, 0x69, 0x66, 0x20, 0x5b, 0x20, 0x22, 0x24, 0x67, 0x72, 0x75, 0x62, 0x5f, 0x63, 0x70, 0x75, + 0x22, 0x20, 0x3d, 0x20, 0x22, 0x61, 0x72, 0x6d, 0x36, 0x34, 0x22, 0x20, 0x5d, 0x3b, 0x20, 0x74, + 0x68, 0x65, 0x6e, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x67, 0x72, 0x75, 0x62, 0x5f, 0x62, 0x69, 0x6e, + 0x61, 0x72, 0x79, 0x3d, 0x22, 0x67, 0x72, 0x75, 0x62, 0x61, 0x61, 0x36, 0x34, 0x2e, 0x65, 0x66, + 0x69, 0x22, 0x0a, 0x65, 0x6c, 0x73, 0x65, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x65, 0x63, 0x68, 0x6f, + 0x20, 0x22, 0x24, 0x67, 0x72, 0x75, 0x62, 0x5f, 0x63, 0x70, 0x75, 0x22, 0x20, 0x22, 0x69, 0x73, + 0x20, 0x6e, 0x6f, 0x74, 0x20, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x65, 0x64, 0x22, 0x0a, + 0x20, 0x20, 0x20, 0x20, 0x67, 0x72, 0x75, 0x62, 0x5f, 0x62, 0x69, 0x6e, 0x61, 0x72, 0x79, 0x3d, + 0x22, 0x6e, 0x6f, 0x6e, 0x65, 0x22, 0x0a, 0x66, 0x69, 0x0a, 0x0a, 0x69, 0x66, 0x20, 0x5b, 0x20, + 0x2d, 0x6e, 0x20, 0x22, 0x24, 0x62, 0x6f, 0x6f, 0x74, 0x5f, 0x66, 0x73, 0x22, 0x20, 0x5d, 0x3b, + 0x20, 0x74, 0x68, 0x65, 0x6e, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x6d, 0x65, 0x6e, 0x75, 0x65, 0x6e, + 0x74, 0x72, 0x79, 0x20, 0x22, 0x43, 0x6f, 0x6e, 0x74, 0x69, 0x6e, 0x75, 0x65, 0x20, 0x74, 0x6f, + 0x20, 0x72, 0x75, 0x6e, 0x20, 0x6d, 0x6f, 0x64, 0x65, 0x22, 0x20, 0x2d, 0x2d, 0x68, 0x6f, 0x74, + 0x6b, 0x65, 0x79, 0x3d, 0x6e, 0x20, 0x2d, 0x2d, 0x69, 0x64, 0x3d, 0x72, 0x75, 0x6e, 0x20, 0x7b, + 0x0a, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x73, 0x65, 0x74, 0x20, 0x72, 0x6f, 0x6f, + 0x74, 0x3d, 0x28, 0x24, 0x62, 0x6f, 0x6f, 0x74, 0x5f, 0x66, 0x73, 0x29, 0x0a, 0x20, 0x20, 0x20, + 0x20, 0x20, 0x20, 0x20, 0x20, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x6c, 0x6f, 0x61, 0x64, 0x65, 0x72, + 0x20, 0x28, 0x24, 0x62, 0x6f, 0x6f, 0x74, 0x5f, 0x66, 0x73, 0x29, 0x2f, 0x45, 0x46, 0x49, 0x2f, + 0x62, 0x6f, 0x6f, 0x74, 0x2f, 0x22, 0x24, 0x67, 0x72, 0x75, 0x62, 0x5f, 0x62, 0x69, 0x6e, 0x61, + 0x72, 0x79, 0x22, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x7d, 0x0a, 0x66, 0x69, 0x0a, 0x0a, 0x23, 0x20, + 0x67, 0x6c, 0x6f, 0x62, 0x62, 0x69, 0x6e, 0x67, 0x20, 0x69, 0x6e, 0x20, 0x67, 0x72, 0x75, 0x62, + 0x20, 0x64, 0x6f, 0x65, 0x73, 0x20, 0x6e, 0x6f, 0x74, 0x20, 0x73, 0x6f, 0x72, 0x74, 0x0a, 0x66, + 0x6f, 0x72, 0x20, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x20, 0x69, 0x6e, 0x20, 0x2f, 0x73, 0x79, 0x73, + 0x74, 0x65, 0x6d, 0x73, 0x2f, 0x2a, 0x3b, 0x20, 0x64, 0x6f, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x23, + 0x20, 0x6d, 0x61, 0x74, 0x63, 0x68, 0x20, 0x74, 0x68, 0x65, 0x20, 0x73, 0x79, 0x73, 0x74, 0x65, + 0x6d, 0x20, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x20, 0x67, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, + 0x65, 0x64, 0x20, 0x62, 0x79, 0x20, 0x73, 0x6e, 0x61, 0x70, 0x64, 0x2c, 0x20, 0x77, 0x68, 0x69, + 0x63, 0x68, 0x20, 0x61, 0x72, 0x65, 0x20, 0x75, 0x73, 0x75, 0x61, 0x6c, 0x6c, 0x79, 0x20, 0x6a, + 0x75, 0x73, 0x74, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x23, 0x20, 0x6e, 0x75, 0x6d, 0x62, 0x65, 0x72, + 0x73, 0x2e, 0x20, 0x65, 0x67, 0x2e, 0x20, 0x32, 0x30, 0x32, 0x31, 0x30, 0x37, 0x30, 0x36, 0x2c, + 0x20, 0x62, 0x75, 0x74, 0x20, 0x63, 0x61, 0x6e, 0x20, 0x62, 0x65, 0x20, 0x68, 0x79, 0x70, 0x68, + 0x65, 0x6e, 0x20, 0x73, 0x65, 0x70, 0x61, 0x72, 0x61, 0x74, 0x65, 0x64, 0x20, 0x6e, 0x75, 0x6d, + 0x62, 0x65, 0x72, 0x73, 0x20, 0x61, 0x6e, 0x64, 0x20, 0x6c, 0x65, 0x74, 0x74, 0x65, 0x72, 0x73, + 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x66, 0x20, 0x21, 0x20, 0x72, 0x65, 0x67, 0x65, 0x78, 0x70, + 0x20, 0x2d, 0x2d, 0x73, 0x65, 0x74, 0x20, 0x31, 0x3a, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x20, 0x22, + 0x2f, 0x28, 0x5b, 0x61, 0x2d, 0x7a, 0x30, 0x2d, 0x39, 0x5d, 0x28, 0x2d, 0x3f, 0x5b, 0x61, 0x2d, + 0x7a, 0x30, 0x2d, 0x39, 0x5d, 0x29, 0x2a, 0x29, 0x5c, 0x24, 0x22, 0x20, 0x22, 0x24, 0x6c, 0x61, + 0x62, 0x65, 0x6c, 0x22, 0x3b, 0x20, 0x74, 0x68, 0x65, 0x6e, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x20, + 0x20, 0x20, 0x20, 0x63, 0x6f, 0x6e, 0x74, 0x69, 0x6e, 0x75, 0x65, 0x0a, 0x20, 0x20, 0x20, 0x20, + 0x66, 0x69, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x23, 0x20, 0x79, 0x65, 0x73, 0x2c, 0x20, 0x79, 0x6f, + 0x75, 0x20, 0x6e, 0x65, 0x65, 0x64, 0x20, 0x74, 0x6f, 0x20, 0x62, 0x61, 0x63, 0x6b, 0x73, 0x6c, + 0x61, 0x73, 0x68, 0x20, 0x74, 0x68, 0x61, 0x74, 0x20, 0x6c, 0x65, 0x73, 0x73, 0x2d, 0x74, 0x68, + 0x61, 0x6e, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x66, 0x20, 0x5b, 0x20, 0x2d, 0x7a, 0x20, 0x22, + 0x24, 0x62, 0x65, 0x73, 0x74, 0x22, 0x20, 0x2d, 0x6f, 0x20, 0x22, 0x24, 0x6c, 0x61, 0x62, 0x65, + 0x6c, 0x22, 0x20, 0x5c, 0x3c, 0x20, 0x22, 0x24, 0x62, 0x65, 0x73, 0x74, 0x22, 0x20, 0x5d, 0x3b, + 0x20, 0x74, 0x68, 0x65, 0x6e, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x73, 0x65, + 0x74, 0x20, 0x62, 0x65, 0x73, 0x74, 0x3d, 0x22, 0x24, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x22, 0x0a, + 0x20, 0x20, 0x20, 0x20, 0x66, 0x69, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x23, 0x20, 0x69, 0x66, 0x20, + 0x67, 0x72, 0x75, 0x62, 0x65, 0x6e, 0x76, 0x20, 0x64, 0x69, 0x64, 0x20, 0x6e, 0x6f, 0x74, 0x20, + 0x70, 0x69, 0x63, 0x6b, 0x20, 0x6d, 0x6f, 0x64, 0x65, 0x2d, 0x73, 0x79, 0x73, 0x74, 0x65, 0x6d, + 0x2c, 0x20, 0x75, 0x73, 0x65, 0x20, 0x62, 0x65, 0x73, 0x74, 0x20, 0x6f, 0x6e, 0x65, 0x0a, 0x20, + 0x20, 0x20, 0x20, 0x69, 0x66, 0x20, 0x5b, 0x20, 0x2d, 0x7a, 0x20, 0x22, 0x24, 0x73, 0x6e, 0x61, + 0x70, 0x64, 0x5f, 0x72, 0x65, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x79, 0x5f, 0x73, 0x79, 0x73, 0x74, + 0x65, 0x6d, 0x22, 0x20, 0x5d, 0x3b, 0x20, 0x74, 0x68, 0x65, 0x6e, 0x0a, 0x20, 0x20, 0x20, 0x20, + 0x20, 0x20, 0x20, 0x20, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x3d, 0x24, 0x73, 0x6e, 0x61, + 0x70, 0x64, 0x5f, 0x72, 0x65, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x79, 0x5f, 0x6d, 0x6f, 0x64, 0x65, + 0x2d, 0x24, 0x62, 0x65, 0x73, 0x74, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x66, 0x69, 0x0a, 0x20, 0x20, + 0x20, 0x20, 0x73, 0x65, 0x74, 0x20, 0x73, 0x6e, 0x61, 0x70, 0x64, 0x5f, 0x72, 0x65, 0x63, 0x6f, + 0x76, 0x65, 0x72, 0x79, 0x5f, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x3d, 0x0a, 0x20, 0x20, 0x20, + 0x20, 0x6c, 0x6f, 0x61, 0x64, 0x5f, 0x65, 0x6e, 0x76, 0x20, 0x2d, 0x2d, 0x66, 0x69, 0x6c, 0x65, + 0x20, 0x2f, 0x73, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x73, 0x2f, 0x24, 0x6c, 0x61, 0x62, 0x65, 0x6c, + 0x2f, 0x67, 0x72, 0x75, 0x62, 0x65, 0x6e, 0x76, 0x20, 0x73, 0x6e, 0x61, 0x70, 0x64, 0x5f, 0x72, + 0x65, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x79, 0x5f, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x20, 0x73, + 0x6e, 0x61, 0x70, 0x64, 0x5f, 0x65, 0x78, 0x74, 0x72, 0x61, 0x5f, 0x63, 0x6d, 0x64, 0x6c, 0x69, + 0x6e, 0x65, 0x5f, 0x61, 0x72, 0x67, 0x73, 0x20, 0x73, 0x6e, 0x61, 0x70, 0x64, 0x5f, 0x66, 0x75, + 0x6c, 0x6c, 0x5f, 0x63, 0x6d, 0x64, 0x6c, 0x69, 0x6e, 0x65, 0x5f, 0x61, 0x72, 0x67, 0x73, 0x0a, + 0x20, 0x20, 0x20, 0x20, 0x73, 0x65, 0x74, 0x20, 0x63, 0x6d, 0x64, 0x6c, 0x69, 0x6e, 0x65, 0x5f, + 0x61, 0x72, 0x67, 0x73, 0x3d, 0x22, 0x24, 0x73, 0x6e, 0x61, 0x70, 0x64, 0x5f, 0x73, 0x74, 0x61, + 0x74, 0x69, 0x63, 0x5f, 0x63, 0x6d, 0x64, 0x6c, 0x69, 0x6e, 0x65, 0x5f, 0x61, 0x72, 0x67, 0x73, + 0x20, 0x24, 0x73, 0x6e, 0x61, 0x70, 0x64, 0x5f, 0x65, 0x78, 0x74, 0x72, 0x61, 0x5f, 0x63, 0x6d, + 0x64, 0x6c, 0x69, 0x6e, 0x65, 0x5f, 0x61, 0x72, 0x67, 0x73, 0x22, 0x0a, 0x20, 0x20, 0x20, 0x20, + 0x69, 0x66, 0x20, 0x5b, 0x20, 0x2d, 0x6e, 0x20, 0x22, 0x24, 0x73, 0x6e, 0x61, 0x70, 0x64, 0x5f, + 0x66, 0x75, 0x6c, 0x6c, 0x5f, 0x63, 0x6d, 0x64, 0x6c, 0x69, 0x6e, 0x65, 0x5f, 0x61, 0x72, 0x67, + 0x73, 0x22, 0x20, 0x5d, 0x3b, 0x20, 0x74, 0x68, 0x65, 0x6e, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x20, + 0x20, 0x20, 0x73, 0x65, 0x74, 0x20, 0x63, 0x6d, 0x64, 0x6c, 0x69, 0x6e, 0x65, 0x5f, 0x61, 0x72, + 0x67, 0x73, 0x3d, 0x22, 0x24, 0x73, 0x6e, 0x61, 0x70, 0x64, 0x5f, 0x66, 0x75, 0x6c, 0x6c, 0x5f, + 0x63, 0x6d, 0x64, 0x6c, 0x69, 0x6e, 0x65, 0x5f, 0x61, 0x72, 0x67, 0x73, 0x22, 0x0a, 0x20, 0x20, + 0x20, 0x20, 0x66, 0x69, 0x0a, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x23, 0x20, 0x57, 0x65, 0x20, 0x63, + 0x6f, 0x75, 0x6c, 0x64, 0x20, 0x22, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x20, 0x2f, 0x73, 0x79, + 0x73, 0x74, 0x65, 0x6d, 0x73, 0x2f, 0x24, 0x73, 0x6e, 0x61, 0x70, 0x64, 0x5f, 0x72, 0x65, 0x63, + 0x6f, 0x76, 0x65, 0x72, 0x79, 0x5f, 0x73, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x2f, 0x67, 0x72, 0x75, + 0x62, 0x2e, 0x63, 0x66, 0x67, 0x22, 0x20, 0x68, 0x65, 0x72, 0x65, 0x20, 0x61, 0x73, 0x20, 0x77, + 0x65, 0x6c, 0x6c, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x6d, 0x65, 0x6e, 0x75, 0x65, 0x6e, 0x74, 0x72, + 0x79, 0x20, 0x22, 0x52, 0x65, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x20, 0x75, 0x73, 0x69, 0x6e, 0x67, + 0x20, 0x24, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x22, 0x20, 0x2d, 0x2d, 0x68, 0x6f, 0x74, 0x6b, 0x65, + 0x79, 0x3d, 0x72, 0x20, 0x2d, 0x2d, 0x69, 0x64, 0x3d, 0x72, 0x65, 0x63, 0x6f, 0x76, 0x65, 0x72, + 0x2d, 0x24, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x20, 0x24, 0x73, 0x6e, 0x61, 0x70, 0x64, 0x5f, 0x72, + 0x65, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x79, 0x5f, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x20, 0x72, + 0x65, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x20, 0x24, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x20, 0x7b, 0x0a, + 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x6c, 0x6f, 0x6f, 0x70, 0x62, 0x61, 0x63, 0x6b, + 0x20, 0x6c, 0x6f, 0x6f, 0x70, 0x20, 0x24, 0x32, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + 0x20, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x6c, 0x6f, 0x61, 0x64, 0x65, 0x72, 0x20, 0x28, 0x6c, 0x6f, + 0x6f, 0x70, 0x29, 0x2f, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x2e, 0x65, 0x66, 0x69, 0x20, 0x73, + 0x6e, 0x61, 0x70, 0x64, 0x5f, 0x72, 0x65, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x79, 0x5f, 0x6d, 0x6f, + 0x64, 0x65, 0x3d, 0x24, 0x33, 0x20, 0x73, 0x6e, 0x61, 0x70, 0x64, 0x5f, 0x72, 0x65, 0x63, 0x6f, + 0x76, 0x65, 0x72, 0x79, 0x5f, 0x73, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x3d, 0x24, 0x34, 0x20, 0x24, + 0x63, 0x6d, 0x64, 0x6c, 0x69, 0x6e, 0x65, 0x5f, 0x61, 0x72, 0x67, 0x73, 0x0a, 0x20, 0x20, 0x20, + 0x20, 0x7d, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x6d, 0x65, 0x6e, 0x75, 0x65, 0x6e, 0x74, 0x72, 0x79, + 0x20, 0x22, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6c, 0x6c, 0x20, 0x75, 0x73, 0x69, 0x6e, 0x67, 0x20, + 0x24, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x22, 0x20, 0x2d, 0x2d, 0x68, 0x6f, 0x74, 0x6b, 0x65, 0x79, + 0x3d, 0x69, 0x20, 0x2d, 0x2d, 0x69, 0x64, 0x3d, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6c, 0x6c, 0x2d, + 0x24, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x20, 0x24, 0x73, 0x6e, 0x61, 0x70, 0x64, 0x5f, 0x72, 0x65, + 0x63, 0x6f, 0x76, 0x65, 0x72, 0x79, 0x5f, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x20, 0x69, 0x6e, + 0x73, 0x74, 0x61, 0x6c, 0x6c, 0x20, 0x24, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x20, 0x7b, 0x0a, 0x20, + 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x6c, 0x6f, 0x6f, 0x70, 0x62, 0x61, 0x63, 0x6b, 0x20, + 0x6c, 0x6f, 0x6f, 0x70, 0x20, 0x24, 0x32, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + 0x63, 0x68, 0x61, 0x69, 0x6e, 0x6c, 0x6f, 0x61, 0x64, 0x65, 0x72, 0x20, 0x28, 0x6c, 0x6f, 0x6f, + 0x70, 0x29, 0x2f, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x2e, 0x65, 0x66, 0x69, 0x20, 0x73, 0x6e, + 0x61, 0x70, 0x64, 0x5f, 0x72, 0x65, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x79, 0x5f, 0x6d, 0x6f, 0x64, + 0x65, 0x3d, 0x24, 0x33, 0x20, 0x73, 0x6e, 0x61, 0x70, 0x64, 0x5f, 0x72, 0x65, 0x63, 0x6f, 0x76, + 0x65, 0x72, 0x79, 0x5f, 0x73, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x3d, 0x24, 0x34, 0x20, 0x24, 0x63, + 0x6d, 0x64, 0x6c, 0x69, 0x6e, 0x65, 0x5f, 0x61, 0x72, 0x67, 0x73, 0x0a, 0x20, 0x20, 0x20, 0x20, + 0x7d, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x6d, 0x65, 0x6e, 0x75, 0x65, 0x6e, 0x74, 0x72, 0x79, 0x20, + 0x22, 0x46, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x79, 0x20, 0x72, 0x65, 0x73, 0x65, 0x74, 0x20, 0x75, + 0x73, 0x69, 0x6e, 0x67, 0x20, 0x24, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x22, 0x20, 0x2d, 0x2d, 0x68, + 0x6f, 0x74, 0x6b, 0x65, 0x79, 0x3d, 0x69, 0x20, 0x2d, 0x2d, 0x69, 0x64, 0x3d, 0x66, 0x61, 0x63, + 0x74, 0x6f, 0x72, 0x79, 0x2d, 0x72, 0x65, 0x73, 0x65, 0x74, 0x2d, 0x24, 0x6c, 0x61, 0x62, 0x65, + 0x6c, 0x20, 0x24, 0x73, 0x6e, 0x61, 0x70, 0x64, 0x5f, 0x72, 0x65, 0x63, 0x6f, 0x76, 0x65, 0x72, + 0x79, 0x5f, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x20, 0x66, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x79, + 0x2d, 0x72, 0x65, 0x73, 0x65, 0x74, 0x20, 0x24, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x20, 0x7b, 0x0a, + 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x6c, 0x6f, 0x6f, 0x70, 0x62, 0x61, 0x63, 0x6b, + 0x20, 0x6c, 0x6f, 0x6f, 0x70, 0x20, 0x24, 0x32, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + 0x20, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x6c, 0x6f, 0x61, 0x64, 0x65, 0x72, 0x20, 0x28, 0x6c, 0x6f, + 0x6f, 0x70, 0x29, 0x2f, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x2e, 0x65, 0x66, 0x69, 0x20, 0x73, + 0x6e, 0x61, 0x70, 0x64, 0x5f, 0x72, 0x65, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x79, 0x5f, 0x6d, 0x6f, + 0x64, 0x65, 0x3d, 0x24, 0x33, 0x20, 0x73, 0x6e, 0x61, 0x70, 0x64, 0x5f, 0x72, 0x65, 0x63, 0x6f, + 0x76, 0x65, 0x72, 0x79, 0x5f, 0x73, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x3d, 0x24, 0x34, 0x20, 0x24, + 0x63, 0x6d, 0x64, 0x6c, 0x69, 0x6e, 0x65, 0x5f, 0x61, 0x72, 0x67, 0x73, 0x0a, 0x20, 0x20, 0x20, + 0x20, 0x7d, 0x0a, 0x64, 0x6f, 0x6e, 0x65, 0x0a, 0x0a, 0x6d, 0x65, 0x6e, 0x75, 0x65, 0x6e, 0x74, + 0x72, 0x79, 0x20, 0x27, 0x55, 0x45, 0x46, 0x49, 0x20, 0x46, 0x69, 0x72, 0x6d, 0x77, 0x61, 0x72, + 0x65, 0x20, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x27, 0x20, 0x2d, 0x2d, 0x68, 0x6f, + 0x74, 0x6b, 0x65, 0x79, 0x3d, 0x66, 0x20, 0x27, 0x75, 0x65, 0x66, 0x69, 0x2d, 0x66, 0x69, 0x72, + 0x6d, 0x77, 0x61, 0x72, 0x65, 0x27, 0x20, 0x7b, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x66, 0x77, 0x73, + 0x65, 0x74, 0x75, 0x70, 0x0a, 0x7d, 0x0a, + }) +} diff --git a/bootloader/assets/grub_test.go b/bootloader/assets/grub_test.go new file mode 100644 index 00000000..78583ec1 --- /dev/null +++ b/bootloader/assets/grub_test.go @@ -0,0 +1,167 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package assets_test + +import ( + "bytes" + "fmt" + "os" + "testing" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/arch/archtest" + "github.com/snapcore/snapd/bootloader/assets" + "github.com/snapcore/snapd/testutil" +) + +// Hook up check.v1 into the "go test" runner +func Test(t *testing.T) { TestingT(t) } + +type grubAssetsTestSuite struct { + testutil.BaseTest +} + +var _ = Suite(&grubAssetsTestSuite{}) + +func (s *grubAssetsTestSuite) SetUpTest(c *C) { + s.BaseTest.SetUpTest(c) + + // By default assume amd64 in the tests: there are specialized + // tests for other arches + s.AddCleanup(archtest.MockArchitecture("amd64")) + snippets := []assets.ForEditions{ + {FirstEdition: 1, Snippet: []byte("console=ttyS0 console=tty1 panic=-1")}, + {FirstEdition: 3, Snippet: []byte("console=ttyS0,115200n8 console=tty1 panic=-1")}, + } + s.AddCleanup(assets.MockSnippetsForEdition("grub.cfg:static-cmdline", snippets)) + s.AddCleanup(assets.MockSnippetsForEdition("grub-recovery.cfg:static-cmdline", snippets)) +} + +func (s *grubAssetsTestSuite) testGrubConfigContains(c *C, name string, edition int, keys ...string) { + a := assets.Internal(name) + c.Assert(a, NotNil) + as := string(a) + for _, canary := range keys { + c.Assert(as, testutil.Contains, canary) + } + idx := bytes.IndexRune(a, '\n') + c.Assert(idx, Not(Equals), -1) + prefix := fmt.Sprintf("# Snapd-Boot-Config-Edition: %d", edition) + c.Assert(string(a[:idx]), Equals, prefix) +} + +func (s *grubAssetsTestSuite) TestGrubConf(c *C) { + s.testGrubConfigContains(c, "grub.cfg", 3, + "snapd_recovery_mode", + "set snapd_static_cmdline_args='console=ttyS0,115200n8 console=tty1 panic=-1'", + ) +} + +func (s *grubAssetsTestSuite) TestGrubRecoveryConf(c *C) { + s.testGrubConfigContains(c, "grub-recovery.cfg", 2, + "snapd_recovery_mode", + "snapd_recovery_system", + "set snapd_static_cmdline_args='console=ttyS0 console=tty1 panic=-1'", + ) +} + +func (s *grubAssetsTestSuite) TestGrubCmdlineSnippetEditions(c *C) { + for _, tc := range []struct { + asset string + edition uint + snip []byte + }{ + {"grub.cfg:static-cmdline", 1, []byte("console=ttyS0 console=tty1 panic=-1")}, + {"grub-recovery.cfg:static-cmdline", 1, []byte("console=ttyS0 console=tty1 panic=-1")}, + } { + snip := assets.SnippetForEdition(tc.asset, tc.edition) + c.Assert(snip, NotNil) + c.Check(snip, DeepEquals, tc.snip) + } +} + +func (s *grubAssetsTestSuite) TestGrubCmdlineSnippetEditionsForArm64(c *C) { + r := archtest.MockArchitecture("arm64") + defer r() + // Make sure to revert later to the prev arch snippets + r = assets.MockCleanState() + defer r() + assets.RegisterGrubSnippets() + for _, tc := range []struct { + asset string + edition uint + snip []byte + }{ + {"grub.cfg:static-cmdline", 1, []byte("panic=-1")}, + {"grub-recovery.cfg:static-cmdline", 1, []byte("panic=-1")}, + } { + snip := assets.SnippetForEdition(tc.asset, tc.edition) + c.Assert(snip, NotNil) + c.Check(snip, DeepEquals, tc.snip) + } +} + +func (s *grubAssetsTestSuite) TestGrubCmdlineSnippetCrossCheck(c *C) { + for _, tc := range []struct { + asset string + snippet string + edition uint + content []byte + pattern string + }{ + { + asset: "grub.cfg", snippet: "grub.cfg:static-cmdline", edition: 3, + content: []byte("console=ttyS0,115200n8 console=tty1 panic=-1"), + pattern: "set snapd_static_cmdline_args='%s'\n", + }, + { + asset: "grub-recovery.cfg", snippet: "grub-recovery.cfg:static-cmdline", edition: 2, + content: []byte("console=ttyS0 console=tty1 panic=-1"), + pattern: "set snapd_static_cmdline_args='%s'\n", + }, + } { + grubCfg := assets.Internal(tc.asset) + c.Assert(grubCfg, NotNil) + prefix := fmt.Sprintf("# Snapd-Boot-Config-Edition: %d", tc.edition) + c.Assert(bytes.HasPrefix(grubCfg, []byte(prefix)), Equals, true) + // get a matching snippet + snip := assets.SnippetForEdition(tc.snippet, tc.edition) + c.Assert(snip, NotNil) + c.Assert(snip, DeepEquals, tc.content, Commentf("%s: '%s' != '%s'", tc.asset, snip, tc.content)) + c.Assert(string(grubCfg), testutil.Contains, fmt.Sprintf(tc.pattern, string(snip))) + } +} + +func (s *grubAssetsTestSuite) TestGrubAssetsWereRegenerated(c *C) { + for _, tc := range []struct { + asset string + file string + }{ + {"grub.cfg", "data/grub.cfg"}, + {"grub-recovery.cfg", "data/grub-recovery.cfg"}, + } { + assetData := assets.Internal(tc.asset) + c.Assert(assetData, NotNil) + data, err := os.ReadFile(tc.file) + c.Assert(err, IsNil) + c.Check(assetData, DeepEquals, data, Commentf("asset %q has not been updated", tc.asset)) + } +} diff --git a/bootloader/bootloader.go b/bootloader/bootloader.go new file mode 100644 index 00000000..3825a1bb --- /dev/null +++ b/bootloader/bootloader.go @@ -0,0 +1,470 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2014-2021 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package bootloader + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/snapcore/snapd/bootloader/assets" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/osutil/kcmdline" + "github.com/snapcore/snapd/snap" +) + +var ( + // ErrBootloader is returned if the bootloader can not be determined. + ErrBootloader = errors.New("cannot determine bootloader") + + // ErrNoTryKernelRef is returned if the bootloader finds no enabled + // try-kernel. + ErrNoTryKernelRef = errors.New("no try-kernel referenced") +) + +// Role indicates whether the bootloader is used for recovery or run mode. +type Role string + +const ( + // RoleSole applies to the sole bootloader used by UC16/18. + RoleSole Role = "" + // RoleRunMode applies to the run mode booloader. + RoleRunMode Role = "run-mode" + // RoleRecovery apllies to the recovery bootloader. + RoleRecovery Role = "recovery" +) + +// Options carries bootloader options. +type Options struct { + // PrepareImageTime indicates whether the booloader is being + // used at prepare-image time, that means not on a runtime + // system. + PrepareImageTime bool `json:"prepare-image-time,omitempty"` + + // Role specifies to use the bootloader for the given role. + Role Role `json:"role,omitempty"` + + // NoSlashBoot indicates to use the native layout of the + // bootloader partition and not the /boot mount. + // It applies only for RoleRunMode. + // It is implied and ignored for RoleRecovery. + // It is an error to set it for RoleSole. + NoSlashBoot bool `json:"no-slash-boot,omitempty"` +} + +func (o *Options) validate() error { + if o == nil { + return nil + } + if o.NoSlashBoot && o.Role == RoleSole { + return fmt.Errorf("internal error: bootloader.RoleSole doesn't expect NoSlashBoot set") + } + if o.PrepareImageTime && o.Role == RoleRunMode { + return fmt.Errorf("internal error: cannot use run mode bootloader at prepare-image time") + } + return nil +} + +// Bootloader provides an interface to interact with the system +// bootloader. +type Bootloader interface { + // Return the value of the specified bootloader variable. + GetBootVars(names ...string) (map[string]string, error) + + // Set the value of the specified bootloader variable. + SetBootVars(values map[string]string) error + + // Name returns the bootloader name. + Name() string + + // Present returns whether the bootloader is currently present on the + // system - in other words whether this bootloader has been installed to the + // current system. Implementations should only return non-nil error if they + // can positively identify that the bootloader is installed, but there is + // actually an error with the installation. + Present() (bool, error) + + // InstallBootConfig will try to install the boot config in the + // given gadgetDir to rootdir. + InstallBootConfig(gadgetDir string, opts *Options) error + + // ExtractKernelAssets extracts kernel assets from the given kernel snap. + ExtractKernelAssets(s snap.PlaceInfo, snapf snap.Container) error + + // RemoveKernelAssets removes the assets for the given kernel snap. + RemoveKernelAssets(s snap.PlaceInfo) error +} + +type RecoveryAwareBootloader interface { + Bootloader + SetRecoverySystemEnv(recoverySystemDir string, values map[string]string) error + GetRecoverySystemEnv(recoverySystemDir string, key string) (string, error) +} + +type ExtractedRecoveryKernelImageBootloader interface { + Bootloader + ExtractRecoveryKernelAssets(recoverySystemDir string, s snap.PlaceInfo, snapf snap.Container) error +} + +// ExtractedRunKernelImageBootloader is a Bootloader that also supports specific +// methods needed to setup booting from an extracted kernel, which is needed to +// implement encryption and/or secure boot. Prototypical implementation is UC20 +// grub implementation with FDE. +type ExtractedRunKernelImageBootloader interface { + Bootloader + + // EnableKernel enables the specified kernel on ubuntu-boot to be used + // during normal boots. The specified kernel should already have been + // extracted. This is usually implemented with a "kernel.efi" symlink + // pointing to the extracted kernel image. + EnableKernel(snap.PlaceInfo) error + + // EnableTryKernel enables the specified kernel on ubuntu-boot to be + // tried by the bootloader on a reboot, to be used in conjunction with + // setting "kernel_status" to "try". The specified kernel should already + // have been extracted. This is usually implemented with a + // "try-kernel.efi" symlink pointing to the extracted kernel image. + EnableTryKernel(snap.PlaceInfo) error + + // Kernel returns the current enabled kernel on the bootloader, not + // necessarily the kernel that was used to boot the current session, but the + // kernel that is enabled to boot on "normal" boots. + // If error is not nil, the first argument shall be non-nil. + Kernel() (snap.PlaceInfo, error) + + // TryKernel returns the current enabled try-kernel on the bootloader, if + // there is no such enabled try-kernel, then ErrNoTryKernelRef is returned. + // If error is not nil, the first argument shall be non-nil. + TryKernel() (snap.PlaceInfo, error) + + // DisableTryKernel disables the current enabled try-kernel on the + // bootloader, if it exists. It does not need to return an error if the + // enabled try-kernel does not exist or is in an inconsistent state before + // disabling it, errors should only be returned when the implementation + // fails to disable the try-kernel. + DisableTryKernel() error +} + +// ComamndLineComponents carries the components of the kernel command line. The +// bootloader is expected to combine the provided components, optionally +// including its built-in static set of arguments, and produce a command line +// that will be passed to the kernel during boot. +type CommandLineComponents struct { + // Argument related to mode selection. + ModeArg string + // Argument related to recovery system selection, relevant for given + // mode argument. + SystemArg string + // Extra arguments requested by the system. + ExtraArgs string + // A complete set of arguments that overrides both the built-in static + // set and ExtraArgs. Note that, it is an error if extra and full + // arguments are non-empty. + FullArgs string + // A list of patterns to remove arguments from the default command line + RemoveArgs []kcmdline.ArgumentPattern +} + +func (c *CommandLineComponents) Validate() error { + if c.ExtraArgs != "" && c.FullArgs != "" { + return fmt.Errorf("cannot use both full and extra components of command line") + } + return nil +} + +// TrustedAssetsBootloader has boot assets that take part in the secure boot +// process and need to be tracked, while other boot assets (typically boot +// config) are managed by snapd. +type TrustedAssetsBootloader interface { + Bootloader + + // ManagedAssets returns a list of boot assets managed by the bootloader + // in the boot filesystem. Does not require rootdir to be set. + ManagedAssets() []string + // UpdateBootConfig attempts to update the boot config assets used by + // the bootloader. Returns true when assets were updated. + UpdateBootConfig() (bool, error) + // CommandLine returns the kernel command line composed of mode and + // system arguments, followed by either a built-in bootloader specific + // static arguments corresponding to the on-disk boot asset edition, and + // any extra arguments or a separate set of arguments provided in the + // components. The command line may be different when using a recovery + // bootloader. + CommandLine(pieces CommandLineComponents) (string, error) + // CandidateCommandLine is similar to CommandLine, but uses the current + // edition of managed built-in boot assets as reference. + CandidateCommandLine(pieces CommandLineComponents) (string, error) + + // DefaultCommandLine returns the default kernel command-line + // used by the bootloader excluding the recovery mode and + // system parameters. + DefaultCommandLine(candidate bool) (string, error) + + // TrustedAssets returns a map of relative paths to asset + // identifers. The paths are inside the bootloader's rootdir + // that are measured in the boot process. The asset + // identifiers correspond to the backward compatible names + // recorded in the modeenv (CurrentTrustedBootAssets and + // CurrentTrustedRecoveryBootAssets). + TrustedAssets() (map[string]string, error) + + // RecoveryBootChains returns the possible load chains for + // recovery modes. It should be called on a RoleRecovery + // bootloader. + RecoveryBootChains(kernelPath string) ([][]BootFile, error) + + // BootChains returns the possible load chains for run mode. + // It should be called on a RoleRecovery bootloader passing + // the RoleRunMode bootloader. + BootChains(runBl Bootloader, kernelPath string) ([][]BootFile, error) +} + +// NotScriptableBootloader cannot change the bootloader environment +// because it supports no scripting or cannot do any writes. This +// applies to piboot for the moment. +type NotScriptableBootloader interface { + Bootloader + + // Sets boot variables from initramfs - this is needed in + // addition to SetBootVars() to prevent side effects like + // re-writing the bootloader configuration. + SetBootVarsFromInitramfs(values map[string]string) error +} + +// RebootBootloader needs arguments to the reboot syscall when snaps +// are being updated. +type RebootBootloader interface { + Bootloader + + // GetRebootArguments returns the needed reboot arguments + GetRebootArguments() (string, error) +} + +func genericInstallBootConfig(gadgetFile, systemFile string) error { + if err := os.MkdirAll(filepath.Dir(systemFile), 0755); err != nil { + return err + } + return osutil.CopyFile(gadgetFile, systemFile, osutil.CopyFlagOverwrite) +} + +func genericSetBootConfigFromAsset(systemFile, assetName string) error { + bootConfig := assets.Internal(assetName) + if bootConfig == nil { + return fmt.Errorf("internal error: no boot asset for %q", assetName) + } + if err := os.MkdirAll(filepath.Dir(systemFile), 0755); err != nil { + return err + } + return osutil.AtomicWriteFile(systemFile, bootConfig, 0644, 0) +} + +func genericUpdateBootConfigFromAssets(systemFile string, assetName string) (updated bool, err error) { + currentBootConfigEdition, err := editionFromDiskConfigAsset(systemFile) + if err != nil && err != errNoEdition { + return false, err + } + if err == errNoEdition { + return false, nil + } + newBootConfig := assets.Internal(assetName) + if len(newBootConfig) == 0 { + return false, fmt.Errorf("no boot config asset with name %q", assetName) + } + bc, err := configAssetFrom(newBootConfig) + if err != nil { + return false, err + } + if bc.Edition() <= currentBootConfigEdition { + // edition of the candidate boot config is lower than or equal + // to one currently installed + return false, nil + } + if err := osutil.AtomicWriteFile(systemFile, bc.Raw(), 0644, 0); err != nil { + return false, err + } + return true, nil +} + +// InstallBootConfig installs the bootloader config from the gadget +// snap dir into the right place. +func InstallBootConfig(gadgetDir, rootDir string, opts *Options) error { + if err := opts.validate(); err != nil { + return err + } + bl, err := ForGadget(gadgetDir, rootDir, opts) + if err != nil { + return fmt.Errorf("cannot find boot config in %q", gadgetDir) + } + return bl.InstallBootConfig(gadgetDir, opts) +} + +type bootloaderNewFunc func(rootdir string, opts *Options) Bootloader + +var ( + // bootloaders list all possible bootloaders by their constructor + // function. + bootloaders = []bootloaderNewFunc{ + newUboot, + newGrub, + newAndroidBoot, + newLk, + newPiboot, + } +) + +var ( + forcedBootloader Bootloader + forcedError error +) + +// Find returns the bootloader for the system +// or an error if no bootloader is found. +// +// The rootdir option is useful for image creation operations. It +// can also be used to find the recovery bootloader, e.g. on uc20: +// +// bootloader.Find("/run/mnt/ubuntu-seed") +func Find(rootdir string, opts *Options) (Bootloader, error) { + if err := opts.validate(); err != nil { + return nil, err + } + if forcedBootloader != nil || forcedError != nil { + return forcedBootloader, forcedError + } + + if rootdir == "" { + rootdir = dirs.GlobalRootDir + } + if opts == nil { + opts = &Options{} + } + + // note that the order of this is not deterministic + for _, blNew := range bootloaders { + bl := blNew(rootdir, opts) + present, err := bl.Present() + if err != nil { + return nil, fmt.Errorf("bootloader %q found but not usable: %v", bl.Name(), err) + } + if present { + return bl, nil + } + } + // no, weeeee + return nil, ErrBootloader +} + +// Force can be used to force Find to always find the specified bootloader; use +// nil to reset to normal lookup. +func Force(booloader Bootloader) { + forcedBootloader = booloader + forcedError = nil +} + +// ForceError can be used to force Find to return an error; use nil to +// reset to normal lookup. +func ForceError(err error) { + forcedBootloader = nil + forcedError = err +} + +func extractKernelAssetsToBootDir(dstDir string, snapf snap.Container, assets []string) error { + // now do the kernel specific bits + if err := os.MkdirAll(dstDir, 0755); err != nil { + return err + } + dir, err := os.Open(dstDir) + if err != nil { + return err + } + defer dir.Close() + + for _, src := range assets { + if err := snapf.Unpack(src, dstDir); err != nil { + return err + } + if err := dir.Sync(); err != nil { + return err + } + } + return nil +} + +func removeKernelAssetsFromBootDir(bootDir string, s snap.PlaceInfo) error { + // remove the kernel blob + blobName := s.Filename() + dstDir := filepath.Join(bootDir, blobName) + if err := os.RemoveAll(dstDir); err != nil { + return err + } + + return nil +} + +// ForGadget returns a bootloader matching a given gadget by inspecting the +// contents of gadget directory or an error if no matching bootloader is found. +func ForGadget(gadgetDir, rootDir string, opts *Options) (Bootloader, error) { + if err := opts.validate(); err != nil { + return nil, err + } + if forcedBootloader != nil || forcedError != nil { + return forcedBootloader, forcedError + } + for _, blNew := range bootloaders { + bl := blNew(rootDir, opts) + markerConf := filepath.Join(gadgetDir, bl.Name()+".conf") + // do we have a marker file? + if osutil.FileExists(markerConf) { + return bl, nil + } + } + return nil, ErrBootloader +} + +// BootFile represents each file in the chains of trusted assets and +// kernels used in the boot process. For example a boot file can be an +// EFI binary or a snap file containing an EFI binary. +type BootFile struct { + // Path is the path to the file in the filesystem or, if Snap + // is set, the relative path inside the snap file. + Path string + // Snap contains the path to the snap file if a snap file is used. + Snap string + // Role is set to the role of the bootloader this boot file + // originates from. + Role Role +} + +func NewBootFile(snap, path string, role Role) BootFile { + return BootFile{ + Snap: snap, + Path: path, + Role: role, + } +} + +// WithPath returns a copy of the BootFile with path updated to the +// specified value. +func (b BootFile) WithPath(path string) BootFile { + b.Path = path + return b +} diff --git a/bootloader/bootloader_test.go b/bootloader/bootloader_test.go new file mode 100644 index 00000000..d8a78930 --- /dev/null +++ b/bootloader/bootloader_test.go @@ -0,0 +1,366 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2014-2015 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package bootloader_test + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "testing" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/bootloader" + "github.com/snapcore/snapd/bootloader/assets" + "github.com/snapcore/snapd/bootloader/bootloadertest" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/testutil" +) + +// Hook up check.v1 into the "go test" runner +func Test(t *testing.T) { TestingT(t) } + +const packageKernel = ` +name: ubuntu-kernel +version: 4.0-1 +type: kernel +vendor: Someone +` + +type baseBootenvTestSuite struct { + testutil.BaseTest + + rootdir string +} + +func (s *baseBootenvTestSuite) SetUpTest(c *C) { + s.BaseTest.SetUpTest(c) + s.AddCleanup(snap.MockSanitizePlugsSlots(func(snapInfo *snap.Info) {})) + s.rootdir = c.MkDir() + dirs.SetRootDir(s.rootdir) + s.AddCleanup(func() { dirs.SetRootDir("") }) +} + +type bootenvTestSuite struct { + baseBootenvTestSuite + + b *bootloadertest.MockBootloader +} + +var _ = Suite(&bootenvTestSuite{}) + +func (s *bootenvTestSuite) SetUpTest(c *C) { + s.baseBootenvTestSuite.SetUpTest(c) + + s.b = bootloadertest.Mock("mocky", c.MkDir()) +} + +func (s *bootenvTestSuite) TestForceBootloader(c *C) { + bootloader.Force(s.b) + defer bootloader.Force(nil) + + got, err := bootloader.Find("", nil) + c.Assert(err, IsNil) + c.Check(got, Equals, s.b) +} + +func (s *bootenvTestSuite) TestForceBootloaderError(c *C) { + myErr := errors.New("zap") + bootloader.ForceError(myErr) + defer bootloader.ForceError(nil) + + got, err := bootloader.Find("", nil) + c.Assert(err, Equals, myErr) + c.Check(got, IsNil) +} + +func (s *bootenvTestSuite) TestInstallBootloaderConfigNoConfig(c *C) { + err := bootloader.InstallBootConfig(c.MkDir(), s.rootdir, nil) + c.Assert(err, ErrorMatches, `cannot find boot config in.*`) +} + +func (s *bootenvTestSuite) TestInstallBootloaderConfigFromGadget(c *C) { + for _, t := range []struct { + name string + gadgetFile, sysFile string + gadgetFileContent []byte + opts *bootloader.Options + }{ + {name: "grub", gadgetFile: "grub.conf", sysFile: "/boot/grub/grub.cfg"}, + // traditional uboot.env - the uboot.env file needs to be non-empty + {name: "uboot.env", gadgetFile: "uboot.conf", sysFile: "/boot/uboot/uboot.env", gadgetFileContent: []byte{1}}, + // boot.scr in place of uboot.env means we create the boot.sel file + { + name: "uboot boot.scr", + gadgetFile: "uboot.conf", + sysFile: "/uboot/ubuntu/boot.sel", + opts: &bootloader.Options{Role: bootloader.RoleRecovery}, + }, + {name: "androidboot", gadgetFile: "androidboot.conf", sysFile: "/boot/androidboot/androidboot.env"}, + {name: "lk", gadgetFile: "lk.conf", sysFile: "/boot/lk/snapbootsel.bin", opts: &bootloader.Options{PrepareImageTime: true}}, + { + name: "piboot", + gadgetFile: "piboot.conf", + sysFile: "/boot/piboot/piboot.conf", + }, + } { + mockGadgetDir := c.MkDir() + rootDir := c.MkDir() + err := os.WriteFile(filepath.Join(mockGadgetDir, t.gadgetFile), t.gadgetFileContent, 0644) + c.Assert(err, IsNil) + err = bootloader.InstallBootConfig(mockGadgetDir, rootDir, t.opts) + c.Assert(err, IsNil, Commentf("installing boot config for %s", t.name)) + fn := filepath.Join(rootDir, t.sysFile) + c.Assert(fn, testutil.FilePresent, Commentf("boot config missing for %s at %s", t.name, t.sysFile)) + } +} + +func (s *bootenvTestSuite) TestInstallBootloaderConfigFromAssets(c *C) { + recoveryOpts := &bootloader.Options{ + Role: bootloader.RoleRecovery, + } + systemBootOpts := &bootloader.Options{ + Role: bootloader.RoleRunMode, + } + defaultRecoveryGrubAsset := assets.Internal("grub-recovery.cfg") + c.Assert(defaultRecoveryGrubAsset, NotNil) + defaultGrubAsset := assets.Internal("grub.cfg") + c.Assert(defaultGrubAsset, NotNil) + + for _, t := range []struct { + name string + gadgetFile, sysFile string + gadgetFileContent []byte + sysFileContent []byte + assetContent []byte + assetName string + err string + opts *bootloader.Options + }{ + { + name: "recovery grub", + opts: recoveryOpts, + gadgetFile: "grub.conf", + // empty file in the gadget + gadgetFileContent: nil, + sysFile: "/EFI/ubuntu/grub.cfg", + assetName: "grub-recovery.cfg", + assetContent: []byte("hello assets"), + // boot config from assets + sysFileContent: []byte("hello assets"), + }, { + name: "recovery grub with non empty gadget file", + opts: recoveryOpts, + gadgetFile: "grub.conf", + gadgetFileContent: []byte("not so empty"), + sysFile: "/EFI/ubuntu/grub.cfg", + assetName: "grub-recovery.cfg", + assetContent: []byte("hello assets"), + // boot config from assets + sysFileContent: []byte("hello assets"), + }, { + name: "recovery grub with default asset", + opts: recoveryOpts, + gadgetFile: "grub.conf", + // empty file in the gadget + gadgetFileContent: nil, + sysFile: "/EFI/ubuntu/grub.cfg", + sysFileContent: defaultRecoveryGrubAsset, + }, { + name: "recovery grub missing asset", + opts: recoveryOpts, + gadgetFile: "grub.conf", + // empty file in the gadget + gadgetFileContent: nil, + sysFile: "/EFI/ubuntu/grub.cfg", + assetName: "grub-recovery.cfg", + // no asset content + err: `internal error: no boot asset for "grub-recovery.cfg"`, + }, { + name: "system-boot grub", + opts: systemBootOpts, + gadgetFile: "grub.conf", + // empty file in the gadget + gadgetFileContent: nil, + sysFile: "/EFI/ubuntu/grub.cfg", + assetName: "grub.cfg", + assetContent: []byte("hello assets"), + sysFileContent: []byte("hello assets"), + }, { + name: "system-boot grub with default asset", + opts: systemBootOpts, + gadgetFile: "grub.conf", + // empty file in the gadget + gadgetFileContent: nil, + sysFile: "/EFI/ubuntu/grub.cfg", + sysFileContent: defaultGrubAsset, + }, + } { + mockGadgetDir := c.MkDir() + rootDir := c.MkDir() + fn := filepath.Join(rootDir, t.sysFile) + err := os.WriteFile(filepath.Join(mockGadgetDir, t.gadgetFile), t.gadgetFileContent, 0644) + c.Assert(err, IsNil) + var restoreAsset func() + if t.assetName != "" { + restoreAsset = assets.MockInternal(t.assetName, t.assetContent) + } + err = bootloader.InstallBootConfig(mockGadgetDir, rootDir, t.opts) + if t.err == "" { + c.Assert(err, IsNil, Commentf("installing boot config for %s", t.name)) + // mocked asset content + c.Assert(fn, testutil.FileEquals, string(t.sysFileContent)) + } else { + c.Assert(err, ErrorMatches, t.err) + c.Assert(fn, testutil.FileAbsent) + } + if restoreAsset != nil { + restoreAsset() + } + } +} + +func (s *bootenvTestSuite) TestBootloaderFindPresentNonNilError(c *C) { + rootdir := c.MkDir() + // add a mock bootloader to the list of bootloaders that Find() uses + mockBl := bootloadertest.Mock("mock", rootdir) + restore := bootloader.MockAddBootloaderToFind(func(dir string, opts *bootloader.Options) bootloader.Bootloader { + c.Assert(dir, Equals, rootdir) + return mockBl + }) + defer restore() + + // make us find our bootloader + mockBl.MockedPresent = true + + bl, err := bootloader.Find(rootdir, nil) + c.Assert(err, IsNil) + c.Assert(bl, NotNil) + c.Assert(bl.Name(), Equals, "mock") + c.Assert(bl, DeepEquals, mockBl) + + // now make finding our bootloader a fatal error, this time we will get the + // error back + mockBl.PresentErr = fmt.Errorf("boom") + _, err = bootloader.Find(rootdir, nil) + c.Assert(err, ErrorMatches, "bootloader \"mock\" found but not usable: boom") +} + +func (s *bootenvTestSuite) TestBootloaderFindBadOptions(c *C) { + _, err := bootloader.Find("", &bootloader.Options{ + PrepareImageTime: true, + Role: bootloader.RoleRunMode, + }) + c.Assert(err, ErrorMatches, "internal error: cannot use run mode bootloader at prepare-image time") + + _, err = bootloader.Find("", &bootloader.Options{ + NoSlashBoot: true, + Role: bootloader.RoleSole, + }) + c.Assert(err, ErrorMatches, "internal error: bootloader.RoleSole doesn't expect NoSlashBoot set") +} + +func (s *bootenvTestSuite) TestBootloaderFind(c *C) { + for _, tc := range []struct { + name string + sysFile string + opts *bootloader.Options + expName string + }{ + {name: "grub", sysFile: "/boot/grub/grub.cfg", expName: "grub"}, + { + // native run partition layout + name: "grub", sysFile: "/EFI/ubuntu/grub.cfg", + opts: &bootloader.Options{Role: bootloader.RoleRunMode, NoSlashBoot: true}, + expName: "grub", + }, + { + // recovery layout + name: "grub", sysFile: "/EFI/ubuntu/grub.cfg", + opts: &bootloader.Options{Role: bootloader.RoleRecovery}, + expName: "grub", + }, + + // traditional uboot.env - the uboot.env file needs to be non-empty + {name: "uboot.env", sysFile: "/boot/uboot/uboot.env", expName: "uboot"}, + // boot.sel uboot variant + { + name: "uboot boot.scr", + sysFile: "/uboot/ubuntu/boot.sel", + opts: &bootloader.Options{Role: bootloader.RoleRunMode, NoSlashBoot: true}, + expName: "uboot", + }, + {name: "androidboot", sysFile: "/boot/androidboot/androidboot.env", expName: "androidboot"}, + // lk is detected differently based on runtime/prepare-image + {name: "lk", sysFile: "/dev/disk/by-partlabel/snapbootsel", expName: "lk"}, + { + name: "lk", sysFile: "/boot/lk/snapbootsel.bin", + expName: "lk", opts: &bootloader.Options{PrepareImageTime: true}, + }, + } { + c.Logf("tc: %v", tc.name) + rootDir := c.MkDir() + err := os.MkdirAll(filepath.Join(rootDir, filepath.Dir(tc.sysFile)), 0755) + c.Assert(err, IsNil) + err = os.WriteFile(filepath.Join(rootDir, tc.sysFile), nil, 0644) + c.Assert(err, IsNil) + bl, err := bootloader.Find(rootDir, tc.opts) + c.Assert(err, IsNil) + c.Assert(bl, NotNil) + c.Check(bl.Name(), Equals, tc.expName) + } +} + +func (s *bootenvTestSuite) TestBootloaderForGadget(c *C) { + for _, tc := range []struct { + name string + gadgetFile string + opts *bootloader.Options + expName string + }{ + {name: "grub", gadgetFile: "grub.conf", expName: "grub"}, + {name: "grub", gadgetFile: "grub.conf", opts: &bootloader.Options{Role: bootloader.RoleRunMode, NoSlashBoot: true}, expName: "grub"}, + {name: "grub", gadgetFile: "grub.conf", opts: &bootloader.Options{Role: bootloader.RoleRecovery}, expName: "grub"}, + {name: "uboot", gadgetFile: "uboot.conf", expName: "uboot"}, + {name: "androidboot", gadgetFile: "androidboot.conf", expName: "androidboot"}, + {name: "lk", gadgetFile: "lk.conf", expName: "lk"}, + } { + c.Logf("tc: %v", tc.name) + gadgetDir := c.MkDir() + rootDir := c.MkDir() + err := os.MkdirAll(filepath.Join(rootDir, filepath.Dir(tc.gadgetFile)), 0755) + c.Assert(err, IsNil) + err = os.WriteFile(filepath.Join(gadgetDir, tc.gadgetFile), nil, 0644) + c.Assert(err, IsNil) + bl, err := bootloader.ForGadget(gadgetDir, rootDir, tc.opts) + c.Assert(err, IsNil) + c.Assert(bl, NotNil) + c.Check(bl.Name(), Equals, tc.expName) + } +} + +func (s *bootenvTestSuite) TestBootFileWithPath(c *C) { + a := bootloader.NewBootFile("", "some/path", bootloader.RoleRunMode) + b := a.WithPath("other/path") + c.Assert(a.Path, Equals, "some/path") + c.Assert(b.Path, Equals, "other/path") +} diff --git a/bootloader/bootloadertest/bootloadertest.go b/bootloader/bootloadertest/bootloadertest.go new file mode 100644 index 00000000..49255f99 --- /dev/null +++ b/bootloader/bootloadertest/bootloadertest.go @@ -0,0 +1,609 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2014-2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package bootloadertest + +import ( + "fmt" + "strings" + + "github.com/snapcore/snapd/bootloader" + "github.com/snapcore/snapd/snap" +) + +// MockBootloader mocks the bootloader interface and records all +// set/get calls. +type MockBootloader struct { + MockedPresent bool + PresentErr error + + BootVars map[string]string + SetBootVarsCalls int + SetErr error + SetErrFunc func() error + GetErr error + + name string + bootdir string + + ExtractKernelAssetsCalls []snap.PlaceInfo + RemoveKernelAssetsCalls []snap.PlaceInfo + + InstallBootConfigCalled []string + InstallBootConfigErr error + + enabledKernel snap.PlaceInfo + enabledTryKernel snap.PlaceInfo + + panicMethods map[string]bool +} + +// ensure MockBootloader(s) implement the Bootloader interface +var _ bootloader.Bootloader = (*MockBootloader)(nil) +var _ bootloader.RecoveryAwareBootloader = (*MockRecoveryAwareBootloader)(nil) +var _ bootloader.TrustedAssetsBootloader = (*MockTrustedAssetsBootloader)(nil) +var _ bootloader.ExtractedRunKernelImageBootloader = (*MockExtractedRunKernelImageBootloader)(nil) +var _ bootloader.ExtractedRecoveryKernelImageBootloader = (*MockExtractedRecoveryKernelImageBootloader)(nil) +var _ bootloader.RecoveryAwareBootloader = (*MockRecoveryAwareTrustedAssetsBootloader)(nil) +var _ bootloader.TrustedAssetsBootloader = (*MockRecoveryAwareTrustedAssetsBootloader)(nil) +var _ bootloader.NotScriptableBootloader = (*MockNotScriptableBootloader)(nil) +var _ bootloader.NotScriptableBootloader = (*MockExtractedRecoveryKernelNotScriptableBootloader)(nil) +var _ bootloader.ExtractedRecoveryKernelImageBootloader = (*MockExtractedRecoveryKernelNotScriptableBootloader)(nil) +var _ bootloader.RebootBootloader = (*MockRebootBootloader)(nil) + +func Mock(name, bootdir string) *MockBootloader { + return &MockBootloader{ + name: name, + bootdir: bootdir, + + BootVars: make(map[string]string), + + panicMethods: make(map[string]bool), + } +} + +func (b *MockBootloader) maybePanic(which string) { + if b.panicMethods[which] { + panic(fmt.Sprintf("mocked reboot panic in %s", which)) + } +} + +func (b *MockBootloader) SetBootVars(values map[string]string) error { + b.maybePanic("SetBootVars") + b.SetBootVarsCalls++ + for k, v := range values { + b.BootVars[k] = v + } + if b.SetErrFunc != nil { + return b.SetErrFunc() + } + return b.SetErr +} + +func (b *MockBootloader) GetBootVars(keys ...string) (map[string]string, error) { + b.maybePanic("GetBootVars") + + out := map[string]string{} + for _, k := range keys { + out[k] = b.BootVars[k] + } + + return out, b.GetErr +} + +func (b *MockBootloader) Name() string { + return b.name +} + +func (b *MockBootloader) Present() (bool, error) { + return b.MockedPresent, b.PresentErr +} + +func (b *MockBootloader) ExtractKernelAssets(s snap.PlaceInfo, snapf snap.Container) error { + b.ExtractKernelAssetsCalls = append(b.ExtractKernelAssetsCalls, s) + return nil +} + +func (b *MockBootloader) RemoveKernelAssets(s snap.PlaceInfo) error { + b.RemoveKernelAssetsCalls = append(b.RemoveKernelAssetsCalls, s) + return nil +} + +func (b *MockBootloader) SetEnabledKernel(s snap.PlaceInfo) (restore func()) { + oldSn := b.enabledTryKernel + oldVar := b.BootVars["snap_kernel"] + b.enabledKernel = s + b.BootVars["snap_kernel"] = s.Filename() + return func() { + b.BootVars["snap_kernel"] = oldVar + b.enabledKernel = oldSn + } +} + +func (b *MockBootloader) SetEnabledTryKernel(s snap.PlaceInfo) (restore func()) { + oldSn := b.enabledTryKernel + oldVar := b.BootVars["snap_try_kernel"] + b.enabledTryKernel = s + b.BootVars["snap_try_kernel"] = s.Filename() + return func() { + b.BootVars["snap_try_kernel"] = oldVar + b.enabledTryKernel = oldSn + } +} + +// InstallBootConfig installs the boot config in the gadget directory to the +// mock bootloader's root directory. +func (b *MockBootloader) InstallBootConfig(gadgetDir string, opts *bootloader.Options) error { + b.InstallBootConfigCalled = append(b.InstallBootConfigCalled, gadgetDir) + return b.InstallBootConfigErr +} + +// SetMockToPanic allows setting any method in the Bootloader interface or derived +// interface to panic instead of returning. This allows one to test what would +// happen if the system was rebooted during execution of a particular function. +// Specifically, the panic will be done immediately entering the function so +// setting SetBootVars to panic will emulate a reboot before any boot vars are +// set persistently +func (b *MockBootloader) SetMockToPanic(f string) (restore func()) { + switch f { + // XXX: update this list as more calls in this interface or derived ones + // are added + case "SetBootVars", "GetBootVars", + "EnableKernel", "EnableTryKernel", "Kernel", "TryKernel", "DisableTryKernel": + + old := b.panicMethods[f] + b.panicMethods[f] = true + return func() { + b.panicMethods[f] = old + } + default: + panic(fmt.Sprintf("unknown bootloader method %q to mock reboot via panic for", f)) + } +} + +// MockRecoveryAwareMixin implements the RecoveryAware interface. +type MockRecoveryAwareMixin struct { + RecoverySystemDir string + RecoverySystemBootVars map[string]string +} + +// MockRecoveryAwareBootloader mocks a bootloader implementing the +// RecoveryAware interface. +type MockRecoveryAwareBootloader struct { + *MockBootloader + MockRecoveryAwareMixin +} + +// RecoveryAware derives a MockRecoveryAwareBootloader from a base +// MockBootloader. +func (b *MockBootloader) RecoveryAware() *MockRecoveryAwareBootloader { + return &MockRecoveryAwareBootloader{MockBootloader: b} +} + +// SetRecoverySystemEnv sets the recovery system environment bootloader +// variables; part of RecoveryAwareBootloader. +func (b *MockRecoveryAwareMixin) SetRecoverySystemEnv(recoverySystemDir string, blVars map[string]string) error { + if recoverySystemDir == "" { + panic("MockBootloader.SetRecoverySystemEnv called without recoverySystemDir") + } + b.RecoverySystemDir = recoverySystemDir + b.RecoverySystemBootVars = blVars + return nil +} + +// GetRecoverySystemEnv gets the recovery system environment bootloader +// variables; part of RecoveryAwareBootloader. +func (b *MockRecoveryAwareMixin) GetRecoverySystemEnv(recoverySystemDir, key string) (string, error) { + if recoverySystemDir == "" { + panic("MockBootloader.GetRecoverySystemEnv called without recoverySystemDir") + } + b.RecoverySystemDir = recoverySystemDir + return b.RecoverySystemBootVars[key], nil +} + +type ExtractedRecoveryKernelCall struct { + RecoverySystemDir string + S snap.PlaceInfo +} + +// MockExtractedRecoveryKernelImageBootloader mocks a bootloader implementing +// the ExtractedRecoveryKernelImage interface. +type MockExtractedRecoveryKernelImageBootloader struct { + *MockBootloader + + ExtractRecoveryKernelAssetsCalls []ExtractedRecoveryKernelCall +} + +// ExtractedRecoveryKernelImage derives a MockRecoveryAwareBootloader from a base +// MockBootloader. +func (b *MockBootloader) ExtractedRecoveryKernelImage() *MockExtractedRecoveryKernelImageBootloader { + return &MockExtractedRecoveryKernelImageBootloader{MockBootloader: b} +} + +// ExtractRecoveryKernelAssets extracts the kernel assets for the provided +// kernel snap into the specified recovery system dir; part of +// RecoveryAwareBootloader. +func (b *MockExtractedRecoveryKernelImageBootloader) ExtractRecoveryKernelAssets(recoverySystemDir string, s snap.PlaceInfo, snapf snap.Container) error { + if recoverySystemDir == "" { + panic("MockBootloader.ExtractRecoveryKernelAssets called without recoverySystemDir") + } + + b.ExtractRecoveryKernelAssetsCalls = append( + b.ExtractRecoveryKernelAssetsCalls, + ExtractedRecoveryKernelCall{ + S: s, + RecoverySystemDir: recoverySystemDir}, + ) + return nil +} + +// MockExtractedRunKernelImageMixin implements the +// ExtractedRunKernelImageBootloader interface. +type MockExtractedRunKernelImageMixin struct { + runKernelImageEnableKernelCalls []snap.PlaceInfo + runKernelImageEnableTryKernelCalls []snap.PlaceInfo + runKernelImageEnabledKernel snap.PlaceInfo + runKernelImageEnabledTryKernel snap.PlaceInfo + + runKernelImageMockedErrs map[string]error + runKernelImageMockedNumCalls map[string]int + + maybePanic func(name string) +} + +// MockExtractedRunKernelImageBootloader mocks a bootloader +// implementing the ExtractedRunKernelImageBootloader interface. +type MockExtractedRunKernelImageBootloader struct { + *MockBootloader + + MockExtractedRunKernelImageMixin +} + +func (b *MockExtractedRunKernelImageBootloader) SetEnabledKernel(kernel snap.PlaceInfo) (restore func()) { + // pick the right implementation + return b.MockExtractedRunKernelImageMixin.SetEnabledKernel(kernel) +} + +func (b *MockExtractedRunKernelImageBootloader) SetEnabledTryKernel(kernel snap.PlaceInfo) (restore func()) { + // pick the right implementation + return b.MockExtractedRunKernelImageMixin.SetEnabledTryKernel(kernel) +} + +// WithExtractedRunKernelImage derives a MockExtractedRunKernelImageBootloader +// from a base MockBootloader. +func (b *MockBootloader) WithExtractedRunKernelImage() *MockExtractedRunKernelImageBootloader { + return &MockExtractedRunKernelImageBootloader{ + MockBootloader: b, + + MockExtractedRunKernelImageMixin: MockExtractedRunKernelImageMixin{ + runKernelImageMockedErrs: make(map[string]error), + runKernelImageMockedNumCalls: make(map[string]int), + maybePanic: b.maybePanic, + }, + } +} + +// SetEnabledKernel sets the current kernel "symlink" as returned +// by Kernel(); returns' a restore function to set it back to what it was +// before. +func (b *MockExtractedRunKernelImageMixin) SetEnabledKernel(kernel snap.PlaceInfo) (restore func()) { + old := b.runKernelImageEnabledKernel + b.runKernelImageEnabledKernel = kernel + return func() { + b.runKernelImageEnabledKernel = old + } +} + +// SetEnabledTryKernel sets the current try-kernel "symlink" as +// returned by TryKernel(). If set to nil, TryKernel()'s second return value +// will be false; returns' a restore function to set it back to what it was +// before. +func (b *MockExtractedRunKernelImageMixin) SetEnabledTryKernel(kernel snap.PlaceInfo) (restore func()) { + old := b.runKernelImageEnabledTryKernel + b.runKernelImageEnabledTryKernel = kernel + return func() { + b.runKernelImageEnabledTryKernel = old + } +} + +// SetRunKernelImageFunctionError allows setting an error to be returned for the +// specified function; it returns a restore function to set it back to what it +// was before. +func (b *MockExtractedRunKernelImageMixin) SetRunKernelImageFunctionError(f string, err error) (restore func()) { + // check the function + switch f { + case "EnableKernel", "EnableTryKernel", "Kernel", "TryKernel", "DisableTryKernel": + old := b.runKernelImageMockedErrs[f] + b.runKernelImageMockedErrs[f] = err + return func() { + b.runKernelImageMockedErrs[f] = old + } + default: + panic(fmt.Sprintf("unknown ExtractedRunKernelImageBootloader method %q to mock error for", f)) + } +} + +// GetRunKernelImageFunctionSnapCalls returns which snaps were specified during +// execution, in order of calls, as well as the number of calls for methods that +// don't take a snap to set. +func (b *MockExtractedRunKernelImageMixin) GetRunKernelImageFunctionSnapCalls(f string) ([]snap.PlaceInfo, int) { + switch f { + case "EnableKernel": + l := b.runKernelImageEnableKernelCalls + return l, len(l) + case "EnableTryKernel": + l := b.runKernelImageEnableTryKernelCalls + return l, len(l) + case "Kernel", "TryKernel", "DisableTryKernel": + return nil, b.runKernelImageMockedNumCalls[f] + default: + panic(fmt.Sprintf("unknown ExtractedRunKernelImageBootloader method %q to return snap args for", f)) + } +} + +// EnableKernel enables the kernel; part of ExtractedRunKernelImageBootloader. +func (b *MockExtractedRunKernelImageMixin) EnableKernel(s snap.PlaceInfo) error { + b.maybePanic("EnableKernel") + b.runKernelImageEnableKernelCalls = append(b.runKernelImageEnableKernelCalls, s) + b.runKernelImageEnabledKernel = s + return b.runKernelImageMockedErrs["EnableKernel"] +} + +// EnableTryKernel enables a try-kernel; part of +// ExtractedRunKernelImageBootloader. +func (b *MockExtractedRunKernelImageMixin) EnableTryKernel(s snap.PlaceInfo) error { + b.maybePanic("EnableTryKernel") + b.runKernelImageEnableTryKernelCalls = append(b.runKernelImageEnableTryKernelCalls, s) + b.runKernelImageEnabledTryKernel = s + return b.runKernelImageMockedErrs["EnableTryKernel"] +} + +// Kernel returns the current kernel set in the bootloader; part of +// ExtractedRunKernelImageBootloader. +func (b *MockExtractedRunKernelImageMixin) Kernel() (snap.PlaceInfo, error) { + b.maybePanic("Kernel") + b.runKernelImageMockedNumCalls["Kernel"]++ + err := b.runKernelImageMockedErrs["Kernel"] + if err != nil { + return nil, err + } + return b.runKernelImageEnabledKernel, nil +} + +// TryKernel returns the current kernel set in the bootloader; part of +// ExtractedRunKernelImageBootloader. +func (b *MockExtractedRunKernelImageMixin) TryKernel() (snap.PlaceInfo, error) { + b.maybePanic("TryKernel") + b.runKernelImageMockedNumCalls["TryKernel"]++ + err := b.runKernelImageMockedErrs["TryKernel"] + if err != nil { + return nil, err + } + if b.runKernelImageEnabledTryKernel == nil { + return nil, bootloader.ErrNoTryKernelRef + } + return b.runKernelImageEnabledTryKernel, nil +} + +// DisableTryKernel removes the current try-kernel "symlink" set in the +// bootloader; part of ExtractedRunKernelImageBootloader. +func (b *MockExtractedRunKernelImageMixin) DisableTryKernel() error { + b.maybePanic("DisableTryKernel") + b.runKernelImageMockedNumCalls["DisableTryKernel"]++ + b.runKernelImageEnabledTryKernel = nil + return b.runKernelImageMockedErrs["DisableTryKernel"] +} + +// MockTrustedAssetsMixin implements the bootloader.TrustedAssetsBootloader +// interface. +type MockTrustedAssetsMixin struct { + TrustedAssetsMap map[string]string + TrustedAssetsErr error + TrustedAssetsCalls int + + RecoveryBootChainList []bootloader.BootFile + RecoveryBootChainErr error + BootChainList []bootloader.BootFile + BootChainErr error + + RecoveryBootChainCalls []string + BootChainRunBl []bootloader.Bootloader + BootChainKernelPath []string + + UpdateErr error + UpdateCalls int + Updated bool + ManagedAssetsList []string + StaticCommandLine string + CandidateStaticCommandLine string + CommandLineErr error +} + +// MockTrustedAssetsBootloader mocks a bootloader implementing the +// bootloader.TrustedAssetsBootloader interface. +type MockTrustedAssetsBootloader struct { + *MockBootloader + + MockTrustedAssetsMixin +} + +func (b *MockBootloader) WithTrustedAssets() *MockTrustedAssetsBootloader { + return &MockTrustedAssetsBootloader{ + MockBootloader: b, + } +} + +func (b *MockTrustedAssetsMixin) ManagedAssets() []string { + return b.ManagedAssetsList +} + +func (b *MockTrustedAssetsMixin) UpdateBootConfig() (bool, error) { + b.UpdateCalls++ + return b.Updated, b.UpdateErr +} + +func glueCommandLine(pieces bootloader.CommandLineComponents, staticArgs string) (string, error) { + if err := pieces.Validate(); err != nil { + return "", err + } + + args := []string(nil) + extraOrFull := []string{staticArgs, pieces.ExtraArgs} + if pieces.FullArgs != "" { + extraOrFull = []string{pieces.FullArgs} + } + for _, argSet := range append([]string{pieces.ModeArg, pieces.SystemArg}, extraOrFull...) { + if argSet != "" { + args = append(args, argSet) + } + } + line := strings.Join(args, " ") + return strings.TrimSpace(line), nil +} + +func (b *MockTrustedAssetsMixin) CommandLine(pieces bootloader.CommandLineComponents) (string, error) { + if b.CommandLineErr != nil { + return "", b.CommandLineErr + } + return glueCommandLine(pieces, b.StaticCommandLine) +} + +func (b *MockTrustedAssetsMixin) CandidateCommandLine(pieces bootloader.CommandLineComponents) (string, error) { + if b.CommandLineErr != nil { + return "", b.CommandLineErr + } + return glueCommandLine(pieces, b.CandidateStaticCommandLine) +} + +func (b *MockTrustedAssetsMixin) DefaultCommandLine(candidate bool) (string, error) { + if b.CommandLineErr != nil { + return "", b.CommandLineErr + } + if candidate { + return b.CandidateStaticCommandLine, nil + } + return b.StaticCommandLine, nil +} + +func (b *MockTrustedAssetsMixin) TrustedAssets() (map[string]string, error) { + b.TrustedAssetsCalls++ + return b.TrustedAssetsMap, b.TrustedAssetsErr +} + +func (b *MockTrustedAssetsMixin) RecoveryBootChains(kernelPath string) ([][]bootloader.BootFile, error) { + b.RecoveryBootChainCalls = append(b.RecoveryBootChainCalls, kernelPath) + return [][]bootloader.BootFile{b.RecoveryBootChainList}, b.RecoveryBootChainErr +} + +func (b *MockTrustedAssetsMixin) BootChains(runBl bootloader.Bootloader, kernelPath string) ([][]bootloader.BootFile, error) { + b.BootChainRunBl = append(b.BootChainRunBl, runBl) + b.BootChainKernelPath = append(b.BootChainKernelPath, kernelPath) + return [][]bootloader.BootFile{b.BootChainList}, b.BootChainErr +} + +// MockRecoveryAwareTrustedAssetsBootloader implements the +// bootloader.RecoveryAwareBootloader and bootloader.TrustedAssetsBootloader +// interfaces. +type MockRecoveryAwareTrustedAssetsBootloader struct { + *MockBootloader + + MockRecoveryAwareMixin + MockTrustedAssetsMixin +} + +func (b *MockBootloader) WithRecoveryAwareTrustedAssets() *MockRecoveryAwareTrustedAssetsBootloader { + return &MockRecoveryAwareTrustedAssetsBootloader{ + MockBootloader: b, + } +} + +// MockNotScriptableBootloader implements the +// bootloader.NotScriptableBootloader interface. +type MockNotScriptableBootloader struct { + *MockBootloader +} + +func (b *MockBootloader) WithNotScriptable() *MockNotScriptableBootloader { + return &MockNotScriptableBootloader{ + MockBootloader: b, + } +} + +func (b *MockNotScriptableBootloader) SetBootVarsFromInitramfs(values map[string]string) error { + for k, v := range values { + b.BootVars[k] = v + } + return nil +} + +// MockExtractedRecoveryKernelNotScriptableBootloader implements the +// bootloader.ExtractedRecoveryKernelImageBootloader interface and +// includes MockNotScriptableBootloader +type MockExtractedRecoveryKernelNotScriptableBootloader struct { + *MockNotScriptableBootloader + + ExtractRecoveryKernelAssetsCalls []ExtractedRecoveryKernelCall +} + +func (b *MockNotScriptableBootloader) WithExtractedRecoveryKernel() *MockExtractedRecoveryKernelNotScriptableBootloader { + return &MockExtractedRecoveryKernelNotScriptableBootloader{ + MockNotScriptableBootloader: b, + } +} + +// ExtractRecoveryKernelAssets extracts the kernel assets for the provided +// kernel snap into the specified recovery system dir; part of +// RecoveryAwareBootloader. +func (b *MockExtractedRecoveryKernelNotScriptableBootloader) ExtractRecoveryKernelAssets(recoverySystemDir string, s snap.PlaceInfo, snapf snap.Container) error { + if recoverySystemDir == "" { + panic("MockBootloader.ExtractRecoveryKernelAssets called without recoverySystemDir") + } + + b.ExtractRecoveryKernelAssetsCalls = append( + b.ExtractRecoveryKernelAssetsCalls, + ExtractedRecoveryKernelCall{ + S: s, + RecoverySystemDir: recoverySystemDir}, + ) + return nil +} + +// MockRebootBootloaderMixin implements the bootloader.RebootBootloader +// interface. +type MockRebootBootloaderMixin struct { + RebootArgs string +} + +// MockRebootBootloader mocks a bootloader implementing the +// bootloader.RebootBootloader interface. +type MockRebootBootloader struct { + *MockBootloader + + MockRebootBootloaderMixin +} + +func (b *MockRebootBootloaderMixin) GetRebootArguments() (string, error) { + return b.RebootArgs, nil +} + +func (b *MockBootloader) WithRebootBootloader() *MockRebootBootloader { + return &MockRebootBootloader{ + MockBootloader: b, + } +} diff --git a/bootloader/bootloadertest/utf16.go b/bootloader/bootloadertest/utf16.go new file mode 100644 index 00000000..3ebd90a8 --- /dev/null +++ b/bootloader/bootloadertest/utf16.go @@ -0,0 +1,37 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package bootloadertest + +import ( + "bytes" + "encoding/binary" + "unicode/utf16" +) + +// UTF16Bytes converts the given string into its UTF16 +// encoding. Convenient for use together with efi.MockVars. +func UTF16Bytes(s string) []byte { + r16 := utf16.Encode(bytes.Runes([]byte(s))) + b := bytes.NewBuffer(make([]byte, 0, (len(r16)+1)*2)) + binary.Write(b, binary.LittleEndian, r16) + // zero termination + binary.Write(b, binary.LittleEndian, uint16(0)) + return b.Bytes() +} diff --git a/bootloader/efi/efi.go b/bootloader/efi/efi.go new file mode 100644 index 00000000..72bf43fd --- /dev/null +++ b/bootloader/efi/efi.go @@ -0,0 +1,183 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +// Package efi supports reading EFI variables. +package efi + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "unicode/utf16" + + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/osutil" +) + +var ErrNoEFISystem = errors.New("not a supported EFI system") + +type VariableAttr uint32 + +// see https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/tree/include/linux/efi.h?h=v5.4.32 +const ( + VariableNonVolatile VariableAttr = 0x00000001 + VariableBootServiceAccess VariableAttr = 0x00000002 + VariableRuntimeAccess VariableAttr = 0x00000004 +) + +var ( + openEFIVar = openEFIVarImpl +) + +const expectedEFIvarfsDir = "/sys/firmware/efi/efivars" + +func openEFIVarImpl(name string) (r io.ReadCloser, attr VariableAttr, size int64, err error) { + mounts, err := osutil.LoadMountInfo() + if err != nil { + return nil, 0, 0, err + } + found := false + for _, mnt := range mounts { + if mnt.MountDir == expectedEFIvarfsDir { + if mnt.FsType == "efivarfs" { + found = true + break + } + } + } + if !found { + return nil, 0, 0, ErrNoEFISystem + } + varf, err := os.Open(filepath.Join(dirs.GlobalRootDir, expectedEFIvarfsDir, name)) + if err != nil { + return nil, 0, 0, err + } + defer func() { + if err != nil { + varf.Close() + } + }() + fi, err := varf.Stat() + if err != nil { + return nil, 0, 0, err + } + sz := fi.Size() + if sz < 4 { + return nil, 0, 0, fmt.Errorf("unexpected size: %d", sz) + } + + if err = binary.Read(varf, binary.LittleEndian, &attr); err != nil { + return nil, 0, 0, err + } + return varf, attr, sz - 4, nil +} + +func cannotReadError(name string, err error) error { + return fmt.Errorf("cannot read EFI var %q: %v", name, err) +} + +// ReadVarBytes will attempt to read the bytes of the value of the +// specified EFI variable, specified by its full name composed of the +// variable name and vendor ID. It also returns the attribute value +// attached to it. It expects to use the efivars filesystem at +// /sys/firmware/efi/efivars. +// https://www.kernel.org/doc/Documentation/filesystems/efivarfs.txt +// for more details. +func ReadVarBytes(name string) ([]byte, VariableAttr, error) { + varf, attr, _, err := openEFIVar(name) + if err != nil { + if err == ErrNoEFISystem { + return nil, 0, err + } + return nil, 0, cannotReadError(name, err) + } + defer varf.Close() + b, err := io.ReadAll(varf) + if err != nil { + return nil, 0, cannotReadError(name, err) + } + return b, attr, nil +} + +// ReadVarString will attempt to read the string value of the +// specified EFI variable, specified by its full name composed of the +// variable name and vendor ID. The string value is expected to be +// encoded as UTF16. It also returns the attribute value attached to +// it. It expects to use the efivars filesystem at +// /sys/firmware/efi/efivars. +// https://www.kernel.org/doc/Documentation/filesystems/efivarfs.txt +// for more details. +func ReadVarString(name string) (string, VariableAttr, error) { + varf, attr, sz, err := openEFIVar(name) + if err != nil { + if err == ErrNoEFISystem { + return "", 0, err + } + return "", 0, cannotReadError(name, err) + } + defer varf.Close() + // TODO: consider using golang.org/x/text/encoding/unicode here + if sz%2 != 0 { + return "", 0, fmt.Errorf("EFI var %q is not a valid UTF16 string, it has an extra byte", name) + } + n := int(sz / 2) + if n == 0 { + return "", attr, nil + } + r16 := make([]uint16, n) + if err := binary.Read(varf, binary.LittleEndian, r16); err != nil { + return "", 0, cannotReadError(name, err) + } + if r16[n-1] == 0 { + n-- + } + b := &bytes.Buffer{} + for _, r := range utf16.Decode(r16[:n]) { + b.WriteRune(r) + } + return b.String(), attr, nil +} + +// MockVars mocks EFI variables as read by ReadVar*, only to be used +// from tests. Set vars to nil to mock a non-EFI system. +func MockVars(vars map[string][]byte, attrs map[string]VariableAttr) (restore func()) { + osutil.MustBeTestBinary("MockVars only to be used from tests") + old := openEFIVar + openEFIVar = func(name string) (io.ReadCloser, VariableAttr, int64, error) { + if vars == nil { + return nil, 0, 0, ErrNoEFISystem + } + if val, ok := vars[name]; ok { + attr, ok := attrs[name] + if !ok { + attr = VariableRuntimeAccess | VariableBootServiceAccess + } + return io.NopCloser(bytes.NewBuffer(val)), attr, int64(len(val)), nil + } + return nil, 0, 0, fmt.Errorf("EFI variable %s not mocked", name) + } + + return func() { + openEFIVar = old + } +} diff --git a/bootloader/efi/efi_test.go b/bootloader/efi/efi_test.go new file mode 100644 index 00000000..854fb2f8 --- /dev/null +++ b/bootloader/efi/efi_test.go @@ -0,0 +1,173 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package efi_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/bootloader/bootloadertest" + "github.com/snapcore/snapd/bootloader/efi" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/testutil" +) + +type efiVarsSuite struct { + testutil.BaseTest + + rootdir string +} + +var _ = Suite(&efiVarsSuite{}) + +func TestBoot(t *testing.T) { TestingT(t) } + +func (s *efiVarsSuite) SetUpTest(c *C) { + s.BaseTest.SetUpTest(c) + + s.rootdir = c.MkDir() + dirs.SetRootDir(s.rootdir) + s.AddCleanup(func() { dirs.SetRootDir("") }) + + err := os.MkdirAll(filepath.Join(s.rootdir, "/sys/firmware/efi/efivars"), 0755) + c.Assert(err, IsNil) + + efivarfsMount := ` +38 24 0:32 / /sys/firmware/efi/efivars rw,nosuid,nodev,noexec,relatime shared:13 - efivarfs efivarfs rw +` + restore := osutil.MockMountInfo(strings.TrimSpace(efivarfsMount)) + s.AddCleanup(restore) +} + +func (s *efiVarsSuite) TestNoEFISystem(c *C) { + // no efivarfs + osutil.MockMountInfo("") + + _, _, err := efi.ReadVarBytes("my-cool-efi-var") + c.Check(err, Equals, efi.ErrNoEFISystem) + + _, _, err = efi.ReadVarString("my-cool-efi-var") + c.Check(err, Equals, efi.ErrNoEFISystem) +} + +func (s *efiVarsSuite) TestSizeError(c *C) { + // mock the efi var file + varPath := filepath.Join(s.rootdir, "/sys/firmware/efi/efivars", "my-cool-efi-var") + err := os.WriteFile(varPath, []byte("\x06"), 0644) + c.Assert(err, IsNil) + + _, _, err = efi.ReadVarBytes("my-cool-efi-var") + c.Check(err, ErrorMatches, `cannot read EFI var "my-cool-efi-var": unexpected size: 1`) +} + +func (s *efiVarsSuite) TestReadVarBytes(c *C) { + // mock the efi var file + varPath := filepath.Join(s.rootdir, "/sys/firmware/efi/efivars", "my-cool-efi-var") + err := os.WriteFile(varPath, []byte("\x06\x00\x00\x00\x01"), 0644) + c.Assert(err, IsNil) + + data, attr, err := efi.ReadVarBytes("my-cool-efi-var") + c.Assert(err, IsNil) + c.Check(attr, Equals, efi.VariableBootServiceAccess|efi.VariableRuntimeAccess) + c.Assert(string(data), Equals, "\x01") +} + +func (s *efiVarsSuite) TestReadVarString(c *C) { + // mock the efi var file + varPath := filepath.Join(s.rootdir, "/sys/firmware/efi/efivars", "my-cool-efi-var") + err := os.WriteFile(varPath, []byte("\x06\x00\x00\x00A\x009\x00F\x005\x00C\x009\x004\x009\x00-\x00A\x00B\x008\x009\x00-\x005\x00B\x004\x007\x00-\x00A\x007\x00B\x00F\x00-\x005\x006\x00D\x00D\x002\x008\x00F\x009\x006\x00E\x006\x005\x00\x00\x00"), 0644) + c.Assert(err, IsNil) + + data, attr, err := efi.ReadVarString("my-cool-efi-var") + c.Assert(err, IsNil) + c.Check(attr, Equals, efi.VariableBootServiceAccess|efi.VariableRuntimeAccess) + c.Assert(data, Equals, "A9F5C949-AB89-5B47-A7BF-56DD28F96E65") +} + +func (s *efiVarsSuite) TestEmpty(c *C) { + // mock the efi var file + varPath := filepath.Join(s.rootdir, "/sys/firmware/efi/efivars", "my-cool-efi-var") + err := os.WriteFile(varPath, []byte("\x06\x00\x00\x00"), 0644) + c.Assert(err, IsNil) + + b, _, err := efi.ReadVarBytes("my-cool-efi-var") + c.Assert(err, IsNil) + c.Check(b, HasLen, 0) + + v, _, err := efi.ReadVarString("my-cool-efi-var") + c.Assert(err, IsNil) + c.Check(v, HasLen, 0) +} + +func (s *efiVarsSuite) TestMockVars(c *C) { + restore := efi.MockVars(map[string][]byte{ + "a": []byte("\x01"), + "b": []byte("\x02"), + }, map[string]efi.VariableAttr{ + "b": efi.VariableNonVolatile | efi.VariableRuntimeAccess | efi.VariableBootServiceAccess, + }) + defer restore() + + b, attr, err := efi.ReadVarBytes("a") + c.Assert(err, IsNil) + c.Check(attr, Equals, efi.VariableBootServiceAccess|efi.VariableRuntimeAccess) + c.Assert(string(b), Equals, "\x01") + + b, attr, err = efi.ReadVarBytes("b") + c.Assert(err, IsNil) + c.Check(attr, Equals, efi.VariableBootServiceAccess|efi.VariableRuntimeAccess|efi.VariableNonVolatile) + c.Assert(string(b), Equals, "\x02") + +} + +func (s *efiVarsSuite) TestMockStringVars(c *C) { + restore := efi.MockVars(map[string][]byte{ + "a": bootloadertest.UTF16Bytes("foo-bar-baz"), + }, nil) + defer restore() + + v, attr, err := efi.ReadVarString("a") + c.Assert(err, IsNil) + c.Check(attr, Equals, efi.VariableBootServiceAccess|efi.VariableRuntimeAccess) + c.Assert(v, Equals, "foo-bar-baz") +} + +func (s *efiVarsSuite) TestMockVarsNoEFISystem(c *C) { + restore := efi.MockVars(nil, nil) + defer restore() + + _, _, err := efi.ReadVarBytes("a") + c.Check(err, Equals, efi.ErrNoEFISystem) +} + +func (s *efiVarsSuite) TestStringOddSize(c *C) { + restore := efi.MockVars(map[string][]byte{ + "a": []byte("\x0a"), + }, nil) + defer restore() + + _, _, err := efi.ReadVarString("a") + c.Check(err, ErrorMatches, `EFI var "a" is not a valid UTF16 string, it has an extra byte`) +} diff --git a/bootloader/export_test.go b/bootloader/export_test.go new file mode 100644 index 00000000..14fd4329 --- /dev/null +++ b/bootloader/export_test.go @@ -0,0 +1,328 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package bootloader + +import ( + "os" + "path/filepath" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/bootloader/lkenv" + "github.com/snapcore/snapd/bootloader/ubootenv" + "github.com/snapcore/snapd/osutil/disks" + "github.com/snapcore/snapd/osutil/kcmdline" + "github.com/snapcore/snapd/snap" +) + +// creates a new Androidboot bootloader object +func NewAndroidBoot(rootdir string) Bootloader { + return newAndroidBoot(rootdir, nil) +} + +func MockAndroidBootFile(c *C, rootdir string, mode os.FileMode) { + f := &androidboot{rootdir: rootdir} + err := os.MkdirAll(f.dir(), 0755) + c.Assert(err, IsNil) + err = os.WriteFile(f.configFile(), nil, mode) + c.Assert(err, IsNil) +} + +func NewUboot(rootdir string, blOpts *Options) ExtractedRecoveryKernelImageBootloader { + return newUboot(rootdir, blOpts).(ExtractedRecoveryKernelImageBootloader) +} + +func MockUbootFiles(c *C, rootdir string, blOpts *Options) { + u := &uboot{rootdir: rootdir} + u.setDefaults() + u.processBlOpts(blOpts) + err := os.MkdirAll(u.dir(), 0755) + c.Assert(err, IsNil) + + // ensure that we have a valid uboot.env too + env, err := ubootenv.Create(u.envFile(), 4096, ubootenv.CreateOptions{HeaderFlagByte: true}) + c.Assert(err, IsNil) + err = env.Save() + c.Assert(err, IsNil) +} + +func NewGrub(rootdir string, opts *Options) RecoveryAwareBootloader { + return newGrub(rootdir, opts).(RecoveryAwareBootloader) +} + +func MockGrubFiles(c *C, rootdir string) { + err := os.MkdirAll(filepath.Join(rootdir, "/boot/grub"), 0755) + c.Assert(err, IsNil) + err = os.WriteFile(filepath.Join(rootdir, "/boot/grub/grub.cfg"), nil, 0644) + c.Assert(err, IsNil) +} + +func NewLk(rootdir string, opts *Options) ExtractedRecoveryKernelImageBootloader { + if opts == nil { + opts = &Options{ + Role: RoleSole, + } + } + return newLk(rootdir, opts).(ExtractedRecoveryKernelImageBootloader) +} + +// LkConfigFile returns the primary lk bootloader environment file. +func LkConfigFile(b Bootloader) (string, error) { + lk := b.(*lk) + return lk.envBackstore(primaryStorage) +} + +func UbootConfigFile(b Bootloader) string { + u := b.(*uboot) + return u.envFile() +} + +func MockLkFiles(c *C, rootdir string, opts *Options) (restore func()) { + var cleanups []func() + if opts == nil { + // default to v1, uc16/uc18 version for test simplicity + opts = &Options{ + Role: RoleSole, + } + } + + l := &lk{rootdir: rootdir} + l.processOpts(opts) + + var version lkenv.Version + switch opts.Role { + case RoleSole: + version = lkenv.V1 + case RoleRunMode: + version = lkenv.V2Run + case RoleRecovery: + version = lkenv.V2Recovery + } + + // setup some role specific things + if opts.Role == RoleRunMode || opts.Role == RoleRecovery { + // then we need to setup some additional files - namely the kernel + // command line and a mock disk for that + lkBootDisk := &disks.MockDiskMapping{ + // mock the partition labels, since these structures won't have + // filesystems, but they will have partition labels + Structure: []disks.Partition{ + { + PartitionLabel: "snapbootsel", + PartitionUUID: "snapbootsel-partuuid", + }, + { + PartitionLabel: "snapbootselbak", + PartitionUUID: "snapbootselbak-partuuid", + }, + { + PartitionLabel: "snaprecoverysel", + PartitionUUID: "snaprecoverysel-partuuid", + }, + { + PartitionLabel: "snaprecoveryselbak", + PartitionUUID: "snaprecoveryselbak-partuuid", + }, + // for run mode kernel snaps + { + PartitionLabel: "boot_a", + PartitionUUID: "boot-a-partuuid", + }, + { + PartitionLabel: "boot_b", + PartitionUUID: "boot-b-partuuid", + }, + // for recovery system kernel snaps + { + PartitionLabel: "boot_ra", + PartitionUUID: "boot-ra-partuuid", + }, + { + PartitionLabel: "boot_rb", + PartitionUUID: "boot-rb-partuuid", + }, + }, + DiskHasPartitions: true, + DevNum: "lk-boot-disk-dev-num", + } + + m := map[string]*disks.MockDiskMapping{ + "lk-boot-disk": lkBootDisk, + } + + // mock the disk + r := disks.MockDeviceNameToDiskMapping(m) + cleanups = append(cleanups, r) + + // now mock the kernel command line + cmdLine := filepath.Join(c.MkDir(), "cmdline") + os.WriteFile(cmdLine, []byte("snapd_lk_boot_disk=lk-boot-disk"), 0644) + r = kcmdline.MockProcCmdline(cmdLine) + cleanups = append(cleanups, r) + } + + // next create empty env file + buf := make([]byte, 4096) + f, err := l.envBackstore(primaryStorage) + c.Assert(err, IsNil) + + c.Assert(os.MkdirAll(filepath.Dir(f), 0755), IsNil) + err = os.WriteFile(f, buf, 0660) + c.Assert(err, IsNil) + + // now write env in it with correct crc + env := lkenv.NewEnv(f, "", version) + if version == lkenv.V2Recovery { + env.InitializeBootPartitions("boot_ra", "boot_rb") + } else { + env.InitializeBootPartitions("boot_a", "boot_b") + } + + err = env.Save() + c.Assert(err, IsNil) + + // also make the empty files for the boot_a and boot_b partitions + if opts.Role == RoleRunMode || opts.Role == RoleRecovery { + // for uc20 roles we need to mock the files in /dev/disk/by-partuuid + // and we also need to mock the snapbootselbak file (the snapbootsel + // was created above when we created envFile()) + for _, label := range []string{"boot_a", "boot_b", "boot_ra", "boot_rb", "snapbootselbak"} { + disk, err := disks.DiskFromDeviceName("lk-boot-disk") + c.Assert(err, IsNil) + partUUID, err := disk.FindMatchingPartitionUUIDWithPartLabel(label) + c.Assert(err, IsNil) + bootFile := filepath.Join(rootdir, "/dev/disk/by-partuuid", partUUID) + c.Assert(os.MkdirAll(filepath.Dir(bootFile), 0755), IsNil) + c.Assert(os.WriteFile(bootFile, nil, 0755), IsNil) + } + } else { + // for non-uc20 roles just mock the files in /dev/disk/by-partlabel + for _, partName := range []string{"boot_a", "boot_b"} { + mockPart := filepath.Join(rootdir, "/dev/disk/by-partlabel/", partName) + err := os.MkdirAll(filepath.Dir(mockPart), 0755) + c.Assert(err, IsNil) + err = os.WriteFile(mockPart, nil, 0600) + c.Assert(err, IsNil) + } + } + return func() { + for _, r := range cleanups { + r() + } + } +} + +func LkRuntimeMode(b Bootloader) bool { + lk := b.(*lk) + return !lk.prepareImageTime +} + +func MockAddBootloaderToFind(blConstructor func(string, *Options) Bootloader) (restore func()) { + oldLen := len(bootloaders) + bootloaders = append(bootloaders, blConstructor) + return func() { + bootloaders = bootloaders[:oldLen] + } +} + +func NewPiboot(rootdir string, opts *Options) ExtractedRecoveryKernelImageBootloader { + return newPiboot(rootdir, opts).(ExtractedRecoveryKernelImageBootloader) +} + +func MockPibootFiles(c *C, rootdir string, blOpts *Options) func() { + oldSeedPartDir := ubuntuSeedDir + ubuntuSeedDir = rootdir + + p := &piboot{rootdir: rootdir} + p.setDefaults() + p.processBlOpts(blOpts) + err := os.MkdirAll(p.dir(), 0755) + c.Assert(err, IsNil) + + // ensure that we have a valid piboot.conf + env, err := ubootenv.Create(p.envFile(), 4096, ubootenv.CreateOptions{HeaderFlagByte: true}) + c.Assert(err, IsNil) + err = env.Save() + c.Assert(err, IsNil) + + // Create configuration files expected to come from the gadget + cmdLineFile, err := os.Create(filepath.Join(rootdir, "cmdline.txt")) + c.Assert(err, IsNil) + cmdLineFile.Close() + cfgFile, err := os.Create(filepath.Join(rootdir, "config.txt")) + c.Assert(err, IsNil) + cfgFile.Close() + + return func() { ubuntuSeedDir = oldSeedPartDir } +} + +func MockRPi4Files(c *C, rootdir string, rpiRevisionCode, eepromTimeStamp []byte) func() { + oldRevCodePath := rpi4RevisionCodesPath + oldEepromTs := rpi4EepromTimeStampPath + rpi4RevisionCodesPath = filepath.Join(rootdir, "linux,revision") + rpi4EepromTimeStampPath = filepath.Join(rootdir, "build-timestamp") + + files := []struct { + path string + data []byte + }{ + { + path: rpi4RevisionCodesPath, + data: rpiRevisionCode, + }, + { + path: rpi4EepromTimeStampPath, + data: eepromTimeStamp, + }, + } + for _, file := range files { + if len(file.data) == 0 { + continue + } + fd, err := os.Create(file.path) + c.Assert(err, IsNil) + defer fd.Close() + written, err := fd.Write(file.data) + c.Assert(err, IsNil) + c.Assert(written, Equals, len(file.data)) + } + + return func() { + rpi4RevisionCodesPath = oldRevCodePath + rpi4EepromTimeStampPath = oldEepromTs + } +} + +func PibootConfigFile(b Bootloader) string { + p := b.(*piboot) + return p.envFile() +} + +func LayoutKernelAssetsToDir(b Bootloader, snapf snap.Container, dstDir string) error { + p := b.(*piboot) + return p.layoutKernelAssetsToDir(snapf, dstDir) +} + +var ( + EditionFromDiskConfigAsset = editionFromDiskConfigAsset + EditionFromConfigAsset = editionFromConfigAsset + ConfigAssetFrom = configAssetFrom + StaticCommandLineForGrubAssetEdition = staticCommandLineForGrubAssetEdition +) diff --git a/bootloader/grub.go b/bootloader/grub.go new file mode 100644 index 00000000..b7a96c59 --- /dev/null +++ b/bootloader/grub.go @@ -0,0 +1,692 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2014-2024 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package bootloader + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/snapcore/snapd/arch" + "github.com/snapcore/snapd/bootloader/assets" + "github.com/snapcore/snapd/bootloader/grubenv" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/osutil/kcmdline" + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/strutil" +) + +// grub implements the required interfaces +var ( + _ Bootloader = (*grub)(nil) + _ RecoveryAwareBootloader = (*grub)(nil) + _ ExtractedRunKernelImageBootloader = (*grub)(nil) + _ TrustedAssetsBootloader = (*grub)(nil) +) + +type grub struct { + rootdir string + + basedir string + + uefiRunKernelExtraction bool + recovery bool + nativePartitionLayout bool + prepareImageTime bool +} + +// newGrub create a new Grub bootloader object +func newGrub(rootdir string, opts *Options) Bootloader { + g := &grub{rootdir: rootdir} + if opts != nil { + // Set the flag to extract the run kernel, only + // for UC20 run mode. + // Both UC16/18 and the recovery mode of UC20 load + // the kernel directly from snaps. + g.uefiRunKernelExtraction = opts.Role == RoleRunMode + g.recovery = opts.Role == RoleRecovery + g.nativePartitionLayout = opts.NoSlashBoot || g.recovery + g.prepareImageTime = opts.PrepareImageTime + } + if g.nativePartitionLayout { + g.basedir = "EFI/ubuntu" + } else { + g.basedir = "boot/grub" + } + + return g +} + +func (g *grub) Name() string { + return "grub" +} + +func (g *grub) dir() string { + if g.rootdir == "" { + panic("internal error: unset rootdir") + } + return filepath.Join(g.rootdir, g.basedir) +} + +func (g *grub) installManagedRecoveryBootConfig() error { + assetName := g.Name() + "-recovery.cfg" + systemFile := filepath.Join(g.rootdir, "/EFI/ubuntu/grub.cfg") + return genericSetBootConfigFromAsset(systemFile, assetName) +} + +func (g *grub) installManagedBootConfig() error { + assetName := g.Name() + ".cfg" + systemFile := filepath.Join(g.rootdir, "/EFI/ubuntu/grub.cfg") + return genericSetBootConfigFromAsset(systemFile, assetName) +} + +func (g *grub) InstallBootConfig(gadgetDir string, opts *Options) error { + if opts != nil && opts.Role == RoleRecovery { + // install managed config for the recovery partition + return g.installManagedRecoveryBootConfig() + } + if opts != nil && opts.Role == RoleRunMode { + // install managed boot config that can handle kernel.efi + return g.installManagedBootConfig() + } + + gadgetFile := filepath.Join(gadgetDir, g.Name()+".conf") + systemFile := filepath.Join(g.rootdir, "/boot/grub/grub.cfg") + return genericInstallBootConfig(gadgetFile, systemFile) +} + +func (g *grub) SetRecoverySystemEnv(recoverySystemDir string, values map[string]string) error { + if recoverySystemDir == "" { + return fmt.Errorf("internal error: recoverySystemDir unset") + } + recoverySystemGrubEnv := filepath.Join(g.rootdir, recoverySystemDir, "grubenv") + if err := os.MkdirAll(filepath.Dir(recoverySystemGrubEnv), 0755); err != nil { + return err + } + genv := grubenv.NewEnv(recoverySystemGrubEnv) + for k, v := range values { + genv.Set(k, v) + } + return genv.Save() +} + +func (g *grub) GetRecoverySystemEnv(recoverySystemDir string, key string) (string, error) { + if recoverySystemDir == "" { + return "", fmt.Errorf("internal error: recoverySystemDir unset") + } + recoverySystemGrubEnv := filepath.Join(g.rootdir, recoverySystemDir, "grubenv") + genv := grubenv.NewEnv(recoverySystemGrubEnv) + if err := genv.Load(); err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", err + } + return genv.Get(key), nil +} + +func (g *grub) Present() (bool, error) { + return osutil.FileExists(filepath.Join(g.dir(), "grub.cfg")), nil +} + +func (g *grub) envFile() string { + return filepath.Join(g.dir(), "grubenv") +} + +func (g *grub) GetBootVars(names ...string) (map[string]string, error) { + out := make(map[string]string) + + env := grubenv.NewEnv(g.envFile()) + if err := env.Load(); err != nil { + return nil, err + } + + for _, name := range names { + out[name] = env.Get(name) + } + + return out, nil +} + +func (g *grub) SetBootVars(values map[string]string) error { + env := grubenv.NewEnv(g.envFile()) + if err := env.Load(); err != nil && !os.IsNotExist(err) { + return err + } + for k, v := range values { + env.Set(k, v) + } + return env.Save() +} + +func (g *grub) extractedKernelDir(prefix string, s snap.PlaceInfo) string { + return filepath.Join( + prefix, + s.Filename(), + ) +} + +func (g *grub) ExtractKernelAssets(s snap.PlaceInfo, snapf snap.Container) error { + // default kernel assets are: + // - kernel.img + // - initrd.img + // - dtbs/* + var assets []string + if g.uefiRunKernelExtraction { + assets = []string{"kernel.efi"} + } else { + assets = []string{"kernel.img", "initrd.img", "dtbs/*"} + } + + // extraction can be forced through either a special file in the kernel snap + // or through an option in the bootloader + _, err := snapf.ReadFile("meta/force-kernel-extraction") + if g.uefiRunKernelExtraction || err == nil { + return extractKernelAssetsToBootDir( + g.extractedKernelDir(g.dir(), s), + snapf, + assets, + ) + } + return nil +} + +func (g *grub) RemoveKernelAssets(s snap.PlaceInfo) error { + return removeKernelAssetsFromBootDir(g.dir(), s) +} + +// ExtractedRunKernelImageBootloader helper methods + +func (g *grub) makeKernelEfiSymlink(s snap.PlaceInfo, name string) error { + // use a relative symlink destination so that it resolves properly, if grub + // is located at /run/mnt/ubuntu-boot or /boot/grub, etc. + target := filepath.Join( + s.Filename(), + "kernel.efi", + ) + + // the location of the destination symlink as an absolute filepath + source := filepath.Join(g.dir(), name) + + // check that the kernel snap has been extracted already so we don't + // inadvertently create a dangling symlink + // expand the relative symlink from g.dir() + if !osutil.FileExists(filepath.Join(g.dir(), target)) { + return fmt.Errorf( + "cannot enable %s at %s: %v", + name, + target, + os.ErrNotExist, + ) + } + + // the symlink doesn't exist so just create it + return osutil.AtomicSymlink(target, source) +} + +// unlinkKernelEfiSymlink will remove the specified symlink if it exists. Note +// that if the symlink is "dangling", it will still remove the symlink without +// returning an error. This is useful for example to disable a try-kernel that +// was incorrectly created. +func (g *grub) unlinkKernelEfiSymlink(name string) error { + symlink := filepath.Join(g.dir(), name) + err := os.Remove(symlink) + if err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +func (g *grub) readKernelSymlink(name string) (snap.PlaceInfo, error) { + // read the symlink from / to + // // and parse the + // directory (which is supposed to be the name of the snap) into the snap + link := filepath.Join(g.dir(), name) + + // check that the symlink is not dangling before continuing + if !osutil.FileExists(link) { + return nil, fmt.Errorf("cannot read dangling symlink %s", name) + } + + targetKernelEfi, err := os.Readlink(link) + if err != nil { + return nil, fmt.Errorf("cannot read %s symlink: %v", link, err) + } + + kernelSnapFileName := filepath.Base(filepath.Dir(targetKernelEfi)) + sn, err := snap.ParsePlaceInfoFromSnapFileName(kernelSnapFileName) + if err != nil { + return nil, fmt.Errorf( + "cannot parse kernel snap file name from symlink target %q: %v", + kernelSnapFileName, + err, + ) + } + return sn, nil +} + +// actual ExtractedRunKernelImageBootloader methods + +// EnableKernel will install a kernel.efi symlink in the bootloader partition, +// pointing to the referenced kernel snap. EnableKernel() will fail if the +// referenced kernel snap does not exist. +func (g *grub) EnableKernel(s snap.PlaceInfo) error { + // add symlink from ubuntuBootPartition/kernel.efi to + // /EFI/ubuntu/.snap/kernel.efi + // so that we are consistent between uc16/uc18 and uc20 with where we + // extract kernels + return g.makeKernelEfiSymlink(s, "kernel.efi") +} + +// EnableTryKernel will install a try-kernel.efi symlink in the bootloader +// partition, pointing towards the referenced kernel snap. EnableTryKernel() +// will fail if the referenced kernel snap does not exist. +func (g *grub) EnableTryKernel(s snap.PlaceInfo) error { + // add symlink from ubuntuBootPartition/kernel.efi to + // /EFI/ubuntu/.snap/kernel.efi + // so that we are consistent between uc16/uc18 and uc20 with where we + // extract kernels + return g.makeKernelEfiSymlink(s, "try-kernel.efi") +} + +// DisableTryKernel will remove the try-kernel.efi symlink if it exists. Note +// that when performing an update, you should probably first use EnableKernel(), +// then DisableTryKernel() for maximum safety. +func (g *grub) DisableTryKernel() error { + return g.unlinkKernelEfiSymlink("try-kernel.efi") +} + +// Kernel will return the kernel snap currently installed in the bootloader +// partition, pointed to by the kernel.efi symlink. +func (g *grub) Kernel() (snap.PlaceInfo, error) { + return g.readKernelSymlink("kernel.efi") +} + +// TryKernel will return the kernel snap currently being tried if it exists and +// false if there is not currently a try-kernel.efi symlink. Note if the symlink +// exists but does not point to an existing file an error will be returned. +func (g *grub) TryKernel() (snap.PlaceInfo, error) { + // check that the _symlink_ exists, not that it points to something real + // we check for whether it is a dangling symlink inside readKernelSymlink, + // which returns an error when the symlink is dangling + _, err := os.Lstat(filepath.Join(g.dir(), "try-kernel.efi")) + if err == nil { + p, err := g.readKernelSymlink("try-kernel.efi") + // if we failed to read the symlink, then the try kernel isn't usable, + // so return err because the symlink is there + if err != nil { + return nil, err + } + return p, nil + } + return nil, ErrNoTryKernelRef +} + +// UpdateBootConfig updates the grub boot config only if it is already managed +// and has a lower edition. +// +// Implements TrustedAssetsBootloader for the grub bootloader. +func (g *grub) UpdateBootConfig() (bool, error) { + // XXX: do we need to take opts here? + bootScriptName := "grub.cfg" + currentBootConfig := filepath.Join(g.dir(), "grub.cfg") + if g.recovery { + // use the recovery asset when asked to do so + bootScriptName = "grub-recovery.cfg" + } + return genericUpdateBootConfigFromAssets(currentBootConfig, bootScriptName) +} + +// ManagedAssets returns a list relative paths to boot assets inside the root +// directory of the filesystem. +// +// Implements TrustedAssetsBootloader for the grub bootloader. +func (g *grub) ManagedAssets() []string { + return []string{ + filepath.Join(g.basedir, "grub.cfg"), + } +} + +func (g *grub) commandLineForEdition(edition uint, pieces CommandLineComponents) (string, error) { + if err := pieces.Validate(); err != nil { + return "", err + } + + var nonSnapdCmdline string + if pieces.FullArgs == "" { + staticCmdline := g.defaultCommandLineForEdition(edition) + + keepDefaultArgs := kcmdline.RemoveMatchingFilter(staticCmdline, pieces.RemoveArgs) + + nonSnapdCmdline = strutil.JoinNonEmpty(append(keepDefaultArgs, pieces.ExtraArgs), " ") + } else { + nonSnapdCmdline = pieces.FullArgs + } + args, err := kcmdline.Split(nonSnapdCmdline) + if err != nil { + return "", fmt.Errorf("cannot use badly formatted kernel command line: %v", err) + } + // join all argument with a single space, see + // grub-core/lib/cmdline.c:grub_create_loader_cmdline() for reference, + // arguments are separated by a single space, the space after last is + // replaced with terminating NULL + snapdArgs := make([]string, 0, 2) + if pieces.ModeArg != "" { + snapdArgs = append(snapdArgs, pieces.ModeArg) + } + if pieces.SystemArg != "" { + snapdArgs = append(snapdArgs, pieces.SystemArg) + } + return strings.Join(append(snapdArgs, args...), " "), nil +} + +func (g *grub) assetName() string { + if g.recovery { + return "grub-recovery.cfg" + } + + return "grub.cfg" +} + +func (g *grub) defaultCommandLineForEdition(edition uint) string { + return staticCommandLineForGrubAssetEdition(g.assetName(), edition) +} + +func editionFromDiskConfigAssetFallback(bootConfig string) (uint, error) { + edition, err := editionFromDiskConfigAsset(bootConfig) + if err != nil { + if err != errNoEdition { + return 0, err + } + // we were called using the TrustedAssetsBootloader interface + // meaning the caller expects to us to use the managed assets, + // since one on disk is not managed, use the initial edition of + // the internal boot asset which is compatible with grub.cfg + // used before we started writing out the files ourselves + edition = 1 + } + + return edition, nil +} + +// CommandLine returns the kernel command line composed of mode and +// system arguments, followed by either a built-in bootloader specific +// static arguments corresponding to the on-disk boot asset edition, and +// any extra arguments or a separate set of arguments provided in the +// components. The command line may be different when using a recovery +// bootloader. +// +// Implements TrustedAssetsBootloader for the grub bootloader. +func (g *grub) CommandLine(pieces CommandLineComponents) (string, error) { + currentBootConfig := filepath.Join(g.dir(), "grub.cfg") + + edition, err := editionFromDiskConfigAssetFallback(currentBootConfig) + if err != nil { + return "", fmt.Errorf("cannot obtain edition number of current boot config: %v", err) + } + + return g.commandLineForEdition(edition, pieces) +} + +// CandidateCommandLine is similar to CommandLine, but uses the current +// edition of managed built-in boot assets as reference. +// +// Implements TrustedAssetsBootloader for the grub bootloader. +func (g *grub) CandidateCommandLine(pieces CommandLineComponents) (string, error) { + edition, err := editionFromInternalConfigAsset(g.assetName()) + if err != nil { + return "", err + } + return g.commandLineForEdition(edition, pieces) +} + +// DefaultCommandLine returns the default kernel command-line used by +// the bootloader excluding the recovery mode and system parameters. +func (g *grub) DefaultCommandLine(candidate bool) (string, error) { + var edition uint + + // if "candidate", we look for the managed boot assets + // (current snapd) rather than the ones currently installed on + // the boot/seed disk. This is needed to know the default + // command line before candidate boot assets are installed + if candidate { + var err error + edition, err = editionFromInternalConfigAsset(g.assetName()) + if err != nil { + return "", err + } + } else { + currentBootConfig := filepath.Join(g.dir(), "grub.cfg") + + var err error + edition, err = editionFromDiskConfigAssetFallback(currentBootConfig) + if err != nil { + return "", fmt.Errorf("cannot obtain edition number of current boot config: %v", err) + } + } + + return g.defaultCommandLineForEdition(edition), nil +} + +// staticCommandLineForGrubAssetEdition fetches a static command line for given +// grub asset edition +func staticCommandLineForGrubAssetEdition(asset string, edition uint) string { + cmdline := assets.SnippetForEdition(fmt.Sprintf("%s:static-cmdline", asset), edition) + if cmdline == nil { + return "" + } + return string(cmdline) +} + +type taggedPath struct { + tag string + path string +} + +func (t taggedPath) Id() string { + basename := filepath.Base(t.path) + if t.tag == "" { + return basename + } + return fmt.Sprintf("%s:%s", t.tag, basename) +} + +// grubBootAssetPath contains the paths for assets in the boot chain. +type grubBootAssetPath struct { + defaultShimBinary taggedPath + defaultGrubBinary taggedPath + fallbackBinary taggedPath + shimBinary taggedPath + grubBinary taggedPath +} + +// grubBootAssetsForArch contains the paths for assets for different +// architectures in a map. +// For backward compliance, we do not have tags +// for asset paths that used to exist before usage of tags. +var grubBootAssetsForArch = map[string]grubBootAssetPath{ + "amd64": { + defaultShimBinary: taggedPath{ + path: filepath.Join("EFI/boot/", "bootx64.efi"), + }, + defaultGrubBinary: taggedPath{ + path: filepath.Join("EFI/boot/", "grubx64.efi"), + }, + fallbackBinary: taggedPath{ + tag: "boot", + path: filepath.Join("EFI/boot/", "fbx64.efi"), + }, + shimBinary: taggedPath{ + tag: "ubuntu", + path: filepath.Join("EFI/ubuntu/", "shimx64.efi"), + }, + grubBinary: taggedPath{ + tag: "ubuntu", + path: filepath.Join("EFI/ubuntu/", "grubx64.efi"), + }, + }, + "arm64": { + defaultShimBinary: taggedPath{ + path: filepath.Join("EFI/boot/", "bootaa64.efi"), + }, + defaultGrubBinary: taggedPath{ + path: filepath.Join("EFI/boot/", "grubaa64.efi"), + }, + fallbackBinary: taggedPath{ + tag: "boot", + path: filepath.Join("EFI/boot/", "fbaa64.efi"), + }, + shimBinary: taggedPath{ + tag: "ubuntu", + path: filepath.Join("EFI/ubuntu/", "shimaa64.efi"), + }, + grubBinary: taggedPath{ + tag: "ubuntu", + path: filepath.Join("EFI/ubuntu/", "grubaa64.efi"), + }, + }, +} + +func (g *grub) getGrubBootAssetsForArch() (*grubBootAssetPath, error) { + if g.prepareImageTime { + return nil, fmt.Errorf("internal error: retrieving boot assets at prepare image time") + } + archi := arch.DpkgArchitecture() + assets, ok := grubBootAssetsForArch[archi] + if !ok { + return nil, fmt.Errorf("cannot find grub assets for %q", archi) + } + return &assets, nil +} + +// getGrubRecoveryModeTrustedAssets returns the list of ordered asset +// chain for recovery mode, which are shim and grub from the seed +// partition. +func (g *grub) getGrubRecoveryModeTrustedAssets() ([][]taggedPath, error) { + assets, err := g.getGrubBootAssetsForArch() + if err != nil { + return nil, err + } + return [][]taggedPath{{assets.shimBinary, assets.grubBinary}, {assets.defaultShimBinary, assets.defaultGrubBinary}}, nil +} + +// getGrubRunModeTrustedAssets returns the list of ordered asset +// chains for run mode, which is grub from the boot partition. +func (g *grub) getGrubRunModeTrustedAssets() ([][]taggedPath, error) { + assets, err := g.getGrubBootAssetsForArch() + if err != nil { + return nil, err + } + return [][]taggedPath{{assets.defaultGrubBinary}}, nil +} + +// TrustedAssets returns the map of relative paths to asset +// identifers. The relative paths are relative to the bootloader's +// rootdir. The asset identifiers correspond to the backward +// compatible names recorded in the modeenv (CurrentTrustedBootAssets +// and CurrentTrustedRecoveryBootAssets). +func (g *grub) TrustedAssets() (map[string]string, error) { + if !g.nativePartitionLayout { + return nil, fmt.Errorf("internal error: trusted assets called without native host-partition layout") + } + ret := make(map[string]string) + var chains [][]taggedPath + var err error + if g.recovery { + chains, err = g.getGrubRecoveryModeTrustedAssets() + } else { + chains, err = g.getGrubRunModeTrustedAssets() + } + if err != nil { + return nil, err + } + for _, chain := range chains { + for _, asset := range chain { + ret[asset.path] = asset.Id() + } + } + return ret, nil +} + +// RecoveryBootChains returns the list of load chains for recovery modes. +// It should be called on a RoleRecovery bootloader. +func (g *grub) RecoveryBootChains(kernelPath string) ([][]BootFile, error) { + if !g.recovery { + return nil, fmt.Errorf("not a recovery bootloader") + } + + // add trusted assets to the recovery chain + assetsSet, err := g.getGrubRecoveryModeTrustedAssets() + if err != nil { + return nil, err + } + chains := make([][]BootFile, 0, len(assetsSet)) + for _, assets := range assetsSet { + chain := make([]BootFile, 0, len(assets)+1) + for _, ta := range assets { + chain = append(chain, NewBootFile("", ta.path, RoleRecovery)) + } + // add recovery kernel to the recovery chain + chain = append(chain, NewBootFile(kernelPath, "kernel.efi", RoleRecovery)) + chains = append(chains, chain) + } + + return chains, nil +} + +// BootChains returns the list of load chains for run mode. +// It should be called on a RoleRecovery bootloader passing the +// RoleRunMode bootloader. +func (g *grub) BootChains(runBl Bootloader, kernelPath string) ([][]BootFile, error) { + if !g.recovery { + return nil, fmt.Errorf("not a recovery bootloader") + } + if runBl.Name() != "grub" { + return nil, fmt.Errorf("run mode bootloader must be grub") + } + + // add trusted assets to the recovery chain + recoveryModeAssetsSet, err := g.getGrubRecoveryModeTrustedAssets() + if err != nil { + return nil, err + } + runModeAssetsSet, err := g.getGrubRunModeTrustedAssets() + if err != nil { + return nil, err + } + chains := make([][]BootFile, 0, len(recoveryModeAssetsSet)*len(runModeAssetsSet)) + for _, recoveryModeAssets := range recoveryModeAssetsSet { + for _, runModeAssets := range runModeAssetsSet { + chain := make([]BootFile, 0, len(recoveryModeAssets)+len(runModeAssets)+1) + for _, ta := range recoveryModeAssets { + chain = append(chain, NewBootFile("", ta.path, RoleRecovery)) + } + for _, ta := range runModeAssets { + chain = append(chain, NewBootFile("", ta.path, RoleRunMode)) + } + // add kernel to the boot chain + chain = append(chain, NewBootFile(kernelPath, "kernel.efi", RoleRunMode)) + chains = append(chains, chain) + } + } + + return chains, nil +} diff --git a/bootloader/grub_test.go b/bootloader/grub_test.go new file mode 100644 index 00000000..fdc153e9 --- /dev/null +++ b/bootloader/grub_test.go @@ -0,0 +1,1423 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2014-2024 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package bootloader_test + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/mvo5/goconfigparser" + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/arch" + "github.com/snapcore/snapd/arch/archtest" + "github.com/snapcore/snapd/bootloader" + "github.com/snapcore/snapd/bootloader/assets" + "github.com/snapcore/snapd/bootloader/grubenv" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/snap/snapfile" + "github.com/snapcore/snapd/snap/snaptest" + "github.com/snapcore/snapd/testutil" +) + +type grubTestSuite struct { + baseBootenvTestSuite + + bootdir string +} + +var _ = Suite(&grubTestSuite{}) + +func (s *grubTestSuite) SetUpTest(c *C) { + s.baseBootenvTestSuite.SetUpTest(c) + bootloader.MockGrubFiles(c, s.rootdir) + + s.bootdir = filepath.Join(s.rootdir, "boot") + // By default assume amd64 in the tests: there are specialized + // tests for other arches + s.AddCleanup(archtest.MockArchitecture("amd64")) + snippets := []assets.ForEditions{ + {FirstEdition: 1, Snippet: []byte("console=ttyS0 console=tty1 panic=-1")}, + } + s.AddCleanup(assets.MockSnippetsForEdition("grub.cfg:static-cmdline", snippets)) + s.AddCleanup(assets.MockSnippetsForEdition("grub-recovery.cfg:static-cmdline", snippets)) +} + +// grubEditenvCmd finds the right grub{,2}-editenv command +func grubEditenvCmd() string { + for _, exe := range []string{"grub2-editenv", "grub-editenv"} { + if osutil.ExecutableExists(exe) { + return exe + } + } + return "" +} + +func grubEnvPath(rootdir string) string { + return filepath.Join(rootdir, "boot/grub/grubenv") +} + +func (s *grubTestSuite) grubEditenvSet(c *C, key, value string) { + if grubEditenvCmd() == "" { + c.Skip("grub{,2}-editenv is not available") + } + + output, err := exec.Command(grubEditenvCmd(), grubEnvPath(s.rootdir), "set", fmt.Sprintf("%s=%s", key, value)).CombinedOutput() + c.Check(err, IsNil) + c.Check(string(output), Equals, "") +} + +func (s *grubTestSuite) grubEditenvGet(c *C, key string) string { + if grubEditenvCmd() == "" { + c.Skip("grub{,2}-editenv is not available") + } + + output, err := exec.Command(grubEditenvCmd(), grubEnvPath(s.rootdir), "list").CombinedOutput() + c.Assert(err, IsNil) + cfg := goconfigparser.New() + cfg.AllowNoSectionHeader = true + err = cfg.ReadString(string(output)) + c.Assert(err, IsNil) + v, err := cfg.Get("", key) + c.Assert(err, IsNil) + return v +} + +func (s *grubTestSuite) makeFakeGrubEnv(c *C) { + s.grubEditenvSet(c, "k", "v") +} + +func (s *grubTestSuite) TestNewGrub(c *C) { + // no files means bl is not present, but we can still create the bl object + c.Assert(os.RemoveAll(s.rootdir), IsNil) + g := bootloader.NewGrub(s.rootdir, nil) + c.Assert(g, NotNil) + c.Assert(g.Name(), Equals, "grub") + + present, err := g.Present() + c.Assert(err, IsNil) + c.Assert(present, Equals, false) + + // now with files present, the bl is present + bootloader.MockGrubFiles(c, s.rootdir) + s.makeFakeGrubEnv(c) + present, err = g.Present() + c.Assert(err, IsNil) + c.Assert(present, Equals, true) +} + +func (s *grubTestSuite) TestGetBootloaderWithGrub(c *C) { + s.makeFakeGrubEnv(c) + + bootloader, err := bootloader.Find(s.rootdir, nil) + c.Assert(err, IsNil) + c.Assert(bootloader.Name(), Equals, "grub") +} + +func (s *grubTestSuite) TestGetBootloaderWithGrubWithDefaultRoot(c *C) { + s.makeFakeGrubEnv(c) + + dirs.SetRootDir(s.rootdir) + defer func() { dirs.SetRootDir("") }() + + bootloader, err := bootloader.Find("", nil) + c.Assert(err, IsNil) + c.Assert(bootloader.Name(), Equals, "grub") +} + +func (s *grubTestSuite) TestGetBootVer(c *C) { + s.makeFakeGrubEnv(c) + s.grubEditenvSet(c, "snap_mode", "regular") + + g := bootloader.NewGrub(s.rootdir, nil) + v, err := g.GetBootVars("snap_mode") + c.Assert(err, IsNil) + c.Check(v, HasLen, 1) + c.Check(v["snap_mode"], Equals, "regular") +} + +func (s *grubTestSuite) TestSetBootVer(c *C) { + s.makeFakeGrubEnv(c) + + g := bootloader.NewGrub(s.rootdir, nil) + err := g.SetBootVars(map[string]string{ + "k1": "v1", + "k2": "v2", + }) + c.Assert(err, IsNil) + + c.Check(s.grubEditenvGet(c, "k1"), Equals, "v1") + c.Check(s.grubEditenvGet(c, "k2"), Equals, "v2") +} + +func (s *grubTestSuite) TestExtractKernelAssetsNoUnpacksKernelForGrub(c *C) { + s.makeFakeGrubEnv(c) + + g := bootloader.NewGrub(s.rootdir, nil) + + files := [][]string{ + {"kernel.img", "I'm a kernel"}, + {"initrd.img", "...and I'm an initrd"}, + {"meta/kernel.yaml", "version: 4.2"}, + } + si := &snap.SideInfo{ + RealName: "ubuntu-kernel", + Revision: snap.R(42), + } + fn := snaptest.MakeTestSnapWithFiles(c, packageKernel, files) + snapf, err := snapfile.Open(fn) + c.Assert(err, IsNil) + + info, err := snap.ReadInfoFromSnapFile(snapf, si) + c.Assert(err, IsNil) + + err = g.ExtractKernelAssets(info, snapf) + c.Assert(err, IsNil) + + // kernel is *not* here + kernimg := filepath.Join(s.bootdir, "grub", "ubuntu-kernel_42.snap", "kernel.img") + c.Assert(osutil.FileExists(kernimg), Equals, false) +} + +func (s *grubTestSuite) TestExtractKernelForceWorks(c *C) { + s.makeFakeGrubEnv(c) + + g := bootloader.NewGrub(s.rootdir, nil) + c.Assert(g, NotNil) + + files := [][]string{ + {"kernel.img", "I'm a kernel"}, + {"initrd.img", "...and I'm an initrd"}, + {"meta/force-kernel-extraction", ""}, + {"meta/kernel.yaml", "version: 4.2"}, + } + si := &snap.SideInfo{ + RealName: "ubuntu-kernel", + Revision: snap.R(42), + } + fn := snaptest.MakeTestSnapWithFiles(c, packageKernel, files) + snapf, err := snapfile.Open(fn) + c.Assert(err, IsNil) + + info, err := snap.ReadInfoFromSnapFile(snapf, si) + c.Assert(err, IsNil) + + err = g.ExtractKernelAssets(info, snapf) + c.Assert(err, IsNil) + + // kernel is extracted + kernimg := filepath.Join(s.bootdir, "grub", "ubuntu-kernel_42.snap", "kernel.img") + c.Assert(osutil.FileExists(kernimg), Equals, true) + // initrd + initrdimg := filepath.Join(s.bootdir, "grub", "ubuntu-kernel_42.snap", "initrd.img") + c.Assert(osutil.FileExists(initrdimg), Equals, true) + + // ensure that removal of assets also works + err = g.RemoveKernelAssets(info) + c.Assert(err, IsNil) + exists, _, err := osutil.DirExists(filepath.Dir(kernimg)) + c.Assert(err, IsNil) + c.Check(exists, Equals, false) +} + +func (s *grubTestSuite) grubDir() string { + return filepath.Join(s.bootdir, "grub") +} + +func (s *grubTestSuite) grubEFINativeDir() string { + return filepath.Join(s.rootdir, "EFI/ubuntu") +} + +func (s *grubTestSuite) makeFakeGrubEFINativeEnv(c *C, content []byte) { + err := os.MkdirAll(s.grubEFINativeDir(), 0755) + c.Assert(err, IsNil) + err = os.WriteFile(filepath.Join(s.grubEFINativeDir(), "grub.cfg"), content, 0644) + c.Assert(err, IsNil) +} + +func (s *grubTestSuite) makeFakeShimFallback(c *C) { + err := os.MkdirAll(filepath.Join(s.rootdir, "/EFI/boot"), 0755) + c.Assert(err, IsNil) + _, err = os.Create(filepath.Join(s.rootdir, "/EFI/boot/fbx64.efi")) + c.Assert(err, IsNil) + _, err = os.Create(filepath.Join(s.rootdir, "/EFI/boot/fbaa64.efi")) + c.Assert(err, IsNil) +} + +func (s *grubTestSuite) TestNewGrubWithOptionRecovery(c *C) { + s.makeFakeGrubEFINativeEnv(c, nil) + + g := bootloader.NewGrub(s.rootdir, &bootloader.Options{Role: bootloader.RoleRecovery}) + c.Assert(g, NotNil) + c.Assert(g.Name(), Equals, "grub") +} + +func (s *grubTestSuite) TestNewGrubWithOptionRecoveryBootEnv(c *C) { + s.makeFakeGrubEFINativeEnv(c, nil) + g := bootloader.NewGrub(s.rootdir, &bootloader.Options{Role: bootloader.RoleRecovery}) + + // check that setting vars goes to the right place + c.Check(filepath.Join(s.grubEFINativeDir(), "grubenv"), testutil.FileAbsent) + err := g.SetBootVars(map[string]string{ + "k1": "v1", + "k2": "v2", + }) + c.Assert(err, IsNil) + c.Check(filepath.Join(s.grubEFINativeDir(), "grubenv"), testutil.FilePresent) + + env, err := g.GetBootVars("k1", "k2") + c.Assert(err, IsNil) + c.Check(env, DeepEquals, map[string]string{ + "k1": "v1", + "k2": "v2", + }) +} + +func (s *grubTestSuite) TestNewGrubWithOptionRecoveryNoEnv(c *C) { + // fake a *regular* grub env + s.makeFakeGrubEnv(c) + + // we can't create a recovery grub with that + g, err := bootloader.Find(s.rootdir, &bootloader.Options{Role: bootloader.RoleRecovery}) + c.Assert(g, IsNil) + c.Assert(err, Equals, bootloader.ErrBootloader) +} + +func (s *grubTestSuite) TestGrubSetRecoverySystemEnv(c *C) { + s.makeFakeGrubEFINativeEnv(c, nil) + g := bootloader.NewGrub(s.rootdir, &bootloader.Options{Role: bootloader.RoleRecovery}) + + // check that we can set a recovery system specific bootenv + bvars := map[string]string{ + "snapd_recovery_kernel": "/snaps/pc-kernel_1.snap", + "other_options": "are-supported", + } + + err := g.SetRecoverySystemEnv("/systems/20191209", bvars) + c.Assert(err, IsNil) + recoverySystemGrubenv := filepath.Join(s.rootdir, "/systems/20191209/grubenv") + c.Assert(recoverySystemGrubenv, testutil.FilePresent) + + genv := grubenv.NewEnv(recoverySystemGrubenv) + err = genv.Load() + c.Assert(err, IsNil) + c.Check(genv.Get("snapd_recovery_kernel"), Equals, "/snaps/pc-kernel_1.snap") + c.Check(genv.Get("other_options"), Equals, "are-supported") +} + +func (s *grubTestSuite) TestGetRecoverySystemEnv(c *C) { + s.makeFakeGrubEFINativeEnv(c, nil) + g := bootloader.NewGrub(s.rootdir, &bootloader.Options{Role: bootloader.RoleRecovery}) + + err := os.MkdirAll(filepath.Join(s.rootdir, "/systems/20191209"), 0755) + c.Assert(err, IsNil) + recoverySystemGrubenv := filepath.Join(s.rootdir, "/systems/20191209/grubenv") + + // does not fail when there is no recovery env + value, err := g.GetRecoverySystemEnv("/systems/20191209", "no_file") + c.Assert(err, IsNil) + c.Check(value, Equals, "") + + genv := grubenv.NewEnv(recoverySystemGrubenv) + genv.Set("snapd_extra_cmdline_args", "foo bar baz") + genv.Set("random_option", `has "some spaces"`) + err = genv.Save() + c.Assert(err, IsNil) + + value, err = g.GetRecoverySystemEnv("/systems/20191209", "snapd_extra_cmdline_args") + c.Assert(err, IsNil) + c.Check(value, Equals, "foo bar baz") + value, err = g.GetRecoverySystemEnv("/systems/20191209", "random_option") + c.Assert(err, IsNil) + c.Check(value, Equals, `has "some spaces"`) + value, err = g.GetRecoverySystemEnv("/systems/20191209", "not_set") + c.Assert(err, IsNil) + c.Check(value, Equals, ``) +} + +func (s *grubTestSuite) makeKernelAssetSnap(c *C, snapFileName string) snap.PlaceInfo { + kernelSnap, err := snap.ParsePlaceInfoFromSnapFileName(snapFileName) + c.Assert(err, IsNil) + + // make a kernel.efi snap as it would be by ExtractKernelAssets() + kernelSnapExtractedAssetsDir := filepath.Join(s.grubDir(), snapFileName) + err = os.MkdirAll(kernelSnapExtractedAssetsDir, 0755) + c.Assert(err, IsNil) + + err = os.WriteFile(filepath.Join(kernelSnapExtractedAssetsDir, "kernel.efi"), nil, 0644) + c.Assert(err, IsNil) + + return kernelSnap +} + +func (s *grubTestSuite) makeKernelAssetSnapAndSymlink(c *C, snapFileName, symlinkName string) snap.PlaceInfo { + kernelSnap := s.makeKernelAssetSnap(c, snapFileName) + + // make a kernel.efi symlink to the kernel.efi above + err := os.Symlink( + filepath.Join(snapFileName, "kernel.efi"), + filepath.Join(s.grubDir(), symlinkName), + ) + c.Assert(err, IsNil) + + return kernelSnap +} + +func (s *grubTestSuite) TestGrubExtractedRunKernelImageKernel(c *C) { + s.makeFakeGrubEnv(c) + g := bootloader.NewGrub(s.rootdir, nil) + eg, ok := g.(bootloader.ExtractedRunKernelImageBootloader) + c.Assert(ok, Equals, true) + + kernel := s.makeKernelAssetSnapAndSymlink(c, "pc-kernel_1.snap", "kernel.efi") + + // ensure that the returned kernel is the same as the one we put there + sn, err := eg.Kernel() + c.Assert(err, IsNil) + c.Assert(sn, DeepEquals, kernel) +} + +func (s *grubTestSuite) TestGrubExtractedRunKernelImageTryKernel(c *C) { + s.makeFakeGrubEnv(c) + g := bootloader.NewGrub(s.rootdir, nil) + eg, ok := g.(bootloader.ExtractedRunKernelImageBootloader) + c.Assert(ok, Equals, true) + + // ensure it doesn't return anything when the symlink doesn't exist + _, err := eg.TryKernel() + c.Assert(err, Equals, bootloader.ErrNoTryKernelRef) + + // when a bad kernel snap name is in the extracted path, it will complain + // appropriately + kernelSnapExtractedAssetsDir := filepath.Join(s.grubDir(), "bad_snap_rev_name") + badKernelSnapPath := filepath.Join(kernelSnapExtractedAssetsDir, "kernel.efi") + tryKernelSymlink := filepath.Join(s.grubDir(), "try-kernel.efi") + err = os.MkdirAll(kernelSnapExtractedAssetsDir, 0755) + c.Assert(err, IsNil) + + err = os.WriteFile(badKernelSnapPath, nil, 0644) + c.Assert(err, IsNil) + + err = os.Symlink("bad_snap_rev_name/kernel.efi", tryKernelSymlink) + c.Assert(err, IsNil) + + _, err = eg.TryKernel() + c.Assert(err, ErrorMatches, "cannot parse kernel snap file name from symlink target \"bad_snap_rev_name\": .*") + + // remove the bad symlink + err = os.Remove(tryKernelSymlink) + c.Assert(err, IsNil) + + // make a real symlink + tryKernel := s.makeKernelAssetSnapAndSymlink(c, "pc-kernel_2.snap", "try-kernel.efi") + + // ensure that the returned kernel is the same as the one we put there + sn, err := eg.TryKernel() + c.Assert(err, IsNil) + c.Assert(sn, DeepEquals, tryKernel) + + // if the destination of the symlink is removed, we get an error + err = os.Remove(filepath.Join(s.grubDir(), "pc-kernel_2.snap", "kernel.efi")) + c.Assert(err, IsNil) + _, err = eg.TryKernel() + c.Assert(err, ErrorMatches, "cannot read dangling symlink try-kernel.efi") +} + +func (s *grubTestSuite) TestGrubExtractedRunKernelImageEnableKernel(c *C) { + s.makeFakeGrubEnv(c) + g := bootloader.NewGrub(s.rootdir, nil) + eg, ok := g.(bootloader.ExtractedRunKernelImageBootloader) + c.Assert(ok, Equals, true) + + // ensure we fail to create a dangling symlink to a kernel snap that was not + // actually extracted + nonExistSnap, err := snap.ParsePlaceInfoFromSnapFileName("pc-kernel_12.snap") + c.Assert(err, IsNil) + err = eg.EnableKernel(nonExistSnap) + c.Assert(err, ErrorMatches, "cannot enable kernel.efi at pc-kernel_12.snap/kernel.efi: file does not exist") + + kernel := s.makeKernelAssetSnap(c, "pc-kernel_1.snap") + + // enable the Kernel we extracted + err = eg.EnableKernel(kernel) + c.Assert(err, IsNil) + + // ensure that the symlink was put where we expect it + asset, err := os.Readlink(filepath.Join(s.grubDir(), "kernel.efi")) + c.Assert(err, IsNil) + c.Assert(asset, DeepEquals, filepath.Join("pc-kernel_1.snap", "kernel.efi")) + + // create a new kernel snap and ensure that we can safely enable that one + // too + kernel2 := s.makeKernelAssetSnap(c, "pc-kernel_2.snap") + err = eg.EnableKernel(kernel2) + c.Assert(err, IsNil) + + // ensure that the symlink was put where we expect it + asset, err = os.Readlink(filepath.Join(s.grubDir(), "kernel.efi")) + c.Assert(err, IsNil) + c.Assert(asset, DeepEquals, filepath.Join("pc-kernel_2.snap", "kernel.efi")) +} + +func (s *grubTestSuite) TestGrubExtractedRunKernelImageEnableTryKernel(c *C) { + s.makeFakeGrubEnv(c) + g := bootloader.NewGrub(s.rootdir, nil) + eg, ok := g.(bootloader.ExtractedRunKernelImageBootloader) + c.Assert(ok, Equals, true) + + kernel := s.makeKernelAssetSnap(c, "pc-kernel_1.snap") + + // enable the Kernel we extracted + err := eg.EnableTryKernel(kernel) + c.Assert(err, IsNil) + + // ensure that the symlink was put where we expect it + asset, err := os.Readlink(filepath.Join(s.grubDir(), "try-kernel.efi")) + c.Assert(err, IsNil) + + c.Assert(asset, DeepEquals, filepath.Join("pc-kernel_1.snap", "kernel.efi")) +} + +func (s *grubTestSuite) TestGrubExtractedRunKernelImageDisableTryKernel(c *C) { + if os.Geteuid() == 0 { + c.Skip("the test cannot be run by the root user") + } + + s.makeFakeGrubEnv(c) + g := bootloader.NewGrub(s.rootdir, nil) + eg, ok := g.(bootloader.ExtractedRunKernelImageBootloader) + c.Assert(ok, Equals, true) + + // trying to disable when the try-kernel.efi symlink is missing does not + // raise any errors + err := eg.DisableTryKernel() + c.Assert(err, IsNil) + + // make the symlink and check that the symlink is missing afterwards + s.makeKernelAssetSnapAndSymlink(c, "pc-kernel_1.snap", "try-kernel.efi") + // make sure symlink is there + c.Assert(filepath.Join(s.grubDir(), "try-kernel.efi"), testutil.FilePresent) + + err = eg.DisableTryKernel() + c.Assert(err, IsNil) + + // ensure that the symlink is no longer there + c.Assert(filepath.Join(s.grubDir(), "try-kernel.efi"), testutil.FileAbsent) + c.Assert(filepath.Join(s.grubDir(), "pc-kernel_1.snap/kernel.efi"), testutil.FilePresent) + + // try again but make sure that the directory cannot be written to + s.makeKernelAssetSnapAndSymlink(c, "pc-kernel_1.snap", "try-kernel.efi") + err = os.Chmod(s.grubDir(), 000) + c.Assert(err, IsNil) + defer os.Chmod(s.grubDir(), 0755) + + err = eg.DisableTryKernel() + c.Assert(err, ErrorMatches, "remove .*/grub/try-kernel.efi: permission denied") +} + +func (s *grubTestSuite) TestKernelExtractionRunImageKernel(c *C) { + s.makeFakeGrubEnv(c) + + g := bootloader.NewGrub(s.rootdir, &bootloader.Options{Role: bootloader.RoleRunMode}) + c.Assert(g, NotNil) + + files := [][]string{ + {"kernel.efi", "I'm a kernel"}, + {"another-kernel-file", "another kernel file"}, + {"meta/kernel.yaml", "version: 4.2"}, + } + si := &snap.SideInfo{ + RealName: "ubuntu-kernel", + Revision: snap.R(42), + } + fn := snaptest.MakeTestSnapWithFiles(c, packageKernel, files) + snapf, err := snapfile.Open(fn) + c.Assert(err, IsNil) + + info, err := snap.ReadInfoFromSnapFile(snapf, si) + c.Assert(err, IsNil) + + err = g.ExtractKernelAssets(info, snapf) + c.Assert(err, IsNil) + + // kernel is extracted + kernefi := filepath.Join(s.bootdir, "grub", "ubuntu-kernel_42.snap", "kernel.efi") + c.Assert(kernefi, testutil.FilePresent) + // other file is not extracted + other := filepath.Join(s.bootdir, "grub", "ubuntu-kernel_42.snap", "another-kernel-file") + c.Assert(other, testutil.FileAbsent) + + // ensure that removal of assets also works + err = g.RemoveKernelAssets(info) + c.Assert(err, IsNil) + exists, _, err := osutil.DirExists(filepath.Dir(kernefi)) + c.Assert(err, IsNil) + c.Check(exists, Equals, false) +} + +func (s *grubTestSuite) TestKernelExtractionRunImageKernelNoSlashBoot(c *C) { + // this is ubuntu-boot but during install we use the native EFI/ubuntu + // layout, same as Recovery, without the /boot mount + s.makeFakeGrubEFINativeEnv(c, nil) + + g := bootloader.NewGrub(s.rootdir, &bootloader.Options{Role: bootloader.RoleRunMode, NoSlashBoot: true}) + c.Assert(g, NotNil) + + files := [][]string{ + {"kernel.efi", "I'm a kernel"}, + {"another-kernel-file", "another kernel file"}, + {"meta/kernel.yaml", "version: 4.2"}, + } + si := &snap.SideInfo{ + RealName: "ubuntu-kernel", + Revision: snap.R(42), + } + fn := snaptest.MakeTestSnapWithFiles(c, packageKernel, files) + snapf, err := snapfile.Open(fn) + c.Assert(err, IsNil) + + info, err := snap.ReadInfoFromSnapFile(snapf, si) + c.Assert(err, IsNil) + + err = g.ExtractKernelAssets(info, snapf) + c.Assert(err, IsNil) + + // kernel is extracted + kernefi := filepath.Join(s.rootdir, "EFI/ubuntu", "ubuntu-kernel_42.snap", "kernel.efi") + c.Assert(kernefi, testutil.FilePresent) + // other file is not extracted + other := filepath.Join(s.rootdir, "EFI/ubuntu", "ubuntu-kernel_42.snap", "another-kernel-file") + c.Assert(other, testutil.FileAbsent) + + // enable the Kernel we extracted + eg, ok := g.(bootloader.ExtractedRunKernelImageBootloader) + c.Assert(ok, Equals, true) + err = eg.EnableKernel(info) + c.Assert(err, IsNil) + + // ensure that the symlink was put where we expect it + asset, err := os.Readlink(filepath.Join(s.rootdir, "EFI/ubuntu", "kernel.efi")) + c.Assert(err, IsNil) + + c.Assert(asset, DeepEquals, filepath.Join("ubuntu-kernel_42.snap", "kernel.efi")) + + // ensure that removal of assets also works + err = g.RemoveKernelAssets(info) + c.Assert(err, IsNil) + exists, _, err := osutil.DirExists(filepath.Dir(kernefi)) + c.Assert(err, IsNil) + c.Check(exists, Equals, false) +} + +func (s *grubTestSuite) TestListTrustedAssetsNotForArch(c *C) { + oldArch := arch.DpkgArchitecture() + defer arch.SetArchitecture(arch.ArchitectureType(oldArch)) + arch.SetArchitecture("non-existing-architecture") + + s.makeFakeGrubEFINativeEnv(c, []byte(`this is +some random boot config`)) + + opts := &bootloader.Options{NoSlashBoot: true} + g := bootloader.NewGrub(s.rootdir, opts) + c.Assert(g, NotNil) + + tg, ok := g.(bootloader.TrustedAssetsBootloader) + c.Assert(ok, Equals, true) + + ta, err := tg.TrustedAssets() + c.Check(err, ErrorMatches, `cannot find grub assets for "non-existing-architecture"`) + c.Check(ta, HasLen, 0) +} + +func (s *grubTestSuite) TestListManagedAssets(c *C) { + s.makeFakeGrubEFINativeEnv(c, []byte(`this is +some random boot config`)) + + opts := &bootloader.Options{NoSlashBoot: true} + g := bootloader.NewGrub(s.rootdir, opts) + c.Assert(g, NotNil) + + tg, ok := g.(bootloader.TrustedAssetsBootloader) + c.Assert(ok, Equals, true) + + c.Check(tg.ManagedAssets(), DeepEquals, []string{ + "EFI/ubuntu/grub.cfg", + }) + + opts = &bootloader.Options{Role: bootloader.RoleRecovery} + tg = bootloader.NewGrub(s.rootdir, opts).(bootloader.TrustedAssetsBootloader) + c.Check(tg.ManagedAssets(), DeepEquals, []string{ + "EFI/ubuntu/grub.cfg", + }) + + // as it called for the root fs rather than a mount point of a partition + // with boot assets + tg = bootloader.NewGrub(s.rootdir, nil).(bootloader.TrustedAssetsBootloader) + c.Check(tg.ManagedAssets(), DeepEquals, []string{ + "boot/grub/grub.cfg", + }) +} + +func (s *grubTestSuite) TestRecoveryUpdateBootConfigNoEdition(c *C) { + // native EFI/ubuntu setup + s.makeFakeGrubEFINativeEnv(c, []byte("recovery boot script")) + + opts := &bootloader.Options{Role: bootloader.RoleRecovery} + g := bootloader.NewGrub(s.rootdir, opts) + c.Assert(g, NotNil) + + restore := assets.MockInternal("grub-recovery.cfg", []byte(`# Snapd-Boot-Config-Edition: 5 +this is mocked grub-recovery.conf +`)) + defer restore() + + tg, ok := g.(bootloader.TrustedAssetsBootloader) + c.Assert(ok, Equals, true) + // install the recovery boot script + updated, err := tg.UpdateBootConfig() + c.Assert(err, IsNil) + c.Assert(updated, Equals, false) + c.Assert(filepath.Join(s.grubEFINativeDir(), "grub.cfg"), testutil.FileEquals, `recovery boot script`) +} + +func (s *grubTestSuite) TestRecoveryUpdateBootConfigUpdates(c *C) { + // native EFI/ubuntu setup + s.makeFakeGrubEFINativeEnv(c, []byte(`# Snapd-Boot-Config-Edition: 1 +recovery boot script`)) + + opts := &bootloader.Options{Role: bootloader.RoleRecovery} + g := bootloader.NewGrub(s.rootdir, opts) + c.Assert(g, NotNil) + + restore := assets.MockInternal("grub-recovery.cfg", []byte(`# Snapd-Boot-Config-Edition: 3 +this is mocked grub-recovery.conf +`)) + defer restore() + restore = assets.MockInternal("grub.cfg", []byte(`# Snapd-Boot-Config-Edition: 4 +this is mocked grub.conf +`)) + defer restore() + tg, ok := g.(bootloader.TrustedAssetsBootloader) + c.Assert(ok, Equals, true) + // install the recovery boot script + updated, err := tg.UpdateBootConfig() + c.Assert(err, IsNil) + c.Assert(updated, Equals, true) + // the recovery boot asset was picked + c.Assert(filepath.Join(s.grubEFINativeDir(), "grub.cfg"), testutil.FileEquals, `# Snapd-Boot-Config-Edition: 3 +this is mocked grub-recovery.conf +`) +} + +func (s *grubTestSuite) testBootUpdateBootConfigUpdates(c *C, oldConfig, newConfig string, update bool) { + // native EFI/ubuntu setup + s.makeFakeGrubEFINativeEnv(c, []byte(oldConfig)) + + opts := &bootloader.Options{NoSlashBoot: true} + g := bootloader.NewGrub(s.rootdir, opts) + c.Assert(g, NotNil) + + restore := assets.MockInternal("grub.cfg", []byte(newConfig)) + defer restore() + + tg, ok := g.(bootloader.TrustedAssetsBootloader) + c.Assert(ok, Equals, true) + updated, err := tg.UpdateBootConfig() + c.Assert(err, IsNil) + c.Assert(updated, Equals, update) + if update { + c.Assert(filepath.Join(s.grubEFINativeDir(), "grub.cfg"), testutil.FileEquals, newConfig) + } else { + c.Assert(filepath.Join(s.grubEFINativeDir(), "grub.cfg"), testutil.FileEquals, oldConfig) + } +} + +func (s *grubTestSuite) TestNoSlashBootUpdateBootConfigNoUpdateWhenNotManaged(c *C) { + oldConfig := `not managed` + newConfig := `# Snapd-Boot-Config-Edition: 3 +this update is not applied +` + // the current boot config is not managed, no update applied + const updateApplied = false + s.testBootUpdateBootConfigUpdates(c, oldConfig, newConfig, updateApplied) +} + +func (s *grubTestSuite) TestNoSlashBootUpdateBootConfigUpdates(c *C) { + oldConfig := `# Snapd-Boot-Config-Edition: 2 +boot script +` + // edition is higher, update is applied + newConfig := `# Snapd-Boot-Config-Edition: 3 +this is updated grub.cfg +` + const updateApplied = true + s.testBootUpdateBootConfigUpdates(c, oldConfig, newConfig, updateApplied) +} + +func (s *grubTestSuite) TestNoSlashBootUpdateBootConfigNoUpdate(c *C) { + oldConfig := `# Snapd-Boot-Config-Edition: 2 +boot script +` + // edition is lower, no update is applied + newConfig := `# Snapd-Boot-Config-Edition: 1 +this is updated grub.cfg +` + const updateApplied = false + s.testBootUpdateBootConfigUpdates(c, oldConfig, newConfig, updateApplied) +} + +func (s *grubTestSuite) TestNoSlashBootUpdateBootConfigSameEdition(c *C) { + oldConfig := `# Snapd-Boot-Config-Edition: 1 +boot script +` + // edition is equal, no update is applied + newConfig := `# Snapd-Boot-Config-Edition: 1 +this is updated grub.cfg +` + const updateApplied = false + s.testBootUpdateBootConfigUpdates(c, oldConfig, newConfig, updateApplied) +} + +func (s *grubTestSuite) TestBootUpdateBootConfigTrivialErr(c *C) { + if os.Geteuid() == 0 { + c.Skip("the test cannot be run by the root user") + } + + oldConfig := `# Snapd-Boot-Config-Edition: 2 +boot script +` + // edition is higher, update is applied + newConfig := `# Snapd-Boot-Config-Edition: 3 +this is updated grub.cfg +` + // native EFI/ubuntu setup + s.makeFakeGrubEFINativeEnv(c, []byte(oldConfig)) + restore := assets.MockInternal("grub.cfg", []byte(newConfig)) + defer restore() + + opts := &bootloader.Options{NoSlashBoot: true} + g := bootloader.NewGrub(s.rootdir, opts) + c.Assert(g, NotNil) + tg, ok := g.(bootloader.TrustedAssetsBootloader) + c.Assert(ok, Equals, true) + + err := os.Chmod(s.grubEFINativeDir(), 0000) + c.Assert(err, IsNil) + defer os.Chmod(s.grubEFINativeDir(), 0755) + + updated, err := tg.UpdateBootConfig() + c.Assert(err, ErrorMatches, "cannot load existing config asset: .*/EFI/ubuntu/grub.cfg: permission denied") + c.Assert(updated, Equals, false) + err = os.Chmod(s.grubEFINativeDir(), 0555) + c.Assert(err, IsNil) + + c.Assert(filepath.Join(s.grubEFINativeDir(), "grub.cfg"), testutil.FileEquals, oldConfig) + + // writing out new config fails + err = os.Chmod(s.grubEFINativeDir(), 0111) + c.Assert(err, IsNil) + updated, err = tg.UpdateBootConfig() + c.Assert(err, ErrorMatches, `open .*/EFI/ubuntu/grub.cfg\..+: permission denied`) + c.Assert(updated, Equals, false) + c.Assert(filepath.Join(s.grubEFINativeDir(), "grub.cfg"), testutil.FileEquals, oldConfig) +} + +func (s *grubTestSuite) TestStaticCmdlineForGrubAsset(c *C) { + restore := assets.MockSnippetsForEdition("grub-asset:static-cmdline", []assets.ForEditions{ + {FirstEdition: 2, Snippet: []byte(`static cmdline "with spaces"`)}, + }) + defer restore() + cmdline := bootloader.StaticCommandLineForGrubAssetEdition("grub-asset", 1) + c.Check(cmdline, Equals, ``) + cmdline = bootloader.StaticCommandLineForGrubAssetEdition("grub-asset", 2) + c.Check(cmdline, Equals, `static cmdline "with spaces"`) + cmdline = bootloader.StaticCommandLineForGrubAssetEdition("grub-asset", 4) + c.Check(cmdline, Equals, `static cmdline "with spaces"`) +} + +func (s *grubTestSuite) TestCommandLineNotManaged(c *C) { + grubCfg := "boot script\n" + + // native EFI/ubuntu setup + s.makeFakeGrubEFINativeEnv(c, []byte(grubCfg)) + + restore := assets.MockSnippetsForEdition("grub.cfg:static-cmdline", []assets.ForEditions{ + {FirstEdition: 1, Snippet: []byte(`static=1`)}, + {FirstEdition: 2, Snippet: []byte(`static=2`)}, + }) + defer restore() + restore = assets.MockSnippetsForEdition("grub-recovery.cfg:static-cmdline", []assets.ForEditions{ + {FirstEdition: 1, Snippet: []byte(`static=1 recovery`)}, + {FirstEdition: 2, Snippet: []byte(`static=2 recovery`)}, + }) + defer restore() + + opts := &bootloader.Options{NoSlashBoot: true} + mg := bootloader.NewGrub(s.rootdir, opts).(bootloader.TrustedAssetsBootloader) + + args, err := mg.CommandLine(bootloader.CommandLineComponents{ + ModeArg: "snapd_recovery_mode=run", + ExtraArgs: "extra", + }) + c.Assert(err, IsNil) + c.Check(args, Equals, "snapd_recovery_mode=run static=1 extra") + + optsRecovery := &bootloader.Options{NoSlashBoot: true, Role: bootloader.RoleRecovery} + mgr := bootloader.NewGrub(s.rootdir, optsRecovery).(bootloader.TrustedAssetsBootloader) + + args, err = mgr.CommandLine(bootloader.CommandLineComponents{ + ModeArg: "snapd_recovery_mode=recover", + SystemArg: "snapd_recovery_system=1234", + ExtraArgs: "extra", + }) + c.Assert(err, IsNil) + c.Check(args, Equals, "snapd_recovery_mode=recover snapd_recovery_system=1234 static=1 recovery extra") +} + +func (s *grubTestSuite) TestCommandLineMocked(c *C) { + grubCfg := `# Snapd-Boot-Config-Edition: 2 +boot script +` + staticCmdline := `arg1 foo=123 panic=-1 arg2="with spaces "` + staticCmdlineEdition3 := `edition=3 static args` + restore := assets.MockSnippetsForEdition("grub.cfg:static-cmdline", []assets.ForEditions{ + {FirstEdition: 1, Snippet: []byte(staticCmdline)}, + {FirstEdition: 3, Snippet: []byte(staticCmdlineEdition3)}, + }) + defer restore() + staticCmdlineRecovery := `recovery config panic=-1` + restore = assets.MockSnippetsForEdition("grub-recovery.cfg:static-cmdline", []assets.ForEditions{ + {FirstEdition: 1, Snippet: []byte(staticCmdlineRecovery)}, + }) + defer restore() + + // native EFI/ubuntu setup + s.makeFakeGrubEFINativeEnv(c, []byte(grubCfg)) + + optsNoSlashBoot := &bootloader.Options{NoSlashBoot: true} + g := bootloader.NewGrub(s.rootdir, optsNoSlashBoot) + c.Assert(g, NotNil) + tg, ok := g.(bootloader.TrustedAssetsBootloader) + c.Assert(ok, Equals, true) + + extraArgs := `extra_arg=1 extra_foo=-1 panic=3 baz="more spaces"` + args, err := tg.CommandLine(bootloader.CommandLineComponents{ + ModeArg: "snapd_recovery_mode=run", + ExtraArgs: extraArgs, + }) + c.Assert(err, IsNil) + c.Check(args, Equals, `snapd_recovery_mode=run arg1 foo=123 panic=-1 arg2="with spaces " extra_arg=1 extra_foo=-1 panic=3 baz="more spaces"`) + + // empty mode/system do not produce confusing results + args, err = tg.CommandLine(bootloader.CommandLineComponents{ + ExtraArgs: extraArgs, + }) + c.Assert(err, IsNil) + c.Check(args, Equals, `arg1 foo=123 panic=-1 arg2="with spaces " extra_arg=1 extra_foo=-1 panic=3 baz="more spaces"`) + + // now check the recovery bootloader + optsRecovery := &bootloader.Options{NoSlashBoot: true, Role: bootloader.RoleRecovery} + mrg := bootloader.NewGrub(s.rootdir, optsRecovery).(bootloader.TrustedAssetsBootloader) + args, err = mrg.CommandLine(bootloader.CommandLineComponents{ + ModeArg: "snapd_recovery_mode=recover", + SystemArg: "snapd_recovery_system=20200202", + ExtraArgs: extraArgs, + }) + c.Assert(err, IsNil) + // static command line from recovery asset + c.Check(args, Equals, `snapd_recovery_mode=recover snapd_recovery_system=20200202 recovery config panic=-1 extra_arg=1 extra_foo=-1 panic=3 baz="more spaces"`) + + // try with a different edition + grubCfg3 := `# Snapd-Boot-Config-Edition: 3 +boot script +` + s.makeFakeGrubEFINativeEnv(c, []byte(grubCfg3)) + tg = bootloader.NewGrub(s.rootdir, optsNoSlashBoot).(bootloader.TrustedAssetsBootloader) + c.Assert(g, NotNil) + extraArgs = `extra_arg=1` + args, err = tg.CommandLine(bootloader.CommandLineComponents{ + ModeArg: "snapd_recovery_mode=run", + ExtraArgs: extraArgs, + }) + c.Assert(err, IsNil) + c.Check(args, Equals, `snapd_recovery_mode=run edition=3 static args extra_arg=1`) + + // full args set overrides static arguments + args, err = tg.CommandLine(bootloader.CommandLineComponents{ + ModeArg: "snapd_recovery_mode=run", + FullArgs: "full for run mode", + }) + c.Assert(err, IsNil) + c.Check(args, Equals, `snapd_recovery_mode=run full for run mode`) + args, err = mrg.CommandLine(bootloader.CommandLineComponents{ + ModeArg: "snapd_recovery_mode=recover", + SystemArg: "snapd_recovery_system=20200202", + FullArgs: "full for recover mode", + }) + c.Assert(err, IsNil) + c.Check(args, Equals, `snapd_recovery_mode=recover snapd_recovery_system=20200202 full for recover mode`) + +} + +func (s *grubTestSuite) TestCandidateCommandLineMocked(c *C) { + grubCfg := `# Snapd-Boot-Config-Edition: 1 +boot script +` + // edition on disk + s.makeFakeGrubEFINativeEnv(c, []byte(grubCfg)) + + edition2 := []byte(`# Snapd-Boot-Config-Edition: 2`) + edition3 := []byte(`# Snapd-Boot-Config-Edition: 3`) + edition4 := []byte(`# Snapd-Boot-Config-Edition: 4`) + + restore := assets.MockInternal("grub.cfg", edition2) + defer restore() + restore = assets.MockInternal("grub-recovery.cfg", edition2) + defer restore() + + restore = assets.MockSnippetsForEdition("grub.cfg:static-cmdline", []assets.ForEditions{ + {FirstEdition: 1, Snippet: []byte(`edition=1`)}, + {FirstEdition: 3, Snippet: []byte(`edition=3`)}, + }) + defer restore() + restore = assets.MockSnippetsForEdition("grub-recovery.cfg:static-cmdline", []assets.ForEditions{ + {FirstEdition: 1, Snippet: []byte(`recovery edition=1`)}, + {FirstEdition: 3, Snippet: []byte(`recovery edition=3`)}, + {FirstEdition: 4, Snippet: []byte(`recovery edition=4up`)}, + }) + defer restore() + + optsNoSlashBoot := &bootloader.Options{NoSlashBoot: true} + mg := bootloader.NewGrub(s.rootdir, optsNoSlashBoot).(bootloader.TrustedAssetsBootloader) + optsRecovery := &bootloader.Options{NoSlashBoot: true, Role: bootloader.RoleRecovery} + recoverymg := bootloader.NewGrub(s.rootdir, optsRecovery).(bootloader.TrustedAssetsBootloader) + + args, err := mg.CandidateCommandLine(bootloader.CommandLineComponents{ + ModeArg: "snapd_recovery_mode=run", + ExtraArgs: "extra=1", + }) + c.Assert(err, IsNil) + c.Check(args, Equals, `snapd_recovery_mode=run edition=1 extra=1`) + args, err = recoverymg.CandidateCommandLine(bootloader.CommandLineComponents{ + ModeArg: "snapd_recovery_mode=recover", + SystemArg: "snapd_recovery_system=20200202", + ExtraArgs: "extra=1", + }) + c.Assert(err, IsNil) + c.Check(args, Equals, `snapd_recovery_mode=recover snapd_recovery_system=20200202 recovery edition=1 extra=1`) + + restore = assets.MockInternal("grub.cfg", edition3) + defer restore() + restore = assets.MockInternal("grub-recovery.cfg", edition3) + defer restore() + + args, err = mg.CandidateCommandLine(bootloader.CommandLineComponents{ + ModeArg: "snapd_recovery_mode=run", + ExtraArgs: "extra=1", + }) + c.Assert(err, IsNil) + c.Check(args, Equals, `snapd_recovery_mode=run edition=3 extra=1`) + args, err = recoverymg.CandidateCommandLine(bootloader.CommandLineComponents{ + ModeArg: "snapd_recovery_mode=recover", + SystemArg: "snapd_recovery_system=20200202", + ExtraArgs: "extra=1", + }) + c.Assert(err, IsNil) + c.Check(args, Equals, `snapd_recovery_mode=recover snapd_recovery_system=20200202 recovery edition=3 extra=1`) + + // bump edition only for recovery + restore = assets.MockInternal("grub-recovery.cfg", edition4) + defer restore() + // boot bootloader unchanged + args, err = mg.CandidateCommandLine(bootloader.CommandLineComponents{ + ModeArg: "snapd_recovery_mode=run", + ExtraArgs: "extra=1", + }) + c.Assert(err, IsNil) + c.Check(args, Equals, `snapd_recovery_mode=run edition=3 extra=1`) + // recovery uses a new edition + args, err = recoverymg.CandidateCommandLine(bootloader.CommandLineComponents{ + ModeArg: "snapd_recovery_mode=recover", + SystemArg: "snapd_recovery_system=20200202", + ExtraArgs: "extra=1", + }) + c.Assert(err, IsNil) + c.Check(args, Equals, `snapd_recovery_mode=recover snapd_recovery_system=20200202 recovery edition=4up extra=1`) + + // the static snippet is ignored when using full arg set + args, err = recoverymg.CandidateCommandLine(bootloader.CommandLineComponents{ + ModeArg: "snapd_recovery_mode=recover", + SystemArg: "snapd_recovery_system=20200202", + FullArgs: "full args set", + }) + c.Assert(err, IsNil) + c.Check(args, Equals, `snapd_recovery_mode=recover snapd_recovery_system=20200202 full args set`) +} + +func (s *grubTestSuite) TestCommandLineReal(c *C) { + grubCfg := `# Snapd-Boot-Config-Edition: 1 +boot script +` + // native EFI/ubuntu setup + s.makeFakeGrubEFINativeEnv(c, []byte(grubCfg)) + + opts := &bootloader.Options{NoSlashBoot: true} + g := bootloader.NewGrub(s.rootdir, opts) + c.Assert(g, NotNil) + tg, ok := g.(bootloader.TrustedAssetsBootloader) + c.Assert(ok, Equals, true) + + extraArgs := "foo bar baz=1" + args, err := tg.CommandLine(bootloader.CommandLineComponents{ + ModeArg: "snapd_recovery_mode=run", + ExtraArgs: extraArgs, + }) + c.Assert(err, IsNil) + c.Check(args, Equals, `snapd_recovery_mode=run console=ttyS0 console=tty1 panic=-1 foo bar baz=1`) + // with full args the static part is not used + args, err = tg.CommandLine(bootloader.CommandLineComponents{ + ModeArg: "snapd_recovery_mode=run", + FullArgs: "full for run mode", + }) + c.Assert(err, IsNil) + c.Check(args, Equals, `snapd_recovery_mode=run full for run mode`) + + // now check the recovery bootloader + opts = &bootloader.Options{NoSlashBoot: true, Role: bootloader.RoleRecovery} + mrg := bootloader.NewGrub(s.rootdir, opts).(bootloader.TrustedAssetsBootloader) + args, err = mrg.CommandLine(bootloader.CommandLineComponents{ + ModeArg: "snapd_recovery_mode=recover", + SystemArg: "snapd_recovery_system=20200202", + ExtraArgs: extraArgs, + }) + c.Assert(err, IsNil) + // static command line from recovery asset + c.Check(args, Equals, `snapd_recovery_mode=recover snapd_recovery_system=20200202 console=ttyS0 console=tty1 panic=-1 foo bar baz=1`) + // similarly, when passed full args, the static part is not used + args, err = mrg.CommandLine(bootloader.CommandLineComponents{ + ModeArg: "snapd_recovery_mode=recover", + SystemArg: "snapd_recovery_system=20200202", + FullArgs: "full for recover mode", + }) + c.Assert(err, IsNil) + c.Check(args, Equals, `snapd_recovery_mode=recover snapd_recovery_system=20200202 full for recover mode`) +} + +func (s *grubTestSuite) TestCommandLineComponentsValidate(c *C) { + grubCfg := `# Snapd-Boot-Config-Edition: 1 +boot script +` + // native EFI/ubuntu setup + s.makeFakeGrubEFINativeEnv(c, []byte(grubCfg)) + + opts := &bootloader.Options{NoSlashBoot: true} + g := bootloader.NewGrub(s.rootdir, opts) + c.Assert(g, NotNil) + tg, ok := g.(bootloader.TrustedAssetsBootloader) + c.Assert(ok, Equals, true) + + args, err := tg.CommandLine(bootloader.CommandLineComponents{ + ModeArg: "snapd_recovery_mode=run", + ExtraArgs: "extra is set", + FullArgs: "full is set", + }) + c.Assert(err, ErrorMatches, "cannot use both full and extra components of command line") + c.Check(args, Equals, "") + // invalid for the candidate command line too + args, err = tg.CandidateCommandLine(bootloader.CommandLineComponents{ + ModeArg: "snapd_recovery_mode=run", + ExtraArgs: "extra is set", + FullArgs: "full is set", + }) + c.Assert(err, ErrorMatches, "cannot use both full and extra components of command line") + c.Check(args, Equals, "") + + // now check the recovery bootloader + opts = &bootloader.Options{NoSlashBoot: true, Role: bootloader.RoleRecovery} + mrg := bootloader.NewGrub(s.rootdir, opts).(bootloader.TrustedAssetsBootloader) + args, err = mrg.CommandLine(bootloader.CommandLineComponents{ + ModeArg: "snapd_recovery_mode=recover", + SystemArg: "snapd_recovery_system=20200202", + ExtraArgs: "extra is set", + FullArgs: "full is set", + }) + c.Assert(err, ErrorMatches, "cannot use both full and extra components of command line") + c.Check(args, Equals, "") + // candidate recovery command line is checks validity of the components too + args, err = mrg.CandidateCommandLine(bootloader.CommandLineComponents{ + ModeArg: "snapd_recovery_mode=recover", + SystemArg: "snapd_recovery_system=20200202", + ExtraArgs: "extra is set", + FullArgs: "full is set", + }) + c.Assert(err, ErrorMatches, "cannot use both full and extra components of command line") + c.Check(args, Equals, "") +} + +func (s *grubTestSuite) TestTrustedAssetsNativePartitionLayout(c *C) { + // native EFI/ubuntu setup + s.makeFakeGrubEFINativeEnv(c, []byte("grub.cfg")) + opts := &bootloader.Options{NoSlashBoot: true} + g := bootloader.NewGrub(s.rootdir, opts) + c.Assert(g, NotNil) + + tab, ok := g.(bootloader.TrustedAssetsBootloader) + c.Assert(ok, Equals, true) + + ta, err := tab.TrustedAssets() + c.Assert(err, IsNil) + c.Check(ta, DeepEquals, map[string]string{ + "EFI/boot/grubx64.efi": "grubx64.efi", + }) + + // recovery bootloader + recoveryOpts := &bootloader.Options{NoSlashBoot: true, Role: bootloader.RoleRecovery} + tarb := bootloader.NewGrub(s.rootdir, recoveryOpts).(bootloader.TrustedAssetsBootloader) + c.Assert(tarb, NotNil) + + ta, err = tarb.TrustedAssets() + c.Assert(err, IsNil) + c.Check(ta, DeepEquals, map[string]string{ + "EFI/boot/bootx64.efi": "bootx64.efi", + "EFI/boot/grubx64.efi": "grubx64.efi", + "EFI/ubuntu/shimx64.efi": "ubuntu:shimx64.efi", + "EFI/ubuntu/grubx64.efi": "ubuntu:grubx64.efi", + }) + + // recovery bootloader, with fallback implemented + s.makeFakeShimFallback(c) + tarb = bootloader.NewGrub(s.rootdir, recoveryOpts).(bootloader.TrustedAssetsBootloader) + c.Assert(tarb, NotNil) + + ta, err = tarb.TrustedAssets() + c.Assert(err, IsNil) + c.Check(ta, DeepEquals, map[string]string{ + "EFI/ubuntu/shimx64.efi": "ubuntu:shimx64.efi", + "EFI/ubuntu/grubx64.efi": "ubuntu:grubx64.efi", + "EFI/boot/bootx64.efi": "bootx64.efi", + "EFI/boot/grubx64.efi": "grubx64.efi", + }) +} + +func (s *grubTestSuite) TestTrustedAssetsRoot(c *C) { + s.makeFakeGrubEnv(c) + g := bootloader.NewGrub(s.rootdir, nil) + tab, ok := g.(bootloader.TrustedAssetsBootloader) + c.Assert(ok, Equals, true) + + ta, err := tab.TrustedAssets() + c.Assert(err, ErrorMatches, "internal error: trusted assets called without native host-partition layout") + c.Check(ta, IsNil) +} + +func (s *grubTestSuite) TestTrustedAssetsFailAtPrepareImageTime(c *C) { + // native EFI/ubuntu setup + s.makeFakeGrubEFINativeEnv(c, []byte("grub.cfg")) + + opts := []bootloader.Options{ + {NoSlashBoot: true, PrepareImageTime: true}, + {NoSlashBoot: true, PrepareImageTime: true, Role: bootloader.RoleRecovery}} + for _, opt := range opts { + g := bootloader.NewGrub(s.rootdir, &opt) + c.Assert(g, NotNil) + + tab, ok := g.(bootloader.TrustedAssetsBootloader) + c.Assert(ok, Equals, true) + + ta, err := tab.TrustedAssets() + c.Assert(err, ErrorMatches, "internal error: retrieving boot assets at prepare image time") + c.Check(ta, IsNil) + } +} + +func (s *grubTestSuite) TestRecoveryBootChains(c *C) { + s.makeFakeGrubEFINativeEnv(c, nil) + g := bootloader.NewGrub(s.rootdir, &bootloader.Options{Role: bootloader.RoleRecovery}) + tab, ok := g.(bootloader.TrustedAssetsBootloader) + c.Assert(ok, Equals, true) + + chains, err := tab.RecoveryBootChains("kernel.snap") + c.Assert(err, IsNil) + c.Assert(chains, HasLen, 2) + c.Assert(chains[0], DeepEquals, []bootloader.BootFile{ + {Path: "EFI/ubuntu/shimx64.efi", Role: bootloader.RoleRecovery}, + {Path: "EFI/ubuntu/grubx64.efi", Role: bootloader.RoleRecovery}, + {Snap: "kernel.snap", Path: "kernel.efi", Role: bootloader.RoleRecovery}, + }) + c.Assert(chains[1], DeepEquals, []bootloader.BootFile{ + {Path: "EFI/boot/bootx64.efi", Role: bootloader.RoleRecovery}, + {Path: "EFI/boot/grubx64.efi", Role: bootloader.RoleRecovery}, + {Snap: "kernel.snap", Path: "kernel.efi", Role: bootloader.RoleRecovery}, + }) +} + +func (s *grubTestSuite) TestRecoveryBootChainsWithFallback(c *C) { + s.makeFakeShimFallback(c) + s.makeFakeGrubEFINativeEnv(c, nil) + g := bootloader.NewGrub(s.rootdir, &bootloader.Options{Role: bootloader.RoleRecovery}) + tab, ok := g.(bootloader.TrustedAssetsBootloader) + c.Assert(ok, Equals, true) + + chains, err := tab.RecoveryBootChains("kernel.snap") + c.Assert(err, IsNil) + c.Assert(chains, HasLen, 2) + c.Assert(chains[0], DeepEquals, []bootloader.BootFile{ + {Path: "EFI/ubuntu/shimx64.efi", Role: bootloader.RoleRecovery}, + {Path: "EFI/ubuntu/grubx64.efi", Role: bootloader.RoleRecovery}, + {Snap: "kernel.snap", Path: "kernel.efi", Role: bootloader.RoleRecovery}, + }) + c.Assert(chains[1], DeepEquals, []bootloader.BootFile{ + {Path: "EFI/boot/bootx64.efi", Role: bootloader.RoleRecovery}, + {Path: "EFI/boot/grubx64.efi", Role: bootloader.RoleRecovery}, + {Snap: "kernel.snap", Path: "kernel.efi", Role: bootloader.RoleRecovery}, + }) +} + +func (s *grubTestSuite) TestRecoveryBootChainsNotRecoveryBootloader(c *C) { + s.makeFakeGrubEnv(c) + g := bootloader.NewGrub(s.rootdir, nil) + tab, ok := g.(bootloader.TrustedAssetsBootloader) + c.Assert(ok, Equals, true) + + _, err := tab.RecoveryBootChains("kernel.snap") + c.Assert(err, ErrorMatches, "not a recovery bootloader") +} + +func (s *grubTestSuite) TestBootChains(c *C) { + s.makeFakeGrubEFINativeEnv(c, nil) + g := bootloader.NewGrub(s.rootdir, &bootloader.Options{Role: bootloader.RoleRecovery}) + tab, ok := g.(bootloader.TrustedAssetsBootloader) + c.Assert(ok, Equals, true) + + g2 := bootloader.NewGrub(s.rootdir, &bootloader.Options{Role: bootloader.RoleRunMode}) + + chains, err := tab.BootChains(g2, "kernel.snap") + c.Assert(err, IsNil) + c.Assert(chains, HasLen, 2) + c.Assert(chains[0], DeepEquals, []bootloader.BootFile{ + {Path: "EFI/ubuntu/shimx64.efi", Role: bootloader.RoleRecovery}, + {Path: "EFI/ubuntu/grubx64.efi", Role: bootloader.RoleRecovery}, + {Path: "EFI/boot/grubx64.efi", Role: bootloader.RoleRunMode}, + {Snap: "kernel.snap", Path: "kernel.efi", Role: bootloader.RoleRunMode}, + }) + c.Assert(chains[1], DeepEquals, []bootloader.BootFile{ + {Path: "EFI/boot/bootx64.efi", Role: bootloader.RoleRecovery}, + {Path: "EFI/boot/grubx64.efi", Role: bootloader.RoleRecovery}, + {Path: "EFI/boot/grubx64.efi", Role: bootloader.RoleRunMode}, + {Snap: "kernel.snap", Path: "kernel.efi", Role: bootloader.RoleRunMode}, + }) +} + +func (s *grubTestSuite) TestBootChainsWithFallback(c *C) { + s.makeFakeShimFallback(c) + s.makeFakeGrubEFINativeEnv(c, nil) + g := bootloader.NewGrub(s.rootdir, &bootloader.Options{Role: bootloader.RoleRecovery}) + tab, ok := g.(bootloader.TrustedAssetsBootloader) + c.Assert(ok, Equals, true) + + g2 := bootloader.NewGrub(s.rootdir, &bootloader.Options{Role: bootloader.RoleRunMode}) + + chains, err := tab.BootChains(g2, "kernel.snap") + c.Assert(err, IsNil) + c.Assert(chains, HasLen, 2) + c.Assert(chains[0], DeepEquals, []bootloader.BootFile{ + {Path: "EFI/ubuntu/shimx64.efi", Role: bootloader.RoleRecovery}, + {Path: "EFI/ubuntu/grubx64.efi", Role: bootloader.RoleRecovery}, + {Path: "EFI/boot/grubx64.efi", Role: bootloader.RoleRunMode}, + {Snap: "kernel.snap", Path: "kernel.efi", Role: bootloader.RoleRunMode}, + }) + c.Assert(chains[1], DeepEquals, []bootloader.BootFile{ + {Path: "EFI/boot/bootx64.efi", Role: bootloader.RoleRecovery}, + {Path: "EFI/boot/grubx64.efi", Role: bootloader.RoleRecovery}, + {Path: "EFI/boot/grubx64.efi", Role: bootloader.RoleRunMode}, + {Snap: "kernel.snap", Path: "kernel.efi", Role: bootloader.RoleRunMode}, + }) +} + +func (s *grubTestSuite) TestBootChainsArm64(c *C) { + s.makeFakeGrubEFINativeEnv(c, nil) + r := archtest.MockArchitecture("arm64") + defer r() + g := bootloader.NewGrub(s.rootdir, &bootloader.Options{Role: bootloader.RoleRecovery}) + tab, ok := g.(bootloader.TrustedAssetsBootloader) + c.Assert(ok, Equals, true) + + g2 := bootloader.NewGrub(s.rootdir, &bootloader.Options{Role: bootloader.RoleRunMode}) + + chains, err := tab.BootChains(g2, "kernel.snap") + c.Assert(err, IsNil) + c.Assert(chains, HasLen, 2) + c.Assert(chains[0], DeepEquals, []bootloader.BootFile{ + {Path: "EFI/ubuntu/shimaa64.efi", Role: bootloader.RoleRecovery}, + {Path: "EFI/ubuntu/grubaa64.efi", Role: bootloader.RoleRecovery}, + {Path: "EFI/boot/grubaa64.efi", Role: bootloader.RoleRunMode}, + {Snap: "kernel.snap", Path: "kernel.efi", Role: bootloader.RoleRunMode}, + }) + c.Assert(chains[1], DeepEquals, []bootloader.BootFile{ + {Path: "EFI/boot/bootaa64.efi", Role: bootloader.RoleRecovery}, + {Path: "EFI/boot/grubaa64.efi", Role: bootloader.RoleRecovery}, + {Path: "EFI/boot/grubaa64.efi", Role: bootloader.RoleRunMode}, + {Snap: "kernel.snap", Path: "kernel.efi", Role: bootloader.RoleRunMode}, + }) +} + +func (s *grubTestSuite) TestBootChainsArm64WithFallback(c *C) { + s.makeFakeGrubEFINativeEnv(c, nil) + s.makeFakeShimFallback(c) + r := archtest.MockArchitecture("arm64") + defer r() + g := bootloader.NewGrub(s.rootdir, &bootloader.Options{Role: bootloader.RoleRecovery}) + tab, ok := g.(bootloader.TrustedAssetsBootloader) + c.Assert(ok, Equals, true) + + g2 := bootloader.NewGrub(s.rootdir, &bootloader.Options{Role: bootloader.RoleRunMode}) + + chains, err := tab.BootChains(g2, "kernel.snap") + c.Assert(err, IsNil) + c.Assert(chains, HasLen, 2) + c.Assert(chains[0], DeepEquals, []bootloader.BootFile{ + {Path: "EFI/ubuntu/shimaa64.efi", Role: bootloader.RoleRecovery}, + {Path: "EFI/ubuntu/grubaa64.efi", Role: bootloader.RoleRecovery}, + {Path: "EFI/boot/grubaa64.efi", Role: bootloader.RoleRunMode}, + {Snap: "kernel.snap", Path: "kernel.efi", Role: bootloader.RoleRunMode}, + }) + c.Assert(chains[1], DeepEquals, []bootloader.BootFile{ + {Path: "EFI/boot/bootaa64.efi", Role: bootloader.RoleRecovery}, + {Path: "EFI/boot/grubaa64.efi", Role: bootloader.RoleRecovery}, + {Path: "EFI/boot/grubaa64.efi", Role: bootloader.RoleRunMode}, + {Snap: "kernel.snap", Path: "kernel.efi", Role: bootloader.RoleRunMode}, + }) +} + +func (s *grubTestSuite) TestBootChainsNotRecoveryBootloader(c *C) { + s.makeFakeGrubEnv(c) + g := bootloader.NewGrub(s.rootdir, nil) + tab, ok := g.(bootloader.TrustedAssetsBootloader) + c.Assert(ok, Equals, true) + + g2 := bootloader.NewGrub(s.rootdir, &bootloader.Options{NoSlashBoot: true, Role: bootloader.RoleRunMode}) + + _, err := tab.BootChains(g2, "kernel.snap") + c.Assert(err, ErrorMatches, "not a recovery bootloader") +} diff --git a/bootloader/grubenv/grubenv.go b/bootloader/grubenv/grubenv.go new file mode 100644 index 00000000..46a8e621 --- /dev/null +++ b/bootloader/grubenv/grubenv.go @@ -0,0 +1,116 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2014-2015 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package grubenv + +import ( + "bytes" + "fmt" + "os" + + "github.com/snapcore/snapd/strutil" +) + +// FIXME: support for escaping (embedded \n in grubenv) missing +type Env struct { + env map[string]string + ordering []string + + path string +} + +func NewEnv(path string) *Env { + return &Env{ + env: make(map[string]string), + path: path, + } +} + +func (g *Env) Get(name string) string { + return g.env[name] +} + +func (g *Env) Set(key, value string) { + if !strutil.ListContains(g.ordering, key) { + g.ordering = append(g.ordering, key) + } + + g.env[key] = value +} + +func (g *Env) Load() error { + buf, err := os.ReadFile(g.path) + if err != nil { + return err + } + if len(buf) != 1024 { + return fmt.Errorf("grubenv %q must be exactly 1024 byte, got %d", g.path, len(buf)) + } + if !bytes.HasPrefix(buf, []byte("# GRUB Environment Block\n")) { + return fmt.Errorf("cannot find grubenv header in %q", g.path) + } + rawEnv := bytes.Split(buf, []byte("\n")) + for _, env := range rawEnv[1:] { + l := bytes.SplitN(env, []byte("="), 2) + // be liberal in what you accept + if len(l) < 2 { + continue + } + k := string(l[0]) + v := string(l[1]) + g.env[k] = v + g.ordering = append(g.ordering, k) + } + + return nil +} + +func (g *Env) Save() error { + w := bytes.NewBuffer(nil) + w.Grow(1024) + + fmt.Fprintf(w, "# GRUB Environment Block\n") + for _, k := range g.ordering { + if _, err := fmt.Fprintf(w, "%s=%s\n", k, g.env[k]); err != nil { + return err + } + } + if w.Len() > 1024 { + return fmt.Errorf("cannot write grubenv %q: bigger than 1024 bytes (%d)", g.path, w.Len()) + } + content := w.Bytes()[:w.Cap()] + for i := w.Len(); i < len(content); i++ { + content[i] = '#' + } + + // write in place to avoid the file moving on disk + // (thats what grubenv is also doing) + f, err := os.Create(g.path) + if err != nil { + return err + } + if _, err := f.Write(content); err != nil { + return err + } + if err := f.Sync(); err != nil { + return err + } + + return f.Close() +} diff --git a/bootloader/grubenv/grubenv_test.go b/bootloader/grubenv/grubenv_test.go new file mode 100644 index 00000000..4b0d79cb --- /dev/null +++ b/bootloader/grubenv/grubenv_test.go @@ -0,0 +1,92 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2014-2015 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package grubenv_test + +import ( + "fmt" + "path/filepath" + "testing" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/bootloader/grubenv" + "github.com/snapcore/snapd/testutil" +) + +// Hook up check.v1 into the "go test" runner +func Test(t *testing.T) { TestingT(t) } + +type grubenvTestSuite struct { + envPath string +} + +var _ = Suite(&grubenvTestSuite{}) + +func (g *grubenvTestSuite) SetUpTest(c *C) { + g.envPath = filepath.Join(c.MkDir(), "grubenv") +} + +func (g *grubenvTestSuite) TestSet(c *C) { + env := grubenv.NewEnv(g.envPath) + c.Check(env, NotNil) + + env.Set("key", "value") + c.Check(env.Get("key"), Equals, "value") +} + +func (g *grubenvTestSuite) TestSave(c *C) { + env := grubenv.NewEnv(g.envPath) + c.Check(env, NotNil) + + env.Set("key1", "value1") + env.Set("key2", "value2") + env.Set("key3", "value3") + env.Set("key4", "value4") + env.Set("key5", "value5") + env.Set("key6", "value6") + env.Set("key7", "value7") + // set "key1" again, ordering (position) does not change + env.Set("key1", "value1") + + err := env.Save() + c.Assert(err, IsNil) + + c.Assert(g.envPath, testutil.FileEquals, `# GRUB Environment Block +key1=value1 +key2=value2 +key3=value3 +key4=value4 +key5=value5 +key6=value6 +key7=value7 +###################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################`) +} + +func (g *grubenvTestSuite) TestSaveOverflow(c *C) { + env := grubenv.NewEnv(g.envPath) + c.Check(env, NotNil) + + for i := 0; i < 101; i++ { + env.Set(fmt.Sprintf("key%d", i), "foo") + } + + err := env.Save() + c.Assert(err, ErrorMatches, `cannot write grubenv .*: bigger than 1024 bytes \(1026\)`) +} diff --git a/bootloader/lk.go b/bootloader/lk.go new file mode 100644 index 00000000..188374f4 --- /dev/null +++ b/bootloader/lk.go @@ -0,0 +1,516 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package bootloader + +import ( + "fmt" + "io" + "os" + "path/filepath" + + "golang.org/x/xerrors" + + "github.com/snapcore/snapd/bootloader/lkenv" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/logger" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/osutil/disks" + "github.com/snapcore/snapd/osutil/kcmdline" + "github.com/snapcore/snapd/snap" +) + +const ( + backupStorage = true + primaryStorage = false +) + +type lk struct { + rootdir string + prepareImageTime bool + + // role is what bootloader role we are, which also maps to which version of + // the underlying lkenv struct we use for bootenv + // * RoleSole == uc16 -> v1 + // * RoleRecovery == uc20 + recovery -> v2 recovery + // * RoleRunMode == uc20 + run -> v2 run + role Role + + // blDisk is what disk the bootloader informed us to use to look for the + // bootloader structure partitions + blDisk disks.Disk +} + +// newLk create a new lk bootloader object +func newLk(rootdir string, opts *Options) Bootloader { + l := &lk{rootdir: rootdir} + + l.processOpts(opts) + + return l +} + +func (l *lk) processOpts(opts *Options) { + if opts != nil { + // XXX: in the long run we want this to go away, we probably add + // something like "boot.PrepareImage()" and add an (optional) + // method "PrepareImage" to the bootloader interface that is + // used to setup a bootloader from prepare-image if things + // are very different from runtime vs image-building mode. + // + // determine mode we are in, runtime or image build + + l.prepareImageTime = opts.PrepareImageTime + + l.role = opts.Role + } +} + +func (l *lk) Name() string { + return "lk" +} + +func (l *lk) dir() string { + if l.prepareImageTime { + // at prepare-image time, then use rootdir and look for /boot/lk/ - + // this is only used in prepare-image time where the binary files exist + // extracted from the gadget + return filepath.Join(l.rootdir, "/boot/lk/") + } + + // for runtime, we should only be using dir() for V1 and the dir is just + // the udev by-partlabel directory + switch l.role { + case RoleSole: + // TODO: this should be adjusted to try and use the kernel cmdline + // parameter for the disk that the bootloader says to find + // the lk partitions on if provided like the UC20 case does, but + // that involves changing many more tests, so let's do that in a + // followup PR + return filepath.Join(l.rootdir, "/dev/disk/by-partlabel/") + case RoleRecovery, RoleRunMode: + // TODO: maybe panic'ing here is a bit harsh... + panic("internal error: shouldn't be using lk.dir() for UC20+ runtime modes!") + default: + panic("unexpected bootloader role for lk dir") + } +} + +func (l *lk) InstallBootConfig(gadgetDir string, opts *Options) error { + // make sure that the opts are put into the object + l.processOpts(opts) + gadgetFile := filepath.Join(gadgetDir, l.Name()+".conf") + // since we are just installing static files from the gadget, there is no + // backup to copy, the backup will be created automatically (if allowed) by + // lkenv when we go to Save() the environment file. + systemFile, err := l.envBackstore(primaryStorage) + if err != nil { + return err + } + return genericInstallBootConfig(gadgetFile, systemFile) +} + +func (l *lk) Present() (bool, error) { + // if we are in prepare-image mode or in V1, just check the env file + if l.prepareImageTime || l.role == RoleSole { + primary, err := l.envBackstore(primaryStorage) + if err != nil { + return false, err + } + + if osutil.FileExists(primary) { + return true, nil + } + + // at prepare-image time, we won't have a backup file from the gadget, + // so just give up here + if l.prepareImageTime { + return false, nil + } + + // but at runtime we should check the backup in case the primary + // partition got corrupted + backup, err := l.envBackstore(backupStorage) + if err != nil { + return false, err + } + return osutil.FileExists(backup), nil + } + + // otherwise for V2, non-sole bootloader roles we need to check on the + // partition name existing, note that devPathForPartName will only return + // partiallyFound as true if it reasonably concludes that this is a lk + // device, so in that case forward err, otherwise return err as nil + partitionLabel := l.partLabelForRole() + _, partiallyFound, err := l.devPathForPartName(partitionLabel) + if partiallyFound { + return true, err + } + return false, nil +} + +func (l *lk) partLabelForRole() string { + // TODO: should the partition labels be fetched from gadget.yaml instead? we + // have roles that we could use in the gadget.yaml structures to find + // them + label := "" + switch l.role { + case RoleSole, RoleRunMode: + label = "snapbootsel" + case RoleRecovery: + label = "snaprecoverysel" + default: + panic(fmt.Sprintf("unknown bootloader role for littlekernel: %s", l.role)) + } + return label +} + +// envBackstore returns a filepath for the lkenv bootloader environment file. +// For prepare-image time operations, it will be a normal config file; for +// runtime operations it will be a device file from a udev-created symlink in +// /dev/disk. If backup is true then the filename is suffixed with "bak" or at +// runtime the partition label is suffixed with "bak". +func (l *lk) envBackstore(backup bool) (string, error) { + partitionLabelOrConfFile := l.partLabelForRole() + if backup { + partitionLabelOrConfFile += "bak" + } + if l.prepareImageTime { + // at prepare-image time, we just use the env file, but append .bin + // since it is a file from the gadget we will evenutally install into + // a partition when flashing the image + return filepath.Join(l.dir(), partitionLabelOrConfFile+".bin"), nil + } + + if l.role == RoleSole { + // for V1, we just use the partition label directly, dir() here will be + // the udev by-partlabel symlink dir. + // see TODO: in l.dir(), this should eventually also be using + // devPathForPartName() too + return filepath.Join(l.dir(), partitionLabelOrConfFile), nil + } + + // for RoleRun or RoleRecovery, we need to find the partition securely + partitionFile, _, err := l.devPathForPartName(partitionLabelOrConfFile) + if err != nil { + return "", err + } + return partitionFile, nil +} + +// devPathForPartName returns the environment file in /dev for the partition +// name, which will always be a partition on the disk given by +// the kernel command line parameter "snapd_lk_boot_disk" set by the bootloader. +// It returns a boolean as the second parameter which is primarily used by +// Present() to indicate if the searching process got "far enough" to reasonably +// conclude that the device is using a lk bootloader, but we had errors finding +// it. This feature is mainly for better error reporting in logs. +func (l *lk) devPathForPartName(partName string) (string, bool, error) { + // lazily initialize l.blDisk if it hasn't yet been initialized + if l.blDisk == nil { + // For security, we want to restrict our search for the partition + // that the binary structure exists on to only the disk that the + // bootloader tells us to search on - it uses a kernel cmdline + // parameter "snapd_lk_boot_disk" to indicated which disk we should look + // for partitions on. In typical boot scenario this will be something like + // "snapd_lk_boot_disk=mmcblk0". + m, err := kcmdline.KeyValues("snapd_lk_boot_disk") + if err != nil { + // return false, since we don't have enough info to conclude there + // is likely a lk bootloader here or not + return "", false, err + } + blDiskName, ok := m["snapd_lk_boot_disk"] + if blDiskName == "" { + // we switch on ok here, since if "snapd_lk_boot_disk" was found at + // all on the kernel command line, we can reasonably assume that + // only the lk bootloader would have put it there, but maybe + // it is buggy and put an empty value there. + if ok { + return "", true, fmt.Errorf("kernel command line parameter \"snapd_lk_boot_disk\" is empty") + } + // if we didn't find the kernel command line parameter at all, then + // we want to return false because we don't have enough info + return "", false, fmt.Errorf("kernel command line parameter \"snapd_lk_boot_disk\" is missing") + } + + disk, err := disks.DiskFromDeviceName(blDiskName) + if err != nil { + return "", true, fmt.Errorf("cannot find disk from bootloader supplied disk name %q: %v", blDiskName, err) + } + + l.blDisk = disk + } + + partitionUUID, err := l.blDisk.FindMatchingPartitionUUIDWithPartLabel(partName) + if err != nil { + return "", true, err + } + + // for the runtime lk bootloader we should never prefix the path with the + // bootloader rootdir and instead always use dirs.GlobalRootDir, since the + // file we are providing is at an absolute location for all bootloaders, + // regardless of role, in /dev, so using dirs.GlobalRootDir ensures that we + // are still able to mock things in test functions, but that we never end up + // trying to use a path like /run/mnt/ubuntu-boot/dev/disk/by-partuuid/... + // for example + return filepath.Join(dirs.GlobalRootDir, "/dev/disk/by-partuuid", partitionUUID), true, nil +} + +func (l *lk) newenv() (*lkenv.Env, error) { + // check which role we are, it affects which struct is used for the env + var version lkenv.Version + switch l.role { + case RoleSole: + version = lkenv.V1 + case RoleRecovery: + version = lkenv.V2Recovery + case RoleRunMode: + version = lkenv.V2Run + } + f, err := l.envBackstore(primaryStorage) + if err != nil { + return nil, err + } + + backup, err := l.envBackstore(backupStorage) + if err != nil { + return nil, err + } + + return lkenv.NewEnv(f, backup, version), nil +} + +func (l *lk) GetBootVars(names ...string) (map[string]string, error) { + out := make(map[string]string) + + env, err := l.newenv() + if err != nil { + return nil, err + } + if err := env.Load(); err != nil { + return nil, err + } + + for _, name := range names { + out[name] = env.Get(name) + } + + return out, nil +} + +func (l *lk) SetBootVars(values map[string]string) error { + env, err := l.newenv() + if err != nil { + return err + } + // if we couldn't find the env, that's okay, as this may be the first thing + // to write boot vars to the env + if err := env.Load(); err != nil { + // if the error was something other than file not found, it is fatal + if !xerrors.Is(err, os.ErrNotExist) { + return err + } + // otherwise at prepare-image time it is okay to not have the file + // existing, but we should always have it at runtime as it is a + // partition, so it is highly unexpected for it to be missing and we + // cannot proceed + // also note that env.Load() will automatically try the backup, so if + // Load() failed to get the backup at runtime there's nothing left to + // try here + if !l.prepareImageTime { + return err + } + } + + // update environment only if something changes + dirty := false + for k, v := range values { + // already set to the right value, nothing to do + if env.Get(k) == v { + continue + } + env.Set(k, v) + dirty = true + } + + if dirty { + return env.Save() + } + + return nil +} + +func (l *lk) ExtractRecoveryKernelAssets(recoverySystemDir string, sn snap.PlaceInfo, snapf snap.Container) error { + if !l.prepareImageTime { + // error case, we cannot be extracting a recovery kernel and also be + // called with !opts.PrepareImageTime (yet) + + // TODO:UC20: this codepath is exercised when creating new + // recovery systems from runtime + return fmt.Errorf("internal error: extracting recovery kernel assets is not supported for a runtime lk bootloader") + } + + env, err := l.newenv() + if err != nil { + return err + } + if err := env.Load(); err != nil { + // don't handle os.ErrNotExist specially here, it doesn't really make + // sense to extract kernel assets if we can't load the existing env, + // since then the caller would just see an error about not being able + // to find the kernel blob name (as they will all be empty in the env), + // when in reality the reason one can't find an available boot image + // partition is because we couldn't read the env file and so returning + // that error is better + return err + } + + recoverySystem := filepath.Base(recoverySystemDir) + + bootPartition, err := env.FindFreeRecoverySystemBootPartition(recoverySystem) + if err != nil { + return err + } + + // we are preparing a recovery system, just extract boot image to bootloader + // directory + logger.Debugf("extracting recovery kernel %s to %s with lk bootloader", sn.SnapName(), recoverySystem) + if err := snapf.Unpack(env.GetBootImageName(), l.dir()); err != nil { + return fmt.Errorf("cannot open unpacked %s: %v", env.GetBootImageName(), err) + } + + if err := env.SetBootPartitionRecoverySystem(bootPartition, recoverySystem); err != nil { + return err + } + + return env.Save() +} + +// ExtractKernelAssets extract kernel assets per bootloader specifics +// lk bootloader requires boot partition to hold valid boot image +// there are two boot partition available, one holding current bootimage +// kernel assets are extracted to other (free) boot partition +// in case this function is called as part of image creation, +// boot image is extracted to the file +func (l *lk) ExtractKernelAssets(s snap.PlaceInfo, snapf snap.Container) error { + blobName := s.Filename() + + logger.Debugf("extracting kernel assets for %s with lk bootloader", s.SnapName()) + + env, err := l.newenv() + if err != nil { + return err + } + if err := env.Load(); err != nil { + // don't handle os.ErrNotExist specially here, it doesn't really make + // sense to extract kernel assets if we can't load the existing env, + // since then the caller would just see an error about not being able + // to find the kernel blob name (as they will all be empty in the env), + // when in reality the reason one can't find an available boot image + // partition is because we couldn't read the env file and so returning + // that error is better + return err + } + + bootPartition, err := env.FindFreeKernelBootPartition(blobName) + if err != nil { + return err + } + + if l.prepareImageTime { + // we are preparing image, just extract boot image to bootloader directory + if err := snapf.Unpack(env.GetBootImageName(), l.dir()); err != nil { + return fmt.Errorf("cannot open unpacked %s: %v", env.GetBootImageName(), err) + } + } else { + // this is live system, extracted bootimg needs to be flashed to + // free bootimg partition and env has to be updated with + // new kernel snap to bootimg partition mapping + tmpdir, err := os.MkdirTemp("", "bootimg") + if err != nil { + return fmt.Errorf("cannot create temp directory: %v", err) + } + defer os.RemoveAll(tmpdir) + + bootImg := env.GetBootImageName() + if err := snapf.Unpack(bootImg, tmpdir); err != nil { + return fmt.Errorf("cannot unpack %s: %v", bootImg, err) + } + // write boot.img to free boot partition + bootimgName := filepath.Join(tmpdir, bootImg) + bif, err := os.Open(bootimgName) + if err != nil { + return fmt.Errorf("cannot open unpacked %s: %v", bootImg, err) + } + defer bif.Close() + var bpart string + // TODO: for RoleSole bootloaders this will eventually be the same + // codepath as for non-RoleSole bootloader + if l.role == RoleSole { + bpart = filepath.Join(l.dir(), bootPartition) + } else { + bpart, _, err = l.devPathForPartName(bootPartition) + if err != nil { + return err + } + } + + bpf, err := os.OpenFile(bpart, os.O_WRONLY, 0660) + if err != nil { + return fmt.Errorf("cannot open boot partition [%s]: %v", bpart, err) + } + defer bpf.Close() + + if _, err := io.Copy(bpf, bif); err != nil { + return err + } + } + + if err := env.SetBootPartitionKernel(bootPartition, blobName); err != nil { + return err + } + + return env.Save() +} + +func (l *lk) RemoveKernelAssets(s snap.PlaceInfo) error { + blobName := s.Filename() + logger.Debugf("removing kernel assets for %s with lk bootloader", s.SnapName()) + + env, err := l.newenv() + if err != nil { + return err + } + if err := env.Load(); err != nil { + // don't handle os.ErrNotExist specially here, it doesn't really make + // sense to delete kernel assets if we can't load the existing env, + // since then the caller would just see an error about not being able + // to find the kernel blob name, when in reality the reason one can't + // find that kernel blob name is because we couldn't read the env file + return err + } + err = env.RemoveKernelFromBootPartition(blobName) + if err == nil { + // found and removed the revision from the bootimg matrix, need to + // update the env to persist the change + return env.Save() + } + return nil +} diff --git a/bootloader/lk_test.go b/bootloader/lk_test.go new file mode 100644 index 00000000..404748e3 --- /dev/null +++ b/bootloader/lk_test.go @@ -0,0 +1,534 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package bootloader_test + +import ( + "fmt" + "os" + "path/filepath" + "sort" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/boot" + "github.com/snapcore/snapd/bootloader" + "github.com/snapcore/snapd/bootloader/lkenv" + "github.com/snapcore/snapd/logger" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/osutil/disks" + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/snap/snapfile" + "github.com/snapcore/snapd/snap/snaptest" +) + +type lkTestSuite struct { + baseBootenvTestSuite +} + +var _ = Suite(&lkTestSuite{}) + +func (s *lkTestSuite) TestNewLk(c *C) { + // TODO: update this test when v1 lk uses the kernel command line parameter + // too + + // no files means bl is not present, but we can still create the bl object + l := bootloader.NewLk(s.rootdir, nil) + c.Assert(l, NotNil) + c.Assert(l.Name(), Equals, "lk") + + present, err := l.Present() + c.Assert(err, IsNil) + c.Assert(present, Equals, false) + + // now with files present, the bl is present + bootloader.MockLkFiles(c, s.rootdir, nil) + present, err = l.Present() + c.Assert(err, IsNil) + c.Assert(present, Equals, true) + c.Check(bootloader.LkRuntimeMode(l), Equals, true) + f, err := bootloader.LkConfigFile(l) + c.Assert(err, IsNil) + c.Check(f, Equals, filepath.Join(s.rootdir, "/dev/disk/by-partlabel", "snapbootsel")) +} + +func (s *lkTestSuite) TestNewLkPresentChecksBackupStorageToo(c *C) { + // no files means bl is not present, but we can still create the bl object + l := bootloader.NewLk(s.rootdir, &bootloader.Options{ + Role: bootloader.RoleSole, + }) + c.Assert(l, NotNil) + c.Assert(l.Name(), Equals, "lk") + + present, err := l.Present() + c.Assert(err, IsNil) + c.Assert(present, Equals, false) + + // now mock just the backup env file + f, err := bootloader.LkConfigFile(l) + c.Assert(err, IsNil) + c.Check(f, Equals, filepath.Join(s.rootdir, "/dev/disk/by-partlabel", "snapbootsel")) + + err = os.MkdirAll(filepath.Dir(f), 0755) + c.Assert(err, IsNil) + + err = os.WriteFile(f+"bak", nil, 0644) + c.Assert(err, IsNil) + + // now the bootloader is present because the backup exists + present, err = l.Present() + c.Assert(err, IsNil) + c.Assert(present, Equals, true) +} + +func (s *lkTestSuite) TestNewLkUC20Run(c *C) { + // no files means bl is not present, but we can still create the bl object + opts := &bootloader.Options{ + Role: bootloader.RoleRunMode, + } + // use ubuntu-boot as the root dir + l := bootloader.NewLk(boot.InitramfsUbuntuBootDir, opts) + c.Assert(l, NotNil) + c.Assert(l.Name(), Equals, "lk") + + present, err := l.Present() + c.Assert(err, IsNil) + c.Assert(present, Equals, false) + + // now with files present, the bl is present + r := bootloader.MockLkFiles(c, s.rootdir, opts) + defer r() + present, err = l.Present() + c.Assert(err, IsNil) + c.Assert(present, Equals, true) + c.Check(bootloader.LkRuntimeMode(l), Equals, true) + f, err := bootloader.LkConfigFile(l) + c.Assert(err, IsNil) + // note that the config file here is not relative to ubuntu-boot dir we used + // when creating the bootloader, it is relative to the rootdir + c.Check(f, Equals, filepath.Join(s.rootdir, "/dev/disk/by-partuuid", "snapbootsel-partuuid")) +} + +func (s *lkTestSuite) TestNewLkUC20Recovery(c *C) { + // no files means bl is not present, but we can still create the bl object + opts := &bootloader.Options{ + Role: bootloader.RoleRecovery, + } + // use ubuntu-seed as the root dir + l := bootloader.NewLk(boot.InitramfsUbuntuSeedDir, opts) + c.Assert(l, NotNil) + c.Assert(l.Name(), Equals, "lk") + + present, err := l.Present() + c.Assert(err, IsNil) + c.Assert(present, Equals, false) + + // now with files present, the bl is present + r := bootloader.MockLkFiles(c, s.rootdir, opts) + defer r() + present, err = l.Present() + c.Assert(err, IsNil) + c.Assert(present, Equals, true) + c.Check(bootloader.LkRuntimeMode(l), Equals, true) + f, err := bootloader.LkConfigFile(l) + c.Assert(err, IsNil) + // note that the config file here is not relative to ubuntu-boot dir we used + // when creating the bootloader, it is relative to the rootdir + c.Check(f, Equals, filepath.Join(s.rootdir, "/dev/disk/by-partuuid", "snaprecoverysel-partuuid")) +} + +func (s *lkTestSuite) TestNewLkImageBuildingTime(c *C) { + for _, role := range []bootloader.Role{bootloader.RoleSole, bootloader.RoleRecovery} { + opts := &bootloader.Options{ + PrepareImageTime: true, + Role: role, + } + r := bootloader.MockLkFiles(c, s.rootdir, opts) + defer r() + l := bootloader.NewLk(s.rootdir, opts) + c.Assert(l, NotNil) + c.Check(bootloader.LkRuntimeMode(l), Equals, false) + f, err := bootloader.LkConfigFile(l) + c.Assert(err, IsNil) + switch role { + case bootloader.RoleSole: + c.Check(f, Equals, filepath.Join(s.rootdir, "/boot/lk", "snapbootsel.bin")) + case bootloader.RoleRecovery: + c.Check(f, Equals, filepath.Join(s.rootdir, "/boot/lk", "snaprecoverysel.bin")) + } + } +} + +func (s *lkTestSuite) TestSetGetBootVar(c *C) { + tt := []struct { + role bootloader.Role + key string + value string + }{ + { + bootloader.RoleSole, + "snap_mode", + boot.TryingStatus, + }, + { + bootloader.RoleRecovery, + "snapd_recovery_mode", + boot.ModeRecover, + }, + { + bootloader.RoleRunMode, + "kernel_status", + boot.TryStatus, + }, + } + for _, t := range tt { + opts := &bootloader.Options{ + Role: t.role, + } + r := bootloader.MockLkFiles(c, s.rootdir, opts) + defer r() + l := bootloader.NewLk(s.rootdir, opts) + bootVars := map[string]string{t.key: t.value} + l.SetBootVars(bootVars) + + v, err := l.GetBootVars(t.key) + c.Assert(err, IsNil) + c.Check(v, HasLen, 1) + c.Check(v[t.key], Equals, t.value) + } +} + +func (s *lkTestSuite) TestExtractKernelAssetsUnpacksBootimgImageBuilding(c *C) { + for _, role := range []bootloader.Role{bootloader.RoleSole, bootloader.RoleRecovery} { + opts := &bootloader.Options{ + PrepareImageTime: true, + Role: role, + } + r := bootloader.MockLkFiles(c, s.rootdir, opts) + defer r() + l := bootloader.NewLk(s.rootdir, opts) + + c.Assert(l, NotNil) + + files := [][]string{ + {"kernel.img", "I'm a kernel"}, + {"initrd.img", "...and I'm an initrd"}, + {"boot.img", "...and I'm an boot image"}, + {"dtbs/foo.dtb", "g'day, I'm foo.dtb"}, + {"dtbs/bar.dtb", "hello, I'm bar.dtb"}, + // must be last + {"meta/kernel.yaml", "version: 4.2"}, + } + si := &snap.SideInfo{ + RealName: "ubuntu-kernel", + Revision: snap.R(42), + } + fn := snaptest.MakeTestSnapWithFiles(c, packageKernel, files) + snapf, err := snapfile.Open(fn) + c.Assert(err, IsNil) + + info, err := snap.ReadInfoFromSnapFile(snapf, si) + c.Assert(err, IsNil) + + if role == bootloader.RoleSole { + err = l.ExtractKernelAssets(info, snapf) + } else { + // this isn't quite how ExtractRecoveryKernel is typically called, + // typically it will be called with an actual recovery system dir, + // but for our purposes this is close enough, we just extract files + // to some directory + err = l.ExtractRecoveryKernelAssets(s.rootdir, info, snapf) + } + c.Assert(err, IsNil) + + // just boot.img and snapbootsel.bin are there, no kernel.img + infos, err := os.ReadDir(filepath.Join(s.rootdir, "boot", "lk", "")) + c.Assert(err, IsNil) + var fnames []string + for _, info := range infos { + fnames = append(fnames, info.Name()) + } + sort.Strings(fnames) + c.Assert(fnames, HasLen, 2) + expFiles := []string{"boot.img"} + if role == bootloader.RoleSole { + expFiles = append(expFiles, "snapbootsel.bin") + } else { + expFiles = append(expFiles, "snaprecoverysel.bin") + } + c.Assert(fnames, DeepEquals, expFiles) + + // clean up the rootdir for the next iteration + c.Assert(os.RemoveAll(s.rootdir), IsNil) + } +} + +func (s *lkTestSuite) TestExtractKernelAssetsUnpacksCustomBootimgImageBuilding(c *C) { + opts := &bootloader.Options{ + PrepareImageTime: true, + Role: bootloader.RoleSole, + } + bootloader.MockLkFiles(c, s.rootdir, opts) + l := bootloader.NewLk(s.rootdir, opts) + + c.Assert(l, NotNil) + + // first configure custom boot image file name + f, err := bootloader.LkConfigFile(l) + c.Assert(err, IsNil) + env := lkenv.NewEnv(f, "", lkenv.V1) + env.Load() + env.Set("bootimg_file_name", "boot-2.img") + err = env.Save() + c.Assert(err, IsNil) + + files := [][]string{ + {"kernel.img", "I'm a kernel"}, + {"initrd.img", "...and I'm an initrd"}, + {"boot-2.img", "...and I'm an boot image"}, + {"dtbs/foo.dtb", "g'day, I'm foo.dtb"}, + {"dtbs/bar.dtb", "hello, I'm bar.dtb"}, + // must be last + {"meta/kernel.yaml", "version: 4.2"}, + } + si := &snap.SideInfo{ + RealName: "ubuntu-kernel", + Revision: snap.R(42), + } + fn := snaptest.MakeTestSnapWithFiles(c, packageKernel, files) + snapf, err := snapfile.Open(fn) + c.Assert(err, IsNil) + + info, err := snap.ReadInfoFromSnapFile(snapf, si) + c.Assert(err, IsNil) + + err = l.ExtractKernelAssets(info, snapf) + c.Assert(err, IsNil) + + // boot-2.img is there + bootimg := filepath.Join(s.rootdir, "boot", "lk", "boot-2.img") + c.Assert(osutil.FileExists(bootimg), Equals, true) +} + +func (s *lkTestSuite) TestExtractKernelAssetsUnpacksAndRemoveInRuntimeMode(c *C) { + logbuf, r := logger.MockLogger() + defer r() + opts := &bootloader.Options{ + Role: bootloader.RoleSole, + } + r = bootloader.MockLkFiles(c, s.rootdir, opts) + defer r() + lk := bootloader.NewLk(s.rootdir, opts) + c.Assert(lk, NotNil) + + // ensure we have a valid boot env + // TODO: this will follow the same logic as RoleRunMode eventually + bootselPartition := filepath.Join(s.rootdir, "/dev/disk/by-partlabel/snapbootsel") + lkenv := lkenv.NewEnv(bootselPartition, "", lkenv.V1) + + // don't need to initialize this env, the same file will already have been + // setup by MockLkFiles() + + // mock a kernel snap that has a boot.img + files := [][]string{ + {"boot.img", "I'm the default boot image name"}, + } + si := &snap.SideInfo{ + RealName: "ubuntu-kernel", + Revision: snap.R(42), + } + fn := snaptest.MakeTestSnapWithFiles(c, packageKernel, files) + snapf, err := snapfile.Open(fn) + c.Assert(err, IsNil) + + info, err := snap.ReadInfoFromSnapFile(snapf, si) + c.Assert(err, IsNil) + + // now extract + err = lk.ExtractKernelAssets(info, snapf) + c.Assert(err, IsNil) + + // and validate it went to the "boot_a" partition + bootA := filepath.Join(s.rootdir, "/dev/disk/by-partlabel/boot_a") + content, err := os.ReadFile(bootA) + c.Assert(err, IsNil) + c.Assert(string(content), Equals, "I'm the default boot image name") + + // also validate that bootB is empty + bootB := filepath.Join(s.rootdir, "/dev/disk/by-partlabel/boot_b") + content, err = os.ReadFile(bootB) + c.Assert(err, IsNil) + c.Assert(content, HasLen, 0) + + // test that boot partition got set + err = lkenv.Load() + c.Assert(err, IsNil) + bootPart, err := lkenv.GetKernelBootPartition("ubuntu-kernel_42.snap") + c.Assert(err, IsNil) + c.Assert(bootPart, Equals, "boot_a") + + // now remove the kernel + err = lk.RemoveKernelAssets(info) + c.Assert(err, IsNil) + // and ensure its no longer available in the boot partitions + err = lkenv.Load() + c.Assert(err, IsNil) + bootPart, err = lkenv.GetKernelBootPartition("ubuntu-kernel_42.snap") + c.Assert(err, ErrorMatches, fmt.Sprintf("cannot find kernel %[1]q: no boot image partition has value %[1]q", "ubuntu-kernel_42.snap")) + c.Assert(bootPart, Equals, "") + + c.Assert(logbuf.String(), Equals, "") +} + +func (s *lkTestSuite) TestExtractKernelAssetsUnpacksAndRemoveInRuntimeModeUC20(c *C) { + logbuf, r := logger.MockLogger() + defer r() + + opts := &bootloader.Options{ + Role: bootloader.RoleRunMode, + } + r = bootloader.MockLkFiles(c, s.rootdir, opts) + defer r() + lk := bootloader.NewLk(s.rootdir, opts) + c.Assert(lk, NotNil) + + // all expected files are created for RoleRunMode bootloader in + // MockLkFiles + + // ensure we have a valid boot env + disk, err := disks.DiskFromDeviceName("lk-boot-disk") + c.Assert(err, IsNil) + + partuuid, err := disk.FindMatchingPartitionUUIDWithPartLabel("snapbootsel") + c.Assert(err, IsNil) + + // also confirm that we can load the backup file partition too + backupPartuuid, err := disk.FindMatchingPartitionUUIDWithPartLabel("snapbootselbak") + c.Assert(err, IsNil) + + bootselPartition := filepath.Join(s.rootdir, "/dev/disk/by-partuuid", partuuid) + bootselPartitionBackup := filepath.Join(s.rootdir, "/dev/disk/by-partuuid", backupPartuuid) + env := lkenv.NewEnv(bootselPartition, "", lkenv.V2Run) + backupEnv := lkenv.NewEnv(bootselPartitionBackup, "", lkenv.V2Run) + + // mock a kernel snap that has a boot.img + files := [][]string{ + {"boot.img", "I'm the default boot image name"}, + } + si := &snap.SideInfo{ + RealName: "ubuntu-kernel", + Revision: snap.R(42), + } + fn := snaptest.MakeTestSnapWithFiles(c, packageKernel, files) + snapf, err := snapfile.Open(fn) + c.Assert(err, IsNil) + + info, err := snap.ReadInfoFromSnapFile(snapf, si) + c.Assert(err, IsNil) + + // now extract + err = lk.ExtractKernelAssets(info, snapf) + c.Assert(err, IsNil) + + // and validate it went to the "boot_a" partition + bootAPartUUID, err := disk.FindMatchingPartitionUUIDWithPartLabel("boot_a") + c.Assert(err, IsNil) + bootA := filepath.Join(s.rootdir, "/dev/disk/by-partuuid", bootAPartUUID) + content, err := os.ReadFile(bootA) + c.Assert(err, IsNil) + c.Assert(string(content), Equals, "I'm the default boot image name") + + // also validate that bootB is empty + bootBPartUUID, err := disk.FindMatchingPartitionUUIDWithPartLabel("boot_b") + c.Assert(err, IsNil) + bootB := filepath.Join(s.rootdir, "/dev/disk/by-partuuid", bootBPartUUID) + content, err = os.ReadFile(bootB) + c.Assert(err, IsNil) + c.Assert(content, HasLen, 0) + + // test that boot partition got set + err = env.Load() + c.Assert(err, IsNil) + bootPart, err := env.GetKernelBootPartition("ubuntu-kernel_42.snap") + c.Assert(err, IsNil) + c.Assert(bootPart, Equals, "boot_a") + + // in the backup too + err = backupEnv.Load() + c.Assert(logbuf.String(), Equals, "") + c.Assert(err, IsNil) + + bootPart, err = backupEnv.GetKernelBootPartition("ubuntu-kernel_42.snap") + c.Assert(err, IsNil) + c.Assert(bootPart, Equals, "boot_a") + + // now remove the kernel + err = lk.RemoveKernelAssets(info) + c.Assert(err, IsNil) + // and ensure its no longer available in the boot partitions + err = env.Load() + c.Assert(err, IsNil) + _, err = env.GetKernelBootPartition("ubuntu-kernel_42.snap") + c.Assert(err, ErrorMatches, fmt.Sprintf("cannot find kernel %[1]q: no boot image partition has value %[1]q", "ubuntu-kernel_42.snap")) + err = backupEnv.Load() + c.Assert(err, IsNil) + // in the backup too + _, err = backupEnv.GetKernelBootPartition("ubuntu-kernel_42.snap") + c.Assert(err, ErrorMatches, fmt.Sprintf("cannot find kernel %[1]q: no boot image partition has value %[1]q", "ubuntu-kernel_42.snap")) + + c.Assert(logbuf.String(), Equals, "") +} + +func (s *lkTestSuite) TestExtractRecoveryKernelAssetsAtRuntime(c *C) { + opts := &bootloader.Options{ + // as called when creating a recovery system at runtime + PrepareImageTime: false, + Role: bootloader.RoleRecovery, + } + r := bootloader.MockLkFiles(c, s.rootdir, opts) + defer r() + l := bootloader.NewLk(s.rootdir, opts) + + c.Assert(l, NotNil) + + files := [][]string{ + {"kernel.img", "I'm a kernel"}, + {"initrd.img", "...and I'm an initrd"}, + {"boot.img", "...and I'm an boot image"}, + {"meta/kernel.yaml", "version: 4.2"}, + } + si := &snap.SideInfo{ + RealName: "ubuntu-kernel", + Revision: snap.R(42), + } + fn := snaptest.MakeTestSnapWithFiles(c, packageKernel, files) + snapf, err := snapfile.Open(fn) + c.Assert(err, IsNil) + + info, err := snap.ReadInfoFromSnapFile(snapf, si) + c.Assert(err, IsNil) + + relativeRecoverySystemDir := "systems/1234" + c.Assert(os.MkdirAll(filepath.Join(s.rootdir, relativeRecoverySystemDir), 0755), IsNil) + err = l.ExtractRecoveryKernelAssets(relativeRecoverySystemDir, info, snapf) + c.Assert(err, ErrorMatches, "internal error: extracting recovery kernel assets is not supported for a runtime lk bootloader") +} + +// TODO:UC20: when runtime addition (and deletion) of recovery systems is +// implemented, add tests for that here with lkenv diff --git a/bootloader/lkenv/export_test.go b/bootloader/lkenv/export_test.go new file mode 100644 index 00000000..ad72faad --- /dev/null +++ b/bootloader/lkenv/export_test.go @@ -0,0 +1,25 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package lkenv + +var ( + CopyString = copyString + CToGoString = cToGoString +) diff --git a/bootloader/lkenv/lkenv.go b/bootloader/lkenv/lkenv.go new file mode 100644 index 00000000..fec772b5 --- /dev/null +++ b/bootloader/lkenv/lkenv.go @@ -0,0 +1,662 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package lkenv + +import ( + "bytes" + "encoding/binary" + "fmt" + "hash/crc32" + "os" + + "golang.org/x/xerrors" + + "github.com/snapcore/snapd/logger" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/strutil" +) + +const ( + SNAP_BOOTSELECT_VERSION_V1 = 0x00010001 + SNAP_BOOTSELECT_VERSION_V2 = 0x00010010 +) + +// const SNAP_BOOTSELECT_SIGNATURE ('S' | ('B' << 8) | ('s' << 16) | ('e' << 24)) +// value comes from S(Snap)B(Boot)se(select) +const SNAP_BOOTSELECT_SIGNATURE = 0x53 | 0x42<<8 | 0x73<<16 | 0x65<<24 + +// const SNAP_BOOTSELECT_RECOVERY_SIGNATURE ('S' | ('R' << 8) | ('s' << 16) | ('e' << 24)) +// value comes from S(Snap)R(Recovery)se(select) +const SNAP_BOOTSELECT_RECOVERY_SIGNATURE = 0x53 | 0x52<<8 | 0x73<<16 | 0x65<<24 + +// SNAP_FILE_NAME_MAX_LEN is the maximum size of a C string representing a snap name, +// such as for a kernel snap revision. +const SNAP_FILE_NAME_MAX_LEN = 256 + +// SNAP_BOOTIMG_PART_NUM is the number of available boot image partitions +const SNAP_BOOTIMG_PART_NUM = 2 + +// SNAP_RUN_BOOTIMG_PART_NUM is the number of available boot image partitions +// for uc20 for kernel/try-kernel in run mode +const SNAP_RUN_BOOTIMG_PART_NUM = 2 + +/** maximum number of available bootimg partitions for recovery systems, min 5 + * NOTE: the number of actual bootimg partitions usable is determined by the + * gadget, this just sets the upper bound of maximum number of recovery systems + * a gadget could define without needing changes here + */ +const SNAP_RECOVERY_BOOTIMG_PART_NUM = 10 + +/* Default boot image file name to be used from kernel snap */ +const BOOTIMG_DEFAULT_NAME = "boot.img" + +// for accessing the Bootimg_matrix +const ( + // the boot image partition label itself + MATRIX_ROW_PARTITION = 0 + // the value of the boot image partition label mapping (i.e. the kernel + // revision or the recovery system label, depending on which specific + // matrix is being operated on) + MATRIX_ROW_VALUE = 1 +) + +type Version int + +const ( + V1 Version = iota + V2Run + V2Recovery +) + +// Number returns the Version of the lkenv version as it is encoded in the +func (v Version) Number() uint32 { + switch v { + case V1: + return SNAP_BOOTSELECT_VERSION_V1 + case V2Run, V2Recovery: + return SNAP_BOOTSELECT_VERSION_V2 + default: + panic(fmt.Sprintf("unknown lkenv version number: %v", v)) + } +} + +// Signature returns the Signature of the lkenv version. +func (v Version) Signature() uint32 { + switch v { + case V1, V2Run: + return SNAP_BOOTSELECT_SIGNATURE + case V2Recovery: + return SNAP_BOOTSELECT_RECOVERY_SIGNATURE + default: + panic(fmt.Sprintf("unknown lkenv version number: %v", v)) + } +} + +type envVariant interface { + // get returns the value of a key in the env object. + get(string) string + + // set sets a key to a value in the env object. + set(string, string) + + // currentCrc32 is a helper method to return the value of the crc32 stored in the + // environment variable - it is NOT a method to calculate the current value, + // it is used to store the crc32 for helper methods that validate the crc32 + // independently of what is in the environment. + currentCrc32() uint32 + // currentVersion is the same kind of helper method as currentCrc32(), + // always returning the value from the object itself. + currentVersion() uint32 + // currentSignature is the same kind of helper method as currentCrc32(), + // always returning the value from the object itself. + currentSignature() uint32 + + // bootImgKernelMatrix returns the boot image matrix from the environment + // which stores the kernel revisions for the boot image partitions. The boot + // image matrix is used for various exported methods such as + // SetBootPartitionKernel(), etc. + bootImgKernelMatrix() (bootimgMatrixGeneric, error) + + // bootImgRecoverySystemMatrix returns the boot image matrix from the + // environment which stores the recovery system labels for the boot image + // partitions. The boot image matrix is used for various recovery system + // methods such as FindFreeRecoverySystemBootPartition(), etc. + bootImgRecoverySystemMatrix() (bootimgMatrixGeneric, error) +} + +var ( + // the variant implementations must all implement envVariant + _ = envVariant(&SnapBootSelect_v1{}) + _ = envVariant(&SnapBootSelect_v2_run{}) + _ = envVariant(&SnapBootSelect_v2_recovery{}) +) + +// Env contains the data of the little kernel environment +type Env struct { + // path is the primary lkenv object file, it can be a regular file during + // build time, or it can be a partition device node at run time + path string + // pathbak is the backup lkenv object file, it too can either be a regular + // file during build time, or a partition device node at run time, and it is + // typically at prepare-image time given by "" + "bak", i.e. + // $PWD/lk.conf and $PWD/lk.confbak but will be different device nodes for + // different partitions at runtime. + pathbak string + // version is the configured version of the lkenv object from NewEnv. + version Version + // variant is the internal implementation of the lkenv object, dependent on + // the version. It is tracked separately such that we can verify a given + // variant matches the specified version when loading an lkenv object from + // disk. + variant envVariant +} + +// cToGoString convert string in passed byte array into string type +// if string in byte array is not terminated, empty string is returned +func cToGoString(c []byte) string { + if end := bytes.IndexByte(c, 0); end >= 0 { + return string(c[:end]) + } + // no trailing \0 - return "" + return "" +} + +// copyString copy passed string into byte array +// make sure string is terminated +// if string does not fit into byte array, it will be concatenated +func copyString(b []byte, s string) { + sl := len(s) + bs := len(b) + if bs > sl { + copy(b[:], s) + b[sl] = 0 + } else { + copy(b[:bs-1], s) + b[bs-1] = 0 + } +} + +// NewEnv creates a new lkenv object referencing the primary bootloader +// environment file at path with the specified version. If the specified filed +// is expected to be a valid lkenv object, then the object should be loaded with +// the Load() method, otherwise the lkenv object can be manipulated in memory +// and later written to disk with Save(). +func NewEnv(path, backupPath string, version Version) *Env { + if backupPath == "" { + // legacy behavior is for the backup file to be the same name/dir, but + // with "bak" appended to it + backupPath = path + "bak" + } + e := &Env{ + path: path, + pathbak: backupPath, + version: version, + } + + switch version { + case V1: + e.variant = newV1() + case V2Recovery: + e.variant = newV2Recovery() + case V2Run: + e.variant = newV2Run() + } + return e +} + +// Load will load the lk bootloader environment from it's configured primary +// environment file, and if that fails it will fallback to trying the backup +// environment file. +func (l *Env) Load() error { + err := l.LoadEnv(l.path) + if err != nil { + logger.Noticef("cannot load primary bootloader environment: %v\n", err) + logger.Noticef("attempting to load backup bootloader environment\n") + return l.LoadEnv(l.pathbak) + } + return nil +} + +type compatErrNotExist struct { + err error +} + +func (e compatErrNotExist) Error() string { + return e.err.Error() +} + +func (e compatErrNotExist) Unwrap() error { + // for go 1.9 (and 1.10) xerrors compatibility, we check if os.PathError + // implements Unwrap(), and if not return os.ErrNotExist directly + if _, ok := e.err.(interface { + Unwrap() error + }); !ok { + return os.ErrNotExist + } + return e.err +} + +// LoadEnv loads the lk bootloader environment from the specified file. The +// bootloader environment in the referenced file must be of the same version +// that the Env object was created with using NewEnv. +// The returned error may wrap os.ErrNotExist, so instead of using +// os.IsNotExist, callers should use xerrors.Is(err,os.ErrNotExist) instead. +func (l *Env) LoadEnv(path string) error { + f, err := os.Open(path) + if err != nil { + // TODO: when we drop support for Go 1.9, this code can go away, in Go + // 1.9 *os.PathError does not implement Unwrap(), and so callers + // that try to call xerrors.Is(err,os.ErrNotExist) will fail, so + // instead we do our own wrapping first such that when Unwrap() is + // called by xerrors.Is() it will see os.ErrNotExist directly when + // compiled with a version of Go that does not implement Unwrap() + // on os.PathError + if os.IsNotExist(err) { + err = compatErrNotExist{err: err} + } + fmtStr := "cannot open LK env file: %w" + return xerrors.Errorf(fmtStr, err) + } + + if err := binary.Read(f, binary.LittleEndian, l.variant); err != nil { + return fmt.Errorf("cannot read LK env from file: %v", err) + } + + // validate the version and signatures + v := l.variant.currentVersion() + s := l.variant.currentSignature() + expV := l.version.Number() + expS := l.version.Signature() + + if expV != v { + return fmt.Errorf("cannot validate %s: expected version 0x%X, got 0x%X", path, expV, v) + } + + if expS != s { + return fmt.Errorf("cannot validate %s: expected signature 0x%X, got 0x%X", path, expS, s) + } + + // independently calculate crc32 to validate structure + w := bytes.NewBuffer(nil) + ss := binary.Size(l.variant) + w.Grow(ss) + if err := binary.Write(w, binary.LittleEndian, l.variant); err != nil { + return fmt.Errorf("cannot write LK env to buffer for validation: %v", err) + } + + crc := crc32.ChecksumIEEE(w.Bytes()[:ss-4]) // size of crc32 itself at the end of the structure + if crc != l.variant.currentCrc32() { + return fmt.Errorf("cannot validate %s: expected checksum 0x%X, got 0x%X", path, crc, l.variant.currentCrc32()) + } + logger.Debugf("validated crc32 as 0x%X for lkenv loaded from file %s", l.variant.currentCrc32(), path) + + return nil +} + +// Save saves the lk bootloader environment to the configured primary +// environment file, and if the backup environment file exists, the backup too. +// Save will also update the CRC32 of the environment when writing the file(s). +func (l *Env) Save() error { + buf := bytes.NewBuffer(nil) + ss := binary.Size(l.variant) + buf.Grow(ss) + if err := binary.Write(buf, binary.LittleEndian, l.variant); err != nil { + return fmt.Errorf("cannot write LK env to buffer for saving: %v", err) + } + + // calculate crc32 + newCrc32 := crc32.ChecksumIEEE(buf.Bytes()[:ss-4]) + logger.Debugf("calculated lk bootloader environment crc32 as 0x%X to save", newCrc32) + // note for efficiency's sake to avoid re-writing the whole structure, we + // re-write _just_ the crc32 to w as little-endian + buf.Truncate(ss - 4) + binary.Write(buf, binary.LittleEndian, &newCrc32) + + err := l.saveEnv(l.path, buf) + if err != nil { + logger.Noticef("failed to save primary bootloader environment: %v", err) + } + // if there is backup environment file save to it as well + if osutil.FileExists(l.pathbak) { + // TODO: if the primary succeeds but saving to the backup fails, we + // don't return non-nil error here, should we? + if err := l.saveEnv(l.pathbak, buf); err != nil { + logger.Noticef("failed to save backup environment: %v", err) + } + } + return err +} + +func (l *Env) saveEnv(path string, buf *bytes.Buffer) error { + f, err := os.OpenFile(path, os.O_WRONLY, 0660) + if err != nil { + return fmt.Errorf("cannot open LK env file for env storing: %v", err) + } + defer f.Close() + + if _, err := f.Write(buf.Bytes()); err != nil { + return fmt.Errorf("cannot write LK env buf to LK env file: %v", err) + } + if err := f.Sync(); err != nil { + return fmt.Errorf("cannot sync LK env file: %v", err) + } + return nil +} + +// Get returns the value of the key from the environment. If the key specified +// is not supported for the environment, the empty string is returned. +func (l *Env) Get(key string) string { + return l.variant.get(key) +} + +// Set assigns the value to the key in the environment. If the key specified is +// not supported for the environment, nothing happens. +func (l *Env) Set(key, value string) { + l.variant.set(key, value) +} + +// InitializeBootPartitions sets the boot image partition label names. +// This function should not be used at run time! +// It should be used only at image build time, if partition labels are not +// pre-filled by gadget built, currently it is only used inside snapd for tests. +func (l *Env) InitializeBootPartitions(bootPartLabels ...string) error { + var matr bootimgMatrixGeneric + var err error + // calculate the min/max limits for bootPartLabels + var min, max int + switch l.version { + case V1, V2Run: + min = 2 + max = 2 + matr, err = l.variant.bootImgKernelMatrix() + case V2Recovery: + min = 1 + max = SNAP_RECOVERY_BOOTIMG_PART_NUM + matr, err = l.variant.bootImgRecoverySystemMatrix() + } + if err != nil { + return err + } + + return matr.initializeBootPartitions(bootPartLabels, min, max) +} + +// FindFreeKernelBootPartition finds a free boot image partition to be used for +// a new kernel revision. It ignores the currently installed boot image +// partition used for the active kernel +func (l *Env) FindFreeKernelBootPartition(kernel string) (string, error) { + matr, err := l.variant.bootImgKernelMatrix() + if err != nil { + return "", err + } + + // the reserved boot image partition value is just the current snap_kernel + // if it is set (it could be unset at image build time where the lkenv is + // unset and has no kernel revision values set for the boot image partitions) + installedKernels := []string{} + if installedKernel := l.variant.get("snap_kernel"); installedKernel != "" { + installedKernels = []string{installedKernel} + } + return matr.findFreeBootPartition(installedKernels, kernel) +} + +// GetKernelBootPartition returns the first found boot image partition label +// that contains a reference to the given kernel revision. If the revision was +// not found, a non-nil error is returned. +func (l *Env) GetKernelBootPartition(kernel string) (string, error) { + matr, err := l.variant.bootImgKernelMatrix() + if err != nil { + return "", err + } + + bootPart, err := matr.getBootPartWithValue(kernel) + if err != nil { + return "", fmt.Errorf("cannot find kernel %q: %v", kernel, err) + } + return bootPart, nil +} + +// SetBootPartitionKernel sets the kernel revision reference for the provided +// boot image partition label. It returns a non-nil err if the provided boot +// image partition label was not found. +func (l *Env) SetBootPartitionKernel(bootpart, kernel string) error { + matr, err := l.variant.bootImgKernelMatrix() + if err != nil { + return err + } + + return matr.setBootPart(bootpart, kernel) +} + +// RemoveKernelFromBootPartition removes from the boot image matrix the +// first found boot image partition that contains a reference to the given +// kernel revision. If the referenced kernel revision was not found, a non-nil +// err is returned, otherwise the reference is removed and nil is returned. +func (l *Env) RemoveKernelFromBootPartition(kernel string) error { + matr, err := l.variant.bootImgKernelMatrix() + if err != nil { + return err + } + + return matr.dropBootPartValue(kernel) +} + +// FindFreeRecoverySystemBootPartition finds a free recovery system boot image +// partition to be used for the recovery kernel from the recovery system. It +// only considers boot image partitions that are currently not set to a recovery +// system to be free. +func (l *Env) FindFreeRecoverySystemBootPartition(recoverySystem string) (string, error) { + matr, err := l.variant.bootImgRecoverySystemMatrix() + if err != nil { + return "", err + } + + // when we create a new recovery system partition, we set all current + // recovery systems as reserved, so first get that list + currentRecoverySystems := matr.assignedBootPartValues() + return matr.findFreeBootPartition(currentRecoverySystems, recoverySystem) +} + +// SetBootPartitionRecoverySystem sets the recovery system reference for the +// provided boot image partition. It returns a non-nil err if the provided boot +// partition reference was not found. +func (l *Env) SetBootPartitionRecoverySystem(bootpart, recoverySystem string) error { + matr, err := l.variant.bootImgRecoverySystemMatrix() + if err != nil { + return err + } + + return matr.setBootPart(bootpart, recoverySystem) +} + +// GetRecoverySystemBootPartition returns the first found boot image partition +// label that contains a reference to the given recovery system. If the recovery +// system was not found, a non-nil error is returned. +func (l *Env) GetRecoverySystemBootPartition(recoverySystem string) (string, error) { + matr, err := l.variant.bootImgRecoverySystemMatrix() + if err != nil { + return "", err + } + + bootPart, err := matr.getBootPartWithValue(recoverySystem) + if err != nil { + return "", fmt.Errorf("cannot find recovery system %q: %v", recoverySystem, err) + } + return bootPart, nil +} + +// RemoveRecoverySystemFromBootPartition removes from the boot image matrix the +// first found boot partition that contains a reference to the given recovery +// system. If the referenced recovery system was not found, a non-nil err is +// returned, otherwise the reference is removed and nil is returned. +func (l *Env) RemoveRecoverySystemFromBootPartition(recoverySystem string) error { + matr, err := l.variant.bootImgRecoverySystemMatrix() + if err != nil { + return err + } + + return matr.dropBootPartValue(recoverySystem) +} + +// GetBootImageName return expected boot image file name in kernel snap. If +// unset, it will return the default boot.img name. +func (l *Env) GetBootImageName() string { + fn := l.Get("bootimg_file_name") + if fn != "" { + return fn + } + return BOOTIMG_DEFAULT_NAME +} + +// common matrix helper methods which operate on the boot image matrix, which is +// a mapping of boot image partition label to either a kernel revision or a +// recovery system label. + +// bootimgMatrixGeneric is a generic slice version of the above two matrix types +// which are both statically sized arrays, and thus not able to be used +// interchangeably while the slice is. +type bootimgMatrixGeneric [][2][SNAP_FILE_NAME_MAX_LEN]byte + +// initializeBootPartitions is a test helper method to set all the boot image +// partition labels for a lkenv object, normally this is done by the gadget at +// image build time and not done by snapd, but we do this in tests. +// The min and max arguments are for size checking of the provided array of +// bootPartLabels +func (matr bootimgMatrixGeneric) initializeBootPartitions(bootPartLabels []string, min, max int) error { + numBootPartLabels := len(bootPartLabels) + + if numBootPartLabels < min || numBootPartLabels > max { + return fmt.Errorf("invalid number of boot image partitions, expected %d got %d", len(matr), numBootPartLabels) + } + for x, label := range bootPartLabels { + copyString(matr[x][MATRIX_ROW_PARTITION][:], label) + } + return nil +} + +// dropBootPartValue will remove the specified bootPartValue from the boot image +// matrix - it _only_ deletes the value, not the boot image partition label +// itself, as the boot image partition labels are static for the lifetime of a +// device and should never be changed (as those values correspond to physical +// names of the formatted partitions and we don't yet support repartitioning of +// any kind). +func (matr bootimgMatrixGeneric) dropBootPartValue(bootPartValue string) error { + for x := range matr { + if cToGoString(matr[x][MATRIX_ROW_PARTITION][:]) != "" { + if bootPartValue == cToGoString(matr[x][MATRIX_ROW_VALUE][:]) { + // clear the string by setting the first element to 0 or NUL + matr[x][MATRIX_ROW_VALUE][0] = 0 + return nil + } + } + } + + return fmt.Errorf("cannot find %q in boot image partitions", bootPartValue) +} + +// setBootPart associates the specified boot image partition label to the +// specified value. +func (matr bootimgMatrixGeneric) setBootPart(bootpart, bootPartValue string) error { + for x := range matr { + if bootpart == cToGoString(matr[x][MATRIX_ROW_PARTITION][:]) { + copyString(matr[x][MATRIX_ROW_VALUE][:], bootPartValue) + return nil + } + } + + return fmt.Errorf("cannot find boot image partition %s", bootpart) +} + +// findFreeBootPartition will return a boot image partition that can be +// used for a new value, specifically skipping the reserved values. It may +// return either a boot image partition that does not contain any value or +// a boot image partition that already contains the specified value. The +// reserved argument is typically used for already installed values, such as the +// currently installed kernel snap revision, so that a new try kernel snap does +// not overwrite the existing installed kernel snap. +func (matr bootimgMatrixGeneric) findFreeBootPartition(reserved []string, newValue string) (string, error) { + for x := range matr { + bootPartLabel := cToGoString(matr[x][MATRIX_ROW_PARTITION][:]) + // skip boot image partition labels that are unset, for example this may + // happen if a system only has 3 physical boot image partitions for + // recovery system kernels, but the same matrix structure has 10 slots + // and all 3 usable slots are in use by installed reserved recovery + // systems. + if bootPartLabel == "" { + continue + } + + val := cToGoString(matr[x][MATRIX_ROW_VALUE][:]) + + // if the value is exactly the same, as requested return it, this needs + // to be handled before checking the reserved values since we may + // sometimes need to find a "free" boot partition for the specific + // kernel revision that is already installed, thus it will show up in + // the reserved list, but it will also be newValue + // this case happens in practice during seeding of kernels on uc16/uc18, + // where we already extracted the kernel at image build time and we will + // go to extract the kernel again during seeding + if val == newValue { + return bootPartLabel, nil + } + + // if this value was reserved, skip it + if strutil.ListContains(reserved, val) { + continue + } + + // otherwise consider it to be free, even if it was set to something + // else - this is because callers should be using reserved to prevent + // overwriting the wrong boot image partition value + return bootPartLabel, nil + } + + return "", fmt.Errorf("cannot find free boot image partition") +} + +// assignedBootPartValues returns all boot image partitions values that are set. +func (matr bootimgMatrixGeneric) assignedBootPartValues() []string { + bootPartValues := make([]string, 0, len(matr)) + for x := range matr { + bootPartLabel := cToGoString(matr[x][MATRIX_ROW_PARTITION][:]) + if bootPartLabel != "" { + // now check the value + bootPartValue := cToGoString(matr[x][MATRIX_ROW_VALUE][:]) + if bootPartValue != "" { + bootPartValues = append(bootPartValues, bootPartValue) + } + } + } + + return bootPartValues +} + +// getBootPartWithValue returns the boot image partition label for the specified value. +// If the boot image partition label does not exist in the matrix, an error will +// be returned. +func (matr bootimgMatrixGeneric) getBootPartWithValue(value string) (string, error) { + for x := range matr { + if value == cToGoString(matr[x][MATRIX_ROW_VALUE][:]) { + return cToGoString(matr[x][MATRIX_ROW_PARTITION][:]), nil + } + } + + return "", fmt.Errorf("no boot image partition has value %q", value) +} diff --git a/bootloader/lkenv/lkenv_test.go b/bootloader/lkenv/lkenv_test.go new file mode 100644 index 00000000..4ca18e59 --- /dev/null +++ b/bootloader/lkenv/lkenv_test.go @@ -0,0 +1,915 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package lkenv_test + +import ( + "bytes" + "compress/gzip" + "encoding/binary" + "fmt" + "hash/crc32" + "io" + "os" + "path/filepath" + "testing" + + "golang.org/x/xerrors" + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/boot" + "github.com/snapcore/snapd/bootloader/lkenv" + "github.com/snapcore/snapd/logger" + "github.com/snapcore/snapd/testutil" +) + +// Hook up check.v1 into the "go test" runner +func Test(t *testing.T) { TestingT(t) } + +type lkenvTestSuite struct { + testutil.BaseTest + + envPath string + envPathbak string +} + +var _ = Suite(&lkenvTestSuite{}) + +var ( + lkversions = []lkenv.Version{ + lkenv.V1, + lkenv.V2Run, + lkenv.V2Recovery, + } +) + +func (l *lkenvTestSuite) SetUpTest(c *C) { + l.BaseTest.SetUpTest(c) + l.envPath = filepath.Join(c.MkDir(), "snapbootsel.bin") + l.envPathbak = l.envPath + "bak" +} + +// unpack test data packed with gzip +func unpackTestData(data []byte) ([]byte, error) { + b := bytes.NewBuffer(data) + r, err := gzip.NewReader(b) + if err != nil { + return nil, err + } + + var env bytes.Buffer + _, err = env.ReadFrom(r) + if err != nil { + return nil, err + } + return env.Bytes(), nil +} + +func (l *lkenvTestSuite) TestCtoGoString(c *C) { + for _, t := range []struct { + input []byte + expected string + }{ + {[]byte{0, 0, 0, 0, 0}, ""}, + {[]byte{'a', 0, 0, 0, 0}, "a"}, + {[]byte{'a', 'b', 0, 0, 0}, "ab"}, + {[]byte{'a', 'b', 'c', 0, 0}, "abc"}, + {[]byte{'a', 'b', 'c', 'd', 0}, "abcd"}, + // no trailing \0 - assume corrupted "" ? + {[]byte{'a', 'b', 'c', 'd', 'e'}, ""}, + // first \0 is the cutoff + {[]byte{'a', 'b', 0, 'z', 0}, "ab"}, + } { + c.Check(lkenv.CToGoString(t.input), Equals, t.expected) + } +} + +func (l *lkenvTestSuite) TestCopyStringHappy(c *C) { + for _, t := range []struct { + input string + expected []byte + }{ + // input up to the size of the buffer works + {"", []byte{0, 0, 0, 0, 0}}, + {"a", []byte{'a', 0, 0, 0, 0}}, + {"ab", []byte{'a', 'b', 0, 0, 0}}, + {"abc", []byte{'a', 'b', 'c', 0, 0}}, + {"abcd", []byte{'a', 'b', 'c', 'd', 0}}, + // only what fit is copied + {"abcde", []byte{'a', 'b', 'c', 'd', 0}}, + {"abcdef", []byte{'a', 'b', 'c', 'd', 0}}, + // strange embedded stuff works + {"ab\000z", []byte{'a', 'b', 0, 'z', 0}}, + } { + b := make([]byte, 5) + lkenv.CopyString(b, t.input) + c.Check(b, DeepEquals, t.expected) + } +} + +func (l *lkenvTestSuite) TestCopyStringNoPanic(c *C) { + // too long, string should get concatenate + b := make([]byte, 5) + defer lkenv.CopyString(b, "12345") + c.Assert(recover(), IsNil) + defer lkenv.CopyString(b, "123456") + c.Assert(recover(), IsNil) +} + +func (l *lkenvTestSuite) TestGetBootImageName(c *C) { + for _, version := range lkversions { + for _, setValue := range []bool{true, false} { + env := lkenv.NewEnv(l.envPath, "", version) + c.Check(env, NotNil) + + if setValue { + env.Set("bootimg_file_name", "some-boot-image-name") + } + + name := env.GetBootImageName() + + if setValue { + c.Assert(name, Equals, "some-boot-image-name") + } else { + c.Assert(name, Equals, "boot.img") + } + } + } +} + +func (l *lkenvTestSuite) TestSet(c *C) { + tt := []struct { + version lkenv.Version + key string + val string + }{ + { + lkenv.V1, + "snap_mode", + boot.TryStatus, + }, + { + lkenv.V2Run, + "kernel_status", + boot.TryingStatus, + }, + { + lkenv.V2Recovery, + "snapd_recovery_mode", + "recover", + }, + } + for _, t := range tt { + env := lkenv.NewEnv(l.envPath, "", t.version) + c.Check(env, NotNil) + env.Set(t.key, t.val) + c.Check(env.Get(t.key), Equals, t.val) + } +} + +func (l *lkenvTestSuite) TestSave(c *C) { + tt := []struct { + version lkenv.Version + keyValuePairs map[string]string + comment string + }{ + { + lkenv.V1, + map[string]string{ + "snap_mode": boot.TryingStatus, + "snap_kernel": "kernel-1", + "snap_try_kernel": "kernel-2", + "snap_core": "core-1", + "snap_try_core": "core-2", + "snap_gadget": "gadget-1", + "snap_try_gadget": "gadget-2", + "bootimg_file_name": "boot.img", + }, + "lkenv v1", + }, + { + lkenv.V2Run, + map[string]string{ + "kernel_status": boot.TryStatus, + "snap_kernel": "kernel-1", + "snap_try_kernel": "kernel-2", + "snap_gadget": "gadget-1", + "snap_try_gadget": "gadget-2", + "bootimg_file_name": "boot.img", + }, + "lkenv v2 run", + }, + { + lkenv.V2Recovery, + map[string]string{ + "snapd_recovery_mode": "recover", + "snapd_recovery_system": "11192020", + "bootimg_file_name": "boot.img", + "try_recovery_system": "1234", + "recovery_system_status": "tried", + }, + "lkenv v2 recovery", + }, + } + for _, t := range tt { + for _, makeBackup := range []bool{true, false} { + var comment CommentInterface + if makeBackup { + comment = Commentf("testcase %s with backup", t.comment) + } else { + comment = Commentf("testcase %s without backup", t.comment) + } + + loggerBuf, restore := logger.MockLogger() + defer restore() + + // make unique files per test case + testFile := filepath.Join(c.MkDir(), "lk.bin") + testFileBackup := testFile + "bak" + if makeBackup { + // create the backup file too + buf := make([]byte, 4096) + err := os.WriteFile(testFileBackup, buf, 0644) + c.Assert(err, IsNil, comment) + } + + buf := make([]byte, 4096) + err := os.WriteFile(testFile, buf, 0644) + c.Assert(err, IsNil, comment) + + env := lkenv.NewEnv(testFile, "", t.version) + c.Check(env, NotNil, comment) + + for k, v := range t.keyValuePairs { + env.Set(k, v) + } + + err = env.Save() + c.Assert(err, IsNil, comment) + + env2 := lkenv.NewEnv(testFile, "", t.version) + err = env2.Load() + c.Assert(err, IsNil, comment) + + for k, v := range t.keyValuePairs { + c.Check(env2.Get(k), Equals, v, comment) + } + + // check the backup too + if makeBackup { + env3 := lkenv.NewEnv(testFileBackup, "", t.version) + err := env3.Load() + c.Assert(err, IsNil, comment) + + for k, v := range t.keyValuePairs { + c.Check(env3.Get(k), Equals, v, comment) + } + + // corrupt the main file and then try to load it - we should + // automatically fallback to the backup file since the backup + // file will not be corrupt + buf := make([]byte, 4096) + f, err := os.OpenFile(testFile, os.O_WRONLY, 0644) + c.Assert(err, IsNil) + _, err = io.Copy(f, bytes.NewBuffer(buf)) + c.Assert(err, IsNil, comment) + + env4 := lkenv.NewEnv(testFile, "", t.version) + err = env4.Load() + c.Assert(err, IsNil, comment) + + for k, v := range t.keyValuePairs { + c.Check(env4.Get(k), Equals, v, comment) + } + + // we should have also had a logged message about being unable + // to load the main file + c.Assert(loggerBuf.String(), testutil.Contains, fmt.Sprintf("cannot load primary bootloader environment: cannot validate %s:", testFile)) + } + } + } +} + +func (l *lkenvTestSuite) TestLoadValidatesCRC32(c *C) { + for _, version := range lkversions { + testFile := filepath.Join(c.MkDir(), "lk.bin") + + // make an out of band lkenv object and set the wrong signature to be + // able to export it to a file + var rawStruct interface{} + switch version { + case lkenv.V1: + rawStruct = lkenv.SnapBootSelect_v1{ + Version: version.Number(), + Signature: version.Signature(), + } + case lkenv.V2Run: + rawStruct = lkenv.SnapBootSelect_v2_run{ + Version: version.Number(), + Signature: version.Signature(), + } + case lkenv.V2Recovery: + rawStruct = lkenv.SnapBootSelect_v2_recovery{ + Version: version.Number(), + Signature: version.Signature(), + } + } + + buf := bytes.NewBuffer(nil) + ss := binary.Size(rawStruct) + buf.Grow(ss) + err := binary.Write(buf, binary.LittleEndian, rawStruct) + c.Assert(err, IsNil) + + // calculate the expected checksum but don't put it into the object when + // we write it out so that the checksum is invalid + expCrc32 := crc32.ChecksumIEEE(buf.Bytes()[:ss-4]) + + err = os.WriteFile(testFile, buf.Bytes(), 0644) + c.Assert(err, IsNil) + + // now try importing the file with LoadEnv() + env := lkenv.NewEnv(testFile, "", version) + c.Assert(env, NotNil) + + err = env.LoadEnv(testFile) + c.Assert(err, ErrorMatches, fmt.Sprintf("cannot validate %s: expected checksum 0x%X, got 0x%X", testFile, expCrc32, 0)) + } + +} + +func (l *lkenvTestSuite) TestNewBackupFileLocation(c *C) { + // creating with the second argument as the empty string falls back to + // the main path + "bak" + for _, version := range lkversions { + logbuf, restore := logger.MockLogger() + defer restore() + + testFile := filepath.Join(c.MkDir(), "lk.bin") + c.Assert(testFile, testutil.FileAbsent) + c.Assert(testFile+"bak", testutil.FileAbsent) + // make empty files for Save() to overwrite + err := os.WriteFile(testFile, nil, 0644) + c.Assert(err, IsNil) + err = os.WriteFile(testFile+"bak", nil, 0644) + c.Assert(err, IsNil) + env := lkenv.NewEnv(testFile, "", version) + c.Assert(env, NotNil) + err = env.Save() + c.Assert(err, IsNil) + + // make sure both the primary and backup files were written and can be + // successfully loaded + env2 := lkenv.NewEnv(testFile, "", version) + err = env2.Load() + c.Assert(err, IsNil) + + env3 := lkenv.NewEnv(testFile+"bak", "", version) + err = env3.Load() + c.Assert(err, IsNil) + + // no messages logged + c.Assert(logbuf.String(), Equals, "") + } + + // now specify a different backup file location + for _, version := range lkversions { + logbuf, restore := logger.MockLogger() + defer restore() + testFile := filepath.Join(c.MkDir(), "lk.bin") + testFileBackup := filepath.Join(c.MkDir(), "lkbackup.bin") + err := os.WriteFile(testFile, nil, 0644) + c.Assert(err, IsNil) + err = os.WriteFile(testFileBackup, nil, 0644) + c.Assert(err, IsNil) + + env := lkenv.NewEnv(testFile, testFileBackup, version) + c.Assert(env, NotNil) + err = env.Save() + c.Assert(err, IsNil) + + // make sure both the primary and backup files were written and can be + // successfully loaded + env2 := lkenv.NewEnv(testFile, "", version) + err = env2.Load() + c.Assert(err, IsNil) + + env3 := lkenv.NewEnv(testFileBackup, "", version) + err = env3.Load() + c.Assert(err, IsNil) + + // no "bak" files present + c.Assert(testFile+"bak", testutil.FileAbsent) + c.Assert(testFileBackup+"bak", testutil.FileAbsent) + + // no messages logged + c.Assert(logbuf.String(), Equals, "") + } +} + +func (l *lkenvTestSuite) TestLoadValidatesVersionSignatureConsistency(c *C) { + + tt := []struct { + version lkenv.Version + binVersion uint32 + binSignature uint32 + validateFailMode string + }{ + { + lkenv.V1, + lkenv.V2Recovery.Number(), + lkenv.V1.Signature(), + "version", + }, + { + lkenv.V1, + lkenv.V1.Number(), + lkenv.V2Recovery.Signature(), + "signature", + }, + { + lkenv.V2Run, + lkenv.V1.Number(), + lkenv.V2Run.Signature(), + "version", + }, + { + lkenv.V2Run, + lkenv.V2Run.Number(), + lkenv.V2Recovery.Signature(), + "signature", + }, + { + lkenv.V2Recovery, + lkenv.V1.Number(), + lkenv.V2Recovery.Signature(), + "version", + }, + { + lkenv.V2Recovery, + lkenv.V2Recovery.Number(), + lkenv.V2Run.Signature(), + "signature", + }, + } + + for _, t := range tt { + testFile := filepath.Join(c.MkDir(), "lk.bin") + + // make an out of band lkenv object and set the wrong signature to be + // able to export it to a file + var rawStruct interface{} + switch t.version { + case lkenv.V1: + rawStruct = lkenv.SnapBootSelect_v1{ + Version: t.binVersion, + Signature: t.binSignature, + } + case lkenv.V2Run: + rawStruct = lkenv.SnapBootSelect_v2_run{ + Version: t.binVersion, + Signature: t.binSignature, + } + case lkenv.V2Recovery: + rawStruct = lkenv.SnapBootSelect_v2_recovery{ + Version: t.binVersion, + Signature: t.binSignature, + } + } + + buf := bytes.NewBuffer(nil) + ss := binary.Size(rawStruct) + buf.Grow(ss) + err := binary.Write(buf, binary.LittleEndian, rawStruct) + c.Assert(err, IsNil) + + // calculate crc32 + newCrc32 := crc32.ChecksumIEEE(buf.Bytes()[:ss-4]) + // note for efficiency's sake to avoid re-writing the whole structure, + // we re-write _just_ the crc32 to w as little-endian + buf.Truncate(ss - 4) + binary.Write(buf, binary.LittleEndian, &newCrc32) + + err = os.WriteFile(testFile, buf.Bytes(), 0644) + c.Assert(err, IsNil) + + // now try importing the file with LoadEnv() + env := lkenv.NewEnv(testFile, "", t.version) + c.Assert(env, NotNil) + + var expNum, gotNum uint32 + switch t.validateFailMode { + case "signature": + expNum = t.version.Signature() + gotNum = t.binSignature + case "version": + expNum = t.version.Number() + gotNum = t.binVersion + } + expErr := fmt.Sprintf( + "cannot validate %s: expected %s 0x%X, got 0x%X", + testFile, + t.validateFailMode, + expNum, + gotNum, + ) + + err = env.LoadEnv(testFile) + c.Assert(err, ErrorMatches, expErr) + } +} + +func (l *lkenvTestSuite) TestLoadPropagatesErrNotExist(c *C) { + // make sure that if the env file doesn't exist, the error returned from + // Load() is os.ErrNotExist, even if it isn't exactly that + env := lkenv.NewEnv("some-nonsense-file-this-doesnt-exist", "", lkenv.V1) + c.Check(env, NotNil) + + err := env.Load() + c.Assert(xerrors.Is(err, os.ErrNotExist), Equals, true, Commentf("err is %+v", err)) + c.Assert(err, ErrorMatches, "cannot open LK env file: open some-nonsense-file-this-doesnt-existbak: no such file or directory") +} + +func (l *lkenvTestSuite) TestLoad(c *C) { + for _, version := range lkversions { + for _, makeBackup := range []bool{true, false} { + loggerBuf, restore := logger.MockLogger() + defer restore() + // make unique files per test case + testFile := filepath.Join(c.MkDir(), "lk.bin") + testFileBackup := testFile + "bak" + if makeBackup { + buf := make([]byte, 100000) + err := os.WriteFile(testFileBackup, buf, 0644) + c.Assert(err, IsNil) + } + + buf := make([]byte, 100000) + err := os.WriteFile(testFile, buf, 0644) + c.Assert(err, IsNil) + + // create an env for this file and try to load it + env := lkenv.NewEnv(testFile, "", version) + c.Check(env, NotNil) + + err = env.Load() + // possible error messages could be "cannot open LK env file: ..." + // or "cannot valid : ..." + if makeBackup { + // here we will read the backup file which exists but like the + // primary file is corrupted + c.Assert(err, ErrorMatches, fmt.Sprintf("cannot validate %s: expected version 0x%X, got 0x0", testFileBackup, version.Number())) + } else { + // here we fail to read the normal file, and automatically try + // to read the backup, but fail because it doesn't exist + c.Assert(err, ErrorMatches, fmt.Sprintf("cannot open LK env file: open %s: no such file or directory", testFileBackup)) + } + + c.Assert(loggerBuf.String(), testutil.Contains, fmt.Sprintf("cannot load primary bootloader environment: cannot validate %s:", testFile)) + c.Assert(loggerBuf.String(), testutil.Contains, "attempting to load backup bootloader environment") + } + } +} + +func (l *lkenvTestSuite) TestGetAndSetAndFindBootPartition(c *C) { + tt := []struct { + version lkenv.Version + // use slices instead of a map since we need a consistent ordering + bootMatrixKeys []string + bootMatrixValues []string + matrixType string + comment string + }{ + { + lkenv.V1, + []string{ + "boot_a", + "boot_b", + }, + []string{ + "kernel-1", + "kernel-2", + }, + "kernel", + "v1", + }, + { + lkenv.V2Run, + []string{ + "boot_a", + "boot_b", + }, + []string{ + "kernel-1", + "kernel-2", + }, + "kernel", + "v2 run", + }, + { + lkenv.V2Recovery, + []string{ + "boot_recovery_1", + }, + []string{ + "20201123", + }, + "recovery-system", + "v2 recovery 1 slot", + }, + { + lkenv.V2Recovery, + []string{ + "boot_recovery_1", + "boot_recovery_2", + }, + []string{ + "20201123", + "20201124", + }, + "recovery-system", + "v2 recovery 2 slots", + }, + { + lkenv.V2Recovery, + []string{ + "boot_recovery_1", + "boot_recovery_2", + "boot_recovery_3", + }, + []string{ + "20201123", + "20201124", + "20201125", + }, + "recovery-system", + "v2 recovery 3 slots", + }, + { + lkenv.V2Recovery, + []string{ + "boot_recovery_1", + "boot_recovery_2", + "boot_recovery_3", + "boot_recovery_4", + "boot_recovery_5", + "boot_recovery_6", + "boot_recovery_7", + "boot_recovery_8", + "boot_recovery_9", + "boot_recovery_10", + }, + []string{ + "20201123", + "20201124", + "20201125", + "20201126", + "20201127", + "20201128", + "20201129", + "20201130", + "20201131", + "20201132", + }, + "recovery-system", + "v2 recovery max slots", + }, + } + + for _, t := range tt { + comment := Commentf(t.comment) + // make sure the key and values are the same length for test case + // consistency check + c.Assert(t.bootMatrixKeys, HasLen, len(t.bootMatrixValues), comment) + + buf := make([]byte, 4096) + err := os.WriteFile(l.envPath, buf, 0644) + c.Assert(err, IsNil, comment) + + env := lkenv.NewEnv(l.envPath, "", t.version) + c.Assert(env, Not(IsNil), comment) + + var findFunc func(string) (string, error) + var setFunc func(string, string) error + var getFunc func(string) (string, error) + var deleteFunc func(string) error + switch t.matrixType { + case "recovery-system": + findFunc = func(s string) (string, error) { return env.FindFreeRecoverySystemBootPartition(s) } + setFunc = func(s1, s2 string) error { return env.SetBootPartitionRecoverySystem(s1, s2) } + getFunc = func(s1 string) (string, error) { return env.GetRecoverySystemBootPartition(s1) } + deleteFunc = func(s1 string) error { return env.RemoveRecoverySystemFromBootPartition(s1) } + case "kernel": + findFunc = func(s string) (string, error) { return env.FindFreeKernelBootPartition(s) } + setFunc = func(s1, s2 string) error { + // for assigning the kernel, we need to also set the + // snap_kernel, since that is used to detect if we should return + // an unset variable or not + + err := env.SetBootPartitionKernel(s1, s2) + c.Assert(err, IsNil, comment) + if err != nil { + return err + } + if env.Get("snap_kernel") == "" { + // only set it the first time so that the delete logic test + // works and we only set the first kernel to be snap_kernel + env.Set("snap_kernel", s2) + } + return nil + } + getFunc = func(s1 string) (string, error) { return env.GetKernelBootPartition(s1) } + deleteFunc = func(s1 string) error { return env.RemoveKernelFromBootPartition(s1) } + default: + c.Errorf("unexpected matrix type, test setup broken (%s)", comment) + } + + err = env.InitializeBootPartitions(t.bootMatrixKeys...) + c.Assert(err, IsNil, comment) + + // before assigning any values to the boot matrix, check that all + // values we try to assign would go to the first bootPartLabel + for _, bootPartValue := range t.bootMatrixKeys { + // we haven't assigned anything yet, so all values should get mapped + // to the first boot image partition + bootPartFound, err := findFunc(bootPartValue) + c.Assert(err, IsNil, comment) + c.Assert(bootPartFound, Equals, t.bootMatrixKeys[0], comment) + } + + // now go and assign them, checking that along the way we are assigning + // to the next slot + // iterate over the key list to keep the same order + for i, bootPart := range t.bootMatrixKeys { + bootPartValue := t.bootMatrixValues[i] + // now we will be assigning things, so we should check that the + // assigned boot image partition matches what we expect + bootPartFound, err := findFunc(bootPartValue) + c.Assert(err, IsNil, comment) + c.Assert(bootPartFound, Equals, bootPart, comment) + + err = setFunc(bootPart, bootPartValue) + c.Assert(err, IsNil, comment) + + // now check that it has the right value + val, err := getFunc(bootPartValue) + c.Assert(err, IsNil, comment) + c.Assert(val, Equals, bootPart, comment) + + // double-check that finding a free slot for this value returns the + // existing slot - this logic specifically is important for uc16 and + // uc18 where during seeding we will end up extracting a kernel to + // the already extracted slot (since the kernel will already have + // been extracted during image build time) + bootPartFound2, err := findFunc(bootPartValue) + c.Assert(err, IsNil, comment) + c.Assert(bootPartFound2, Equals, bootPart, comment) + } + + // now check that trying to find a free slot for a new recovery system + // fails because we are full + if t.matrixType == "recovery-system" { + thing, err := findFunc("some-random-value") + c.Check(thing, Equals, "") + c.Assert(err, ErrorMatches, "cannot find free boot image partition", comment) + } + + // test that removing the last one works + lastIndex := len(t.bootMatrixValues) - 1 + lastValue := t.bootMatrixValues[lastIndex] + lastKey := t.bootMatrixKeys[lastIndex] + err = deleteFunc(lastValue) + c.Assert(err, IsNil, comment) + + // trying to delete again will fail since it won't exist + err = deleteFunc(lastValue) + c.Assert(err, ErrorMatches, fmt.Sprintf("cannot find %q in boot image partitions", lastValue), comment) + + // trying to find it will return the last slot + slot, err := findFunc(lastValue) + c.Assert(err, IsNil, comment) + c.Assert(slot, Equals, lastKey, comment) + } +} + +func (l *lkenvTestSuite) TestV1NoRecoverySystemSupport(c *C) { + env := lkenv.NewEnv(l.envPath, "", lkenv.V1) + c.Assert(env, NotNil) + + _, err := env.FindFreeRecoverySystemBootPartition("blah") + c.Assert(err, ErrorMatches, "internal error: v1 lkenv has no boot image partition recovery system matrix") + + err = env.SetBootPartitionRecoverySystem("blah", "blah") + c.Assert(err, ErrorMatches, "internal error: v1 lkenv has no boot image partition recovery system matrix") + + _, err = env.GetRecoverySystemBootPartition("blah") + c.Assert(err, ErrorMatches, "internal error: v1 lkenv has no boot image partition recovery system matrix") + + err = env.RemoveRecoverySystemFromBootPartition("blah") + c.Assert(err, ErrorMatches, "internal error: v1 lkenv has no boot image partition recovery system matrix") +} + +func (l *lkenvTestSuite) TestV2RunNoRecoverySystemSupport(c *C) { + env := lkenv.NewEnv(l.envPath, "", lkenv.V2Run) + c.Assert(env, NotNil) + + _, err := env.FindFreeRecoverySystemBootPartition("blah") + c.Assert(err, ErrorMatches, "internal error: v2 run lkenv has no boot image partition recovery system matrix") + + err = env.SetBootPartitionRecoverySystem("blah", "blah") + c.Assert(err, ErrorMatches, "internal error: v2 run lkenv has no boot image partition recovery system matrix") + + _, err = env.GetRecoverySystemBootPartition("blah") + c.Assert(err, ErrorMatches, "internal error: v2 run lkenv has no boot image partition recovery system matrix") + + err = env.RemoveRecoverySystemFromBootPartition("blah") + c.Assert(err, ErrorMatches, "internal error: v2 run lkenv has no boot image partition recovery system matrix") +} + +func (l *lkenvTestSuite) TestV2RecoveryNoKernelSupport(c *C) { + env := lkenv.NewEnv(l.envPath, "", lkenv.V2Recovery) + c.Assert(env, NotNil) + + _, err := env.FindFreeKernelBootPartition("blah") + c.Assert(err, ErrorMatches, "internal error: v2 recovery lkenv has no boot image partition kernel matrix") + + err = env.SetBootPartitionKernel("blah", "blah") + c.Assert(err, ErrorMatches, "internal error: v2 recovery lkenv has no boot image partition kernel matrix") + + _, err = env.GetKernelBootPartition("blah") + c.Assert(err, ErrorMatches, "internal error: v2 recovery lkenv has no boot image partition kernel matrix") + + err = env.RemoveKernelFromBootPartition("blah") + c.Assert(err, ErrorMatches, "internal error: v2 recovery lkenv has no boot image partition kernel matrix") +} + +func (l *lkenvTestSuite) TestZippedDataSample(c *C) { + // TODO: add binary data test for v2 structures generated with gadget build + // tool when it has been updated for v2 + + // test data is generated with gadget build helper tool: + // $ parts/snap-boot-sel-env/build/lk-boot-env -w test.bin \ + // --snap-mode="trying" --snap-kernel="kernel-1" --snap-try-kernel="kernel-2" \ + // --snap-core="core-1" --snap-try-core="core-2" --reboot-reason="" \ + // --boot-0-part="boot_a" --boot-1-part="boot_b" --boot-0-snap="kernel-1" \ + // --boot-1-snap="kernel-3" --bootimg-file="boot.img" + // $ cat test.bin | gzip | xxd -i + gzipedData := []byte{ + 0x1f, 0x8b, 0x08, 0x00, 0x95, 0x88, 0x77, 0x5d, 0x00, 0x03, 0xed, 0xd7, + 0xc1, 0x09, 0xc2, 0x40, 0x10, 0x05, 0xd0, 0xa4, 0x20, 0x05, 0x63, 0x07, + 0x96, 0xa0, 0x05, 0x88, 0x91, 0x25, 0x04, 0x35, 0x0b, 0x6b, 0x2e, 0x1e, + 0xac, 0xcb, 0xf6, 0xc4, 0x90, 0x1e, 0x06, 0xd9, 0xf7, 0x2a, 0xf8, 0xc3, + 0x1f, 0x18, 0xe6, 0x74, 0x78, 0xa6, 0xb6, 0x69, 0x9b, 0xb9, 0xbc, 0xc6, + 0x69, 0x68, 0xaa, 0x75, 0xcd, 0x25, 0x6d, 0x76, 0xd1, 0x29, 0xe2, 0x2c, + 0xf3, 0x77, 0xd1, 0x29, 0xe2, 0xdc, 0x52, 0x99, 0xd2, 0xbd, 0xde, 0x0d, + 0x58, 0xe7, 0xaf, 0x78, 0x03, 0x80, 0x5a, 0xf5, 0x39, 0xcf, 0xe7, 0x4b, + 0x74, 0x8a, 0x38, 0xb5, 0xdf, 0xbf, 0xa5, 0xff, 0x3e, 0x3a, 0x45, 0x9c, + 0xb5, 0xff, 0x7d, 0x74, 0x8e, 0x28, 0xbf, 0xfe, 0xb7, 0xe3, 0xa3, 0xe2, + 0x0f, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xf8, 0x17, 0xc7, 0xf7, 0xa7, 0xfb, 0x02, 0x1c, 0xdf, 0x44, 0x21, 0x0c, + 0x3a, 0x00, 0x00} + + // uncompress test data to sample env file + rawData, err := unpackTestData(gzipedData) + c.Assert(err, IsNil) + err = os.WriteFile(l.envPath, rawData, 0644) + c.Assert(err, IsNil) + err = os.WriteFile(l.envPathbak, rawData, 0644) + c.Assert(err, IsNil) + + env := lkenv.NewEnv(l.envPath, "", lkenv.V1) + c.Check(env, NotNil) + err = env.Load() + c.Assert(err, IsNil) + c.Check(env.Get("snap_mode"), Equals, boot.TryingStatus) + c.Check(env.Get("snap_kernel"), Equals, "kernel-1") + c.Check(env.Get("snap_try_kernel"), Equals, "kernel-2") + c.Check(env.Get("snap_core"), Equals, "core-1") + c.Check(env.Get("snap_try_core"), Equals, "core-2") + c.Check(env.Get("bootimg_file_name"), Equals, "boot.img") + c.Check(env.Get("reboot_reason"), Equals, "") + // first partition should be with label 'boot_a' and 'kernel-1' revision + p, err := env.GetKernelBootPartition("kernel-1") + c.Check(p, Equals, "boot_a") + c.Assert(err, IsNil) + // test second boot partition is free with label "boot_b" + p, err = env.FindFreeKernelBootPartition("kernel-2") + c.Check(p, Equals, "boot_b") + c.Assert(err, IsNil) +} diff --git a/bootloader/lkenv/lkenv_v1.go b/bootloader/lkenv/lkenv_v1.go new file mode 100644 index 00000000..931e12d5 --- /dev/null +++ b/bootloader/lkenv/lkenv_v1.go @@ -0,0 +1,216 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package lkenv + +import ( + "fmt" +) + +// Following structure has to be kept in sync with c structure defined by +// include/lk/snappy-boot_v1.h +// c headerfile is used by bootloader, this ensures sync of the environment +// between snapd and bootloader +// +// when this structure needs to be updated, +// new version should be introduced instead together with c header file, +// which is to be adopted by bootloader +// +// !!! Support for old version has to be maintained, as it is not guaranteed +// all existing bootloader would adopt new version! +type SnapBootSelect_v1 struct { + /* Contains value BOOTSELECT_SIGNATURE defined above */ + Signature uint32 + /* snappy boot select version */ + Version uint32 + + /* snap_mode, one of: 'empty', "try", "trying" */ + Snap_mode [SNAP_FILE_NAME_MAX_LEN]byte + /* current core snap revision */ + Snap_core [SNAP_FILE_NAME_MAX_LEN]byte + /* try core snap revision */ + Snap_try_core [SNAP_FILE_NAME_MAX_LEN]byte + /* current kernel snap revision */ + Snap_kernel [SNAP_FILE_NAME_MAX_LEN]byte + /* current kernel snap revision */ + Snap_try_kernel [SNAP_FILE_NAME_MAX_LEN]byte + + /* gadget_mode, one of: 'empty', "try", "trying" */ + Gadget_mode [SNAP_FILE_NAME_MAX_LEN]byte + /* GADGET assets: current gadget assets revision */ + Snap_gadget [SNAP_FILE_NAME_MAX_LEN]byte + /* GADGET assets: try gadget assets revision */ + Snap_try_gadget [SNAP_FILE_NAME_MAX_LEN]byte + + /** + * Reboot reason + * optional parameter to signal bootloader alternative reboot reasons + * e.g. recovery/factory-reset/boot asset update + */ + Reboot_reason [SNAP_FILE_NAME_MAX_LEN]byte + + /** + * Matrix for mapping of boot img partition to installed kernel snap revision + * + * First column represents boot image partition label (e.g. boot_a,boot_b ) + * value are static and should be populated at gadget built time + * or latest at image build time. Values are not further altered at run time. + * Second column represents name currently installed kernel snap + * e.g. pi2-kernel_123.snap + * initial value representing initial kernel snap revision + * is populated at image build time by snapd + * + * There are two rows in the matrix, representing current and previous kernel revision + * following describes how this matrix should be modified at different stages: + * - at image build time: + * - extracted kernel snap revision name should be filled + * into free slot (first row, second column) + * - snapd: + * - when new kernel snap revision is being installed, snapd cycles through + * matrix to find unused 'boot slot' to be used for new kernel snap revision + * from free slot, first column represents partition label to which kernel + * snap boot image should be extracted. Second column is then populated with + * kernel snap revision name. + * - snap_mode, snap_try_kernel, snap_try_core behaves same way as with u-boot + * - bootloader: + * - bootloader reads snap_mode to determine if snap_kernel or snap_try_kernel is used + * to get kernel snap revision name + * kernel snap revision is then used to search matrix to determine + * partition label to be used for current boot + * - bootloader NEVER alters this matrix values + * + * [ ] [ ] + * [ ] [ ] + */ + Bootimg_matrix [SNAP_BOOTIMG_PART_NUM][2][SNAP_FILE_NAME_MAX_LEN]byte + + /** + * name of the boot image from kernel snap to be used for extraction + * when not defined or empty, default boot.img will be used + */ + Bootimg_file_name [SNAP_FILE_NAME_MAX_LEN]byte + + /** + * gadget assets: Matrix for mapping of gadget asset partitions + * Optional boot asset tracking, based on bootloader support + * Some boot chains support A/B boot assets for increased robustness + * example being A/B TrustExecutionEnvironment + * This matrix can be used to track current and try boot assets for + * robust updates + * Use of Gadget_asset_matrix matches use of Bootimg_matrix + * + * [ ] [ ] + * [ ] [ ] + */ + Gadget_asset_matrix [SNAP_BOOTIMG_PART_NUM][2][SNAP_FILE_NAME_MAX_LEN]byte + + /* unused placeholders for additional parameters in the future */ + Unused_key_01 [SNAP_FILE_NAME_MAX_LEN]byte + Unused_key_02 [SNAP_FILE_NAME_MAX_LEN]byte + Unused_key_03 [SNAP_FILE_NAME_MAX_LEN]byte + Unused_key_04 [SNAP_FILE_NAME_MAX_LEN]byte + Unused_key_05 [SNAP_FILE_NAME_MAX_LEN]byte + Unused_key_06 [SNAP_FILE_NAME_MAX_LEN]byte + Unused_key_07 [SNAP_FILE_NAME_MAX_LEN]byte + Unused_key_08 [SNAP_FILE_NAME_MAX_LEN]byte + Unused_key_09 [SNAP_FILE_NAME_MAX_LEN]byte + Unused_key_10 [SNAP_FILE_NAME_MAX_LEN]byte + Unused_key_11 [SNAP_FILE_NAME_MAX_LEN]byte + Unused_key_12 [SNAP_FILE_NAME_MAX_LEN]byte + Unused_key_13 [SNAP_FILE_NAME_MAX_LEN]byte + Unused_key_14 [SNAP_FILE_NAME_MAX_LEN]byte + Unused_key_15 [SNAP_FILE_NAME_MAX_LEN]byte + Unused_key_16 [SNAP_FILE_NAME_MAX_LEN]byte + Unused_key_17 [SNAP_FILE_NAME_MAX_LEN]byte + Unused_key_18 [SNAP_FILE_NAME_MAX_LEN]byte + Unused_key_19 [SNAP_FILE_NAME_MAX_LEN]byte + Unused_key_20 [SNAP_FILE_NAME_MAX_LEN]byte + + /* unused array of 10 key value pairs */ + Key_value_pairs [10][2][SNAP_FILE_NAME_MAX_LEN]byte + + /* crc32 value for structure */ + Crc32 uint32 +} + +func newV1() *SnapBootSelect_v1 { + return &SnapBootSelect_v1{ + Version: SNAP_BOOTSELECT_VERSION_V1, + Signature: SNAP_BOOTSELECT_SIGNATURE, + } +} + +func (v1 *SnapBootSelect_v1) currentCrc32() uint32 { return v1.Crc32 } +func (v1 *SnapBootSelect_v1) currentVersion() uint32 { return v1.Version } +func (v1 *SnapBootSelect_v1) currentSignature() uint32 { return v1.Signature } + +func (v1 *SnapBootSelect_v1) get(key string) string { + switch key { + case "snap_mode": + return cToGoString(v1.Snap_mode[:]) + case "snap_kernel": + return cToGoString(v1.Snap_kernel[:]) + case "snap_try_kernel": + return cToGoString(v1.Snap_try_kernel[:]) + case "snap_core": + return cToGoString(v1.Snap_core[:]) + case "snap_try_core": + return cToGoString(v1.Snap_try_core[:]) + case "snap_gadget": + return cToGoString(v1.Snap_gadget[:]) + case "snap_try_gadget": + return cToGoString(v1.Snap_try_gadget[:]) + case "reboot_reason": + return cToGoString(v1.Reboot_reason[:]) + case "bootimg_file_name": + return cToGoString(v1.Bootimg_file_name[:]) + } + return "" +} + +func (v1 *SnapBootSelect_v1) set(key, value string) { + switch key { + case "snap_mode": + copyString(v1.Snap_mode[:], value) + case "snap_kernel": + copyString(v1.Snap_kernel[:], value) + case "snap_try_kernel": + copyString(v1.Snap_try_kernel[:], value) + case "snap_core": + copyString(v1.Snap_core[:], value) + case "snap_try_core": + copyString(v1.Snap_try_core[:], value) + case "snap_gadget": + copyString(v1.Snap_gadget[:], value) + case "snap_try_gadget": + copyString(v1.Snap_try_gadget[:], value) + case "reboot_reason": + copyString(v1.Reboot_reason[:], value) + case "bootimg_file_name": + copyString(v1.Bootimg_file_name[:], value) + } +} + +func (v1 *SnapBootSelect_v1) bootImgKernelMatrix() (bootimgMatrixGeneric, error) { + return (bootimgMatrixGeneric)((&v1.Bootimg_matrix)[:]), nil +} + +func (v1 *SnapBootSelect_v1) bootImgRecoverySystemMatrix() (bootimgMatrixGeneric, error) { + return nil, fmt.Errorf("internal error: v1 lkenv has no boot image partition recovery system matrix") +} diff --git a/bootloader/lkenv/lkenv_v2.go b/bootloader/lkenv/lkenv_v2.go new file mode 100644 index 00000000..bcb31b2d --- /dev/null +++ b/bootloader/lkenv/lkenv_v2.go @@ -0,0 +1,365 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package lkenv + +import ( + "fmt" +) + +/** + * Following structure has to be kept in sync with c structure defined by + * include/lk/snappy-boot_v2.h + * c headerfile is used by bootloader, this ensures sync of the environment + * between snapd and bootloader + + * when this structure needs to be updated, + * new version should be introduced instead together with c header file, + * which is to be adopted by bootloader + * + * !!! Support for old version has to be maintained, as it is not guaranteed + * all existing bootloader would adopt new version! + */ + +type SnapBootSelect_v2_recovery struct { + /* Contains value BOOTSELECT_SIGNATURE defined above */ + Signature uint32 + /* snappy boot select version */ + Version uint32 + + /** snapd_recovery_mode is what mode the system will be booted in, one of + * "install", "recover" or "run" + */ + Snapd_recovery_mode [SNAP_FILE_NAME_MAX_LEN]byte + + /** snapd_recovery_system defines the recovery system label to be used when + * booting the system, it must be defined to one of the values in the + * bootimg matrix below + */ + Snapd_recovery_system [SNAP_FILE_NAME_MAX_LEN]byte + + /** + * Matrix for mapping of recovery system boot img partition to kernel snap + * revisions for those recovery systems + * + * First column represents boot image partition label (e.g. recov_a, recov_a) + * value are static and should be populated at gadget build time + * or latest at image build time. Values are not further altered at run + * time. + * Second column represents the name of the currently installed recovery + * system label there - note that every recovery system has only one + * kernel for it, so this is in effect a proxy for the kernel revision + * + * The initial value representing initial single recovery system is + * populated at image build time by snapd + * + * There are SNAP_RECOVERY_BOOTIMG_PART_NUM rows in the matrix, representing + * all possible recovery systems on the image. + * The following describes how this matrix should be modified at different + * stages: + * - at image build time: + * - default recovery system label should be filled into free slot + * (first row, second column) + * - snapd: + * - when new recovery system is being created, snapd cycles + * through matrix to find unused 'boot slot' to be used for new + * recovery system from free slot, first column represents partition + * label to which kernel snap boot image should be extracted. Second + * column is then populated recovery system label. + * - snapd_recovery_mode and snapd_recovery_system are written/used + * normally when transitioning to/from recover/install/run modes + * - bootloader: + * - bootloader reads snapd_recovery_system to determine what label + * should be searched for in the matrix, then finds the corresponding + * partition label for the kernel snap from that recovery system. Then + * snapd_recovery_mode is read and both variables are put onto the + * kernel commandline when booting the linux kernel + * - bootloader NEVER alters this matrix values + * + * [ ] [ ] + * [ ] [ ] + */ + Bootimg_matrix [SNAP_RECOVERY_BOOTIMG_PART_NUM][2][SNAP_FILE_NAME_MAX_LEN]byte + + /* name of the boot image from kernel snap to be used for extraction + when not defined or empty, default boot.img will be used */ + Bootimg_file_name [SNAP_FILE_NAME_MAX_LEN]byte + + /** try_recovery_system contains the label of a recovery system to be + * tried. This entry is completely transparent to the bootloader and is + * only modified by snapd or snap-bootstrap. + */ + Try_recovery_system [SNAP_FILE_NAME_MAX_LEN]byte + + /** recovery_system_status contains the status of a tried recovery + * systems, which is one of "", "try", "tried". This entry is completely + * transparent to the bootloader and is only modified by snapd or + * snap-bootstrap + */ + Recovery_system_status [SNAP_FILE_NAME_MAX_LEN]byte + + /** device_lock_state contains the lock state of the device. It is used by the + * bootloader to track device lock changes. When lock state changes, device goes + * automatically to install mode. This entry is completely transparent + * to the snapd and is only modified by bootloader. + * Only first char in the array is used (device_lock_state[0]) + * Permitted values: + * 0: DEVICE_STATE_UNKNOWN: initial value at first boot. + * This is changed by the bootloader to reflect actual device state. + * 1: DEVICE_STATE_UNLOCKED: unlocked device + * 2: DEVICE_STATE_LOCKED: locked device + */ + Device_lock_state [SNAP_FILE_NAME_MAX_LEN]byte + + /* unused placeholders for additional parameters in the future */ + Unused_key_01 [SNAP_FILE_NAME_MAX_LEN]byte + Unused_key_02 [SNAP_FILE_NAME_MAX_LEN]byte + Unused_key_03 [SNAP_FILE_NAME_MAX_LEN]byte + Unused_key_04 [SNAP_FILE_NAME_MAX_LEN]byte + Unused_key_05 [SNAP_FILE_NAME_MAX_LEN]byte + Unused_key_06 [SNAP_FILE_NAME_MAX_LEN]byte + Unused_key_07 [SNAP_FILE_NAME_MAX_LEN]byte + Unused_key_08 [SNAP_FILE_NAME_MAX_LEN]byte + Unused_key_09 [SNAP_FILE_NAME_MAX_LEN]byte + Unused_key_10 [SNAP_FILE_NAME_MAX_LEN]byte + Unused_key_11 [SNAP_FILE_NAME_MAX_LEN]byte + Unused_key_12 [SNAP_FILE_NAME_MAX_LEN]byte + Unused_key_13 [SNAP_FILE_NAME_MAX_LEN]byte + Unused_key_14 [SNAP_FILE_NAME_MAX_LEN]byte + Unused_key_15 [SNAP_FILE_NAME_MAX_LEN]byte + Unused_key_16 [SNAP_FILE_NAME_MAX_LEN]byte + Unused_key_17 [SNAP_FILE_NAME_MAX_LEN]byte + + /* unused array of 10 key value pairs */ + Key_value_pairs [10][2][SNAP_FILE_NAME_MAX_LEN]byte + + /* crc32 value for structure */ + Crc32 uint32 +} + +func newV2Recovery() *SnapBootSelect_v2_recovery { + return &SnapBootSelect_v2_recovery{ + Version: SNAP_BOOTSELECT_VERSION_V2, + Signature: SNAP_BOOTSELECT_RECOVERY_SIGNATURE, + } +} + +func (v2recovery *SnapBootSelect_v2_recovery) currentVersion() uint32 { return v2recovery.Version } +func (v2recovery *SnapBootSelect_v2_recovery) currentSignature() uint32 { return v2recovery.Signature } +func (v2recovery *SnapBootSelect_v2_recovery) currentCrc32() uint32 { return v2recovery.Crc32 } + +func (v2recovery *SnapBootSelect_v2_recovery) get(key string) string { + switch key { + case "snapd_recovery_mode": + return cToGoString(v2recovery.Snapd_recovery_mode[:]) + case "snapd_recovery_system": + return cToGoString(v2recovery.Snapd_recovery_system[:]) + case "bootimg_file_name": + return cToGoString(v2recovery.Bootimg_file_name[:]) + case "try_recovery_system": + return cToGoString(v2recovery.Try_recovery_system[:]) + case "recovery_system_status": + return cToGoString(v2recovery.Recovery_system_status[:]) + } + return "" +} + +func (v2recovery *SnapBootSelect_v2_recovery) set(key, value string) { + switch key { + case "snapd_recovery_mode": + copyString(v2recovery.Snapd_recovery_mode[:], value) + case "snapd_recovery_system": + copyString(v2recovery.Snapd_recovery_system[:], value) + case "bootimg_file_name": + copyString(v2recovery.Bootimg_file_name[:], value) + case "try_recovery_system": + copyString(v2recovery.Try_recovery_system[:], value) + case "recovery_system_status": + copyString(v2recovery.Recovery_system_status[:], value) + } +} + +func (v2recovery *SnapBootSelect_v2_recovery) bootImgRecoverySystemMatrix() (bootimgMatrixGeneric, error) { + return (bootimgMatrixGeneric)((&v2recovery.Bootimg_matrix)[:]), nil +} + +func (v2recovery *SnapBootSelect_v2_recovery) bootImgKernelMatrix() (bootimgMatrixGeneric, error) { + return nil, fmt.Errorf("internal error: v2 recovery lkenv has no boot image partition kernel matrix") +} + +type SnapBootSelect_v2_run struct { + /* Contains value BOOTSELECT_SIGNATURE defined above */ + Signature uint32 + /* snappy boot select version */ + Version uint32 + + /* kernel_status, one of: 'empty', "try", "trying" */ + Kernel_status [SNAP_FILE_NAME_MAX_LEN]byte + /* current kernel snap revision */ + Snap_kernel [SNAP_FILE_NAME_MAX_LEN]byte + /* current try kernel snap revision */ + Snap_try_kernel [SNAP_FILE_NAME_MAX_LEN]byte + + /* gadget_mode, one of: 'empty', "try", "trying" */ + Gadget_mode [SNAP_FILE_NAME_MAX_LEN]byte + /* GADGET assets: current gadget assets revision */ + Snap_gadget [SNAP_FILE_NAME_MAX_LEN]byte + /* GADGET assets: try gadget assets revision */ + Snap_try_gadget [SNAP_FILE_NAME_MAX_LEN]byte + + /** + * Matrix for mapping of run mode boot img partition to installed kernel + * snap revision + * + * First column represents boot image partition label (e.g. boot_a,boot_b ) + * value are static and should be populated at gadget built time + * or latest at image build time. Values are not further altered at run + * time. + * Second column represents name currently installed kernel snap + * e.g. pi2-kernel_123.snap + * initial value representing initial kernel snap revision + * is populated at image build time by snapd + * + * There are two rows in the matrix, representing current and previous + * kernel revision + * The following describes how this matrix should be modified at different + * stages: + * - snapd in install mode: + * - extracted kernel snap revision name should be filled + * into free slot (first row, second row) + * - snapd in run mode: + * - when new kernel snap revision is being installed, snapd cycles + * through matrix to find unused 'boot slot' to be used for new kernel + * snap revision from free slot, first column represents partition + * label to which kernel snap boot image should be extracted. Second + * column is then populated with kernel snap revision name. + * - kernel_status, snap_try_kernel, snap_try_core behaves same way as + * with u-boot + * - bootloader: + * - bootloader reads kernel_status to determine if snap_kernel or + * snap_try_kernel is used to get kernel snap revision name. + * kernel snap revision is then used to search matrix to determine + * partition label to be used for current boot + * - bootloader NEVER alters this matrix values + * + * [ ] [ ] + * [ ] [ ] + */ + Bootimg_matrix [SNAP_RUN_BOOTIMG_PART_NUM][2][SNAP_FILE_NAME_MAX_LEN]byte + + /* name of the boot image from kernel snap to be used for extraction + when not defined or empty, default boot.img will be used */ + Bootimg_file_name [SNAP_FILE_NAME_MAX_LEN]byte + + /** + * gadget assets: Matrix for mapping of gadget asset partitions + * Optional boot asset tracking, based on bootloader support + * Some boot chains support A/B boot assets for increased robustness + * example being A/B TrustExecutionEnvironment + * This matrix can be used to track current and try boot assets for + * robust updates + * Use of Gadget_asset_matrix matches use of Bootimg_matrix + * + * [ ] [ ] + * [ ] [ ] + */ + Gadget_asset_matrix [SNAP_RUN_BOOTIMG_PART_NUM][2][SNAP_FILE_NAME_MAX_LEN]byte + + /* unused placeholders for additional parameters in the future */ + Unused_key_01 [SNAP_FILE_NAME_MAX_LEN]byte + Unused_key_02 [SNAP_FILE_NAME_MAX_LEN]byte + Unused_key_03 [SNAP_FILE_NAME_MAX_LEN]byte + Unused_key_04 [SNAP_FILE_NAME_MAX_LEN]byte + Unused_key_05 [SNAP_FILE_NAME_MAX_LEN]byte + Unused_key_06 [SNAP_FILE_NAME_MAX_LEN]byte + Unused_key_07 [SNAP_FILE_NAME_MAX_LEN]byte + Unused_key_08 [SNAP_FILE_NAME_MAX_LEN]byte + Unused_key_09 [SNAP_FILE_NAME_MAX_LEN]byte + Unused_key_10 [SNAP_FILE_NAME_MAX_LEN]byte + Unused_key_11 [SNAP_FILE_NAME_MAX_LEN]byte + Unused_key_12 [SNAP_FILE_NAME_MAX_LEN]byte + Unused_key_13 [SNAP_FILE_NAME_MAX_LEN]byte + Unused_key_14 [SNAP_FILE_NAME_MAX_LEN]byte + Unused_key_15 [SNAP_FILE_NAME_MAX_LEN]byte + Unused_key_16 [SNAP_FILE_NAME_MAX_LEN]byte + Unused_key_17 [SNAP_FILE_NAME_MAX_LEN]byte + Unused_key_18 [SNAP_FILE_NAME_MAX_LEN]byte + Unused_key_19 [SNAP_FILE_NAME_MAX_LEN]byte + Unused_key_20 [SNAP_FILE_NAME_MAX_LEN]byte + + /* unused array of 10 key value pairs */ + Key_value_pairs [10][2][SNAP_FILE_NAME_MAX_LEN]byte + + /* crc32 value for structure */ + Crc32 uint32 +} + +func newV2Run() *SnapBootSelect_v2_run { + return &SnapBootSelect_v2_run{ + Version: SNAP_BOOTSELECT_VERSION_V2, + Signature: SNAP_BOOTSELECT_SIGNATURE, + } +} + +func (v2run *SnapBootSelect_v2_run) currentCrc32() uint32 { return v2run.Crc32 } +func (v2run *SnapBootSelect_v2_run) currentVersion() uint32 { return v2run.Version } +func (v2run *SnapBootSelect_v2_run) currentSignature() uint32 { return v2run.Signature } + +func (v2run *SnapBootSelect_v2_run) get(key string) string { + switch key { + case "kernel_status": + return cToGoString(v2run.Kernel_status[:]) + case "snap_kernel": + return cToGoString(v2run.Snap_kernel[:]) + case "snap_try_kernel": + return cToGoString(v2run.Snap_try_kernel[:]) + case "snap_gadget": + return cToGoString(v2run.Snap_gadget[:]) + case "snap_try_gadget": + return cToGoString(v2run.Snap_try_gadget[:]) + case "bootimg_file_name": + return cToGoString(v2run.Bootimg_file_name[:]) + } + return "" +} + +func (v2run *SnapBootSelect_v2_run) set(key, value string) { + switch key { + case "kernel_status": + copyString(v2run.Kernel_status[:], value) + case "snap_kernel": + copyString(v2run.Snap_kernel[:], value) + case "snap_try_kernel": + copyString(v2run.Snap_try_kernel[:], value) + case "snap_gadget": + copyString(v2run.Snap_gadget[:], value) + case "snap_try_gadget": + copyString(v2run.Snap_try_gadget[:], value) + case "bootimg_file_name": + copyString(v2run.Bootimg_file_name[:], value) + } +} + +func (v2run *SnapBootSelect_v2_run) bootImgKernelMatrix() (bootimgMatrixGeneric, error) { + return (bootimgMatrixGeneric)((&v2run.Bootimg_matrix)[:]), nil +} + +func (v2run *SnapBootSelect_v2_run) bootImgRecoverySystemMatrix() (bootimgMatrixGeneric, error) { + return nil, fmt.Errorf("internal error: v2 run lkenv has no boot image partition recovery system matrix") +} diff --git a/bootloader/piboot.go b/bootloader/piboot.go new file mode 100644 index 00000000..0ee34b11 --- /dev/null +++ b/bootloader/piboot.go @@ -0,0 +1,475 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2021 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package bootloader + +import ( + "encoding/binary" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/snapcore/snapd/bootloader/ubootenv" + "github.com/snapcore/snapd/logger" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/snap" +) + +// ensure piboot implements the required interfaces +var ( + _ Bootloader = (*piboot)(nil) + _ ExtractedRecoveryKernelImageBootloader = (*piboot)(nil) + _ NotScriptableBootloader = (*piboot)(nil) + _ RebootBootloader = (*piboot)(nil) +) + +const ( + pibootCfgFilename = "piboot.conf" + pibootPartFolder = "/piboot/ubuntu/" +) + +// TODO The ubuntu-seed folder should be eventually passed around when +// creating the bootloader. +// This is in a variable so it can be mocked in tests +var ubuntuSeedDir = "/run/mnt/ubuntu-seed/" + +// More variables to facilitate mocking +var rpi4RevisionCodesPath = "/sys/firmware/devicetree/base/system/linux,revision" +var rpi4EepromTimeStampPath = "/proc/device-tree/chosen/bootloader/build-timestamp" + +type piboot struct { + rootdir string + basedir string + prepareImageTime bool +} + +func (p *piboot) setDefaults() { + p.basedir = "/boot/piboot/" +} + +func (p *piboot) processBlOpts(blOpts *Options) { + if blOpts == nil { + return + } + + p.prepareImageTime = blOpts.PrepareImageTime + switch { + case blOpts.Role == RoleRecovery || blOpts.NoSlashBoot: + if !blOpts.PrepareImageTime { + p.rootdir = ubuntuSeedDir + } + // RoleRecovery or NoSlashBoot imply we use + // the environment file in /piboot/ubuntu as + // it exists on the partition directly + p.basedir = pibootPartFolder + } +} + +// newPiboot creates a new Piboot bootloader object +func newPiboot(rootdir string, blOpts *Options) Bootloader { + p := &piboot{ + rootdir: rootdir, + } + p.setDefaults() + p.processBlOpts(blOpts) + return p +} + +func (p *piboot) Name() string { + return "piboot" +} + +func (p *piboot) dir() string { + if p.rootdir == "" { + panic("internal error: unset rootdir") + } + return filepath.Join(p.rootdir, p.basedir) +} + +func (p *piboot) envFile() string { + return filepath.Join(p.dir(), pibootCfgFilename) +} + +// piboot enabled if env file exists +func (p *piboot) Present() (bool, error) { + return osutil.FileExists(p.envFile()), nil +} + +// Variables stored in ubuntu-seed: +// +// snapd_recovery_system +// snapd_recovery_mode +// snapd_recovery_kernel +// +// Variables stored in ubuntu-boot: +// +// kernel_status +// snap_kernel +// snap_try_kernel +// snapd_extra_cmdline_args +// snapd_full_cmdline_args +// recovery_system_status +// try_recovery_system +func (p *piboot) SetBootVars(values map[string]string) error { + env, err := ubootenv.OpenWithFlags(p.envFile(), ubootenv.OpenBestEffort) + if err != nil { + return err + } + + // Set when we change a boot env variable, to know if we need to save the env + dirtyEnv := false + // Flag to know if we need to write piboot's config.txt or tryboot.txt + reconfigBootloader := false + for k, v := range values { + // already set to the right value, nothing to do + if env.Get(k) == v { + continue + } + env.Set(k, v) + dirtyEnv = true + // Cases that change the bootloader configuration + if k == "snapd_recovery_mode" || k == "kernel_status" { + reconfigBootloader = true + } + if k == "snap_try_kernel" && v == "" { + // Refresh (ok or not) finished, remove tryboot.txt. + // os_prefix in config.txt will be changed now in + // loadAndApplyConfig in the ok case. Note that removing + // it is safe as tryboot.txt is used only when a special + // volatile boot flag is set, so we always have a valid + // config.txt that will allow booting. + trybootPath := filepath.Join(ubuntuSeedDir, "tryboot.txt") + if err := os.Remove(trybootPath); err != nil { + logger.Noticef("cannot remove %s: %v", trybootPath, err) + } + } + } + + if dirtyEnv { + if err := env.Save(); err != nil { + return err + } + } + + if reconfigBootloader { + if err := p.loadAndApplyConfig(env); err != nil { + return err + } + } + + return nil +} + +func (p *piboot) SetBootVarsFromInitramfs(values map[string]string) error { + env, err := ubootenv.OpenWithFlags(p.envFile(), ubootenv.OpenBestEffort) + if err != nil { + return err + } + + dirtyEnv := false + for k, v := range values { + // already set to the right value, nothing to do + if env.Get(k) == v { + continue + } + env.Set(k, v) + dirtyEnv = true + } + + if dirtyEnv { + if err := env.Save(); err != nil { + return err + } + } + + return nil +} + +func (p *piboot) loadAndApplyConfig(env *ubootenv.Env) error { + var prefix, cfgDir, dstDir string + + cfgFile := "config.txt" + if env.Get("snapd_recovery_mode") == "run" { + kernelSnap := env.Get("snap_kernel") + kernStat := env.Get("kernel_status") + if kernStat == "try" { + // snap_try_kernel will be set when installing a new kernel + kernelSnap = env.Get("snap_try_kernel") + cfgFile = "tryboot.txt" + } + prefix = filepath.Join(pibootPartFolder, kernelSnap) + cfgDir = ubuntuSeedDir + dstDir = filepath.Join(ubuntuSeedDir, prefix) + } else { + // install/recovery modes, use recovery kernel + prefix = filepath.Join("/systems", env.Get("snapd_recovery_system"), + "kernel") + cfgDir = p.rootdir + dstDir = filepath.Join(p.rootdir, prefix) + } + + logger.Debugf("configure piboot %s with prefix %q, cfgDir %q, dstDir %q", + cfgFile, prefix, cfgDir, dstDir) + + if err := os.MkdirAll(dstDir, 0755); err != nil { + return err + } + return p.applyConfig(env, cfgFile, prefix, cfgDir, dstDir) +} + +// Writes os_prefix in RPi config.txt or tryboot.txt +func (p *piboot) writeRPiCfgWithOsPrefix(prefix, inFile, outFile string) error { + buf, err := os.ReadFile(inFile) + if err != nil { + return err + } + + lines := strings.Split(string(buf), "\n") + + replaced := false + newOsPrefix := "os_prefix=" + prefix + "/" + for i, line := range lines { + if strings.HasPrefix(line, "os_prefix=") { + if replaced { + logger.Noticef("unexpected extra os_prefix line: %q", line) + lines[i] = "# " + lines[i] + continue + } + lines[i] = newOsPrefix + replaced = true + } + } + if !replaced { + lines = append(lines, newOsPrefix) + lines = append(lines, "") + } + + output := strings.Join(lines, "\n") + return osutil.AtomicWriteFile(outFile, []byte(output), 0644, 0) +} + +func (p *piboot) writeCmdline(env *ubootenv.Env, defaultsFile, outFile string) error { + buf, err := os.ReadFile(defaultsFile) + if err != nil { + return err + } + + lines := strings.Split(string(buf), "\n") + cmdline := lines[0] + + mode := env.Get("snapd_recovery_mode") + cmdline += " snapd_recovery_mode=" + mode + if mode != "run" { + cmdline += " snapd_recovery_system=" + env.Get("snapd_recovery_system") + } + // Signal when we are trying a new kernel + kernelStatus := env.Get("kernel_status") + if kernelStatus == "try" { + cmdline += " kernel_status=trying" + } + cmdline += "\n" + + logger.Debugf("writing kernel command line to %s", outFile) + + return osutil.AtomicWriteFile(outFile, []byte(cmdline), 0644, 0) +} + +// Configure pi bootloader with a given os_prefix. cfgDir contains the +// config files, and dstDir is where we will place the kernel command +// line. +func (p *piboot) applyConfig(env *ubootenv.Env, + configFile, prefix, cfgDir, dstDir string) error { + + cmdlineFile := filepath.Join(dstDir, "cmdline.txt") + refCmdlineFile := filepath.Join(cfgDir, "cmdline.txt") + currentConfigFile := filepath.Join(cfgDir, "config.txt") + + if err := p.writeCmdline(env, refCmdlineFile, cmdlineFile); err != nil { + return err + } + if err := p.writeRPiCfgWithOsPrefix(prefix, currentConfigFile, + filepath.Join(cfgDir, configFile)); err != nil { + return err + } + + return nil +} + +func (p *piboot) GetBootVars(names ...string) (map[string]string, error) { + env, err := ubootenv.OpenWithFlags(p.envFile(), ubootenv.OpenBestEffort) + if err != nil { + return nil, err + } + + out := make(map[string]string, len(names)) + for _, name := range names { + out[name] = env.Get(name) + } + + return out, nil +} + +func (p *piboot) InstallBootConfig(gadgetDir string, blOpts *Options) error { + // We create an empty env file + err := os.MkdirAll(filepath.Dir(p.envFile()), 0755) + if err != nil { + return err + } + + // TODO: what's a reasonable size for this file? + env, err := ubootenv.Create(p.envFile(), 4096, ubootenv.CreateOptions{HeaderFlagByte: true}) + if err != nil { + return err + } + + return env.Save() +} + +func (p *piboot) layoutKernelAssetsToDir(snapf snap.Container, dstDir string) error { + assets := []string{"kernel.img", "initrd.img", "dtbs/*"} + if err := extractKernelAssetsToBootDir(dstDir, snapf, assets); err != nil { + return err + } + + // remove subdirs so mv does not complain about non-empty dirs + // if extraction happens multiple times + newOvDir := filepath.Join(dstDir, "overlays/") + if err := os.RemoveAll(newOvDir); err != nil { + return err + } + // armhf and arm64 pi-kernel store dtbs in different places + // (dtbs/ or dtbs/broadcom/ respectively) + var dtbDir string + if _, isDir, _ := osutil.DirExists(filepath.Join(dstDir, "dtbs/broadcom")); isDir { + dtbDir = "dtbs/broadcom" + overlaysDir := filepath.Join(dstDir, "dtbs/overlays/") + if err := os.Rename(overlaysDir, newOvDir); err != nil { + return err + } + } else { + dtbDir = "dtbs" + } + + dtbFiles := filepath.Join(dstDir, dtbDir, "*") + if output, err := exec.Command("sh", "-c", + "mv "+dtbFiles+" "+dstDir).CombinedOutput(); err != nil { + return fmt.Errorf("cannot move RPi dtbs to %s:\n%s", + dstDir, output) + } + + // README file is needed so os_prefix is honored for overlays. See + // https://www.raspberrypi.com/documentation/computers/config_txt.html#os_prefix + readmeOverlays, err := os.Create(filepath.Join(dstDir, "overlays", "README")) + if err != nil { + return err + } + readmeOverlays.Close() + return nil +} + +func (p *piboot) eepromVersionSupportsTryboot() (bool, error) { + // To find out the EEPROM version we do the same as the + // rpi-eeprom-update script (see + // https://github.com/raspberrypi/rpi-eeprom/blob/master/rpi-eeprom-update) + buf, err := os.ReadFile(rpi4EepromTimeStampPath) + if err != nil { + return false, err + } + + // The timestamp is seconds since the epoch, UTC time + eepromTs := binary.BigEndian.Uint32(buf) + // 2021-03-18 or more modern supports tryboot, see + // https://github.com/raspberrypi/rpi-eeprom/blob/master/firmware/release-notes.md#2021-04-19---promote-2021-03-18-from-latest-to-default---default + // The timestamp we compare with (1616057651 seconds since the epoch, + // which is jue 18 mar 2021 08:54:11 UTC) can be found with: + // $ strings pieeprom-2021-03-18.bin | grep BUILD_TIMESTAMP + return eepromTs >= 1616057651, nil +} + +func (p *piboot) isRaspberryPi4() bool { + // For RPi4 detection we do the same as the rpi-eeprom-update script (see + // https://github.com/raspberrypi/rpi-eeprom/blob/master/rpi-eeprom-update) + buf, err := os.ReadFile(rpi4RevisionCodesPath) + if err != nil { + return false + } + + // This is an RPi4 if we have new style codes (RPi2 or newer) and the + // processor is BCM2711 (RPi4's SoC). For details, see + // https://www.raspberrypi.com/documentation/computers/raspberry-pi.html#raspberry-pi-revision-codes + boardInfo := binary.BigEndian.Uint32(buf) + return ((boardInfo>>23)&1) == 1 && ((boardInfo>>12)&0xF) == 3 +} + +func (p *piboot) ExtractKernelAssets(s snap.PlaceInfo, snapf snap.Container) error { + if !p.prepareImageTime { + // If this is an RPi4, check first if EEPROM supports tryboot + if p.isRaspberryPi4() { + supportsTryboot, err := p.eepromVersionSupportsTryboot() + if err != nil { + return fmt.Errorf("cannot check EEPROM version: %v", err) + } + if !supportsTryboot { + return fmt.Errorf("your EEPROM does not support tryboot, please upgrade to a newer one before installing Ubuntu Core - see http://forum.snapcraft.io/t/29455 for more details") + } + } + } + + // Rootdir will point to ubuntu-boot, but we need to put things in ubuntu-seed + dstDir := filepath.Join(ubuntuSeedDir, pibootPartFolder, s.Filename()) + + logger.Debugf("ExtractKernelAssets to %s", dstDir) + + return p.layoutKernelAssetsToDir(snapf, dstDir) +} + +func (p *piboot) ExtractRecoveryKernelAssets(recoverySystemDir string, s snap.PlaceInfo, + snapf snap.Container) error { + if recoverySystemDir == "" { + return fmt.Errorf("internal error: recoverySystemDir unset") + } + + recoveryKernelAssetsDir := + filepath.Join(p.rootdir, recoverySystemDir, "kernel") + logger.Debugf("ExtractRecoveryKernelAssets to %s", recoveryKernelAssetsDir) + + return p.layoutKernelAssetsToDir(snapf, recoveryKernelAssetsDir) +} + +func (p *piboot) RemoveKernelAssets(s snap.PlaceInfo) error { + return removeKernelAssetsFromBootDir( + filepath.Join(ubuntuSeedDir, pibootPartFolder), s) +} + +func (p *piboot) GetRebootArguments() (string, error) { + env, err := ubootenv.OpenWithFlags(p.envFile(), ubootenv.OpenBestEffort) + if err != nil { + return "", err + } + + kernStat := env.Get("kernel_status") + if kernStat == "try" { + // The reboot parameter makes sure we use tryboot.cfg config + return "0 tryboot", nil + } + + return "", nil +} diff --git a/bootloader/piboot.md b/bootloader/piboot.md new file mode 100644 index 00000000..36caff7e --- /dev/null +++ b/bootloader/piboot.md @@ -0,0 +1,128 @@ +# Support for piboot booloader on UC + +## Introduction + +In this document we describe the support for piboot on Raspberry Pi +devices when running Ubuntu Core. When we talk about "piboot", we mean +the different closed-source firmware bits that are run on RPi devices +before the Linux kernel or alternative bootloaders like U-Boot are +started. + +Support for piboot has been introduced to avoid the dependency on +U-Boot for UC images on Raspberry Pi devices. U-Boot is not officially +supported by the RPi foundation, which has led in the past to delays +in our releases and feature gaps. + +## Piboot features + +Recently, a failsafe OS update feature was added to piboot [1]. It is +implemented with a flag that is set when rebooting with a special +parameter. When that flag is set, piboot loads as configuration a file +named `tryboot.txt` instead of the usual `config.txt`. This flag is +cleared by piboot on reboots. Furthermore, it is volatile, so it does +not survive after power offs. To enable the flag, the reboot syscall +must be called with the special "0 tryboot" argument (from command +line: `sudo reboot "0 tryboot"`). + +With this new feature, it has been possible to implement piboot +support in snapd. However, piboot has far less capabilities than grub +or U-Boot, which are the more usual bootloaders for UC. The main +limitations it has are: + +1. Piboot avoids any write to disk +1. We can cold-boot only from the first partition in the disk. This + implies that we need to write boot assets to the ubuntu-seed + partition instead of to ubuntu-boot. (NOTE: it seems like there is + actually an undocumented way to cold-boot from any FAT partition, + by using a file called `autoboot.txt` - however, this is not + officially supported by the RPi foundation and that status is not + expected to change any time soon). +1. As explained, the OS updates mechanism depends on a volatile flag + that gets removed in cold boots. That makes it not possible to + distinguish sometimes between failed updates and having + power-cycled a device before really trying a pending update. +1. There is no scripting language for the RPi bootloader. The only way + to influence its behavior is by changes to the + `{config,tryboot}.txt` files. Their capabilities are described in + the reference for the format [2]. + +## Implementation + +Due to the piboot limitations, the support in snapd/UC has some +peculiarities. The way we will use it is + +1. Piboot config files can have an `os_prefix` setting that we will + leverage to point to different folders for different kernel snaps + (`/piboot/ubuntu/pi-kernel_.snap/`, or `/systems//kernel/` + on first boot). These folders will contain different kernel, + initrd, dtbs, dtbos, and `cmdline.txt` each one, so refreshing a + kernel will be a matter of extracting/creating these assets and + setting `os_prefix` appropriately. +1. On kernel refresh, we will create a `tryboot.txt` file pointing to + the new assets in its `os_prefix` setting, and reboot with a "0 + tryboot" parameter so we use `tryboot.txt` after rebooting. The + `cmdline.txt` in the `os_prefix` folder will contain a special + parameter (`kernel_status=trying`) so UC knows that we are using + the configuration from `tryboot.txt`. With this, the OS can + understand if the new kernel has been used to boot. + +To keep state we have environment files that, differently to grub or +U-Boot, are not directly read or written by the bootloader, but are +instead translated to bootloader configuration files when kernel +updates happen. These environment files are named `piboot.conf` and +live in the `piboot/ubuntu/` folder inside ubuntu-seed partition, and +for robustness they use the same format as the `uboot.env` files, with +a CRC header. In run mode, the folder from the boot partition would be +mounted in the `/boot/piboot/` directory. This is analogous to what is +done by UC for grub and U-Boot environment files. + +### Snapd + +The piboot environment files have the same format as U-Boot +environment files so we leverage the existing snapd codebase and take +advantage of error detection capabilities. We store there key/value +pairs. The `GetBootVars()`/`SetBootVars()` methods for the bootloader +interface read and modify these files in the usual way. However, these +files do not directly affect the bootloader any more, so we need to +generate the bootloader configuration files when some of the variables +are changed, that is, when `SetBootVars()` is called it will re-create +the bootloader configuration depending on what has changed. The method +that implements the changes will have as input the environment file in +the seed partition, and will generate `{config,tryboot}.txt` and +`cmdline.txt` accordingly. + +The logic that changes the `kernel_status` variable in other +bootloaders (see `bootloader/assets/data/grub.cfg`) cannot be done by +piboot as it does not support scripting and is closed source. Instead, +basically the same thing has been implemented in the initramfs, and it +is run as part of the snap bootstrap code (see +`boot.updateNotScriptableBootloaderStatus()` function). Running it +from the initramfs ensures that the code is run only once in +boot. This code looks at the kernel command line and checks if +`kernel_status=trying` is present to change the status in the +environment file to `try`. Once snapd starts, it will check and set +`kernel_status` in the usual way for any bootloader. + +### RPi gadget + +Gadgets of the “bootloader: piboot” type need to have + +1. An empty `piboot.conf file` (which will contain environment variables + at runtime as explained above, and will be filled with values by + snapd during image preparation, installation and kernel updates) +1. Reference configuration file for the bootloader, named + `config.txt`. It will be used to create `config.txt` in the seed + partition, with a different `os_prefix`. +1. Reference kernel command line file for the bootloader, named + `cmdline.txt`. It will be used to generate the UC kernel + command line. + +The reference files will be used while configuring the bootloader from +snapd. The rest of it will be very similar to our current pi gadget +snaps. + +## References + +[1] https://www.raspberrypi.com/documentation/computers/raspberry-pi.html#fail-safe-os-updates-tryboot + +[2] https://www.raspberrypi.com/documentation/computers/config_txt.html diff --git a/bootloader/piboot_test.go b/bootloader/piboot_test.go new file mode 100644 index 00000000..8d54c412 --- /dev/null +++ b/bootloader/piboot_test.go @@ -0,0 +1,695 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2021 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package bootloader_test + +import ( + "os" + "path/filepath" + "strings" + "time" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/boot" + "github.com/snapcore/snapd/bootloader" + "github.com/snapcore/snapd/bootloader/ubootenv" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/snap/snapfile" + "github.com/snapcore/snapd/snap/snaptest" + "github.com/snapcore/snapd/testutil" +) + +type pibootTestSuite struct { + baseBootenvTestSuite +} + +var _ = Suite(&pibootTestSuite{}) + +func (s *pibootTestSuite) TestNewPiboot(c *C) { + // no files means bl is not present, but we can still create the bl object + p := bootloader.NewPiboot(s.rootdir, nil) + c.Assert(p, NotNil) + c.Assert(p.Name(), Equals, "piboot") + + present, err := p.Present() + c.Assert(err, IsNil) + c.Assert(present, Equals, false) + + // now with files present, the bl is present + r := bootloader.MockPibootFiles(c, s.rootdir, nil) + defer r() + present, err = p.Present() + c.Assert(err, IsNil) + c.Assert(present, Equals, true) +} + +func (s *pibootTestSuite) TestPibootGetEnvVar(c *C) { + // We need PrepareImageTime due to fixed reference to /run/mnt otherwise + opts := bootloader.Options{PrepareImageTime: true, + Role: bootloader.RoleRunMode, NoSlashBoot: true} + r := bootloader.MockPibootFiles(c, s.rootdir, &opts) + defer r() + p := bootloader.NewPiboot(s.rootdir, &opts) + c.Assert(p, NotNil) + err := p.SetBootVars(map[string]string{ + "snap_mode": "", + "snap_core": "4", + }) + c.Assert(err, IsNil) + + m, err := p.GetBootVars("snap_mode", "snap_core") + c.Assert(err, IsNil) + c.Assert(m, DeepEquals, map[string]string{ + "snap_mode": "", + "snap_core": "4", + }) +} + +func (s *pibootTestSuite) TestGetBootloaderWithPiboot(c *C) { + r := bootloader.MockPibootFiles(c, s.rootdir, nil) + defer r() + + bootloader, err := bootloader.Find(s.rootdir, nil) + c.Assert(err, IsNil) + c.Assert(bootloader.Name(), Equals, "piboot") +} + +func (s *pibootTestSuite) testPibootSetEnvWriteOnlyIfChanged(c *C, fromInitramfs bool) { + opts := bootloader.Options{PrepareImageTime: true, + Role: bootloader.RoleRunMode, NoSlashBoot: true} + r := bootloader.MockPibootFiles(c, s.rootdir, &opts) + defer r() + p := bootloader.NewPiboot(s.rootdir, &opts) + c.Assert(p, NotNil) + + envFile := bootloader.PibootConfigFile(p) + env, err := ubootenv.OpenWithFlags(envFile, ubootenv.OpenBestEffort) + c.Assert(err, IsNil) + env.Set("snap_ab", "b") + env.Set("snap_mode", "") + err = env.Save() + c.Assert(err, IsNil) + + st, err := os.Stat(envFile) + c.Assert(err, IsNil) + time.Sleep(100 * time.Millisecond) + + // note that we set to the same var to the same value as above + if fromInitramfs { + nsbl, ok := p.(bootloader.NotScriptableBootloader) + c.Assert(ok, Equals, true) + err = nsbl.SetBootVarsFromInitramfs(map[string]string{"snap_ab": "b"}) + } else { + err = p.SetBootVars(map[string]string{"snap_ab": "b"}) + } + c.Assert(err, IsNil) + + st2, err := os.Stat(envFile) + c.Assert(err, IsNil) + c.Assert(st.ModTime(), Equals, st2.ModTime()) +} + +func (s *pibootTestSuite) TestPibootSetEnvWriteOnlyIfChanged(c *C) { + // Run test from rootfs and from initramfs + fromInitramfs := false + s.testPibootSetEnvWriteOnlyIfChanged(c, fromInitramfs) + fromInitramfs = true + s.testPibootSetEnvWriteOnlyIfChanged(c, fromInitramfs) +} + +func (s *pibootTestSuite) testExtractKernelAssets(c *C, dtbDir string) { + opts := bootloader.Options{PrepareImageTime: true, + Role: bootloader.RoleRunMode, NoSlashBoot: true} + r := bootloader.MockPibootFiles(c, s.rootdir, &opts) + defer r() + p := bootloader.NewPiboot(s.rootdir, &opts) + + files := [][]string{ + {"kernel.img", "I'm a kernel"}, + {"initrd.img", "...and I'm an initrd"}, + {filepath.Join(dtbDir, "foo.dtb"), "g'day, I'm foo.dtb"}, + {"dtbs/overlays/bar.dtbo", "hello, I'm bar.dtbo"}, + // must be last + {"meta/kernel.yaml", "version: 4.2"}, + } + fn := snaptest.MakeTestSnapWithFiles(c, packageKernel, files) + snapf, err := snapfile.Open(fn) + c.Assert(err, IsNil) + + assetsDir, err := os.MkdirTemp("", "kernel-assets") + c.Assert(err, IsNil) + defer os.RemoveAll(assetsDir) + + err = bootloader.LayoutKernelAssetsToDir(p, snapf, assetsDir) + c.Assert(err, IsNil) + // Do again, as extracting might be called again for an + // already extracted kernel. + err = bootloader.LayoutKernelAssetsToDir(p, snapf, assetsDir) + c.Assert(err, IsNil) + + // Extraction folders for files slice + destDirs := []string{ + assetsDir, assetsDir, assetsDir, filepath.Join(assetsDir, "overlays"), + } + for i, dir := range destDirs { + fullFn := filepath.Join(dir, filepath.Base(files[i][0])) + c.Check(fullFn, testutil.FileEquals, files[i][1]) + } + + // Check that file required by piboot is created + readmeFn := filepath.Join(assetsDir, "overlays", "README") + c.Check(readmeFn, testutil.FilePresent) +} + +func (s *pibootTestSuite) TestExtractKernelAssets(c *C) { + // armhf and arm64 kernel snaps store dtbs in different places + s.testExtractKernelAssets(c, "dtbs") + s.testExtractKernelAssets(c, "dtbs/broadcom") +} + +func (s *pibootTestSuite) testExtractRecoveryKernelAssets(c *C, dtbDir string) { + opts := bootloader.Options{PrepareImageTime: true, + Role: bootloader.RoleRunMode, NoSlashBoot: true} + r := bootloader.MockPibootFiles(c, s.rootdir, &opts) + defer r() + p := bootloader.NewPiboot(s.rootdir, &opts) + + files := [][]string{ + {"kernel.img", "I'm a kernel"}, + {"initrd.img", "...and I'm an initrd"}, + {filepath.Join(dtbDir, "foo.dtb"), "g'day, I'm foo.dtb"}, + {"dtbs/overlays/bar.dtbo", "hello, I'm bar.dtbo"}, + // must be last + {"meta/kernel.yaml", "version: 4.2"}, + } + si := &snap.SideInfo{ + RealName: "ubuntu-kernel", + Revision: snap.R(42), + } + fn := snaptest.MakeTestSnapWithFiles(c, packageKernel, files) + snapf, err := snapfile.Open(fn) + c.Assert(err, IsNil) + + info, err := snap.ReadInfoFromSnapFile(snapf, si) + c.Assert(err, IsNil) + + // try with empty recovery dir first to check the errors + err = p.ExtractRecoveryKernelAssets("", info, snapf) + c.Assert(err, ErrorMatches, "internal error: recoverySystemDir unset") + + // now the expected behavior + err = p.ExtractRecoveryKernelAssets("recovery-dir", info, snapf) + c.Assert(err, IsNil) + + // Extraction folders for files slice + assetsDir := filepath.Join(s.rootdir, "recovery-dir", "kernel") + destDirs := []string{ + assetsDir, assetsDir, assetsDir, filepath.Join(assetsDir, "overlays"), + } + for i, dir := range destDirs { + fullFn := filepath.Join(dir, filepath.Base(files[i][0])) + c.Check(fullFn, testutil.FileEquals, files[i][1]) + } + + // Check that file required by piboot is created + readmeFn := filepath.Join(assetsDir, "overlays", "README") + c.Check(readmeFn, testutil.FilePresent) +} + +func (s *pibootTestSuite) TestExtractRecoveryKernelAssets(c *C) { + // armhf and arm64 kernel snaps store dtbs in different places + s.testExtractRecoveryKernelAssets(c, "dtbs") + s.testExtractRecoveryKernelAssets(c, "dtbs/broadcom") +} + +func (s *pibootTestSuite) TestPibootUC20OptsPlacement(c *C) { + tt := []struct { + blOpts *bootloader.Options + expEnv string + comment string + }{ + { + &bootloader.Options{PrepareImageTime: true, + Role: bootloader.RoleRunMode, NoSlashBoot: true}, + "/piboot/ubuntu/piboot.conf", + "uc20 install mode piboot.conf", + }, + { + &bootloader.Options{PrepareImageTime: true, + Role: bootloader.RoleRunMode}, + "/boot/piboot/piboot.conf", + "uc20 run mode piboot.conf", + }, + { + &bootloader.Options{PrepareImageTime: true, + Role: bootloader.RoleRecovery}, + "/piboot/ubuntu/piboot.conf", + "uc20 recovery piboot.conf", + }, + } + + for _, t := range tt { + dir := c.MkDir() + restore := bootloader.MockPibootFiles(c, dir, t.blOpts) + p := bootloader.NewPiboot(dir, t.blOpts) + c.Assert(p, NotNil, Commentf(t.comment)) + c.Assert(bootloader.PibootConfigFile(p), Equals, + filepath.Join(dir, t.expEnv), Commentf(t.comment)) + + // if we set boot vars on the piboot, we can open the config file and + // get the same variables + c.Assert(p.SetBootVars(map[string]string{"hello": "there"}), IsNil) + env, err := ubootenv.OpenWithFlags(filepath.Join(dir, t.expEnv), + ubootenv.OpenBestEffort) + c.Assert(err, IsNil) + c.Assert(env.Get("hello"), Equals, "there") + restore() + } +} + +func (s *pibootTestSuite) TestCreateConfig(c *C) { + opts := bootloader.Options{PrepareImageTime: false, + Role: bootloader.RoleRunMode, NoSlashBoot: true} + r := bootloader.MockPibootFiles(c, s.rootdir, &opts) + defer r() + p := bootloader.NewPiboot(s.rootdir, &opts) + + err := p.SetBootVars(map[string]string{ + "snap_kernel": "pi-kernel_1", + "snapd_recovery_mode": "run", + "kernel_status": boot.DefaultStatus}) + c.Assert(err, IsNil) + + files := []struct { + path string + data string + }{ + { + path: filepath.Join(s.rootdir, "config.txt"), + data: "\nos_prefix=/piboot/ubuntu/pi-kernel_1/\n", + }, + { + path: filepath.Join(s.rootdir, "piboot/ubuntu/pi-kernel_1/cmdline.txt"), + data: " snapd_recovery_mode=run\n", + }, + } + for _, fInfo := range files { + readData, err := os.ReadFile(fInfo.path) + c.Assert(err, IsNil) + c.Assert(string(readData), Equals, fInfo.data) + } +} + +func (s *pibootTestSuite) TestCreateTrybootCfg(c *C) { + opts := bootloader.Options{PrepareImageTime: false, + Role: bootloader.RoleRunMode, NoSlashBoot: true} + r := bootloader.MockPibootFiles(c, s.rootdir, &opts) + defer r() + p := bootloader.NewPiboot(s.rootdir, &opts) + + err := p.SetBootVars(map[string]string{ + "snap_kernel": "pi-kernel_1", + "snap_try_kernel": "pi-kernel_2", + "snapd_recovery_mode": "run", + "kernel_status": boot.TryStatus}) + c.Assert(err, IsNil) + + files := []struct { + path string + data string + }{ + { + path: filepath.Join(s.rootdir, "tryboot.txt"), + data: "\nos_prefix=/piboot/ubuntu/pi-kernel_2/\n", + }, + { + path: filepath.Join(s.rootdir, "piboot/ubuntu/pi-kernel_2/cmdline.txt"), + data: " snapd_recovery_mode=run kernel_status=trying\n", + }, + } + for _, fInfo := range files { + readData, err := os.ReadFile(fInfo.path) + c.Assert(err, IsNil) + c.Assert(string(readData), Equals, fInfo.data) + } + + // Now set variables like in an after update reboot + err = p.SetBootVars(map[string]string{ + "snap_kernel": "pi-kernel_2", + "snap_try_kernel": "", + "snapd_recovery_mode": "run", + "kernel_status": boot.DefaultStatus}) + c.Assert(err, IsNil) + + c.Assert(osutil.FileExists(filepath.Join(s.rootdir, "tryboot.txt")), Equals, false) + + files = []struct { + path string + data string + }{ + { + path: filepath.Join(s.rootdir, "config.txt"), + data: "\nos_prefix=/piboot/ubuntu/pi-kernel_2/\n", + }, + { + path: filepath.Join(s.rootdir, "piboot/ubuntu/pi-kernel_2/cmdline.txt"), + data: " snapd_recovery_mode=run\n", + }, + } + for _, fInfo := range files { + readData, err := os.ReadFile(fInfo.path) + c.Assert(err, IsNil) + c.Assert(string(readData), Equals, fInfo.data) + } +} + +func (s *pibootTestSuite) TestCreateConfigCurrentNotEmpty(c *C) { + opts := bootloader.Options{PrepareImageTime: false, + Role: bootloader.RoleRunMode, NoSlashBoot: true} + r := bootloader.MockPibootFiles(c, s.rootdir, &opts) + defer r() + + // Get some extra kernel command line parameters + err := os.WriteFile(filepath.Join(s.rootdir, "cmdline.txt"), + []byte("opt1=foo bar\n"), 0644) + c.Assert(err, IsNil) + // Add some options to already existing config.txt + err = os.WriteFile(filepath.Join(s.rootdir, "config.txt"), + []byte("rpi.option1=val\nos_prefix=1\nrpi.option2=val\n"), 0644) + c.Assert(err, IsNil) + p := bootloader.NewPiboot(s.rootdir, &opts) + + err = p.SetBootVars(map[string]string{ + "snap_kernel": "pi-kernel_1", + "snapd_recovery_mode": "run", + "kernel_status": boot.DefaultStatus}) + c.Assert(err, IsNil) + + files := []struct { + path string + data string + }{ + { + path: filepath.Join(s.rootdir, "config.txt"), + data: "rpi.option1=val\nos_prefix=/piboot/ubuntu/pi-kernel_1/\nrpi.option2=val\n", + }, + { + path: filepath.Join(s.rootdir, "piboot/ubuntu/pi-kernel_1/cmdline.txt"), + data: "opt1=foo bar snapd_recovery_mode=run\n", + }, + } + for _, fInfo := range files { + readData, err := os.ReadFile(fInfo.path) + c.Assert(err, IsNil) + c.Assert(string(readData), Equals, fInfo.data) + } + + // Now set variables like in an update + err = p.SetBootVars(map[string]string{ + "snap_kernel": "pi-kernel_1", + "snap_try_kernel": "pi-kernel_2", + "snapd_recovery_mode": "run", + "kernel_status": boot.TryStatus}) + c.Assert(err, IsNil) + + files = []struct { + path string + data string + }{ + { + path: filepath.Join(s.rootdir, "tryboot.txt"), + data: "rpi.option1=val\nos_prefix=/piboot/ubuntu/pi-kernel_2/\nrpi.option2=val\n", + }, + { + path: filepath.Join(s.rootdir, "config.txt"), + data: "rpi.option1=val\nos_prefix=/piboot/ubuntu/pi-kernel_1/\nrpi.option2=val\n", + }, + { + path: filepath.Join(s.rootdir, "piboot/ubuntu/pi-kernel_2/cmdline.txt"), + data: "opt1=foo bar snapd_recovery_mode=run kernel_status=trying\n", + }, + } + for _, fInfo := range files { + readData, err := os.ReadFile(fInfo.path) + c.Assert(err, IsNil) + c.Assert(string(readData), Equals, fInfo.data) + } +} + +func (s *pibootTestSuite) TestOnlyOneOsPrefix(c *C) { + opts := bootloader.Options{PrepareImageTime: false, + Role: bootloader.RoleRunMode, NoSlashBoot: true} + r := bootloader.MockPibootFiles(c, s.rootdir, &opts) + defer r() + + // Introuce two os_prefix lines + err := os.WriteFile(filepath.Join(s.rootdir, "config.txt"), + []byte("os_prefix=1\nos_prefix=2\n"), 0644) + c.Assert(err, IsNil) + p := bootloader.NewPiboot(s.rootdir, &opts) + + err = p.SetBootVars(map[string]string{ + "snap_kernel": "pi-kernel_1", + "snapd_recovery_mode": "run", + "kernel_status": boot.DefaultStatus}) + c.Assert(err, IsNil) + + files := []struct { + path string + data string + }{ + { + path: filepath.Join(s.rootdir, "config.txt"), + data: "os_prefix=/piboot/ubuntu/pi-kernel_1/\n# os_prefix=2\n", + }, + { + path: filepath.Join(s.rootdir, "piboot/ubuntu/pi-kernel_1/cmdline.txt"), + data: " snapd_recovery_mode=run\n", + }, + } + for _, fInfo := range files { + readData, err := os.ReadFile(fInfo.path) + c.Assert(err, IsNil) + c.Assert(string(readData), Equals, fInfo.data) + } +} + +func (s *pibootTestSuite) TestGetRebootArguments(c *C) { + opts := bootloader.Options{PrepareImageTime: false, + Role: bootloader.RoleRunMode, NoSlashBoot: true} + r := bootloader.MockPibootFiles(c, s.rootdir, &opts) + defer r() + p := bootloader.NewPiboot(s.rootdir, &opts) + c.Assert(p, NotNil) + rbl, ok := p.(bootloader.RebootBootloader) + c.Assert(ok, Equals, true) + + args, err := rbl.GetRebootArguments() + c.Assert(err, IsNil) + c.Assert(args, Equals, "") + + err = p.SetBootVars(map[string]string{"kernel_status": "try"}) + c.Assert(err, IsNil) + + args, err = rbl.GetRebootArguments() + c.Assert(err, IsNil) + c.Assert(args, Equals, "0 tryboot") + err = p.SetBootVars(map[string]string{"kernel_status": ""}) + c.Assert(err, IsNil) +} + +func (s *pibootTestSuite) TestGetRebootArgumentsNoEnv(c *C) { + opts := bootloader.Options{PrepareImageTime: false, + Role: bootloader.RoleRunMode, NoSlashBoot: true} + p := bootloader.NewPiboot(s.rootdir, &opts) + c.Assert(p, NotNil) + rbl, ok := p.(bootloader.RebootBootloader) + c.Assert(ok, Equals, true) + + args, err := rbl.GetRebootArguments() + c.Assert(err, ErrorMatches, "open .*/piboot.conf: no such file or directory") + c.Assert(args, Equals, "") +} + +func (s *pibootTestSuite) TestSetBootVarsFromInitramfs(c *C) { + opts := bootloader.Options{PrepareImageTime: false, + Role: bootloader.RoleRunMode, NoSlashBoot: true} + r := bootloader.MockPibootFiles(c, s.rootdir, &opts) + defer r() + p := bootloader.NewPiboot(s.rootdir, &opts) + c.Assert(p, NotNil) + nsbl, ok := p.(bootloader.NotScriptableBootloader) + c.Assert(ok, Equals, true) + + err := nsbl.SetBootVarsFromInitramfs(map[string]string{"kernel_status": "trying"}) + c.Assert(err, IsNil) + + m, err := p.GetBootVars("kernel_status") + c.Assert(err, IsNil) + c.Assert(m, DeepEquals, map[string]string{ + "kernel_status": "trying", + }) +} + +func (s *pibootTestSuite) testExtractKernelAssetsAndRemove(c *C, dtbDir string) { + opts := bootloader.Options{PrepareImageTime: false, + Role: bootloader.RoleRunMode, NoSlashBoot: true} + r := bootloader.MockPibootFiles(c, s.rootdir, &opts) + defer r() + p := bootloader.NewPiboot(s.rootdir, &opts) + c.Assert(p, NotNil) + + files := [][]string{ + {"kernel.img", "I'm a kernel"}, + {"initrd.img", "...and I'm an initrd"}, + {filepath.Join(dtbDir, "foo.dtb"), "g'day, I'm foo.dtb"}, + {"dtbs/overlays/bar.dtbo", "hello, I'm bar.dtbo"}, + // must be last + {"meta/kernel.yaml", "version: 4.2"}, + } + si := &snap.SideInfo{ + RealName: "ubuntu-kernel", + Revision: snap.R(42), + } + fn := snaptest.MakeTestSnapWithFiles(c, packageKernel, files) + snapf, err := snapfile.Open(fn) + c.Assert(err, IsNil) + + info, err := snap.ReadInfoFromSnapFile(snapf, si) + c.Assert(err, IsNil) + + err = p.ExtractKernelAssets(info, snapf) + c.Assert(err, IsNil) + + // this is where the kernel/initrd is unpacked + kernelAssetsDir := filepath.Join(s.rootdir, "piboot", "ubuntu", "ubuntu-kernel_42.snap") + + for _, def := range files { + if def[0] == "meta/kernel.yaml" { + break + } + + destPath := def[0] + if strings.HasPrefix(destPath, "dtbs/broadcom/") { + destPath = strings.TrimPrefix(destPath, "dtbs/broadcom/") + } else if strings.HasPrefix(destPath, "dtbs/") { + destPath = strings.TrimPrefix(destPath, "dtbs/") + } + fullFn := filepath.Join(kernelAssetsDir, destPath) + c.Check(fullFn, testutil.FileEquals, def[1]) + } + + // remove + err = p.RemoveKernelAssets(info) + c.Assert(err, IsNil) + + c.Check(osutil.FileExists(kernelAssetsDir), Equals, false) +} + +func (s *pibootTestSuite) TestExtractKernelAssetsAndRemove(c *C) { + // armhf and arm64 kernel snaps store dtbs in different places + s.testExtractKernelAssetsAndRemove(c, "dtbs") + s.testExtractKernelAssetsAndRemove(c, "dtbs/broadcom") +} + +func (s *pibootTestSuite) testExtractKernelAssetsOnRPi4CheckEeprom(c *C, rpiRevisionCode, eepromTimeStamp []byte, errExpected bool) { + opts := bootloader.Options{PrepareImageTime: false, + Role: bootloader.RoleRunMode, NoSlashBoot: true} + r := bootloader.MockPibootFiles(c, s.rootdir, &opts) + defer r() + r = bootloader.MockRPi4Files(c, s.rootdir, rpiRevisionCode, eepromTimeStamp) + defer r() + p := bootloader.NewPiboot(s.rootdir, &opts) + c.Assert(p, NotNil) + + files := [][]string{ + {"kernel.img", "I'm a kernel"}, + {"initrd.img", "...and I'm an initrd"}, + {"dtbs/broadcom/foo.dtb", "g'day, I'm foo.dtb"}, + {"dtbs/overlays/bar.dtbo", "hello, I'm bar.dtbo"}, + // must be last + {"meta/kernel.yaml", "version: 4.2"}, + } + si := &snap.SideInfo{ + RealName: "ubuntu-kernel", + Revision: snap.R(42), + } + fn := snaptest.MakeTestSnapWithFiles(c, packageKernel, files) + snapf, err := snapfile.Open(fn) + c.Assert(err, IsNil) + + info, err := snap.ReadInfoFromSnapFile(snapf, si) + c.Assert(err, IsNil) + + err = p.ExtractKernelAssets(info, snapf) + if errExpected { + c.Check(err.Error(), Equals, + "your EEPROM does not support tryboot, please upgrade to a newer one before installing Ubuntu Core - see http://forum.snapcraft.io/t/29455 for more details") + return + } + + c.Assert(err, IsNil) + + // this is where the kernel/initrd is unpacked + kernelAssetsDir := filepath.Join(s.rootdir, "piboot", "ubuntu", "ubuntu-kernel_42.snap") + + for _, def := range files { + if def[0] == "meta/kernel.yaml" { + break + } + + destPath := def[0] + if strings.HasPrefix(destPath, "dtbs/broadcom/") { + destPath = strings.TrimPrefix(destPath, "dtbs/broadcom/") + } else if strings.HasPrefix(destPath, "dtbs/") { + destPath = strings.TrimPrefix(destPath, "dtbs/") + } + fullFn := filepath.Join(kernelAssetsDir, destPath) + c.Check(fullFn, testutil.FileEquals, def[1]) + } + + // remove + err = p.RemoveKernelAssets(info) + c.Assert(err, IsNil) + + c.Check(osutil.FileExists(kernelAssetsDir), Equals, false) +} + +func (s *pibootTestSuite) TestExtractKernelAssetsOnRPi4CheckEeprom(c *C) { + // Rev code is RPi4, eeprom supports tryboot + expectFailure := false + s.testExtractKernelAssetsOnRPi4CheckEeprom(c, + []byte{0x00, 0xc0, 0x31, 0x11}, + []byte{0x61, 0xf0, 0x09, 0x91}, + expectFailure) + // Rev code is RPi4, eeprom does not support tryboot + expectFailure = true + s.testExtractKernelAssetsOnRPi4CheckEeprom(c, + []byte{0x00, 0xc0, 0x31, 0x11}, + []byte{0x60, 0x53, 0x15, 0x32}, + expectFailure) + // Rev code is RPi3 + expectFailure = false + s.testExtractKernelAssetsOnRPi4CheckEeprom(c, + []byte{0x00, 0xa0, 0x20, 0x82}, + []byte{}, + expectFailure) +} diff --git a/bootloader/uboot.go b/bootloader/uboot.go new file mode 100644 index 00000000..f497f9e5 --- /dev/null +++ b/bootloader/uboot.go @@ -0,0 +1,219 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2014-2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package bootloader + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/snapcore/snapd/bootloader/ubootenv" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/snap" +) + +// uboot implements the required interfaces +var ( + _ Bootloader = (*uboot)(nil) + _ ExtractedRecoveryKernelImageBootloader = (*uboot)(nil) +) + +type uboot struct { + rootdir string + basedir string + + ubootEnvFileName string +} + +func (u *uboot) setDefaults() { + u.basedir = "/boot/uboot/" + u.ubootEnvFileName = "uboot.env" +} + +func (u *uboot) processBlOpts(blOpts *Options) { + if blOpts != nil { + switch { + case blOpts.Role == RoleRecovery || blOpts.NoSlashBoot: + // RoleRecovery or NoSlashBoot imply we use + // the "boot.sel" simple text format file in + // /uboot/ubuntu as it exists on the partition + // directly + u.basedir = "/uboot/ubuntu/" + fallthrough + case blOpts.Role == RoleRunMode: + // if RoleRunMode (and no NoSlashBoot), we + // expect to find /boot/uboot/boot.sel + u.ubootEnvFileName = "boot.sel" + } + } +} + +// newUboot create a new Uboot bootloader object +func newUboot(rootdir string, blOpts *Options) Bootloader { + u := &uboot{ + rootdir: rootdir, + } + u.setDefaults() + u.processBlOpts(blOpts) + + return u +} + +func (u *uboot) Name() string { + return "uboot" +} + +func (u *uboot) dir() string { + if u.rootdir == "" { + panic("internal error: unset rootdir") + } + return filepath.Join(u.rootdir, u.basedir) +} + +func (u *uboot) useHeaderFlagByte(gadgetDir string) bool { + // if there is a "pattern" boot.sel in the gadget snap, we follow its + // lead. If opening it as a uboot env fails in any way we just go with + // the default. + gadgetEnv, err := ubootenv.OpenWithFlags(filepath.Join(gadgetDir, u.ubootEnvFileName), ubootenv.OpenBestEffort) + if err == nil { + return gadgetEnv.HeaderFlagByte() + } + + // Otherwise we use the (historical) default and assume uboot is built with + // SYS_REDUNDAND_ENVIRONMENT=y + return true +} + +func (u *uboot) InstallBootConfig(gadgetDir string, blOpts *Options) error { + gadgetFile := filepath.Join(gadgetDir, u.Name()+".conf") + // if the gadget file is empty, then we don't install anything + // this is because there are some gadgets, namely the 20 pi gadget right + // now, that don't use a uboot.env to boot and instead use a boot.scr, and + // installing a uboot.env file of any form in the root directory will break + // the boot.scr, so for these setups we just don't install anything + // TODO:UC20: how can we do this better? maybe parse the file to get the + // actual format? + st, err := os.Stat(gadgetFile) + if err != nil { + return err + } + if st.Size() == 0 { + // we have an empty uboot.conf, and hence a uboot bootloader in the + // gadget, but nothing to copy in this case and instead just install our + // own boot.sel file + u.processBlOpts(blOpts) + + err := os.MkdirAll(filepath.Dir(u.envFile()), 0755) + if err != nil { + return err + } + + // TODO:UC20: what's a reasonable size for this file? + env, err := ubootenv.Create(u.envFile(), 4096, ubootenv.CreateOptions{HeaderFlagByte: u.useHeaderFlagByte(gadgetDir)}) + if err != nil { + return err + } + + if err := env.Save(); err != nil { + return nil + } + + return nil + } + + // InstallBootConfig gets called on a uboot that does not come from newUboot + // so we need to apply the defaults here + u.setDefaults() + + if blOpts != nil && blOpts.Role == RoleRecovery { + // not supported yet, this is traditional uboot.env from gadget + // TODO:UC20: support this use-case + return fmt.Errorf("non-empty uboot.env not supported on UC20+ yet") + } + + systemFile := u.envFile() + return genericInstallBootConfig(gadgetFile, systemFile) +} + +func (u *uboot) Present() (bool, error) { + return osutil.FileExists(u.envFile()), nil +} + +func (u *uboot) envFile() string { + return filepath.Join(u.dir(), u.ubootEnvFileName) +} + +func (u *uboot) SetBootVars(values map[string]string) error { + env, err := ubootenv.OpenWithFlags(u.envFile(), ubootenv.OpenBestEffort) + if err != nil { + return err + } + + dirty := false + for k, v := range values { + // already set to the right value, nothing to do + if env.Get(k) == v { + continue + } + env.Set(k, v) + dirty = true + } + + if dirty { + return env.Save() + } + + return nil +} + +func (u *uboot) GetBootVars(names ...string) (map[string]string, error) { + out := map[string]string{} + + env, err := ubootenv.OpenWithFlags(u.envFile(), ubootenv.OpenBestEffort) + if err != nil { + return nil, err + } + + for _, name := range names { + out[name] = env.Get(name) + } + + return out, nil +} + +func (u *uboot) ExtractKernelAssets(s snap.PlaceInfo, snapf snap.Container) error { + dstDir := filepath.Join(u.dir(), s.Filename()) + assets := []string{"kernel.img", "initrd.img", "dtbs/*"} + return extractKernelAssetsToBootDir(dstDir, snapf, assets) +} + +func (u *uboot) ExtractRecoveryKernelAssets(recoverySystemDir string, s snap.PlaceInfo, snapf snap.Container) error { + if recoverySystemDir == "" { + return fmt.Errorf("internal error: recoverySystemDir unset") + } + + recoverySystemUbootKernelAssetsDir := filepath.Join(u.rootdir, recoverySystemDir, "kernel") + assets := []string{"kernel.img", "initrd.img", "dtbs/*"} + return extractKernelAssetsToBootDir(recoverySystemUbootKernelAssetsDir, snapf, assets) +} + +func (u *uboot) RemoveKernelAssets(s snap.PlaceInfo) error { + return removeKernelAssetsFromBootDir(u.dir(), s) +} diff --git a/bootloader/uboot_test.go b/bootloader/uboot_test.go new file mode 100644 index 00000000..c2c9da3a --- /dev/null +++ b/bootloader/uboot_test.go @@ -0,0 +1,317 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2014-2015 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package bootloader_test + +import ( + "os" + "path/filepath" + "time" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/bootloader" + "github.com/snapcore/snapd/bootloader/ubootenv" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/snap/snapfile" + "github.com/snapcore/snapd/snap/snaptest" + "github.com/snapcore/snapd/testutil" +) + +type ubootTestSuite struct { + baseBootenvTestSuite +} + +var _ = Suite(&ubootTestSuite{}) + +func (s *ubootTestSuite) TestNewUboot(c *C) { + // no files means bl is not present, but we can still create the bl object + u := bootloader.NewUboot(s.rootdir, nil) + c.Assert(u, NotNil) + c.Assert(u.Name(), Equals, "uboot") + + present, err := u.Present() + c.Assert(err, IsNil) + c.Assert(present, Equals, false) + + // now with files present, the bl is present + bootloader.MockUbootFiles(c, s.rootdir, nil) + present, err = u.Present() + c.Assert(err, IsNil) + c.Assert(present, Equals, true) +} + +func (s *ubootTestSuite) TestUbootGetEnvVar(c *C) { + bootloader.MockUbootFiles(c, s.rootdir, nil) + u := bootloader.NewUboot(s.rootdir, nil) + c.Assert(u, NotNil) + err := u.SetBootVars(map[string]string{ + "snap_mode": "", + "snap_core": "4", + }) + c.Assert(err, IsNil) + + m, err := u.GetBootVars("snap_mode", "snap_core") + c.Assert(err, IsNil) + c.Assert(m, DeepEquals, map[string]string{ + "snap_mode": "", + "snap_core": "4", + }) +} + +func (s *ubootTestSuite) TestGetBootloaderWithUboot(c *C) { + bootloader.MockUbootFiles(c, s.rootdir, nil) + + bootloader, err := bootloader.Find(s.rootdir, nil) + c.Assert(err, IsNil) + c.Assert(bootloader.Name(), Equals, "uboot") +} + +func (s *ubootTestSuite) TestUbootSetEnvNoUselessWrites(c *C) { + bootloader.MockUbootFiles(c, s.rootdir, nil) + u := bootloader.NewUboot(s.rootdir, nil) + c.Assert(u, NotNil) + + envFile := bootloader.UbootConfigFile(u) + env, err := ubootenv.Create(envFile, 4096, ubootenv.CreateOptions{HeaderFlagByte: true}) + c.Assert(err, IsNil) + env.Set("snap_ab", "b") + env.Set("snap_mode", "") + err = env.Save() + c.Assert(err, IsNil) + + st, err := os.Stat(envFile) + c.Assert(err, IsNil) + time.Sleep(100 * time.Millisecond) + + // note that we set to the same var as above + err = u.SetBootVars(map[string]string{"snap_ab": "b"}) + c.Assert(err, IsNil) + + env, err = ubootenv.Open(envFile) + c.Assert(err, IsNil) + c.Assert(env.String(), Equals, "snap_ab=b\n") + + st2, err := os.Stat(envFile) + c.Assert(err, IsNil) + c.Assert(st.ModTime(), Equals, st2.ModTime()) +} + +func (s *ubootTestSuite) TestUbootSetBootVarFwEnv(c *C) { + bootloader.MockUbootFiles(c, s.rootdir, nil) + u := bootloader.NewUboot(s.rootdir, nil) + + err := u.SetBootVars(map[string]string{"key": "value"}) + c.Assert(err, IsNil) + + content, err := u.GetBootVars("key") + c.Assert(err, IsNil) + c.Assert(content, DeepEquals, map[string]string{"key": "value"}) +} + +func (s *ubootTestSuite) TestUbootGetBootVarFwEnv(c *C) { + bootloader.MockUbootFiles(c, s.rootdir, nil) + u := bootloader.NewUboot(s.rootdir, nil) + + err := u.SetBootVars(map[string]string{"key2": "value2"}) + c.Assert(err, IsNil) + + content, err := u.GetBootVars("key2") + c.Assert(err, IsNil) + c.Assert(content, DeepEquals, map[string]string{"key2": "value2"}) +} + +func (s *ubootTestSuite) TestExtractKernelAssetsAndRemove(c *C) { + bootloader.MockUbootFiles(c, s.rootdir, nil) + u := bootloader.NewUboot(s.rootdir, nil) + + files := [][]string{ + {"kernel.img", "I'm a kernel"}, + {"initrd.img", "...and I'm an initrd"}, + {"dtbs/foo.dtb", "g'day, I'm foo.dtb"}, + {"dtbs/bar.dtb", "hello, I'm bar.dtb"}, + // must be last + {"meta/kernel.yaml", "version: 4.2"}, + } + si := &snap.SideInfo{ + RealName: "ubuntu-kernel", + Revision: snap.R(42), + } + fn := snaptest.MakeTestSnapWithFiles(c, packageKernel, files) + snapf, err := snapfile.Open(fn) + c.Assert(err, IsNil) + + info, err := snap.ReadInfoFromSnapFile(snapf, si) + c.Assert(err, IsNil) + + err = u.ExtractKernelAssets(info, snapf) + c.Assert(err, IsNil) + + // this is where the kernel/initrd is unpacked + kernelAssetsDir := filepath.Join(s.rootdir, "boot", "uboot", "ubuntu-kernel_42.snap") + + for _, def := range files { + if def[0] == "meta/kernel.yaml" { + break + } + + fullFn := filepath.Join(kernelAssetsDir, def[0]) + c.Check(fullFn, testutil.FileEquals, def[1]) + } + + // remove + err = u.RemoveKernelAssets(info) + c.Assert(err, IsNil) + + c.Check(osutil.FileExists(kernelAssetsDir), Equals, false) +} + +func (s *ubootTestSuite) TestExtractRecoveryKernelAssets(c *C) { + bootloader.MockUbootFiles(c, s.rootdir, nil) + u := bootloader.NewUboot(s.rootdir, nil) + + files := [][]string{ + {"kernel.img", "I'm a kernel"}, + {"initrd.img", "...and I'm an initrd"}, + {"dtbs/foo.dtb", "foo dtb"}, + {"dtbs/bar.dto", "bar dtbo"}, + // must be last + {"meta/kernel.yaml", "version: 4.2"}, + } + si := &snap.SideInfo{ + RealName: "ubuntu-kernel", + Revision: snap.R(42), + } + fn := snaptest.MakeTestSnapWithFiles(c, packageKernel, files) + snapf, err := snapfile.Open(fn) + c.Assert(err, IsNil) + + info, err := snap.ReadInfoFromSnapFile(snapf, si) + c.Assert(err, IsNil) + + // try with empty recovery dir first to check the errors + err = u.ExtractRecoveryKernelAssets("", info, snapf) + c.Assert(err, ErrorMatches, "internal error: recoverySystemDir unset") + + // now the expected behavior + err = u.ExtractRecoveryKernelAssets("recovery-dir", info, snapf) + c.Assert(err, IsNil) + + // this is where the kernel/initrd is unpacked + kernelAssetsDir := filepath.Join(s.rootdir, "recovery-dir", "kernel") + + for _, def := range files { + if def[0] == "meta/kernel.yaml" { + break + } + + fullFn := filepath.Join(kernelAssetsDir, def[0]) + c.Check(fullFn, testutil.FileEquals, def[1]) + } +} + +func (s *ubootTestSuite) TestUbootUC20OptsPlacement(c *C) { + tt := []struct { + blOpts *bootloader.Options + expEnv string + comment string + }{ + { + nil, + "/boot/uboot/uboot.env", + "traditional uboot.env", + }, + { + &bootloader.Options{Role: bootloader.RoleRunMode, NoSlashBoot: true}, + "/uboot/ubuntu/boot.sel", + "uc20 install mode boot.sel", + }, + { + &bootloader.Options{Role: bootloader.RoleRunMode}, + "/boot/uboot/boot.sel", + "uc20 run mode boot.sel", + }, + { + &bootloader.Options{Role: bootloader.RoleRecovery}, + "/uboot/ubuntu/boot.sel", + "uc20 recovery boot.sel", + }, + } + + for _, t := range tt { + dir := c.MkDir() + bootloader.MockUbootFiles(c, dir, t.blOpts) + u := bootloader.NewUboot(dir, t.blOpts) + c.Assert(u, NotNil, Commentf(t.comment)) + c.Assert(bootloader.UbootConfigFile(u), Equals, filepath.Join(dir, t.expEnv), Commentf(t.comment)) + + // if we set boot vars on the uboot, we can open the config file and + // get the same variables + c.Assert(u.SetBootVars(map[string]string{"hello": "there"}), IsNil) + env, err := ubootenv.Open(filepath.Join(dir, t.expEnv)) + c.Assert(err, IsNil) + c.Assert(env.Get("hello"), Equals, "there") + } +} + +func (s *ubootTestSuite) TestUbootInstallBootConfigHeaderSizeFromGadget(c *C) { + + opts := &bootloader.Options{Role: bootloader.RoleRecovery} + u := bootloader.NewUboot(s.rootdir, opts) + + gadgetDir := c.MkDir() + confFile, err := os.Create(filepath.Join(gadgetDir, "uboot.conf")) + c.Assert(err, IsNil) + err = confFile.Close() + c.Assert(err, IsNil) + + for _, variant := range []bool{true, false} { + env, err := ubootenv.Create(filepath.Join(gadgetDir, "boot.sel"), 4096, ubootenv.CreateOptions{HeaderFlagByte: variant}) + c.Assert(err, IsNil) + err = env.Save() + c.Assert(err, IsNil) + + err = u.InstallBootConfig(gadgetDir, opts) + c.Assert(err, IsNil) + + env, err = ubootenv.Open(filepath.Join(s.rootdir, "/uboot/ubuntu/boot.sel")) + c.Assert(err, IsNil) + c.Assert(env.HeaderFlagByte(), Equals, variant) + } +} + +func (s *ubootTestSuite) TestUbootInstallBootConfigHeaderSizeDefault(c *C) { + + opts := &bootloader.Options{Role: bootloader.RoleRecovery} + u := bootloader.NewUboot(s.rootdir, opts) + + gadgetDir := c.MkDir() + confFile, err := os.Create(filepath.Join(gadgetDir, "uboot.conf")) + c.Assert(err, IsNil) + err = confFile.Close() + c.Assert(err, IsNil) + + err = u.InstallBootConfig(gadgetDir, opts) + c.Assert(err, IsNil) + + env, err := ubootenv.Open(filepath.Join(s.rootdir, "/uboot/ubuntu/boot.sel")) + c.Assert(err, IsNil) + c.Assert(env.HeaderFlagByte(), Equals, true) +} diff --git a/bootloader/ubootenv/env.go b/bootloader/ubootenv/env.go new file mode 100644 index 00000000..ccf889c7 --- /dev/null +++ b/bootloader/ubootenv/env.go @@ -0,0 +1,348 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package ubootenv + +import ( + "bufio" + "bytes" + "encoding/binary" + "errors" + "fmt" + "hash/crc32" + "io" + "os" + "path/filepath" + "sort" + "strings" +) + +// Env contains the data of the uboot environment +type Env struct { + fname string + size int + headerFlagByte bool + data map[string]string +} + +// little endian helpers +func readUint32(data []byte) uint32 { + var ret uint32 + buf := bytes.NewBuffer(data) + binary.Read(buf, binary.LittleEndian, &ret) + return ret +} + +func writeUint32(u uint32) []byte { + buf := bytes.NewBuffer(nil) + binary.Write(buf, binary.LittleEndian, &u) + return buf.Bytes() +} + +const sizeOfUint32 = 4 + +func calcHeaderSize(headerFlagByte bool) int { + if headerFlagByte { + // If uboot uses a header flag byte, header is 4 byte crc + flag byte + return sizeOfUint32 + 1 + } + // otherwise, just a 4 byte crc + return sizeOfUint32 +} + +type CreateOptions struct { + HeaderFlagByte bool +} + +// Create a new empty uboot env file with the given size +func Create(fname string, size int, opts CreateOptions) (*Env, error) { + f, err := os.Create(fname) + if err != nil { + return nil, err + } + defer f.Close() + + env := &Env{ + fname: fname, + size: size, + headerFlagByte: opts.HeaderFlagByte, + data: make(map[string]string), + } + + return env, nil +} + +// OpenFlags instructs open how to alter its behavior. +type OpenFlags int + +const ( + // OpenBestEffort instructs OpenWithFlags to skip malformed data without returning an error. + OpenBestEffort OpenFlags = 1 << iota +) + +// Open opens a existing uboot env file +func Open(fname string) (*Env, error) { + return OpenWithFlags(fname, OpenFlags(0)) +} + +// OpenWithFlags opens a existing uboot env file, passing additional flags. +func OpenWithFlags(fname string, flags OpenFlags) (*Env, error) { + f, err := os.Open(fname) + if err != nil { + return nil, err + } + defer f.Close() + + contentWithHeader, err := io.ReadAll(f) + if err != nil { + return nil, err + } + + // Most systems have SYS_REDUNDAND_ENVIRONMENT=y, so try that first + tryHeaderFlagByte := true + env, err := readEnv(contentWithHeader, flags, tryHeaderFlagByte) + // if there is a bad CRC, maybe we just assumed the wrong header size + if errors.Is(err, errBadCrc) { + tryHeaderFlagByte := false + env, err = readEnv(contentWithHeader, flags, tryHeaderFlagByte) + } + // if error was not one of the ones that might indicate we assumed the wrong + // header size, or there is still an error after checking both header sizes + // something is actually wrong + if err != nil { + return nil, fmt.Errorf("cannot open %q: %w", fname, err) + } + + env.fname = fname + return env, nil +} + +var errBadCrc = errors.New("bad CRC") + +func readEnv(contentWithHeader []byte, flags OpenFlags, headerFlagByte bool) (*Env, error) { + + // The minimum valid env is 6 bytes (4 byte CRC + 2 null bytes for EOF) + // The maximum header length is 5 bytes (4 byte CRC, + ) + // If we always make sure our env is 6 bytes long, we'll never run into + // trouble doing some sort of OOB slicing below, but also we will + // accept all legal envs + if len(contentWithHeader) < 6 { + return nil, errors.New("smaller than expected environment block") + } + + headerSize := calcHeaderSize(headerFlagByte) + + crc := readUint32(contentWithHeader) + + payload := contentWithHeader[headerSize:] + actualCRC := crc32.ChecksumIEEE(payload) + if crc != actualCRC { + return nil, fmt.Errorf("%w %v != %v", errBadCrc, crc, actualCRC) + } + + if eof := bytes.Index(payload, []byte{0, 0}); eof >= 0 { + payload = payload[:eof] + } + + data, err := parseData(payload, flags) + if err != nil { + return nil, err + } + + env := &Env{ + size: len(contentWithHeader), + headerFlagByte: headerFlagByte, + data: data, + } + + return env, nil +} + +func parseData(data []byte, flags OpenFlags) (map[string]string, error) { + out := make(map[string]string) + + for _, envStr := range bytes.Split(data, []byte{0}) { + if len(envStr) == 0 || envStr[0] == 0 || envStr[0] == 255 { + continue + } + l := strings.SplitN(string(envStr), "=", 2) + if len(l) != 2 || l[0] == "" { + if flags&OpenBestEffort == OpenBestEffort { + continue + } + return nil, fmt.Errorf("cannot parse line %q as key=value pair", envStr) + } + key := l[0] + value := l[1] + out[key] = value + } + + return out, nil +} + +func (env *Env) String() string { + out := "" + + env.iterEnv(func(key, value string) { + out += fmt.Sprintf("%s=%s\n", key, value) + }) + + return out +} + +func (env *Env) Size() int { + return env.size +} + +func (env *Env) HeaderFlagByte() bool { + return env.headerFlagByte +} + +// Get the value of the environment variable +func (env *Env) Get(name string) string { + return env.data[name] +} + +// Set an environment name to the given value, if the value is empty +// the variable will be removed from the environment +func (env *Env) Set(name, value string) { + if name == "" { + panic(fmt.Sprintf("Set() can not be called with empty key for value: %q", value)) + } + if value == "" { + delete(env.data, name) + return + } + env.data[name] = value +} + +// iterEnv calls the passed function f with key, value for environment +// vars. The order is guaranteed (unlike just iterating over the map) +func (env *Env) iterEnv(f func(key, value string)) { + keys := make([]string, 0, len(env.data)) + for k := range env.data { + keys = append(keys, k) + } + sort.Strings(keys) + + for _, k := range keys { + if k == "" { + panic("iterEnv iterating over a empty key") + } + + f(k, env.data[k]) + } +} + +// Save will write out the environment data +func (env *Env) Save() error { + headerSize := calcHeaderSize(env.headerFlagByte) + + w := bytes.NewBuffer(nil) + // will panic if the buffer can't grow, all writes to + // the buffer will be ok because we sized it correctly + w.Grow(env.size - headerSize) + + // write the payload + env.iterEnv(func(key, value string) { + w.Write([]byte(fmt.Sprintf("%s=%s", key, value))) + w.Write([]byte{0}) + }) + + // write double \0 to mark the end of the env + w.Write([]byte{0}) + + // no keys, so no previous \0 was written so we write one here + if len(env.data) == 0 { + w.Write([]byte{0}) + } + + // write ff into the remaining parts + writtenSoFar := w.Len() + for i := 0; i < env.size-headerSize-writtenSoFar; i++ { + w.Write([]byte{0xff}) + } + + // checksum + crc := crc32.ChecksumIEEE(w.Bytes()) + + // ensure dir sync + dir, err := os.Open(filepath.Dir(env.fname)) + if err != nil { + return err + } + defer dir.Close() + + // Note that we overwrite the existing file and do not do + // the usual write-rename. The rationale is that we want to + // minimize the amount of writes happening on a potential + // FAT partition where the env is loaded from. The file will + // always be of a fixed size so we know the writes will not + // fail because of ENOSPC. + // + // The size of the env file never changes so we do not + // truncate it. + // + // We also do not O_TRUNC to avoid reallocations on the FS + // to minimize risk of fs corruption. + f, err := os.OpenFile(env.fname, os.O_WRONLY, 0666) + if err != nil { + return err + } + defer f.Close() + + if _, err := f.Write(writeUint32(crc)); err != nil { + return err + } + // padding bytes (e.g. for redundant header) + pad := make([]byte, headerSize-binary.Size(crc)) + if _, err := f.Write(pad); err != nil { + return err + } + if _, err := f.Write(w.Bytes()); err != nil { + return err + } + + if err := f.Sync(); err != nil { + return err + } + + return dir.Sync() +} + +// Import is a helper that imports a given text file that contains +// "key=value" paris into the uboot env. Lines starting with ^# are +// ignored (like the input file on mkenvimage) +func (env *Env) Import(r io.Reader) error { + scanner := bufio.NewScanner(r) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "#") || len(line) == 0 { + continue + } + l := strings.SplitN(line, "=", 2) + if len(l) == 1 { + return fmt.Errorf("Invalid line: %q", line) + } + env.data[l[0]] = l[1] + + } + + return scanner.Err() +} diff --git a/bootloader/ubootenv/env_test.go b/bootloader/ubootenv/env_test.go new file mode 100644 index 00000000..188dfc75 --- /dev/null +++ b/bootloader/ubootenv/env_test.go @@ -0,0 +1,424 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016-2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package ubootenv_test + +import ( + "bytes" + "hash/crc32" + "io" + "os" + "path/filepath" + "strings" + "testing" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/bootloader/ubootenv" +) + +// Hook up check.v1 into the "go test" runner +func Test(t *testing.T) { TestingT(t) } + +type uenvTestSuite struct { + envFile string +} + +var _ = Suite(&uenvTestSuite{}) + +func (u *uenvTestSuite) SetUpTest(c *C) { + u.envFile = filepath.Join(c.MkDir(), "uboot.env") +} + +func (u *uenvTestSuite) TestSetNoDuplicate(c *C) { + env, err := ubootenv.Create(u.envFile, 4096, ubootenv.CreateOptions{HeaderFlagByte: true}) + c.Assert(err, IsNil) + env.Set("foo", "bar") + env.Set("foo", "bar") + c.Assert(env.String(), Equals, "foo=bar\n") +} + +func (u *uenvTestSuite) TestOpenEnv(c *C) { + env, err := ubootenv.Create(u.envFile, 4096, ubootenv.CreateOptions{HeaderFlagByte: true}) + c.Assert(err, IsNil) + env.Set("foo", "bar") + c.Assert(env.String(), Equals, "foo=bar\n") + err = env.Save() + c.Assert(err, IsNil) + + env2, err := ubootenv.Open(u.envFile) + c.Assert(err, IsNil) + c.Assert(env2.String(), Equals, "foo=bar\n") +} + +func (u *uenvTestSuite) TestOpenEnvNoHeaderFlagByte(c *C) { + env, err := ubootenv.Create(u.envFile, 4096, ubootenv.CreateOptions{HeaderFlagByte: false}) + c.Assert(err, IsNil) + env.Set("foo", "bar") + c.Assert(env.String(), Equals, "foo=bar\n") + err = env.Save() + c.Assert(err, IsNil) + + env2, err := ubootenv.Open(u.envFile) + c.Assert(err, IsNil) + c.Assert(env2.String(), Equals, "foo=bar\n") +} + +func (u *uenvTestSuite) TestOpenEnvBadEmpty(c *C) { + empty := filepath.Join(c.MkDir(), "empty.env") + + err := os.WriteFile(empty, nil, 0644) + c.Assert(err, IsNil) + + _, err = ubootenv.Open(empty) + c.Assert(err, ErrorMatches, `cannot open ".*": smaller than expected environment block`) +} + +func (u *uenvTestSuite) TestOpenEnvBadCRC(c *C) { + corrupted := filepath.Join(c.MkDir(), "corrupted.env") + + buf := make([]byte, 4096) + err := os.WriteFile(corrupted, buf, 0644) + c.Assert(err, IsNil) + + _, err = ubootenv.Open(corrupted) + c.Assert(err, ErrorMatches, `cannot open ".*": bad CRC 0 != .*`) +} + +func (u *uenvTestSuite) TestGetSimple(c *C) { + env, err := ubootenv.Create(u.envFile, 4096, ubootenv.CreateOptions{HeaderFlagByte: true}) + c.Assert(err, IsNil) + env.Set("foo", "bar") + c.Assert(env.Get("foo"), Equals, "bar") +} + +func (u *uenvTestSuite) TestGetNoSuchEntry(c *C) { + env, err := ubootenv.Create(u.envFile, 4096, ubootenv.CreateOptions{HeaderFlagByte: true}) + c.Assert(err, IsNil) + c.Assert(env.Get("no-such-entry"), Equals, "") +} + +func (u *uenvTestSuite) TestImport(c *C) { + env, err := ubootenv.Create(u.envFile, 4096, ubootenv.CreateOptions{HeaderFlagByte: true}) + c.Assert(err, IsNil) + + r := strings.NewReader("foo=bar\n#comment\n\nbaz=baz") + err = env.Import(r) + c.Assert(err, IsNil) + // order is alphabetic + c.Assert(env.String(), Equals, "baz=baz\nfoo=bar\n") +} + +func (u *uenvTestSuite) TestImportHasError(c *C) { + env, err := ubootenv.Create(u.envFile, 4096, ubootenv.CreateOptions{HeaderFlagByte: true}) + c.Assert(err, IsNil) + + r := strings.NewReader("foxy") + err = env.Import(r) + c.Assert(err, ErrorMatches, "Invalid line: \"foxy\"") +} + +func (u *uenvTestSuite) TestSetEmptyUnsets(c *C) { + env, err := ubootenv.Create(u.envFile, 4096, ubootenv.CreateOptions{HeaderFlagByte: true}) + c.Assert(err, IsNil) + + env.Set("foo", "bar") + c.Assert(env.String(), Equals, "foo=bar\n") + env.Set("foo", "") + c.Assert(env.String(), Equals, "") +} + +func (u *uenvTestSuite) makeUbootEnvFromData(c *C, mockData []byte, useHeaderFlagByte bool) { + w := bytes.NewBuffer(nil) + crc := crc32.ChecksumIEEE(mockData) + w.Write(ubootenv.WriteUint32(crc)) + if useHeaderFlagByte { + w.Write([]byte{0}) + } + w.Write(mockData) + + f, err := os.Create(u.envFile) + c.Assert(err, IsNil) + defer f.Close() + _, err = f.Write(w.Bytes()) + c.Assert(err, IsNil) +} + +// ensure that the data after \0\0 is discarded (except for crc) +func (u *uenvTestSuite) TestReadStopsAfterDoubleNull(c *C) { + mockData := []byte{ + // foo=bar + 0x66, 0x6f, 0x6f, 0x3d, 0x62, 0x61, 0x72, + // eof + 0x00, 0x00, + // junk after eof as written by fw_setenv sometimes + // =b + 0x3d, 62, + // empty + 0xff, 0xff, + } + u.makeUbootEnvFromData(c, mockData, true) + + env, err := ubootenv.Open(u.envFile) + c.Assert(err, IsNil) + c.Assert(env.String(), Equals, "foo=bar\n") + c.Assert(env.HeaderFlagByte(), Equals, true) + + u.makeUbootEnvFromData(c, mockData, false) + + env, err = ubootenv.Open(u.envFile) + c.Assert(err, IsNil) + c.Assert(env.String(), Equals, "foo=bar\n") + c.Assert(env.HeaderFlagByte(), Equals, false) +} + +// ensure that the malformed data is not causing us to panic. +func (u *uenvTestSuite) TestErrorOnMalformedData(c *C) { + mockData := []byte{ + // foo + 0x66, 0x6f, 0x6f, + // eof + 0x00, 0x00, + } + u.makeUbootEnvFromData(c, mockData, true) + + env, err := ubootenv.Open(u.envFile) + c.Assert(err, ErrorMatches, `cannot open ".*": cannot parse line "foo" as key=value pair`) + c.Assert(env, IsNil) + + u.makeUbootEnvFromData(c, mockData, false) + + env, err = ubootenv.Open(u.envFile) + c.Assert(err, ErrorMatches, `cannot open ".*": cannot parse line "foo" as key=value pair`) + c.Assert(env, IsNil) +} + +// ensure that the malformed data is not causing us to panic. +func (u *uenvTestSuite) TestOpenBestEffort(c *C) { + testCases := map[string][]byte{"noise": { + // key1=value1 + 0x6b, 0x65, 0x79, 0x31, 0x3d, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x31, 0x00, + // foo + 0x66, 0x6f, 0x6f, 0x00, + // key2=value2 + 0x6b, 0x65, 0x79, 0x32, 0x3d, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x32, 0x00, + // eof + 0x00, 0x00, + }, "no-eof": { + // key1=value1 + 0x6b, 0x65, 0x79, 0x31, 0x3d, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x31, 0x00, + // key2=value2 + 0x6b, 0x65, 0x79, 0x32, 0x3d, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x32, 0x00, + // NO EOF! + }, "noise-eof": { + // key1=value1 + 0x6b, 0x65, 0x79, 0x31, 0x3d, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x31, 0x00, + // key2=value2 + 0x6b, 0x65, 0x79, 0x32, 0x3d, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x32, 0x00, + // foo + 0x66, 0x6f, 0x6f, 0x00, + }} + for testName, mockData := range testCases { + u.makeUbootEnvFromData(c, mockData, true) + + env, err := ubootenv.OpenWithFlags(u.envFile, ubootenv.OpenBestEffort) + c.Assert(err, IsNil, Commentf(testName)) + c.Check(env.String(), Equals, "key1=value1\nkey2=value2\n", Commentf(testName)) + c.Assert(env.HeaderFlagByte(), Equals, true) + + u.makeUbootEnvFromData(c, mockData, false) + + env, err = ubootenv.OpenWithFlags(u.envFile, ubootenv.OpenBestEffort) + c.Assert(err, IsNil, Commentf(testName)) + c.Check(env.String(), Equals, "key1=value1\nkey2=value2\n", Commentf(testName)) + c.Assert(env.HeaderFlagByte(), Equals, false) + } +} + +func (u *uenvTestSuite) TestErrorOnMissingKeyInKeyValuePair(c *C) { + mockData := []byte{ + // =foo + 0x3d, 0x66, 0x6f, 0x6f, + // eof + 0x00, 0x00, + } + u.makeUbootEnvFromData(c, mockData, true) + + env, err := ubootenv.Open(u.envFile) + c.Assert(err, ErrorMatches, `cannot open ".*": cannot parse line "=foo" as key=value pair`) + c.Assert(env, IsNil) + + u.makeUbootEnvFromData(c, mockData, false) + + env, err = ubootenv.Open(u.envFile) + c.Assert(err, ErrorMatches, `cannot open ".*": cannot parse line "=foo" as key=value pair`) + c.Assert(env, IsNil) +} + +func (u *uenvTestSuite) TestReadEmptyFile(c *C) { + mockData := []byte{ + // eof + 0x00, 0x00, + // empty + 0xff, 0xff, + } + u.makeUbootEnvFromData(c, mockData, true) + + env, err := ubootenv.Open(u.envFile) + c.Assert(err, IsNil) + c.Assert(env.String(), Equals, "") + c.Assert(env.HeaderFlagByte(), Equals, true) + + u.makeUbootEnvFromData(c, mockData, false) + + env, err = ubootenv.Open(u.envFile) + c.Assert(err, IsNil) + c.Assert(env.String(), Equals, "") + c.Assert(env.HeaderFlagByte(), Equals, false) +} + +func (u *uenvTestSuite) TestWritesEmptyFileWithDoubleNewline(c *C) { + env, err := ubootenv.Create(u.envFile, 12, ubootenv.CreateOptions{HeaderFlagByte: true}) + c.Assert(err, IsNil) + err = env.Save() + c.Assert(err, IsNil) + + r, err := os.Open(u.envFile) + c.Assert(err, IsNil) + defer r.Close() + content, err := io.ReadAll(r) + c.Assert(err, IsNil) + c.Assert(content, DeepEquals, []byte{ + // crc + 0x11, 0x38, 0xb3, 0x89, + // redundant + 0x0, + // eof + 0x0, 0x0, + // footer + 0xff, 0xff, 0xff, 0xff, 0xff, + }) + + env, err = ubootenv.Open(u.envFile) + c.Assert(err, IsNil) + c.Assert(env.String(), Equals, "") + c.Assert(env.HeaderFlagByte(), Equals, true) +} + +func (u *uenvTestSuite) TestWritesEmptyFileWithDoubleNewlineNoHeaderFlagByte(c *C) { + env, err := ubootenv.Create(u.envFile, 11, ubootenv.CreateOptions{HeaderFlagByte: false}) + c.Assert(err, IsNil) + err = env.Save() + c.Assert(err, IsNil) + + r, err := os.Open(u.envFile) + c.Assert(err, IsNil) + defer r.Close() + content, err := io.ReadAll(r) + c.Assert(err, IsNil) + c.Assert(content, DeepEquals, []byte{ + // crc + 0x11, 0x38, 0xb3, 0x89, + // eof + 0x0, 0x0, + // footer + 0xff, 0xff, 0xff, 0xff, 0xff, + }) + + env, err = ubootenv.Open(u.envFile) + c.Assert(err, IsNil) + c.Assert(env.String(), Equals, "") + c.Assert(env.HeaderFlagByte(), Equals, false) +} + +func (u *uenvTestSuite) TestWritesContentCorrectly(c *C) { + totalSize := 16 + + env, err := ubootenv.Create(u.envFile, totalSize, ubootenv.CreateOptions{HeaderFlagByte: true}) + c.Assert(err, IsNil) + env.Set("a", "b") + env.Set("c", "d") + err = env.Save() + c.Assert(err, IsNil) + + r, err := os.Open(u.envFile) + c.Assert(err, IsNil) + defer r.Close() + content, err := io.ReadAll(r) + c.Assert(err, IsNil) + c.Assert(content, DeepEquals, []byte{ + // crc + 0xc7, 0xd9, 0x6b, 0xc5, + // redundant + 0x0, + // a=b + 0x61, 0x3d, 0x62, + // eol + 0x0, + // c=d + 0x63, 0x3d, 0x64, + // eof + 0x0, 0x0, + // footer + 0xff, 0xff, + }) + + env, err = ubootenv.Open(u.envFile) + c.Assert(err, IsNil) + c.Assert(env.String(), Equals, "a=b\nc=d\n") + c.Assert(env.Size(), Equals, totalSize) + c.Assert(env.HeaderFlagByte(), Equals, true) +} + +func (u *uenvTestSuite) TestWritesContentCorrectlyNoHeaderFlagByte(c *C) { + totalSize := 15 + + env, err := ubootenv.Create(u.envFile, totalSize, ubootenv.CreateOptions{HeaderFlagByte: false}) + c.Assert(err, IsNil) + env.Set("a", "b") + env.Set("c", "d") + err = env.Save() + c.Assert(err, IsNil) + + r, err := os.Open(u.envFile) + c.Assert(err, IsNil) + defer r.Close() + content, err := io.ReadAll(r) + c.Assert(err, IsNil) + c.Assert(content, DeepEquals, []byte{ + // crc + 0xc7, 0xd9, 0x6b, 0xc5, + // a=b + 0x61, 0x3d, 0x62, + // eol + 0x0, + // c=d + 0x63, 0x3d, 0x64, + // eof + 0x0, 0x0, + // footer + 0xff, 0xff, + }) + + env, err = ubootenv.Open(u.envFile) + c.Assert(err, IsNil) + c.Assert(env.String(), Equals, "a=b\nc=d\n") + c.Assert(env.Size(), Equals, totalSize) + c.Assert(env.HeaderFlagByte(), Equals, false) +} diff --git a/bootloader/ubootenv/export_test.go b/bootloader/ubootenv/export_test.go new file mode 100644 index 00000000..765ec627 --- /dev/null +++ b/bootloader/ubootenv/export_test.go @@ -0,0 +1,24 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016-2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package ubootenv + +var ( + WriteUint32 = writeUint32 +) diff --git a/bootloader/withbootassettesting.go b/bootloader/withbootassettesting.go new file mode 100644 index 00000000..c66a29e5 --- /dev/null +++ b/bootloader/withbootassettesting.go @@ -0,0 +1,115 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- +//go:build withbootassetstesting + +/* + * Copyright (C) 2021 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package bootloader + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/snapcore/snapd/bootloader/assets" + "github.com/snapcore/snapd/logger" + "github.com/snapcore/snapd/snapdenv" +) + +var maybeInjectOsReadlink = os.Readlink + +func MockMaybeInjectOsReadlink(m func(string) (string, error)) (restore func()) { + old := maybeInjectOsReadlink + maybeInjectOsReadlink = m + return func() { + maybeInjectOsReadlink = old + } +} + +func MaybeInjectTestingBootloaderAssets() { + // this code is ran only when snapd is built with specific testing tag + + if !snapdenv.Testing() { + return + } + + // log an info level message, it is a testing build of snapd anyway + logger.Noticef("maybe inject boot assets?") + + // is there a marker file at /usr/lib/snapd/ in the snap? + selfExe, err := maybeInjectOsReadlink("/proc/self/exe") + if err != nil { + panic(fmt.Sprintf("cannot readlink: %v", err)) + } + + injectPieceRaw, err := os.ReadFile(filepath.Join(filepath.Dir(selfExe), "bootassetstesting")) + if os.IsNotExist(err) { + logger.Noticef("no boot asset testing marker") + return + } + if len(injectPieceRaw) == 0 { + logger.Noticef("boot asset testing snippet is empty") + } + injectPiece := strings.TrimSpace(string(injectPieceRaw)) + + // with boot assets testing enabled and the marker file present, inject + // a mock boot config update + + grubBootconfig := assets.Internal("grub.cfg") + if grubBootconfig == nil { + panic("no bootconfig") + } + snippets := assets.SnippetsForEditions("grub.cfg:static-cmdline") + if len(snippets) == 0 { + panic(fmt.Sprintf("cannot obtain internal grub.cfg:static-cmdline snippets")) + } + + internalEdition, err := editionFromConfigAsset(bytes.NewReader(grubBootconfig)) + if err != nil { + panic(fmt.Sprintf("cannot inject boot config for asset: %v", err)) + } + // bump the injected edition number + injectedEdition := internalEdition + 1 + + logger.Noticef("injecting grub boot assets for testing, edition: %v snippet: %q", injectedEdition, injectPiece) + + lastSnippet := string(snippets[len(snippets)-1].Snippet) + injectedSnippet := lastSnippet + " " + injectPiece + injectedSnippets := append(snippets, + assets.ForEditions{FirstEdition: injectedEdition, Snippet: []byte(injectedSnippet)}) + + assets.InjectSnippetsForEditions("grub.cfg:static-cmdline", injectedSnippets) + + origGrubBoot := string(grubBootconfig) + bumpedEdition := strings.Replace(origGrubBoot, + fmt.Sprintf("%s%d", editionHeader, internalEdition), + fmt.Sprintf("%s%d", editionHeader, injectedEdition), + 1) + // see data/grub.cfg for reference + bumpedCmdlineAndEdition := strings.Replace(bumpedEdition, + fmt.Sprintf(`set snapd_static_cmdline_args='%s'`, lastSnippet), + fmt.Sprintf(`set snapd_static_cmdline_args='%s'`, injectedSnippet), + 1) + + assets.InjectInternal("grub.cfg", []byte(bumpedCmdlineAndEdition)) +} + +func init() { + MaybeInjectTestingBootloaderAssets() +} diff --git a/bootloader/withbootassettesting_test.go b/bootloader/withbootassettesting_test.go new file mode 100644 index 00000000..705711a2 --- /dev/null +++ b/bootloader/withbootassettesting_test.go @@ -0,0 +1,95 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- +//go:build withbootassetstesting + +/* + * Copyright (C) 2021 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package bootloader_test + +import ( + "os" + "path/filepath" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/bootloader" + "github.com/snapcore/snapd/bootloader/assets" + "github.com/snapcore/snapd/snapdenv" +) + +type withbootasetstestingTestSuite struct { + baseBootenvTestSuite +} + +var _ = Suite(&withbootasetstestingTestSuite{}) + +func (s *withbootasetstestingTestSuite) TestInjects(c *C) { + d := c.MkDir() + c.Assert(os.WriteFile(filepath.Join(d, "bootassetstesting"), []byte("with-bootassetstesting\n"), 0644), IsNil) + restore := bootloader.MockMaybeInjectOsReadlink(func(_ string) (string, error) { + return filepath.Join(d, "foo"), nil + }) + defer restore() + restore = snapdenv.MockTesting(true) + defer restore() + restore = assets.MockSnippetsForEdition("grub.cfg:static-cmdline", []assets.ForEditions{ + {FirstEdition: 2, Snippet: []byte(`foo bar baz`)}, + }) + defer restore() + restore = assets.MockInternal("grub.cfg", []byte(`# Snapd-Boot-Config-Edition: 5 +set snapd_static_cmdline_args='foo bar baz' +this is mocked grub-recovery.conf +`)) + defer restore() + + bootloader.MaybeInjectTestingBootloaderAssets() + + bumped := assets.Internal("grub.cfg") + c.Check(string(bumped), Equals, `# Snapd-Boot-Config-Edition: 6 +set snapd_static_cmdline_args='foo bar baz with-bootassetstesting' +this is mocked grub-recovery.conf +`) + cmdline := bootloader.StaticCommandLineForGrubAssetEdition("grub.cfg", 6) + c.Check(cmdline, Equals, `foo bar baz with-bootassetstesting`) +} + +func (s *withbootasetstestingTestSuite) TestNoMarker(c *C) { + d := c.MkDir() + restore := bootloader.MockMaybeInjectOsReadlink(func(_ string) (string, error) { + return filepath.Join(d, "foo"), nil + }) + defer restore() + restore = snapdenv.MockTesting(true) + defer restore() + restore = assets.MockSnippetsForEdition("grub.cfg:static-cmdline", []assets.ForEditions{ + {FirstEdition: 2, Snippet: []byte(`foo bar baz`)}, + }) + defer restore() + grubCfg := `# Snapd-Boot-Config-Edition: 5 +set snapd_static_cmdline_args='foo bar baz' +this is mocked grub-recovery.conf +` + restore = assets.MockInternal("grub.cfg", []byte(grubCfg)) + defer restore() + + bootloader.MaybeInjectTestingBootloaderAssets() + + notBumped := assets.Internal("grub.cfg") + c.Check(string(notBumped), Equals, grubCfg) + cmdline := bootloader.StaticCommandLineForGrubAssetEdition("grub.cfg", 5) + c.Check(cmdline, Equals, `foo bar baz`) +} diff --git a/build-aux/snap/local/apparmor/af_names.h b/build-aux/snap/local/apparmor/af_names.h new file mode 100644 index 00000000..27ad9784 --- /dev/null +++ b/build-aux/snap/local/apparmor/af_names.h @@ -0,0 +1,240 @@ +/* + this file was generated on a Ubuntu kinetic install from the upstream + apparmor-3.0.7 release tarball as follows: + + AA_VER=3.0.7 + wget \ + "https://launchpad.net/apparmor/3.0/${AA_VER}/+download/apparmor-${AA_VER}.tar.gz" + tar xf "apparmor-${AA_VER}.tar.gz" + cd "apparmor-${AA_VER}" + make -C parser af_names.h + + */ +#ifndef AF_UNSPEC +# define AF_UNSPEC 0 +#endif +AA_GEN_NET_ENT("unspec", AF_UNSPEC) + +#ifndef AF_UNIX +# define AF_UNIX 1 +#endif +AA_GEN_NET_ENT("unix", AF_UNIX) + +#ifndef AF_INET +# define AF_INET 2 +#endif +AA_GEN_NET_ENT("inet", AF_INET) + +#ifndef AF_AX25 +# define AF_AX25 3 +#endif +AA_GEN_NET_ENT("ax25", AF_AX25) + +#ifndef AF_IPX +# define AF_IPX 4 +#endif +AA_GEN_NET_ENT("ipx", AF_IPX) + +#ifndef AF_APPLETALK +# define AF_APPLETALK 5 +#endif +AA_GEN_NET_ENT("appletalk", AF_APPLETALK) + +#ifndef AF_NETROM +# define AF_NETROM 6 +#endif +AA_GEN_NET_ENT("netrom", AF_NETROM) + +#ifndef AF_BRIDGE +# define AF_BRIDGE 7 +#endif +AA_GEN_NET_ENT("bridge", AF_BRIDGE) + +#ifndef AF_ATMPVC +# define AF_ATMPVC 8 +#endif +AA_GEN_NET_ENT("atmpvc", AF_ATMPVC) + +#ifndef AF_X25 +# define AF_X25 9 +#endif +AA_GEN_NET_ENT("x25", AF_X25) + +#ifndef AF_INET6 +# define AF_INET6 10 +#endif +AA_GEN_NET_ENT("inet6", AF_INET6) + +#ifndef AF_ROSE +# define AF_ROSE 11 +#endif +AA_GEN_NET_ENT("rose", AF_ROSE) + +#ifndef AF_NETBEUI +# define AF_NETBEUI 13 +#endif +AA_GEN_NET_ENT("netbeui", AF_NETBEUI) + +#ifndef AF_SECURITY +# define AF_SECURITY 14 +#endif +AA_GEN_NET_ENT("security", AF_SECURITY) + +#ifndef AF_KEY +# define AF_KEY 15 +#endif +AA_GEN_NET_ENT("key", AF_KEY) + +#ifndef AF_NETLINK +# define AF_NETLINK 16 +#endif +AA_GEN_NET_ENT("netlink", AF_NETLINK) + +#ifndef AF_PACKET +# define AF_PACKET 17 +#endif +AA_GEN_NET_ENT("packet", AF_PACKET) + +#ifndef AF_ASH +# define AF_ASH 18 +#endif +AA_GEN_NET_ENT("ash", AF_ASH) + +#ifndef AF_ECONET +# define AF_ECONET 19 +#endif +AA_GEN_NET_ENT("econet", AF_ECONET) + +#ifndef AF_ATMSVC +# define AF_ATMSVC 20 +#endif +AA_GEN_NET_ENT("atmsvc", AF_ATMSVC) + +#ifndef AF_RDS +# define AF_RDS 21 +#endif +AA_GEN_NET_ENT("rds", AF_RDS) + +#ifndef AF_SNA +# define AF_SNA 22 +#endif +AA_GEN_NET_ENT("sna", AF_SNA) + +#ifndef AF_IRDA +# define AF_IRDA 23 +#endif +AA_GEN_NET_ENT("irda", AF_IRDA) + +#ifndef AF_PPPOX +# define AF_PPPOX 24 +#endif +AA_GEN_NET_ENT("pppox", AF_PPPOX) + +#ifndef AF_WANPIPE +# define AF_WANPIPE 25 +#endif +AA_GEN_NET_ENT("wanpipe", AF_WANPIPE) + +#ifndef AF_LLC +# define AF_LLC 26 +#endif +AA_GEN_NET_ENT("llc", AF_LLC) + +#ifndef AF_IB +# define AF_IB 27 +#endif +AA_GEN_NET_ENT("ib", AF_IB) + +#ifndef AF_MPLS +# define AF_MPLS 28 +#endif +AA_GEN_NET_ENT("mpls", AF_MPLS) + +#ifndef AF_CAN +# define AF_CAN 29 +#endif +AA_GEN_NET_ENT("can", AF_CAN) + +#ifndef AF_TIPC +# define AF_TIPC 30 +#endif +AA_GEN_NET_ENT("tipc", AF_TIPC) + +#ifndef AF_BLUETOOTH +# define AF_BLUETOOTH 31 +#endif +AA_GEN_NET_ENT("bluetooth", AF_BLUETOOTH) + +#ifndef AF_IUCV +# define AF_IUCV 32 +#endif +AA_GEN_NET_ENT("iucv", AF_IUCV) + +#ifndef AF_RXRPC +# define AF_RXRPC 33 +#endif +AA_GEN_NET_ENT("rxrpc", AF_RXRPC) + +#ifndef AF_ISDN +# define AF_ISDN 34 +#endif +AA_GEN_NET_ENT("isdn", AF_ISDN) + +#ifndef AF_PHONET +# define AF_PHONET 35 +#endif +AA_GEN_NET_ENT("phonet", AF_PHONET) + +#ifndef AF_IEEE802154 +# define AF_IEEE802154 36 +#endif +AA_GEN_NET_ENT("ieee802154", AF_IEEE802154) + +#ifndef AF_CAIF +# define AF_CAIF 37 +#endif +AA_GEN_NET_ENT("caif", AF_CAIF) + +#ifndef AF_ALG +# define AF_ALG 38 +#endif +AA_GEN_NET_ENT("alg", AF_ALG) + +#ifndef AF_NFC +# define AF_NFC 39 +#endif +AA_GEN_NET_ENT("nfc", AF_NFC) + +#ifndef AF_VSOCK +# define AF_VSOCK 40 +#endif +AA_GEN_NET_ENT("vsock", AF_VSOCK) + +#ifndef AF_KCM +# define AF_KCM 41 +#endif +AA_GEN_NET_ENT("kcm", AF_KCM) + +#ifndef AF_QIPCRTR +# define AF_QIPCRTR 42 +#endif +AA_GEN_NET_ENT("qipcrtr", AF_QIPCRTR) + +#ifndef AF_SMC +# define AF_SMC 43 +#endif +AA_GEN_NET_ENT("smc", AF_SMC) + +#ifndef AF_XDP +# define AF_XDP 44 +#endif +AA_GEN_NET_ENT("xdp", AF_XDP) + +#ifndef AF_MCTP +# define AF_MCTP 45 +#endif +AA_GEN_NET_ENT("mctp", AF_MCTP) + + +#define AA_AF_MAX 46 + diff --git a/build-aux/snap/patches/apparmor/parser-replace-dynamic_cast-with-is_type-method.patch b/build-aux/snap/patches/apparmor/parser-replace-dynamic_cast-with-is_type-method.patch new file mode 100644 index 00000000..159e8deb --- /dev/null +++ b/build-aux/snap/patches/apparmor/parser-replace-dynamic_cast-with-is_type-method.patch @@ -0,0 +1,791 @@ +From 5aab543a3b03ecaea356a02928e5bb5b7a0d8fa5 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Alfonso=20S=C3=A1nchez-Beato?= + +Date: Mon, 15 Feb 2021 16:26:18 +0100 +Subject: [PATCH] parser: replace dynamic_cast with is_type method + +The dynamic_cast operator is slow as it needs to look at RTTI +information and even does some string comparisons, especially in deep +hierarchies like the one for Node. Profiling with callgrind showed +that dynamic_cast can eat a huge portion of the running time, as it +takes most of the time that is spent in the simplify_tree() +function. For some complex profiles, the number of calls to +dynamic_cast can be in the range of millions. + +This commit replaces the use of dynamic_cast in the Node hierarchy +with a method called is_type(), which returns true if the pointer can +be casted to the specified type. It works by looking at a Node object +field that is an integer with bits set for each type up in the +hierarchy. Therefore, dynamic_cast is replaced by a simple bits +operation. + +This change can reduce the compilation times for some profiles more +that 50%, especially in arm/arm64 arch. This opens the door to maybe +avoid "-O no-expr-simplify" in the snapd daemon, as now that option +would make the compilation slower in almost all cases. + +This is the example profile used in some of my tests, with this change +the run-time is around 1/3 of what it was before on an x86 laptop: + +profile "test" (attach_disconnected,mediate_deleted) { +dbus send + bus={fcitx,session} + path=/inputcontext_[0-9]* + interface=org.fcitx.Fcitx.InputContext + member="{Close,Destroy,Enable}IC" + peer=(label=unconfined), +dbus send + bus={fcitx,session} + path=/inputcontext_[0-9]* + interface=org.fcitx.Fcitx.InputContext + member=Reset + peer=(label=unconfined), +dbus receive + bus=fcitx + peer=(label=unconfined), +dbus receive + bus=session + interface=org.fcitx.Fcitx.* + peer=(label=unconfined), +dbus send + bus={fcitx,session} + path=/inputcontext_[0-9]* + interface=org.fcitx.Fcitx.InputContext + member="Focus{In,Out}" + peer=(label=unconfined), +dbus send + bus={fcitx,session} + path=/inputcontext_[0-9]* + interface=org.fcitx.Fcitx.InputContext + member="{CommitPreedit,Set*}" + peer=(label=unconfined), +dbus send + bus={fcitx,session} + path=/inputcontext_[0-9]* + interface=org.fcitx.Fcitx.InputContext + member="{MouseEvent,ProcessKeyEvent}" + peer=(label=unconfined), +dbus send + bus={fcitx,session} + path=/inputcontext_[0-9]* + interface=org.freedesktop.DBus.Properties + member=GetAll + peer=(label=unconfined), +dbus (send) + bus=session + path=/org/a11y/bus + interface=org.a11y.Bus + member=GetAddress + peer=(label=unconfined), +dbus (send) + bus=session + path=/org/a11y/bus + interface=org.freedesktop.DBus.Properties + member=Get{,All} + peer=(label=unconfined), +dbus (receive, send) + bus=accessibility + path=/org/a11y/atspi/** + peer=(label=unconfined), +dbus (send) + bus=system + path=/org/freedesktop/Accounts + interface=org.freedesktop.DBus.Introspectable + member=Introspect + peer=(label=unconfined), +dbus (send) + bus=system + path=/org/freedesktop/Accounts + interface=org.freedesktop.Accounts + member=FindUserById + peer=(label=unconfined), +dbus (receive, send) + bus=system + path=/org/freedesktop/Accounts/User[0-9]* + interface=org.freedesktop.DBus.Properties + member={Get,PropertiesChanged} + peer=(label=unconfined), +dbus (send) + bus=session + interface=org.gtk.Actions + member=Changed + peer=(name=org.freedesktop.DBus, label=unconfined), +dbus (receive) + bus=session + interface=org.gtk.Actions + member={Activate,DescribeAll,SetState} + peer=(label=unconfined), +dbus (receive) + bus=session + interface=org.gtk.Menus + member={Start,End} + peer=(label=unconfined), +dbus (send) + bus=session + interface=org.gtk.Menus + member=Changed + peer=(name=org.freedesktop.DBus, label=unconfined), +dbus (send) + bus=session + path="/com/ubuntu/MenuRegistrar" + interface="com.ubuntu.MenuRegistrar" + member="{Register,Unregister}{App,Surface}Menu" + peer=(label=unconfined), +} +--- + parser/libapparmor_re/aare_rules.cc | 10 +- + parser/libapparmor_re/expr-tree.cc | 63 +++++------ + parser/libapparmor_re/expr-tree.h | 162 +++++++++++++++++++++------- + parser/libapparmor_re/hfa.cc | 9 +- + 4 files changed, 165 insertions(+), 79 deletions(-) + +diff --git a/parser/libapparmor_re/aare_rules.cc b/parser/libapparmor_re/aare_rules.cc +index 1d56b3cb0..b250e1013 100644 +--- a/parser/libapparmor_re/aare_rules.cc ++++ b/parser/libapparmor_re/aare_rules.cc +@@ -97,11 +97,11 @@ bool aare_rules::add_rule_vec(int deny, uint32_t perms, uint32_t audit, + */ + exact_match = 1; + for (depth_first_traversal i(tree); i && exact_match; i++) { +- if (dynamic_cast(*i) || +- dynamic_cast(*i) || +- dynamic_cast(*i) || +- dynamic_cast(*i) || +- dynamic_cast(*i)) ++ if ((*i)->is_type(NODE_TYPE_STAR) || ++ (*i)->is_type(NODE_TYPE_PLUS) || ++ (*i)->is_type(NODE_TYPE_ANYCHAR) || ++ (*i)->is_type(NODE_TYPE_CHARSET) || ++ (*i)->is_type(NODE_TYPE_NOTCHARSET)) + exact_match = 0; + } + +diff --git a/parser/libapparmor_re/expr-tree.cc b/parser/libapparmor_re/expr-tree.cc +index 28aa35000..7dc18b041 100644 +--- a/parser/libapparmor_re/expr-tree.cc ++++ b/parser/libapparmor_re/expr-tree.cc +@@ -210,7 +210,7 @@ int TwoChildNode::normalize_eps(int dir) + // Test for E | (E | E) and E . (E . E) which will + // result in an infinite loop + Node *c = child[!dir]; +- if (dynamic_cast(c) && ++ if (c->is_type(NODE_TYPE_TWOCHILD) && + &epsnode == c->child[dir] && + &epsnode == c->child[!dir]) { + c->release(); +@@ -229,7 +229,7 @@ void CatNode::normalize(int dir) + for (;;) { + if (normalize_eps(dir)) { + continue; +- } else if (dynamic_cast(child[dir])) { ++ } else if (child[dir]->is_type(NODE_TYPE_CAT)) { + // (ab)c -> a(bc) + rotate_node(this, dir); + } else { +@@ -248,11 +248,11 @@ void AltNode::normalize(int dir) + for (;;) { + if (normalize_eps(dir)) { + continue; +- } else if (dynamic_cast(child[dir])) { ++ } else if (child[dir]->is_type(NODE_TYPE_ALT)) { + // (a | b) | c -> a | (b | c) + rotate_node(this, dir); +- } else if (dynamic_cast(child[dir]) && +- dynamic_cast(child[!dir])) { ++ } else if (child[dir]->is_type(NODE_TYPE_CHARSET) && ++ child[!dir]->is_type(NODE_TYPE_CHAR)) { + // [a] | b -> b | [a] + Node *c = child[dir]; + child[dir] = child[!dir]; +@@ -344,7 +344,7 @@ static Node *alt_to_charsets(Node *t, int dir) + + static Node *basic_alt_factor(Node *t, int dir) + { +- if (!dynamic_cast(t)) ++ if (!t->is_type(NODE_TYPE_ALT)) + return t; + + if (t->child[dir]->eq(t->child[!dir])) { +@@ -355,8 +355,8 @@ static Node *basic_alt_factor(Node *t, int dir) + return tmp; + } + // (ab) | (ac) -> a(b|c) +- if (dynamic_cast(t->child[dir]) && +- dynamic_cast(t->child[!dir]) && ++ if (t->child[dir]->is_type(NODE_TYPE_CAT) && ++ t->child[!dir]->is_type(NODE_TYPE_CAT) && + t->child[dir]->child[dir]->eq(t->child[!dir]->child[dir])) { + // (ab) | (ac) -> a(b|c) + Node *left = t->child[dir]; +@@ -369,7 +369,7 @@ static Node *basic_alt_factor(Node *t, int dir) + return left; + } + // a | (ab) -> a (E | b) -> a (b | E) +- if (dynamic_cast(t->child[!dir]) && ++ if (t->child[!dir]->is_type(NODE_TYPE_CAT) && + t->child[dir]->eq(t->child[!dir]->child[dir])) { + Node *c = t->child[!dir]; + t->child[dir]->release(); +@@ -379,7 +379,7 @@ static Node *basic_alt_factor(Node *t, int dir) + return c; + } + // ab | (a) -> a (b | E) +- if (dynamic_cast(t->child[dir]) && ++ if (t->child[dir]->is_type(NODE_TYPE_CAT) && + t->child[dir]->child[dir]->eq(t->child[!dir])) { + Node *c = t->child[dir]; + t->child[!dir]->release(); +@@ -394,7 +394,7 @@ static Node *basic_alt_factor(Node *t, int dir) + + static Node *basic_simplify(Node *t, int dir) + { +- if (dynamic_cast(t) && &epsnode == t->child[!dir]) { ++ if (t->is_type(NODE_TYPE_CAT) && &epsnode == t->child[!dir]) { + // aE -> a + Node *tmp = t->child[dir]; + t->child[dir] = NULL; +@@ -419,7 +419,7 @@ static Node *basic_simplify(Node *t, int dir) + */ + Node *simplify_tree_base(Node *t, int dir, bool &mod) + { +- if (dynamic_cast(t)) ++ if (t->is_type(NODE_TYPE_IMPORTANT)) + return t; + + for (int i = 0; i < 2; i++) { +@@ -442,15 +442,15 @@ Node *simplify_tree_base(Node *t, int dir, bool &mod) + } + + /* all tests after this must meet 2 alt node condition */ +- if (!dynamic_cast(t) || +- !dynamic_cast(t->child[!dir])) ++ if (!t->is_type(NODE_TYPE_ALT) || ++ !t->child[!dir]->is_type(NODE_TYPE_ALT)) + break; + + // a | (a | b) -> (a | b) + // a | (b | (c | a)) -> (b | (c | a)) + Node *p = t; + Node *i = t->child[!dir]; +- for (; dynamic_cast(i); p = i, i = i->child[!dir]) { ++ for (; i->is_type(NODE_TYPE_ALT); p = i, i = i->child[!dir]) { + if (t->child[dir]->eq(i->child[dir])) { + Node *tmp = t->child[!dir]; + t->child[!dir] = NULL; +@@ -475,19 +475,19 @@ Node *simplify_tree_base(Node *t, int dir, bool &mod) + int count = 0; + Node *subject = t->child[dir]; + Node *a = subject; +- if (dynamic_cast(subject)) ++ if (subject->is_type(NODE_TYPE_CAT)) + a = subject->child[dir]; + + for (pp = p = t, i = t->child[!dir]; +- dynamic_cast(i);) { +- if ((dynamic_cast(i->child[dir]) && ++ i->is_type(NODE_TYPE_ALT);) { ++ if ((i->child[dir]->is_type(NODE_TYPE_CAT) && + a->eq(i->child[dir]->child[dir])) || + (a->eq(i->child[dir]))) { + // extract matching alt node + p->child[!dir] = i->child[!dir]; + i->child[!dir] = subject; + subject = basic_simplify(i, dir); +- if (dynamic_cast(subject)) ++ if (subject->is_type(NODE_TYPE_CAT)) + a = subject->child[dir]; + else + a = subject; +@@ -502,7 +502,7 @@ Node *simplify_tree_base(Node *t, int dir, bool &mod) + } + + // last altnode in chain check other dir as well +- if ((dynamic_cast(i) && ++ if ((i->is_type(NODE_TYPE_CAT) && + a->eq(i->child[dir])) || (a->eq(i))) { + count++; + if (t == p) { +@@ -528,7 +528,7 @@ int debug_tree(Node *t) + { + int nodes = 1; + +- if (!dynamic_cast(t)) { ++ if (!t->is_type(NODE_TYPE_IMPORTANT)) { + if (t->child[0]) + nodes += debug_tree(t->child[0]); + if (t->child[1]) +@@ -539,30 +539,30 @@ int debug_tree(Node *t) + + static void count_tree_nodes(Node *t, struct node_counts *counts) + { +- if (dynamic_cast(t)) { ++ if (t->is_type(NODE_TYPE_ALT)) { + counts->alt++; + count_tree_nodes(t->child[0], counts); + count_tree_nodes(t->child[1], counts); +- } else if (dynamic_cast(t)) { ++ } else if (t->is_type(NODE_TYPE_CAT)) { + counts->cat++; + count_tree_nodes(t->child[0], counts); + count_tree_nodes(t->child[1], counts); +- } else if (dynamic_cast(t)) { ++ } else if (t->is_type(NODE_TYPE_PLUS)) { + counts->plus++; + count_tree_nodes(t->child[0], counts); +- } else if (dynamic_cast(t)) { ++ } else if (t->is_type(NODE_TYPE_STAR)) { + counts->star++; + count_tree_nodes(t->child[0], counts); +- } else if (dynamic_cast(t)) { ++ } else if (t->is_type(NODE_TYPE_OPTIONAL)) { + counts->optional++; + count_tree_nodes(t->child[0], counts); +- } else if (dynamic_cast(t)) { ++ } else if (t->is_type(NODE_TYPE_CHAR)) { + counts->charnode++; +- } else if (dynamic_cast(t)) { ++ } else if (t->is_type(NODE_TYPE_ANYCHAR)) { + counts->any++; +- } else if (dynamic_cast(t)) { ++ } else if (t->is_type(NODE_TYPE_CHARSET)) { + counts->charset++; +- } else if (dynamic_cast(t)) { ++ } else if (t->is_type(NODE_TYPE_NOTCHARSET)) { + counts->notcharset++; + } + } +@@ -635,7 +635,8 @@ Node *simplify_tree(Node *t, dfaflags_t flags) + void flip_tree(Node *node) + { + for (depth_first_traversal i(node); i; i++) { +- if (CatNode *cat = dynamic_cast(*i)) { ++ if ((*i)->is_type(NODE_TYPE_CAT)) { ++ CatNode *cat = static_cast(*i); + swap(cat->child[0], cat->child[1]); + } + } +diff --git a/parser/libapparmor_re/expr-tree.h b/parser/libapparmor_re/expr-tree.h +index 551dd0eeb..8ada4a4a7 100644 +--- a/parser/libapparmor_re/expr-tree.h ++++ b/parser/libapparmor_re/expr-tree.h +@@ -222,16 +222,43 @@ typedef struct Cases { + + ostream &operator<<(ostream &os, Node &node); + ++#define NODE_TYPE_NODE 0 ++#define NODE_TYPE_INNER (1 << 0) ++#define NODE_TYPE_ONECHILD (1 << 1) ++#define NODE_TYPE_TWOCHILD (1 << 2) ++#define NODE_TYPE_LEAF (1 << 3) ++#define NODE_TYPE_EPS (1 << 4) ++#define NODE_TYPE_IMPORTANT (1 << 5) ++#define NODE_TYPE_C (1 << 6) ++#define NODE_TYPE_CHAR (1 << 7) ++#define NODE_TYPE_CHARSET (1 << 8) ++#define NODE_TYPE_NOTCHARSET (1 << 9) ++#define NODE_TYPE_ANYCHAR (1 << 10) ++#define NODE_TYPE_STAR (1 << 11) ++#define NODE_TYPE_OPTIONAL (1 << 12) ++#define NODE_TYPE_PLUS (1 << 13) ++#define NODE_TYPE_CAT (1 << 14) ++#define NODE_TYPE_ALT (1 << 15) ++#define NODE_TYPE_SHARED (1 << 16) ++#define NODE_TYPE_ACCEPT (1 << 17) ++#define NODE_TYPE_MATCHFLAG (1 << 18) ++#define NODE_TYPE_EXACTMATCHFLAG (1 << 19) ++#define NODE_TYPE_DENYMATCHFLAG (1 << 20) ++ + /* An abstract node in the syntax tree. */ + class Node { + public: +- Node(): nullable(false), label(0) { child[0] = child[1] = 0; } +- Node(Node *left): nullable(false), label(0) ++ Node(): nullable(false), type_flags(NODE_TYPE_NODE), label(0) ++ { ++ child[0] = child[1] = 0; ++ } ++ Node(Node *left): nullable(false), type_flags(NODE_TYPE_NODE), label(0) + { + child[0] = left; + child[1] = 0; + } +- Node(Node *left, Node *right): nullable(false), label(0) ++ Node(Node *left, Node *right): nullable(false), ++ type_flags(NODE_TYPE_NODE), label(0) + { + child[0] = left; + child[1] = right; +@@ -302,6 +329,13 @@ public: + NodeSet firstpos, lastpos, followpos; + /* child 0 is left, child 1 is right */ + Node *child[2]; ++ /* ++ * Bitmap that stores supported pointer casts for the Node, composed ++ * by the NODE_TYPE_* flags. This is used by is_type() as a substitute ++ * of costly dynamic_cast calls. ++ */ ++ unsigned type_flags; ++ bool is_type(unsigned type) { return type_flags & type; } + + unsigned int label; /* unique number for debug etc */ + /** +@@ -315,25 +349,34 @@ public: + + class InnerNode: public Node { + public: +- InnerNode(): Node() { }; +- InnerNode(Node *left): Node(left) { }; +- InnerNode(Node *left, Node *right): Node(left, right) { }; ++ InnerNode(): Node() { type_flags |= NODE_TYPE_INNER; }; ++ InnerNode(Node *left): Node(left) { type_flags |= NODE_TYPE_INNER; }; ++ InnerNode(Node *left, Node *right): Node(left, right) ++ { ++ type_flags |= NODE_TYPE_INNER; ++ }; + }; + + class OneChildNode: public InnerNode { + public: +- OneChildNode(Node *left): InnerNode(left) { }; ++ OneChildNode(Node *left): InnerNode(left) ++ { ++ type_flags |= NODE_TYPE_ONECHILD; ++ }; + }; + + class TwoChildNode: public InnerNode { + public: +- TwoChildNode(Node *left, Node *right): InnerNode(left, right) { }; ++ TwoChildNode(Node *left, Node *right): InnerNode(left, right) ++ { ++ type_flags |= NODE_TYPE_TWOCHILD; ++ }; + virtual int normalize_eps(int dir); + }; + + class LeafNode: public Node { + public: +- LeafNode(): Node() { }; ++ LeafNode(): Node() { type_flags |= NODE_TYPE_LEAF; }; + virtual void normalize(int dir __attribute__((unused))) { return; } + }; + +@@ -342,6 +385,7 @@ class EpsNode: public LeafNode { + public: + EpsNode(): LeafNode() + { ++ type_flags |= NODE_TYPE_EPS; + nullable = true; + label = 0; + } +@@ -356,7 +400,7 @@ public: + void compute_lastpos() { } + int eq(Node *other) + { +- if (dynamic_cast(other)) ++ if (other->is_type(NODE_TYPE_EPS)) + return 1; + return 0; + } +@@ -373,7 +417,7 @@ public: + */ + class ImportantNode: public LeafNode { + public: +- ImportantNode(): LeafNode() { } ++ ImportantNode(): LeafNode() { type_flags |= NODE_TYPE_IMPORTANT; } + void compute_firstpos() { firstpos.insert(this); } + void compute_lastpos() { lastpos.insert(this); } + virtual void follow(Cases &cases) = 0; +@@ -386,7 +430,7 @@ public: + */ + class CNode: public ImportantNode { + public: +- CNode(): ImportantNode() { } ++ CNode(): ImportantNode() { type_flags |= NODE_TYPE_C; } + int is_accept(void) { return false; } + int is_postprocess(void) { return false; } + }; +@@ -394,7 +438,7 @@ public: + /* Match one specific character (/c/). */ + class CharNode: public CNode { + public: +- CharNode(transchar c): c(c) { } ++ CharNode(transchar c): c(c) { type_flags |= NODE_TYPE_CHAR; } + void follow(Cases &cases) + { + NodeSet **x = &cases.cases[c]; +@@ -408,8 +452,8 @@ public: + } + int eq(Node *other) + { +- CharNode *o = dynamic_cast(other); +- if (o) { ++ if (other->is_type(NODE_TYPE_CHAR)) { ++ CharNode *o = static_cast(other); + return c == o->c; + } + return 0; +@@ -439,7 +483,10 @@ public: + /* Match a set of characters (/[abc]/). */ + class CharSetNode: public CNode { + public: +- CharSetNode(Chars &chars): chars(chars) { } ++ CharSetNode(Chars &chars): chars(chars) ++ { ++ type_flags |= NODE_TYPE_CHARSET; ++ } + void follow(Cases &cases) + { + for (Chars::iterator i = chars.begin(); i != chars.end(); i++) { +@@ -455,8 +502,11 @@ public: + } + int eq(Node *other) + { +- CharSetNode *o = dynamic_cast(other); +- if (!o || chars.size() != o->chars.size()) ++ if (!other->is_type(NODE_TYPE_CHARSET)) ++ return 0; ++ ++ CharSetNode *o = static_cast(other); ++ if (chars.size() != o->chars.size()) + return 0; + + for (Chars::iterator i = chars.begin(), j = o->chars.begin(); +@@ -498,7 +548,10 @@ public: + /* Match all except one character (/[^abc]/). */ + class NotCharSetNode: public CNode { + public: +- NotCharSetNode(Chars &chars): chars(chars) { } ++ NotCharSetNode(Chars &chars): chars(chars) ++ { ++ type_flags |= NODE_TYPE_NOTCHARSET; ++ } + void follow(Cases &cases) + { + if (!cases.otherwise) +@@ -522,8 +575,11 @@ public: + } + int eq(Node *other) + { +- NotCharSetNode *o = dynamic_cast(other); +- if (!o || chars.size() != o->chars.size()) ++ if (!other->is_type(NODE_TYPE_NOTCHARSET)) ++ return 0; ++ ++ NotCharSetNode *o = static_cast(other); ++ if (chars.size() != o->chars.size()) + return 0; + + for (Chars::iterator i = chars.begin(), j = o->chars.begin(); +@@ -565,7 +621,7 @@ public: + /* Match any character (/./). */ + class AnyCharNode: public CNode { + public: +- AnyCharNode() { } ++ AnyCharNode() { type_flags |= NODE_TYPE_ANYCHAR; } + void follow(Cases &cases) + { + if (!cases.otherwise) +@@ -579,7 +635,7 @@ public: + } + int eq(Node *other) + { +- if (dynamic_cast(other)) ++ if (other->is_type(NODE_TYPE_ANYCHAR)) + return 1; + return 0; + } +@@ -589,7 +645,11 @@ public: + /* Match a node zero or more times. (This is a unary operator.) */ + class StarNode: public OneChildNode { + public: +- StarNode(Node *left): OneChildNode(left) { nullable = true; } ++ StarNode(Node *left): OneChildNode(left) ++ { ++ type_flags |= NODE_TYPE_STAR; ++ nullable = true; ++ } + void compute_firstpos() { firstpos = child[0]->firstpos; } + void compute_lastpos() { lastpos = child[0]->lastpos; } + void compute_followpos() +@@ -601,7 +661,7 @@ public: + } + int eq(Node *other) + { +- if (dynamic_cast(other)) ++ if (other->is_type(NODE_TYPE_STAR)) + return child[0]->eq(other->child[0]); + return 0; + } +@@ -618,12 +678,16 @@ public: + /* Match a node zero or one times. */ + class OptionalNode: public OneChildNode { + public: +- OptionalNode(Node *left): OneChildNode(left) { nullable = true; } ++ OptionalNode(Node *left): OneChildNode(left) ++ { ++ type_flags |= NODE_TYPE_OPTIONAL; ++ nullable = true; ++ } + void compute_firstpos() { firstpos = child[0]->firstpos; } + void compute_lastpos() { lastpos = child[0]->lastpos; } + int eq(Node *other) + { +- if (dynamic_cast(other)) ++ if (other->is_type(NODE_TYPE_OPTIONAL)) + return child[0]->eq(other->child[0]); + return 0; + } +@@ -638,7 +702,9 @@ public: + /* Match a node one or more times. (This is a unary operator.) */ + class PlusNode: public OneChildNode { + public: +- PlusNode(Node *left): OneChildNode(left) { ++ PlusNode(Node *left): OneChildNode(left) ++ { ++ type_flags |= NODE_TYPE_PLUS; + } + void compute_nullable() { nullable = child[0]->nullable; } + void compute_firstpos() { firstpos = child[0]->firstpos; } +@@ -651,7 +717,7 @@ public: + } + } + int eq(Node *other) { +- if (dynamic_cast(other)) ++ if (other->is_type(NODE_TYPE_PLUS)) + return child[0]->eq(other->child[0]); + return 0; + } +@@ -667,7 +733,10 @@ public: + /* Match a pair of consecutive nodes. */ + class CatNode: public TwoChildNode { + public: +- CatNode(Node *left, Node *right): TwoChildNode(left, right) { } ++ CatNode(Node *left, Node *right): TwoChildNode(left, right) ++ { ++ type_flags |= NODE_TYPE_CAT; ++ } + void compute_nullable() + { + nullable = child[0]->nullable && child[1]->nullable; +@@ -695,7 +764,7 @@ public: + } + int eq(Node *other) + { +- if (dynamic_cast(other)) { ++ if (other->is_type(NODE_TYPE_CAT)) { + if (!child[0]->eq(other->child[0])) + return 0; + return child[1]->eq(other->child[1]); +@@ -730,7 +799,10 @@ public: + /* Match one of two alternative nodes. */ + class AltNode: public TwoChildNode { + public: +- AltNode(Node *left, Node *right): TwoChildNode(left, right) { } ++ AltNode(Node *left, Node *right): TwoChildNode(left, right) ++ { ++ type_flags |= NODE_TYPE_ALT; ++ } + void compute_nullable() + { + nullable = child[0]->nullable || child[1]->nullable; +@@ -745,7 +817,7 @@ public: + } + int eq(Node *other) + { +- if (dynamic_cast(other)) { ++ if (other->is_type(NODE_TYPE_ALT)) { + if (!child[0]->eq(other->child[0])) + return 0; + return child[1]->eq(other->child[1]); +@@ -780,7 +852,10 @@ public: + + class SharedNode: public ImportantNode { + public: +- SharedNode() { } ++ SharedNode() ++ { ++ type_flags |= NODE_TYPE_SHARED; ++ } + void release(void) + { + /* don't delete SharedNodes via release as they are shared, and +@@ -803,14 +878,17 @@ public: + */ + class AcceptNode: public SharedNode { + public: +- AcceptNode() { } ++ AcceptNode() { type_flags |= NODE_TYPE_ACCEPT; } + int is_accept(void) { return true; } + int is_postprocess(void) { return false; } + }; + + class MatchFlag: public AcceptNode { + public: +- MatchFlag(uint32_t flag, uint32_t audit): flag(flag), audit(audit) { } ++ MatchFlag(uint32_t flag, uint32_t audit): flag(flag), audit(audit) ++ { ++ type_flags |= NODE_TYPE_MATCHFLAG; ++ } + ostream &dump(ostream &os) { return os << "< 0x" << hex << flag << '>'; } + + uint32_t flag; +@@ -819,12 +897,18 @@ public: + + class ExactMatchFlag: public MatchFlag { + public: +- ExactMatchFlag(uint32_t flag, uint32_t audit): MatchFlag(flag, audit) {} ++ ExactMatchFlag(uint32_t flag, uint32_t audit): MatchFlag(flag, audit) ++ { ++ type_flags |= NODE_TYPE_EXACTMATCHFLAG; ++ } + }; + + class DenyMatchFlag: public MatchFlag { + public: +- DenyMatchFlag(uint32_t flag, uint32_t quiet): MatchFlag(flag, quiet) {} ++ DenyMatchFlag(uint32_t flag, uint32_t quiet): MatchFlag(flag, quiet) ++ { ++ type_flags |= NODE_TYPE_DENYMATCHFLAG; ++ } + }; + + /* Traverse the syntax tree depth-first in an iterator-like manner. */ +@@ -833,7 +917,7 @@ class depth_first_traversal { + void push_left(Node *node) { + pos.push(node); + +- while (dynamic_cast(node)) { ++ while (node->is_type(NODE_TYPE_INNER)) { + pos.push(node->child[0]); + node = node->child[0]; + } +diff --git a/parser/libapparmor_re/hfa.cc b/parser/libapparmor_re/hfa.cc +index 9cea4c3fc..e1ef1803b 100644 +--- a/parser/libapparmor_re/hfa.cc ++++ b/parser/libapparmor_re/hfa.cc +@@ -1352,17 +1352,18 @@ int accept_perms(NodeSet *state, perms_t &perms, bool filedfa) + return error; + + for (NodeSet::iterator i = state->begin(); i != state->end(); i++) { +- MatchFlag *match; +- if (!(match = dynamic_cast(*i))) ++ if (!(*i)->is_type(NODE_TYPE_MATCHFLAG)) + continue; +- if (dynamic_cast(match)) { ++ ++ MatchFlag *match = static_cast(*i); ++ if (match->is_type(NODE_TYPE_EXACTMATCHFLAG)) { + /* exact match only ever happens with x */ + if (filedfa && !is_merged_x_consistent(exact_match_allow, + match->flag)) + error = 1;; + exact_match_allow |= match->flag; + exact_audit |= match->audit; +- } else if (dynamic_cast(match)) { ++ } else if (match->is_type(NODE_TYPE_DENYMATCHFLAG)) { + perms.deny |= match->flag; + perms.quiet |= match->audit; + } else { +-- +2.34.1 diff --git a/build-aux/snap/snapcraft.yaml b/build-aux/snap/snapcraft.yaml new file mode 100644 index 00000000..c672db31 --- /dev/null +++ b/build-aux/snap/snapcraft.yaml @@ -0,0 +1,201 @@ +name: snapd +type: snapd +summary: Daemon and tooling that enable snap packages +description: | + Install, configure, refresh and remove snap packages. Snaps are + 'universal' packages that work across many different Linux systems, + enabling secure distribution of the latest apps and utilities for + cloud, servers, desktops and the internet of things. + + Start with 'snap list' to see installed snaps. +adopt-info: snapd-deb +# build-base is needed here for snapcraft to build this snap as with "modern" +# snapcraft +build-base: core +package-repositories: + - type: apt + ppa: snappy-dev/image +grade: stable +license: GPL-3.0 + +# Note that this snap is unusual in that it has no "apps" section. +# +# It is started via re-exec on classic systems and via special +# handling in the core18 snap on Ubuntu Core Systems. +# +# Because snapd itself manages snaps it must currently run totally +# unconfined (even devmode is not enough). +# +# See the comments from jdstrand in +# https://forum.snapcraft.io/t/5547/10 +parts: + snapd-deb: + plugin: nil + source: . + build-snaps: [go/1.18/stable] + # these packages are needed to call mkversion.sh in override-pull, all other + # dependencies are installed using apt-get build-dep + build-packages: + - git + - dpkg-dev + after: [apparmor] + override-pull: | + snapcraftctl pull + # set version, this needs dpkg-parsechangelog (from dpkg-dev) and git + snapcraftctl set-version "$(./mkversion.sh --output-only)" + # Ensure that ./debian/ packaging which we are about to use + # matches the current `build-base` release. I.e. ubuntu-16.04 + # for build-base:core, etc. + ./generate-packaging-dir + # install build dependencies + export DEBIAN_FRONTEND=noninteractive + export DEBCONF_NONINTERACTIVE_SEEN=true + sudo -E apt-get build-dep -y ./ + ./get-deps.sh --skip-unused-check + override-build: | + # unset the LD_FLAGS and LD_LIBRARY_PATH vars that snapcraft sets for us + # as those will point to the $SNAPCRAFT_STAGE which on re-builds will + # contain things like libc and friends that confuse the debian package + # build system + # TODO: should we unset $PATH to not include $SNAPCRAFT_STAGE too? + unset LD_FLAGS + unset LD_LIBRARY_PATH + # if we are root, disable tests because a number of them fail when run as + # root + if [ "$(id -u)" = "0" ]; then + DEB_BUILD_OPTIONS=nocheck + export DEB_BUILD_OPTIONS + fi + # run the real build (but just build the binary package, and don't + # bother compressing it too much) + dpkg-buildpackage -b -Zgzip -zfast -uc -us + dpkg-deb -x $(pwd)/../snapd_*.deb $SNAPCRAFT_PART_INSTALL + # not included in the deb as it's only used with UC20 preseeding. + cp -a data/preseed.json $SNAPCRAFT_PART_INSTALL/usr/lib/snapd/ + # Note that this check should run *after* dpkg-buildpackage was run + # as this will re-run "go generate" which may cause a dirty tree + # + # TODO: when something like "craftctl get-version" is ready, then we can + # use that, but until then, we have to re-run mkversion.sh to check if the + # version number was set as "dirty" from the override-pull step or during + # the build step + if sh -x ./mkversion.sh --output-only | grep "dirty"; then + mkdir -p $SNAPCRAFT_PART_INSTALL/usr/lib/snapd + ( + echo "dirty git tree during build detected:" + git status + git diff + ) > $SNAPCRAFT_PART_INSTALL/usr/lib/snapd/dirty-git-tree-info.txt + fi + # copy helper for collecting debug output + cp -av debug-tools/snap-debug-info.sh $SNAPCRAFT_PART_INSTALL/usr/lib/snapd/ + + # xdelta is used to enable delta downloads (even if the host does not have it) + xdelta3: + plugin: nil + stage-packages: + - xdelta3 + stage: + - usr/bin/* + - usr/lib/* + - lib/* + # squashfs-tools are used by `snap pack` + squashfs-tools: + plugin: nil + stage-packages: + - squashfs-tools + stage: + - usr/bin/* + - usr/lib/* + - lib/* + # liblzma5 is part of core but the snapd snap needs to run even without core + liblzma5: + plugin: nil + stage-packages: + - liblzma5 + stage: + - lib/* + # Needed by squashfs-tools + zlib: + plugin: nil + stage-packages: + - zlib1g + stage: + - lib/* + # Also needed by squashfs-tools + zstd: + plugin: nil + stage-packages: + - libzstd1 + stage: + - usr/lib/* + - lib/* + # libc6 is part of core but we need it in the snapd snap for + # CommandFromSystemSnap + libc6: + plugin: nil + stage-packages: + - libc6 + - libc-bin + - libgcc1 + stage: + - lib/* + - usr/lib/* + - lib64/* + - etc/ld.so.conf + - etc/ld.so.conf.d/* + override-stage: | + snapcraftctl stage + # fix symlinks of ld.so to be relative + if [ "$(readlink -f lib64/ld-linux-x86-64.so.2)" = "/lib/x86_64-linux-gnu/ld-2.23.so" ]; then + ln -f -s ../lib/x86_64-linux-gnu/ld-2.23.so lib64/ld-linux-x86-64.so.2 + fi + if [ "$(readlink -f lib64/ld64.so.2)" = "/lib/powerpc64le-linux-gnu/ld-2.23.so" ]; then + ln -f -s ../lib/powerpc64le-linux-gnu/ld-2.23.so lib64/ld64.so.2 + fi + apparmor: + plugin: autotools + build-packages: [bison, flex, gettext, g++, pkg-config, wget] + source: https://launchpad.net/apparmor/3.0/3.0.8/+download/apparmor-3.0.8.tar.gz + override-pull: | + snapcraftctl pull + # add support for mediating posix mqueue's and userns - these come from + # the ubuntu source package for lunar + # https://git.launchpad.net/ubuntu/+source/apparmor/tree/debian/patches/ubuntu?h=ubuntu/lunar + for feature in mqueue userns; do + wget https://git.launchpad.net/ubuntu/+source/apparmor/plain/debian/patches/ubuntu/add-${feature}-support.patch?h=ubuntu/lunar -O - | patch -p1 + done + # apply local apparmor patches + for patch in $SNAPCRAFT_PROJECT_DIR/build-aux/snap/patches/apparmor/*; do + patch -p1 < $patch + done + override-build: | + cd $SNAPCRAFT_PART_BUILD/libraries/libapparmor + ./autogen.sh + ./configure --prefix=/usr --disable-man-pages --disable-perl --disable-python --disable-ruby + make -j$(nproc) + # place libapparmor into staging area for use by snap-confine + make -C src install DESTDIR=$SNAPCRAFT_STAGE + cd $SNAPCRAFT_PART_BUILD/parser + # copy in a pregenerated list of network address families so that the + # parser gets built to support as many as possible even if glibc in + # the current build environment does not support them + # For some reason, some snapcraft version remove the "build-aux" folder + # and move the contents up when the data is uploaded; this conditional + # manages it. + if [ -d "$SNAPCRAFT_PROJECT_DIR/build-aux" ]; then + cp $SNAPCRAFT_PROJECT_DIR/build-aux/snap/local/apparmor/af_names.h . + else + cp $SNAPCRAFT_PROJECT_DIR/snap/local/apparmor/af_names.h . + fi + make -j$(nproc) + mkdir -p $SNAPCRAFT_PART_INSTALL/usr/lib/snapd + cp -a apparmor_parser $SNAPCRAFT_PART_INSTALL/usr/lib/snapd/ + mkdir -p $SNAPCRAFT_PART_INSTALL/usr/lib/snapd/apparmor + cp -a parser.conf $SNAPCRAFT_PART_INSTALL/usr/lib/snapd/apparmor/ + cd $SNAPCRAFT_PART_BUILD/profiles + make -j$(nproc) + mkdir -p $SNAPCRAFT_PART_INSTALL/usr/lib/snapd/apparmor.d + cp -a apparmor.d/abi $SNAPCRAFT_PART_INSTALL/usr/lib/snapd/apparmor.d/ + cp -a apparmor.d/abstractions $SNAPCRAFT_PART_INSTALL/usr/lib/snapd/apparmor.d/ + cp -a apparmor.d/tunables $SNAPCRAFT_PART_INSTALL/usr/lib/snapd/apparmor.d/ diff --git a/c-vendor/README b/c-vendor/README new file mode 100644 index 00000000..f41d6673 --- /dev/null +++ b/c-vendor/README @@ -0,0 +1,2 @@ +This directory contains the vendored C code - with `go mod` it is +not possible to vendor non-go directories anymore. diff --git a/c-vendor/vendor.sh b/c-vendor/vendor.sh new file mode 100755 index 00000000..4d4553b3 --- /dev/null +++ b/c-vendor/vendor.sh @@ -0,0 +1,31 @@ +#!/bin/sh + +set -e + +# XXX: provide a nice declarative format +# XXX2: reuse vendor.json and write some python3 code? +if [ ! -d ./squashfuse ]; then + git clone https://github.com/vasi/squashfuse +fi + +# This is the commit that was tagged as 0.5.0, released on September 2023: +# https://github.com/vasi/squashfuse/releases/tag/v0.5.0 +# It contains bug fixes and enables multithreading support to squashfuse_ll +# by default. +# It still should work with both "libfuse-dev" and "libfuse3-dev" which +# is important as 16.04 only has libfuse-dev and 21.10 only has libfuse3-dev +SQUASHFUSE_REF=3f4dd2928ab362f8b20eab2be864d8e622472df5 + +if [ -d ./squashfuse/.git ]; then + cd squashfuse + + # shellcheck disable=SC1083 + if ! git rev-parse --verify $SQUASHFUSE_REF^{commit}; then + # if the pinned commit isn't known, update the repo + git checkout master + git pull + fi + + git checkout "$SQUASHFUSE_REF" +fi + diff --git a/check-commit-email.py b/check-commit-email.py new file mode 100755 index 00000000..32e089a9 --- /dev/null +++ b/check-commit-email.py @@ -0,0 +1,66 @@ +#!/usr/bin/python3 + +# This script checks that the committer and author email addresses of commits +# are valid - this is important because we need to be able to import the git +# repo to launchpad and launchpad does not like git commits that are formatted +# badly. +# This can be run either on a merge commit in which case the commits it +# evaluates is just limited to the "other" branch (the one being merged into the +# destination) like we do in CI workflows. Or it can be run on a normal commit +# like a developer would do locally in which case this ends up checking all +# commits locally - some day that may grow to be unwieldy but for now seems ok. + +import os +import subprocess +from email.utils import parseaddr + + +def get_commit_range(): + # For CI, the head revision is a synthesised merge commit, + # merging the proposed branch into the destination branch. + # So the first parent is our destination, and the second is + # our proposal. + lines = subprocess.check_output( + ["git", "cat-file", "-p", "@"], text=True + ).splitlines() + parents = [ + line[len("parent ") :].strip() for line in lines if line.startswith("parent ") + ] + if len(parents) == 1: + # not a merge commit, so return nothing to use default git log behavior + # and check all commits + return "" + elif len(parents) == 2: + # merge commit so use "foo..bar" syntax to just check the proposed + # commits + dest, proposed = parents + return "{}..{}".format(dest, proposed) + else: + raise RuntimeError("expected two parents, but got {}".format(parents)) + + +if __name__ == "__main__": + if not os.path.exists('.git'): + exit(0) + commitrange = get_commit_range() + args = ["git", "log", "--format=format:%h,%ce%n%h,%ae"] + if commitrange != "": + args.append(commitrange) + for line in subprocess.check_output(args, text=True).split("\n"): + parsed = line.split(",", 1) + commithash = parsed[0] + potentialemail = parsed[1] + if potentialemail == "": + continue + name, addr = parseaddr(potentialemail) + if addr == "": + print( + "Found invalid email %s for commmit %s" % (potentialemail, commithash) + ) + exit(1) + if not addr.isascii(): + print( + "Found invalid non-ascii email %s for commmit %s" + % (potentialemail, commithash) + ) + exit(1) diff --git a/check-pr-title.py b/check-pr-title.py new file mode 100755 index 00000000..ae87fe72 --- /dev/null +++ b/check-pr-title.py @@ -0,0 +1,78 @@ +#!/usr/bin/python3 + +import argparse +import re +import urllib.request + +from html.parser import HTMLParser + + +class InvalidPRTitle(Exception): + def __init__(self, invalid_title): + self.invalid_title = invalid_title + + +class GithubTitleParser(HTMLParser): + def __init__(self): + HTMLParser.__init__(self) + self._cur_tag = "" + self.title = "" + + def handle_starttag(self, tag, attributes): + self._cur_tag = tag + + def handle_endtag(self, tag): + self._cur_tag = "" + + def handle_data(self, data): + if self._cur_tag == "title": + self.title = data + + +def check_pr_title(pr_number: int): + # ideally we would use the github API - however we can't because: + # a) its rate limiting and travis IPs hit the API a lot so we regularly + # get errors + # b) using a API token is tricky because travis will not allow the secure + # vars for forks + # so instead we just scrape the html title which is unlikely to change + # radically + parser = GithubTitleParser() + with urllib.request.urlopen( + "https://github.com/snapcore/snapd/pull/{}".format(pr_number) + ) as f: + parser.feed(f.read().decode("utf-8")) + # the title has the format: + # "Added api endpoint for downloading snaps by glower · Pull Request #6958 · snapcore/snapd · GitHub" + # so we rsplit() once to get the title (rsplit to not get confused by + # possible "by" words in the real title) + title = parser.title.rsplit(" by ", maxsplit=1)[0] + print(title) + # cover most common cases: + # package: foo + # package, otherpackage/subpackage: this is a title + # tests/regression/lp-12341234: foo + # [RFC] foo: bar + if not re.match(r"[a-zA-Z0-9_\-\*/,. \[\]{}]+: .*", title): + raise InvalidPRTitle(title) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument( + "pr_number", metavar="PR number", help="the github PR number to check" + ) + args = parser.parse_args() + try: + check_pr_title(args.pr_number) + except InvalidPRTitle as e: + print('Invalid PR title: "{}"\n'.format(e.invalid_title)) + print("Please provide a title in the following format:") + print("module: short description") + print("E.g.:") + print("daemon: fix frobinator bug") + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/client/aliases.go b/client/aliases.go new file mode 100644 index 00000000..bf45f4cf --- /dev/null +++ b/client/aliases.go @@ -0,0 +1,102 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client + +import ( + "bytes" + "encoding/json" +) + +// aliasAction represents an action performed on aliases. +// With action "unalias" if Snap and Alias are set to the same value, +// snapd will check if what is referred to is indeed a snap or an alias. +type aliasAction struct { + Action string `json:"action"` + Snap string `json:"snap,omitempty"` + App string `json:"app,omitempty"` + Alias string `json:"alias,omitempty"` +} + +// performAliasAction performs a single action on aliases. +func (client *Client) performAliasAction(sa *aliasAction) (changeID string, err error) { + b, err := json.Marshal(sa) + if err != nil { + return "", err + } + return client.doAsync("POST", "/v2/aliases", nil, nil, bytes.NewReader(b)) +} + +// Alias sets up a manual alias from alias to app in snapName. +func (client *Client) Alias(snapName, app, alias string) (changeID string, err error) { + return client.performAliasAction(&aliasAction{ + Action: "alias", + Snap: snapName, + App: app, + Alias: alias, + }) +} + +// // DisableAllAliases disables all aliases of a snap, removing all manual ones. +func (client *Client) DisableAllAliases(snapName string) (changeID string, err error) { + return client.performAliasAction(&aliasAction{ + Action: "unalias", + Snap: snapName, + }) +} + +// RemoveManualAlias removes a manual alias. +func (client *Client) RemoveManualAlias(alias string) (changeID string, err error) { + return client.performAliasAction(&aliasAction{ + Action: "unalias", + Alias: alias, + }) +} + +// Unalias tears down a manual alias or disables all aliases of a snap (removing all manual ones) +func (client *Client) Unalias(aliasOrSnap string) (changeID string, err error) { + return client.performAliasAction(&aliasAction{ + Action: "unalias", + Snap: aliasOrSnap, + Alias: aliasOrSnap, + }) +} + +// Prefer enables all aliases of a snap in preference to conflicting aliases +// of other snaps whose aliases will be disabled (removed for manual ones). +func (client *Client) Prefer(snapName string) (changeID string, err error) { + return client.performAliasAction(&aliasAction{ + Action: "prefer", + Snap: snapName, + }) +} + +// AliasStatus represents the status of an alias. +type AliasStatus struct { + Command string `json:"command"` + Status string `json:"status"` + Manual string `json:"manual,omitempty"` + Auto string `json:"auto,omitempty"` +} + +// Aliases returns a map snap -> alias -> AliasStatus for all snaps and aliases in the system. +func (client *Client) Aliases() (allStatuses map[string]map[string]AliasStatus, err error) { + _, err = client.doSync("GET", "/v2/aliases", nil, nil, nil, &allStatuses) + return +} diff --git a/client/aliases_test.go b/client/aliases_test.go new file mode 100644 index 00000000..f4646f10 --- /dev/null +++ b/client/aliases_test.go @@ -0,0 +1,200 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client_test + +import ( + "encoding/json" + + "gopkg.in/check.v1" + + "github.com/snapcore/snapd/client" +) + +func (cs *clientSuite) TestClientAliasCallsEndpoint(c *check.C) { + cs.cli.Alias("alias-snap", "cmd1", "alias1") + c.Check(cs.req.Method, check.Equals, "POST") + c.Check(cs.req.URL.Path, check.Equals, "/v2/aliases") +} + +func (cs *clientSuite) TestClientAlias(c *check.C) { + cs.status = 202 + cs.rsp = `{ + "type": "async", + "status-code": 202, + "result": { }, + "change": "chgid" + }` + id, err := cs.cli.Alias("alias-snap", "cmd1", "alias1") + c.Assert(err, check.IsNil) + c.Check(id, check.Equals, "chgid") + var body map[string]interface{} + decoder := json.NewDecoder(cs.req.Body) + err = decoder.Decode(&body) + c.Check(err, check.IsNil) + c.Check(body, check.DeepEquals, map[string]interface{}{ + "action": "alias", + "snap": "alias-snap", + "app": "cmd1", + "alias": "alias1", + }) +} + +func (cs *clientSuite) TestClientUnaliasCallsEndpoint(c *check.C) { + cs.cli.Unalias("alias1") + c.Check(cs.req.Method, check.Equals, "POST") + c.Check(cs.req.URL.Path, check.Equals, "/v2/aliases") +} + +func (cs *clientSuite) TestClientUnalias(c *check.C) { + cs.status = 202 + cs.rsp = `{ + "type": "async", + "status-code": 202, + "result": { }, + "change": "chgid" + }` + id, err := cs.cli.Unalias("alias1") + c.Assert(err, check.IsNil) + c.Check(id, check.Equals, "chgid") + var body map[string]interface{} + decoder := json.NewDecoder(cs.req.Body) + err = decoder.Decode(&body) + c.Check(err, check.IsNil) + c.Check(body, check.DeepEquals, map[string]interface{}{ + "action": "unalias", + "snap": "alias1", + "alias": "alias1", + }) +} + +func (cs *clientSuite) TestClientDisableAllAliasesCallsEndpoint(c *check.C) { + cs.cli.DisableAllAliases("some-snap") + c.Check(cs.req.Method, check.Equals, "POST") + c.Check(cs.req.URL.Path, check.Equals, "/v2/aliases") +} + +func (cs *clientSuite) TestClientDisableAllAliases(c *check.C) { + cs.status = 202 + cs.rsp = `{ + "type": "async", + "status-code": 202, + "result": { }, + "change": "chgid" + }` + id, err := cs.cli.DisableAllAliases("some-snap") + c.Assert(err, check.IsNil) + c.Check(id, check.Equals, "chgid") + var body map[string]interface{} + decoder := json.NewDecoder(cs.req.Body) + err = decoder.Decode(&body) + c.Check(err, check.IsNil) + c.Check(body, check.DeepEquals, map[string]interface{}{ + "action": "unalias", + "snap": "some-snap", + }) +} + +func (cs *clientSuite) TestClientRemoveManualAliasCallsEndpoint(c *check.C) { + cs.cli.RemoveManualAlias("alias1") + c.Check(cs.req.Method, check.Equals, "POST") + c.Check(cs.req.URL.Path, check.Equals, "/v2/aliases") +} + +func (cs *clientSuite) TestClientRemoveManualAlias(c *check.C) { + cs.status = 202 + cs.rsp = `{ + "type": "async", + "status-code": 202, + "result": { }, + "change": "chgid" + }` + id, err := cs.cli.RemoveManualAlias("alias1") + c.Assert(err, check.IsNil) + c.Check(id, check.Equals, "chgid") + var body map[string]interface{} + decoder := json.NewDecoder(cs.req.Body) + err = decoder.Decode(&body) + c.Check(err, check.IsNil) + c.Check(body, check.DeepEquals, map[string]interface{}{ + "action": "unalias", + "alias": "alias1", + }) +} + +func (cs *clientSuite) TestClientPreferCallsEndpoint(c *check.C) { + cs.cli.Prefer("some-snap") + c.Check(cs.req.Method, check.Equals, "POST") + c.Check(cs.req.URL.Path, check.Equals, "/v2/aliases") +} + +func (cs *clientSuite) TestClientPrefer(c *check.C) { + cs.status = 202 + cs.rsp = `{ + "type": "async", + "status-code": 202, + "result": { }, + "change": "chgid" + }` + id, err := cs.cli.Prefer("some-snap") + c.Assert(err, check.IsNil) + c.Check(id, check.Equals, "chgid") + var body map[string]interface{} + decoder := json.NewDecoder(cs.req.Body) + err = decoder.Decode(&body) + c.Check(err, check.IsNil) + c.Check(body, check.DeepEquals, map[string]interface{}{ + "action": "prefer", + "snap": "some-snap", + }) +} + +func (cs *clientSuite) TestClientAliasesCallsEndpoint(c *check.C) { + _, _ = cs.cli.Aliases() + c.Check(cs.req.Method, check.Equals, "GET") + c.Check(cs.req.URL.Path, check.Equals, "/v2/aliases") +} + +func (cs *clientSuite) TestClientAliases(c *check.C) { + cs.rsp = `{ + "type": "sync", + "result": { + "foo": { + "foo0": {"command": "foo", "status": "auto", "auto": "foo"}, + "foo_reset": {"command": "foo.reset", "manual": "reset", "status": "manual"} + }, + "bar": { + "bar_dump": {"command": "bar.dump", "status": "manual", "manual": "dump"}, + "bar_dump.1": {"command": "bar.dump", "status": "disabled", "auto": "dump"} + } + } + }` + allStatuses, err := cs.cli.Aliases() + c.Assert(err, check.IsNil) + c.Check(allStatuses, check.DeepEquals, map[string]map[string]client.AliasStatus{ + "foo": { + "foo0": {Command: "foo", Status: "auto", Auto: "foo"}, + "foo_reset": {Command: "foo.reset", Status: "manual", Manual: "reset"}, + }, + "bar": { + "bar_dump": {Command: "bar.dump", Status: "manual", Manual: "dump"}, + "bar_dump.1": {Command: "bar.dump", Status: "disabled", Auto: "dump"}, + }, + }) +} diff --git a/client/apps.go b/client/apps.go new file mode 100644 index 00000000..9611a064 --- /dev/null +++ b/client/apps.go @@ -0,0 +1,402 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2015-2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/url" + "os/user" + "strconv" + "strings" + "time" + + "github.com/snapcore/snapd/snap" +) + +// AppActivator is a thing that activates the app that is a service in the +// system. +type AppActivator struct { + Name string + // Type describes the type of the unit, either timer or socket + Type string + Active bool + Enabled bool +} + +// AppInfo describes a single snap application. +type AppInfo struct { + Snap string `json:"snap,omitempty"` + Name string `json:"name"` + DesktopFile string `json:"desktop-file,omitempty"` + Daemon string `json:"daemon,omitempty"` + DaemonScope snap.DaemonScope `json:"daemon-scope,omitempty"` + Enabled bool `json:"enabled,omitempty"` + Active bool `json:"active,omitempty"` + CommonID string `json:"common-id,omitempty"` + Activators []AppActivator `json:"activators,omitempty"` +} + +// IsService returns true if the application is a background daemon. +func (a *AppInfo) IsService() bool { + if a == nil { + return false + } + if a.Daemon == "" { + return false + } + + return true +} + +// AppOptions represent the options of the Apps call. +type AppOptions struct { + // If Service is true, only return apps that are services + // (app.IsService() is true); otherwise, return all. + Service bool + // Global if set, returns only the global status of the services. This + // is only relevant for user services, where we either return the status + // of the services for the current user, or the global enable status. + // For root-users, global is always implied. + Global bool +} + +// Apps returns information about all matching apps. Each name can be +// either a snap or a snap.app. If names is empty, list all (that +// satisfy opts). +func (client *Client) Apps(names []string, opts AppOptions) ([]*AppInfo, error) { + q := make(url.Values) + if len(names) > 0 { + q.Add("names", strings.Join(names, ",")) + } + if opts.Service { + q.Add("select", "service") + } + if opts.Global { + q.Add("global", fmt.Sprintf("%t", opts.Global)) + } + + var appInfos []*AppInfo + _, err := client.doSync("GET", "/v2/apps", q, nil, nil, &appInfos) + + return appInfos, err +} + +// LogOptions represent the options of the Logs call. +type LogOptions struct { + N int // The maximum number of log lines to retrieve initially. If <0, no limit. + Follow bool // Whether to continue returning new lines as they appear +} + +// A Log holds the information of a single syslog entry +type Log struct { + Timestamp time.Time `json:"timestamp"` // Timestamp of the event, in RFC3339 format to µs precision. + Message string `json:"message"` // The log message itself + SID string `json:"sid"` // The syslog identifier + PID string `json:"pid"` // The process identifier +} + +// String will format the log entry with the timestamp in the local timezone +func (l Log) String() string { + return l.fmtLog(time.Local) +} + +// StringInUTC will format the log entry with the timestamp in UTC +func (l Log) StringInUTC() string { + return l.fmtLog(time.UTC) +} + +func (l Log) fmtLog(timezone *time.Location) string { + if timezone == nil { + timezone = time.Local + } + + return fmt.Sprintf("%s %s[%s]: %s", l.Timestamp.In(timezone).Format(time.RFC3339), l.SID, l.PID, l.Message) +} + +// Logs asks for the logs of a series of services, by name. +func (client *Client) Logs(names []string, opts LogOptions) (<-chan Log, error) { + query := url.Values{} + if len(names) > 0 { + query.Set("names", strings.Join(names, ",")) + } + query.Set("n", strconv.Itoa(opts.N)) + if opts.Follow { + query.Set("follow", strconv.FormatBool(opts.Follow)) + } + + rsp, err := client.raw(context.Background(), "GET", "/v2/logs", query, nil, nil) + if err != nil { + return nil, err + } + + if rsp.StatusCode != 200 { + var r response + defer rsp.Body.Close() + if err := decodeInto(rsp.Body, &r); err != nil { + return nil, err + } + return nil, r.err(client, rsp.StatusCode) + } + + ch := make(chan Log, 20) + go func() { + // logs come in application/json-seq, described in RFC7464: it's + // a series of . Decoders are + // expected to skip invalid or truncated or empty records. + scanner := bufio.NewScanner(rsp.Body) + for scanner.Scan() { + buf := scanner.Bytes() // the scanner prunes the ending LF + if len(buf) < 1 { + // truncated record? skip + continue + } + idx := bytes.IndexByte(buf, 0x1E) // find the initial RS + if idx < 0 { + // no RS? skip + continue + } + buf = buf[idx+1:] // drop the initial RS + var log Log + if err := json.Unmarshal(buf, &log); err != nil { + // truncated/corrupted/binary record? skip + continue + } + ch <- log + } + close(ch) + rsp.Body.Close() + }() + + return ch, nil +} + +type UserSelection int + +const ( + UserSelectionList UserSelection = iota + UserSelectionSelf + UserSelectionAll +) + +// UserSelector is a support structure for correctly translating a way of +// representing both a list of user-names, and specific keywords like "self" +// and "all" for JSON marshalling. +// +// When "Selector == UserSelectionList" then Names is used as the data source and +// the data is treated like a list of strings. +// When "Selector == UserSelectionSelf|UserSelectionAll", then the data source will +// be a single string that represent this in the form of "self|all". +type UserSelector struct { + Names []string + Selector UserSelection +} + +// UserList returns a decoded list of users which takes any keyword into account. +// Takes the current user to be able to handle special keywords like 'user'. +func (us *UserSelector) UserList(currentUser *user.User) ([]string, error) { + switch us.Selector { + case UserSelectionList: + return us.Names, nil + case UserSelectionSelf: + if currentUser == nil { + return nil, fmt.Errorf(`internal error: for "self" the current user must be provided`) + } + if currentUser.Uid == "0" { + return nil, fmt.Errorf(`cannot use "self" for root user`) + } + return []string{currentUser.Username}, nil + case UserSelectionAll: + // Empty list indicates all. + return nil, nil + } + return nil, fmt.Errorf("internal error: unsupported selector %d specified", us.Selector) +} + +func (us UserSelector) MarshalJSON() ([]byte, error) { + switch us.Selector { + case UserSelectionList: + return json.Marshal(us.Names) + case UserSelectionSelf: + return json.Marshal("self") + case UserSelectionAll: + return json.Marshal("all") + default: + return nil, fmt.Errorf("internal error: unsupported selector %d specified", us.Selector) + } +} + +func (us *UserSelector) UnmarshalJSON(b []byte) error { + // Try treating it as a list of usernames first + var users []string + if err := json.Unmarshal(b, &users); err == nil { + us.Names = users + us.Selector = UserSelectionList + return nil + } + + // Fallback to string, which would indicate a keyword + var s string + if err := json.Unmarshal(b, &s); err != nil { + return fmt.Errorf("cannot unmarshal, expected a string or a list of strings") + } + + switch s { + case "self": + us.Selector = UserSelectionSelf + case "all": + us.Selector = UserSelectionAll + default: + return fmt.Errorf(`cannot unmarshal, expected one of: "self", "all"`) + } + return nil +} + +type ScopeSelector []string + +func (ss *ScopeSelector) UnmarshalJSON(b []byte) error { + var scopes []string + if err := json.Unmarshal(b, &scopes); err != nil { + return fmt.Errorf("cannot unmarshal, expected a list of strings") + } + + if len(scopes) > 2 { + return fmt.Errorf("unexpected number of scopes: %v", scopes) + } + + for _, s := range scopes { + switch s { + case "system", "user": + default: + return fmt.Errorf(`cannot unmarshal, expected one of: "system", "user"`) + } + } + *ss = scopes + return nil +} + +// ErrNoNames is returned by Start, Stop, or Restart, when the given +// list of things on which to operate is empty. +var ErrNoNames = errors.New(`"names" must not be empty`) + +type appInstruction struct { + Action string `json:"action"` + Names []string `json:"names"` + Scope ScopeSelector `json:"scope,omitempty"` + Users UserSelector `json:"users,omitempty"` + StartOptions + StopOptions + RestartOptions +} + +// StartOptions represent the different options of the Start call. +type StartOptions struct { + // Enable, as well as starting, the listed services. A + // disabled service does not start on boot. + Enable bool `json:"enable,omitempty"` +} + +// Start services. +// +// It takes a list of names that can be snaps, of which all their +// services are started, or snap.service which are individual +// services to start; it shouldn't be empty. +func (client *Client) Start(names []string, scope ScopeSelector, users UserSelector, opts StartOptions) (changeID string, err error) { + if len(names) == 0 { + return "", ErrNoNames + } + + buf, err := json.Marshal(appInstruction{ + Action: "start", + Names: names, + Scope: scope, + Users: users, + StartOptions: opts, + }) + if err != nil { + return "", err + } + return client.doAsync("POST", "/v2/apps", nil, nil, bytes.NewReader(buf)) +} + +// StopOptions represent the different options of the Stop call. +type StopOptions struct { + // Disable, as well as stopping, the listed services. A + // service that is not disabled starts on boot. + Disable bool `json:"disable,omitempty"` +} + +// Stop services. +// +// It takes a list of names that can be snaps, of which all their +// services are stopped, or snap.service which are individual +// services to stop; it shouldn't be empty. +func (client *Client) Stop(names []string, scope ScopeSelector, users UserSelector, opts StopOptions) (changeID string, err error) { + if len(names) == 0 { + return "", ErrNoNames + } + + buf, err := json.Marshal(appInstruction{ + Action: "stop", + Names: names, + Scope: scope, + Users: users, + StopOptions: opts, + }) + if err != nil { + return "", err + } + return client.doAsync("POST", "/v2/apps", nil, nil, bytes.NewReader(buf)) +} + +// RestartOptions represent the different options of the Restart call. +type RestartOptions struct { + // Reload the services, if possible (i.e. if the App has a + // ReloadCommand, invoque it), instead of restarting. + Reload bool `json:"reload,omitempty"` +} + +// Restart services. +// +// It takes a list of names that can be snaps, of which all their +// services are restarted, or snap.service which are individual +// services to restart; it shouldn't be empty. If the service is not +// running, starts it. +func (client *Client) Restart(names []string, scope ScopeSelector, users UserSelector, opts RestartOptions) (changeID string, err error) { + if len(names) == 0 { + return "", ErrNoNames + } + + buf, err := json.Marshal(appInstruction{ + Action: "restart", + Names: names, + Scope: scope, + Users: users, + RestartOptions: opts, + }) + if err != nil { + return "", err + } + return client.doAsync("POST", "/v2/apps", nil, nil, bytes.NewReader(buf)) +} diff --git a/client/apps_test.go b/client/apps_test.go new file mode 100644 index 00000000..baae65ea --- /dev/null +++ b/client/apps_test.go @@ -0,0 +1,681 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client_test + +import ( + "encoding/json" + "fmt" + "os/user" + "strconv" + "strings" + + "gopkg.in/check.v1" + + "github.com/snapcore/snapd/client" +) + +func mksvc(snap, app string) *client.AppInfo { + return &client.AppInfo{ + Snap: snap, + Name: app, + Daemon: "simple", + Active: true, + Enabled: true, + } + +} + +func testClientApps(cs *clientSuite, c *check.C) ([]*client.AppInfo, error) { + services, err := cs.cli.Apps([]string{"foo", "bar"}, client.AppOptions{}) + c.Check(cs.req.URL.Path, check.Equals, "/v2/apps") + c.Check(cs.req.Method, check.Equals, "GET") + query := cs.req.URL.Query() + c.Check(query, check.HasLen, 1) + c.Check(query.Get("names"), check.Equals, "foo,bar") + + return services, err +} + +func testClientAppsService(cs *clientSuite, c *check.C) ([]*client.AppInfo, error) { + services, err := cs.cli.Apps([]string{"foo", "bar"}, client.AppOptions{Service: true}) + c.Check(cs.req.URL.Path, check.Equals, "/v2/apps") + c.Check(cs.req.Method, check.Equals, "GET") + query := cs.req.URL.Query() + c.Check(query, check.HasLen, 2) + c.Check(query.Get("names"), check.Equals, "foo,bar") + c.Check(query.Get("select"), check.Equals, "service") + + return services, err +} + +func testClientAppsGlobal(cs *clientSuite, c *check.C) ([]*client.AppInfo, error) { + services, err := cs.cli.Apps([]string{"foo", "bar"}, client.AppOptions{Global: true}) + c.Check(cs.req.URL.Path, check.Equals, "/v2/apps") + c.Check(cs.req.Method, check.Equals, "GET") + query := cs.req.URL.Query() + c.Check(query, check.HasLen, 2) + c.Check(query.Get("names"), check.Equals, "foo,bar") + c.Check(query.Get("global"), check.Equals, "true") + + return services, err +} + +var appcheckers = []func(*clientSuite, *check.C) ([]*client.AppInfo, error){testClientApps, testClientAppsService, testClientAppsGlobal} + +func (cs *clientSuite) TestClientServiceGetHappy(c *check.C) { + expected := []*client.AppInfo{mksvc("foo", "foo"), mksvc("bar", "bar1")} + buf, err := json.Marshal(expected) + c.Assert(err, check.IsNil) + cs.rsp = fmt.Sprintf(`{"type": "sync", "result": %s}`, buf) + for _, chkr := range appcheckers { + actual, err := chkr(cs, c) + c.Assert(err, check.IsNil) + c.Check(actual, check.DeepEquals, expected) + } +} + +func (cs *clientSuite) TestClientServiceGetSad(c *check.C) { + cs.err = fmt.Errorf("xyzzy") + for _, chkr := range appcheckers { + actual, err := chkr(cs, c) + c.Assert(err, check.ErrorMatches, ".* xyzzy") + c.Check(actual, check.HasLen, 0) + } +} + +func (cs *clientSuite) TestClientAppCommonID(c *check.C) { + expected := []*client.AppInfo{{ + Snap: "foo", + Name: "foo", + CommonID: "org.foo", + }} + buf, err := json.Marshal(expected) + c.Assert(err, check.IsNil) + cs.rsp = fmt.Sprintf(`{"type": "sync", "result": %s}`, buf) + for _, chkr := range appcheckers { + actual, err := chkr(cs, c) + c.Assert(err, check.IsNil) + c.Check(actual, check.DeepEquals, expected) + } +} + +func testClientLogs(cs *clientSuite, c *check.C) ([]client.Log, error) { + ch, err := cs.cli.Logs([]string{"foo", "bar"}, client.LogOptions{N: -1, Follow: false}) + c.Check(cs.req.URL.Path, check.Equals, "/v2/logs") + c.Check(cs.req.Method, check.Equals, "GET") + + // logs cannot have a deadline because of "-f" + _, ok := cs.req.Context().Deadline() + c.Check(ok, check.Equals, false) + + query := cs.req.URL.Query() + c.Check(query, check.HasLen, 2) + c.Check(query.Get("names"), check.Equals, "foo,bar") + c.Check(query.Get("n"), check.Equals, "-1") + + var logs []client.Log + if ch != nil { + for log := range ch { + logs = append(logs, log) + } + } + + return logs, err +} + +func (cs *clientSuite) TestClientLogsHappy(c *check.C) { + cs.rsp = ` +{"message":"hello"} +{"message":"bye"} +`[1:] // remove the first \n + + logs, err := testClientLogs(cs, c) + c.Assert(err, check.IsNil) + c.Check(logs, check.DeepEquals, []client.Log{{Message: "hello"}, {Message: "bye"}}) +} + +func (cs *clientSuite) TestClientLogsDealsWithIt(c *check.C) { + cs.rsp = `this is a line with no RS on it +this is a line with a RS after some junk{"message": "hello"} +{"message": "bye"} +and that was a regular line. The next one is empty, despite having a RS (and the one after is entirely empty): + + +` + logs, err := testClientLogs(cs, c) + c.Assert(err, check.IsNil) + c.Check(logs, check.DeepEquals, []client.Log{{Message: "hello"}, {Message: "bye"}}) +} + +func (cs *clientSuite) TestClientLogsSad(c *check.C) { + cs.err = fmt.Errorf("xyzzy") + actual, err := testClientLogs(cs, c) + c.Assert(err, check.ErrorMatches, ".* xyzzy") + c.Check(actual, check.HasLen, 0) +} + +func (cs *clientSuite) TestClientLogsOpts(c *check.C) { + const ( + maxint = int((^uint(0)) >> 1) + minint = -maxint - 1 + ) + for _, names := range [][]string{nil, {}, {"foo"}, {"foo", "bar"}} { + for _, n := range []int{-1, 0, 1, minint, maxint} { + for _, follow := range []bool{true, false} { + iterdesc := check.Commentf("names: %v, n: %v, follow: %v", names, n, follow) + + ch, err := cs.cli.Logs(names, client.LogOptions{N: n, Follow: follow}) + c.Check(err, check.IsNil, iterdesc) + c.Check(cs.req.URL.Path, check.Equals, "/v2/logs", iterdesc) + c.Check(cs.req.Method, check.Equals, "GET", iterdesc) + query := cs.req.URL.Query() + numQ := 0 + + var namesout []string + if ns := query.Get("names"); ns != "" { + namesout = strings.Split(ns, ",") + } + + c.Check(len(namesout), check.Equals, len(names), iterdesc) + if len(names) != 0 { + c.Check(namesout, check.DeepEquals, names, iterdesc) + numQ++ + } + + nout, nerr := strconv.Atoi(query.Get("n")) + c.Check(nerr, check.IsNil, iterdesc) + c.Check(nout, check.Equals, n, iterdesc) + numQ++ + + if follow { + fout, ferr := strconv.ParseBool(query.Get("follow")) + c.Check(fout, check.Equals, true, iterdesc) + c.Check(ferr, check.IsNil, iterdesc) + numQ++ + } + + c.Check(query, check.HasLen, numQ, iterdesc) + + for x := range ch { + c.Logf("expecting empty channel, got %v during %s", x, iterdesc) + c.Fail() + } + } + } + } +} + +func (cs *clientSuite) TestClientLogsNotFound(c *check.C) { + cs.rsp = `{"type":"error","status-code":404,"status":"Not Found","result":{"message":"snap \"foo\" not found","kind":"snap-not-found","value":"foo"}}` + cs.status = 404 + actual, err := testClientLogs(cs, c) + c.Assert(err, check.ErrorMatches, `snap "foo" not found`) + c.Check(actual, check.HasLen, 0) +} + +func (cs *clientSuite) checkCommonFields(c *check.C, reqOp map[string]interface{}, names []string, scope client.ScopeSelector, users client.UserSelector, comment check.CommentInterface) { + inames := make([]interface{}, len(names)) + for i, name := range names { + inames[i] = interface{}(name) + } + + c.Check(reqOp["names"], check.DeepEquals, inames, comment) + if len(scope) > 0 { + snames := make([]interface{}, len(scope)) + for i, scope := range scope { + snames[i] = interface{}(scope) + } + c.Check(reqOp["scope"], check.DeepEquals, snames, comment) + } else { + c.Check(reqOp["scope"], check.IsNil, comment) + } + switch users.Selector { + case client.UserSelectionSelf: + c.Check(reqOp["users"], check.Equals, `self`, comment) + case client.UserSelectionAll: + c.Check(reqOp["users"], check.Equals, `all`, comment) + case client.UserSelectionList: + if len(users.Names) > 0 { + unames := make([]interface{}, len(users.Names)) + for i, u := range users.Names { + unames[i] = interface{}(u) + } + c.Check(reqOp["users"], check.DeepEquals, unames, comment) + } else { + c.Check(reqOp["users"], check.IsNil, comment) + } + } +} + +func (cs *clientSuite) TestClientServiceStart(c *check.C) { + cs.status = 202 + cs.rsp = `{"type": "async", "status-code": 202, "change": "24"}` + + tests := []struct { + names []string + scope client.ScopeSelector + users client.UserSelector + opts client.StartOptions + }{ + {}, + { + opts: client.StartOptions{ + Enable: true, + }, + }, + { + names: []string{"foo"}, + }, + { + names: []string{"foo"}, + opts: client.StartOptions{ + Enable: true, + }, + }, + { + names: []string{"foo", "bar", "baz"}, + }, + { + names: []string{"foo", "bar", "baz"}, + opts: client.StartOptions{ + Enable: true, + }, + }, + { + names: []string{"foo"}, + scope: []string{"user"}, + }, + { + names: []string{"foo"}, + scope: []string{"system"}, + }, + { + names: []string{"foo"}, + scope: []string{"system", "user"}, + }, + { + names: []string{"foo"}, + users: client.UserSelector{ + Selector: client.UserSelectionSelf, + }, + }, + { + names: []string{"foo"}, + users: client.UserSelector{ + Selector: client.UserSelectionAll, + }, + }, + } + + for _, sc := range tests { + comment := check.Commentf("{%q; %q; %q; %#v}", sc.names, sc.scope, sc.users, sc.opts) + id, err := cs.cli.Start(sc.names, sc.scope, sc.users, sc.opts) + if len(sc.names) == 0 { + c.Check(id, check.Equals, "", comment) + c.Check(err, check.Equals, client.ErrNoNames, comment) + c.Check(cs.req, check.IsNil, comment) // i.e. the request was never done + } else { + c.Assert(err, check.IsNil, comment) + c.Check(id, check.Equals, "24", comment) + c.Check(cs.req.URL.Path, check.Equals, "/v2/apps", comment) + c.Check(cs.req.Method, check.Equals, "POST", comment) + c.Check(cs.req.URL.Query(), check.HasLen, 0, comment) + + var reqOp map[string]interface{} + c.Assert(json.NewDecoder(cs.req.Body).Decode(&reqOp), check.IsNil, comment) + c.Check(reqOp["action"], check.Equals, "start", comment) + cs.checkCommonFields(c, reqOp, sc.names, sc.scope, sc.users, comment) + if sc.opts.Enable { + c.Check(reqOp["enable"], check.Equals, true, comment) + } else { + c.Check(reqOp["enable"], check.IsNil, comment) + } + } + } +} + +func (cs *clientSuite) TestClientServiceStop(c *check.C) { + cs.status = 202 + cs.rsp = `{"type": "async", "status-code": 202, "change": "24"}` + + tests := []struct { + names []string + scope client.ScopeSelector + users client.UserSelector + opts client.StopOptions + }{ + {}, + { + opts: client.StopOptions{ + Disable: true, + }, + }, + { + names: []string{"foo"}, + }, + { + names: []string{"foo"}, + opts: client.StopOptions{ + Disable: true, + }, + }, + { + names: []string{"foo", "bar", "baz"}, + }, + { + names: []string{"foo", "bar", "baz"}, + opts: client.StopOptions{ + Disable: true, + }, + }, + { + names: []string{"foo"}, + scope: []string{"user"}, + }, + { + names: []string{"foo"}, + scope: []string{"system"}, + }, + { + names: []string{"foo"}, + scope: []string{"system", "user"}, + }, + { + names: []string{"foo"}, + users: client.UserSelector{ + Selector: client.UserSelectionSelf, + }, + }, + { + names: []string{"foo"}, + users: client.UserSelector{ + Selector: client.UserSelectionAll, + }, + }, + } + + for _, sc := range tests { + comment := check.Commentf("{%q; %q; %q; %#v}", sc.names, sc.scope, sc.users, sc.opts) + id, err := cs.cli.Stop(sc.names, sc.scope, sc.users, sc.opts) + if len(sc.names) == 0 { + c.Check(id, check.Equals, "", comment) + c.Check(err, check.Equals, client.ErrNoNames, comment) + c.Check(cs.req, check.IsNil, comment) // i.e. the request was never done + } else { + c.Assert(err, check.IsNil, comment) + c.Check(id, check.Equals, "24", comment) + c.Check(cs.req.URL.Path, check.Equals, "/v2/apps", comment) + c.Check(cs.req.Method, check.Equals, "POST", comment) + c.Check(cs.req.URL.Query(), check.HasLen, 0, comment) + + var reqOp map[string]interface{} + c.Assert(json.NewDecoder(cs.req.Body).Decode(&reqOp), check.IsNil, comment) + c.Check(reqOp["action"], check.Equals, "stop", comment) + cs.checkCommonFields(c, reqOp, sc.names, sc.scope, sc.users, comment) + if sc.opts.Disable { + c.Check(reqOp["disable"], check.Equals, true, comment) + } else { + c.Check(reqOp["disable"], check.IsNil, comment) + } + } + } +} + +func (cs *clientSuite) TestClientServiceRestart(c *check.C) { + cs.status = 202 + cs.rsp = `{"type": "async", "status-code": 202, "change": "24"}` + + tests := []struct { + names []string + scope client.ScopeSelector + users client.UserSelector + opts client.RestartOptions + }{ + {}, + { + opts: client.RestartOptions{ + Reload: true, + }, + }, + { + names: []string{"foo"}, + }, + { + names: []string{"foo"}, + opts: client.RestartOptions{ + Reload: true, + }, + }, + { + names: []string{"foo", "bar", "baz"}, + }, + { + names: []string{"foo", "bar", "baz"}, + opts: client.RestartOptions{ + Reload: true, + }, + }, + { + names: []string{"foo"}, + scope: []string{"user"}, + }, + { + names: []string{"foo"}, + scope: []string{"system"}, + }, + { + names: []string{"foo"}, + scope: []string{"system", "user"}, + }, + { + names: []string{"foo"}, + users: client.UserSelector{ + Selector: client.UserSelectionSelf, + }, + }, + { + names: []string{"foo"}, + users: client.UserSelector{ + Selector: client.UserSelectionAll, + }, + }, + } + + for _, sc := range tests { + comment := check.Commentf("{%q; %q; %q; %#v}", sc.names, sc.scope, sc.users, sc.opts) + id, err := cs.cli.Restart(sc.names, sc.scope, sc.users, sc.opts) + if len(sc.names) == 0 { + c.Check(id, check.Equals, "", comment) + c.Check(err, check.Equals, client.ErrNoNames, comment) + c.Check(cs.req, check.IsNil, comment) // i.e. the request was never done + } else { + c.Assert(err, check.IsNil, comment) + c.Check(id, check.Equals, "24", comment) + c.Check(cs.req.URL.Path, check.Equals, "/v2/apps", comment) + c.Check(cs.req.Method, check.Equals, "POST", comment) + c.Check(cs.req.URL.Query(), check.HasLen, 0, comment) + + var reqOp map[string]interface{} + c.Assert(json.NewDecoder(cs.req.Body).Decode(&reqOp), check.IsNil, comment) + cs.checkCommonFields(c, reqOp, sc.names, sc.scope, sc.users, comment) + c.Check(reqOp["action"], check.Equals, "restart", comment) + if sc.opts.Reload { + c.Check(reqOp["reload"], check.Equals, true, comment) + } else { + c.Check(reqOp["reload"], check.IsNil, comment) + } + } + } +} + +type userSelectorSuite struct{} + +var _ = check.Suite(&userSelectorSuite{}) + +func (s *userSelectorSuite) TestUserScopeMarshalListOfUsernames(c *check.C) { + us := client.UserSelector{ + Names: []string{"user", "user-two"}, + } + b, err := json.Marshal(us) + c.Assert(err, check.IsNil) + c.Check(string(b), check.Equals, `["user","user-two"]`) +} + +func (s *userSelectorSuite) TestUserScopeMarshalStringKeyword(c *check.C) { + us := client.UserSelector{ + Selector: client.UserSelectionSelf, + } + b, err := json.Marshal(us) + c.Assert(err, check.IsNil) + c.Check(string(b), check.Equals, `"self"`) +} + +func (s *userSelectorSuite) TestUserScopeMarshalInvalidSelector(c *check.C) { + us := client.UserSelector{ + Selector: 42, + } + _, err := json.Marshal(us) + c.Assert(err, check.ErrorMatches, `.* internal error: unsupported selector 42 specified`) +} + +func (s *userSelectorSuite) TestUserScopeUnmarshalInvalidType(c *check.C) { + const userScopeJson = `1` + var us client.UserSelector + err := json.Unmarshal([]byte(userScopeJson), &us) + c.Assert(err, check.ErrorMatches, `cannot unmarshal, expected a string or a list of strings`) +} + +func (s *userSelectorSuite) TestUserScopeUnmarshalListOfUsernames(c *check.C) { + const userScopeJson = `["my-user","other-user"]` + var us client.UserSelector + err := json.Unmarshal([]byte(userScopeJson), &us) + c.Assert(err, check.IsNil) + c.Check(us, check.DeepEquals, client.UserSelector{ + Names: []string{"my-user", "other-user"}, + }) +} + +func (s *userSelectorSuite) TestUserScopeUnmarshalStringKeyword(c *check.C) { + const userScopeJson = `"all"` + var us client.UserSelector + err := json.Unmarshal([]byte(userScopeJson), &us) + c.Assert(err, check.IsNil) + c.Check(us, check.DeepEquals, client.UserSelector{ + Selector: client.UserSelectionAll, + }) +} + +func (s *userSelectorSuite) TestUserListCurrentUser(c *check.C) { + us := client.UserSelector{ + Selector: client.UserSelectionSelf, + } + + users, err := us.UserList(&user.User{ + Uid: "1000", + Username: "my-user", + }) + c.Assert(err, check.IsNil) + c.Check(users, check.DeepEquals, []string{"my-user"}) +} + +func (s *userSelectorSuite) TestUserListCurrentUserInvalidNil(c *check.C) { + us := client.UserSelector{ + Selector: client.UserSelectionSelf, + } + + users, err := us.UserList(nil) + c.Assert(err, check.ErrorMatches, `internal error: for "self" the current user must be provided`) + c.Check(users, check.IsNil) +} + +func (s *userSelectorSuite) TestUserListCurrentUserNotValidForRoot(c *check.C) { + us := client.UserSelector{ + Selector: client.UserSelectionSelf, + } + + users, err := us.UserList(&user.User{ + Uid: "0", + Username: "my-user", + }) + c.Assert(err, check.ErrorMatches, `cannot use "self" for root user`) + c.Check(users, check.IsNil) +} + +func (s *userSelectorSuite) TestUserListInvalidSelector(c *check.C) { + us := client.UserSelector{ + Selector: 42, + } + + users, err := us.UserList(nil) + c.Assert(err, check.ErrorMatches, `internal error: unsupported selector 42 specified`) + c.Check(users, check.IsNil) +} + +func (s *userSelectorSuite) TestUserListUsersReturnsEmpty(c *check.C) { + us := client.UserSelector{ + Selector: client.UserSelectionAll, + } + + users, err := us.UserList(nil) + c.Assert(err, check.IsNil) + c.Check(users, check.IsNil) +} + +type scopeSelectorSuite struct{} + +var _ = check.Suite(&scopeSelectorSuite{}) + +func (s *scopeSelectorSuite) TestScopeUnmarshalInvalidType(c *check.C) { + const userScopeJson = `1` + var us client.ScopeSelector + err := json.Unmarshal([]byte(userScopeJson), &us) + c.Assert(err, check.ErrorMatches, `cannot unmarshal, expected a list of strings`) +} + +func (s *scopeSelectorSuite) TestScopeUnmarshalInvalidKeyword(c *check.C) { + const userScopeJson = `["all"]` + var us client.ScopeSelector + err := json.Unmarshal([]byte(userScopeJson), &us) + c.Assert(err, check.ErrorMatches, `cannot unmarshal, expected one of: "system", "user"`) +} + +func (s *scopeSelectorSuite) TestScopeUnmarshalNone(c *check.C) { + const userScopeJson = `[]` + var us client.ScopeSelector + err := json.Unmarshal([]byte(userScopeJson), &us) + c.Assert(err, check.IsNil) + c.Check(us, check.DeepEquals, client.ScopeSelector{}) +} + +func (s *scopeSelectorSuite) TestScopeUnmarshalSystem(c *check.C) { + const userScopeJson = `["system"]` + var us client.ScopeSelector + err := json.Unmarshal([]byte(userScopeJson), &us) + c.Assert(err, check.IsNil) + c.Check(us, check.DeepEquals, client.ScopeSelector{"system"}) +} + +func (s *scopeSelectorSuite) TestScopeUnmarshalUser(c *check.C) { + const userScopeJson = `["user"]` + var us client.ScopeSelector + err := json.Unmarshal([]byte(userScopeJson), &us) + c.Assert(err, check.IsNil) + c.Check(us, check.DeepEquals, client.ScopeSelector{"user"}) +} diff --git a/client/aspects.go b/client/aspects.go new file mode 100644 index 00000000..f080e5a2 --- /dev/null +++ b/client/aspects.go @@ -0,0 +1,54 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2024 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client + +import ( + "bytes" + "encoding/json" + "fmt" + "net/url" + "strings" +) + +func (c *Client) AspectGet(aspectID string, requests []string) (result map[string]interface{}, err error) { + query := url.Values{} + query.Add("fields", strings.Join(requests, ",")) + + endpoint := fmt.Sprintf("/v2/aspects/%s", aspectID) + _, err = c.doSync("GET", endpoint, query, nil, nil, &result) + if err != nil { + return nil, err + } + + return result, nil +} + +func (c *Client) AspectSet(aspectID string, requestValues map[string]interface{}) (changeID string, err error) { + body, err := json.Marshal(requestValues) + if err != nil { + return "", err + } + + headers := make(map[string]string) + headers["Content-Type"] = "application/json" + + endpoint := fmt.Sprintf("/v2/aspects/%s", aspectID) + return c.doAsync("PUT", endpoint, nil, headers, bytes.NewReader(body)) +} diff --git a/client/aspects_test.go b/client/aspects_test.go new file mode 100644 index 00000000..270c07f0 --- /dev/null +++ b/client/aspects_test.go @@ -0,0 +1,61 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2024 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client_test + +import ( + "encoding/json" + "io" + "net/url" + + . "gopkg.in/check.v1" +) + +func (cs *clientSuite) TestAspectGet(c *C) { + cs.rsp = `{"type": "sync", "result":{"foo":"baz","bar":1}}` + + res, err := cs.cli.AspectGet("a/b/c", []string{"foo", "bar"}) + c.Check(err, IsNil) + c.Check(res, DeepEquals, map[string]interface{}{"foo": "baz", "bar": json.Number("1")}) + c.Assert(cs.reqs, HasLen, 1) + c.Check(cs.reqs[0].Method, Equals, "GET") + c.Check(cs.reqs[0].URL.Path, Equals, "/v2/aspects/a/b/c") + c.Check(cs.reqs[0].URL.Query(), DeepEquals, url.Values{"fields": []string{"foo,bar"}}) +} + +func (cs *clientSuite) TestAspectSet(c *C) { + cs.status = 202 + cs.rsp = `{"type": "async", "status-code": 202, "change": "123"}` + + chgID, err := cs.cli.AspectSet("a/b/c", map[string]interface{}{"foo": "bar", "baz": json.Number("1")}) + c.Check(err, IsNil) + c.Check(chgID, Equals, "123") + c.Assert(cs.reqs, HasLen, 1) + c.Check(cs.reqs[0].Method, Equals, "PUT") + c.Check(cs.reqs[0].Header.Get("Content-Type"), Equals, "application/json") + c.Check(cs.reqs[0].URL.Path, Equals, "/v2/aspects/a/b/c") + data, err := io.ReadAll(cs.reqs[0].Body) + c.Assert(err, IsNil) + + // need to decode because entries may have been encoded in any order + res := make(map[string]interface{}) + err = json.Unmarshal(data, &res) + c.Assert(err, IsNil) + c.Check(res, DeepEquals, map[string]interface{}{"foo": "bar", "baz": float64(1)}) +} diff --git a/client/asserts.go b/client/asserts.go new file mode 100644 index 00000000..88186f61 --- /dev/null +++ b/client/asserts.go @@ -0,0 +1,153 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client + +import ( + "bytes" + "context" + "fmt" + "io" + "net/url" + "strconv" + + "golang.org/x/xerrors" + + // for parsing + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/snap" +) + +// Ack tries to add an assertion to the system assertion +// database. To succeed the assertion must be valid, its signature +// verified with a known public key and the assertion consistent with +// and its prerequisite in the database. +func (client *Client) Ack(b []byte) error { + var rsp interface{} + if _, err := client.doSync("POST", "/v2/assertions", nil, nil, bytes.NewReader(b), &rsp); err != nil { + return err + } + + return nil +} + +// AssertionTypes returns a list of assertion type names. +func (client *Client) AssertionTypes() ([]string, error) { + var types struct { + Types []string `json:"types"` + } + _, err := client.doSync("GET", "/v2/assertions", nil, nil, nil, &types) + if err != nil { + fmt := "cannot get assertion type names: %w" + return nil, xerrors.Errorf(fmt, err) + } + + return types.Types, nil +} + +// KnownOptions represent the options of the Known call. +type KnownOptions struct { + // If Remote is true, the store is queried to find the assertion + Remote bool +} + +// Known queries assertions with type assertTypeName and matching assertion headers. +func (client *Client) Known(assertTypeName string, headers map[string]string, opts *KnownOptions) ([]asserts.Assertion, error) { + if opts == nil { + opts = &KnownOptions{} + } + + path := fmt.Sprintf("/v2/assertions/%s", assertTypeName) + q := url.Values{} + + if len(headers) > 0 { + for k, v := range headers { + q.Set(k, v) + } + } + if opts.Remote { + q.Set("remote", "true") + } + + response, cancel, err := client.rawWithTimeout(context.Background(), "GET", path, q, nil, nil, nil) + if err != nil { + fmt := "failed to query assertions: %w" + return nil, xerrors.Errorf(fmt, err) + } + defer cancel() + defer response.Body.Close() + if response.StatusCode != 200 { + return nil, parseError(response) + } + + assertionsCount, err := strconv.Atoi(response.Header.Get("X-Ubuntu-Assertions-Count")) + if err != nil { + return nil, fmt.Errorf("invalid assertions count") + } + + dec := asserts.NewDecoder(response.Body) + + asserts := []asserts.Assertion{} + + // TODO: make sure asserts can decode and deal with unknown types + for { + a, err := dec.Decode() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("failed to decode assertions: %v", err) + } + asserts = append(asserts, a) + } + + if len(asserts) != assertionsCount { + return nil, fmt.Errorf("response did not have the expected number of assertions") + } + + return asserts, nil +} + +// StoreAccount returns the full store account info for the specified accountID +func (client *Client) StoreAccount(accountID string) (*snap.StoreAccount, error) { + assertions, err := client.Known("account", map[string]string{"account-id": accountID}, nil) + if err != nil { + return nil, err + } + switch len(assertions) { + case 1: + // happy case, break out of the switch + case 0: + return nil, fmt.Errorf("no assertion found for account-id %s", accountID) + default: + // unknown how this could happen... + return nil, fmt.Errorf("multiple assertions for account-id %s", accountID) + } + + acct, ok := assertions[0].(*asserts.Account) + if !ok { + return nil, fmt.Errorf("incorrect type of account assertion returned") + } + return &snap.StoreAccount{ + ID: acct.AccountID(), + Username: acct.Username(), + DisplayName: acct.DisplayName(), + Validation: acct.Validation(), + }, nil +} diff --git a/client/asserts_test.go b/client/asserts_test.go new file mode 100644 index 00000000..1e532ae1 --- /dev/null +++ b/client/asserts_test.go @@ -0,0 +1,238 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client_test + +import ( + "errors" + "io" + "net/http" + "net/url" + + "golang.org/x/xerrors" + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/client" + "github.com/snapcore/snapd/snap" +) + +func (cs *clientSuite) TestClientAssert(c *C) { + cs.rsp = `{ + "type": "sync", + "result": {} + }` + a := []byte("Assertion.") + err := cs.cli.Ack(a) + c.Assert(err, IsNil) + body, err := io.ReadAll(cs.req.Body) + c.Assert(err, IsNil) + c.Check(body, DeepEquals, a) + c.Check(cs.req.Method, Equals, "POST") + c.Check(cs.req.URL.Path, Equals, "/v2/assertions") +} + +func (cs *clientSuite) TestClientAssertsTypes(c *C) { + cs.rsp = `{ + "result": { + "types": ["one", "two"] + }, + "status": "OK", + "status-code": 200, + "type": "sync" +}` + typs, err := cs.cli.AssertionTypes() + c.Assert(err, IsNil) + c.Check(typs, DeepEquals, []string{"one", "two"}) +} + +func (cs *clientSuite) TestClientAssertsCallsEndpoint(c *C) { + _, _ = cs.cli.Known("snap-revision", nil, nil) + c.Check(cs.req.Method, Equals, "GET") + c.Check(cs.req.URL.Path, Equals, "/v2/assertions/snap-revision") +} + +func (cs *clientSuite) TestClientAssertsOptsCallsEndpoint(c *C) { + _, _ = cs.cli.Known("snap-revision", nil, &client.KnownOptions{Remote: true}) + c.Check(cs.req.Method, Equals, "GET") + c.Check(cs.req.URL.Path, Equals, "/v2/assertions/snap-revision") + c.Check(cs.req.URL.Query()["remote"], DeepEquals, []string{"true"}) +} + +func (cs *clientSuite) TestClientAssertsCallsEndpointWithFilter(c *C) { + _, _ = cs.cli.Known("snap-revision", map[string]string{ + "snap-id": "snap-id-1", + "snap-sha3-384": "sha3-384...", + }, nil) + u, err := url.ParseRequestURI(cs.req.URL.String()) + c.Assert(err, IsNil) + c.Check(u.Path, Equals, "/v2/assertions/snap-revision") + c.Check(u.Query(), DeepEquals, url.Values{ + "snap-sha3-384": []string{"sha3-384..."}, + "snap-id": []string{"snap-id-1"}, + }) +} + +func (cs *clientSuite) TestClientAssertsHttpError(c *C) { + cs.err = errors.New("fail") + _, err := cs.cli.Known("snap-build", nil, nil) + c.Assert(err, ErrorMatches, "failed to query assertions: cannot communicate with server: fail") +} + +func (cs *clientSuite) TestClientAssertsJSONError(c *C) { + cs.status = 400 + cs.header = http.Header{} + cs.header.Add("Content-type", "application/json") + cs.rsp = `{ + "status-code": 400, + "type": "error", + "result": { + "message": "invalid" + } + }` + _, err := cs.cli.Known("snap-build", nil, nil) + c.Assert(err, ErrorMatches, "invalid") +} + +func (cs *clientSuite) TestClientAsserts(c *C) { + cs.header = http.Header{} + cs.header.Add("X-Ubuntu-Assertions-Count", "2") + cs.rsp = `type: snap-revision +authority-id: store-id1 +snap-sha3-384: P1wNUk5O_5tO5spqOLlqUuAk7gkNYezIMHp5N9hMUg1a6YEjNeaCc4T0BaYz7IWs +snap-id: snap-id-1 +snap-size: 123 +snap-revision: 1 +developer-id: dev-id1 +revision: 1 +timestamp: 2015-11-25T20:00:00Z +body-length: 0 +sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij + +openpgp ... + +type: snap-revision +authority-id: store-id1 +snap-sha3-384: 0Yt6-GXQeTZWUAHo1IKDpS9kqO6zMaizY6vGEfGM-aSfpghPKir1Ic7teQ5Zadaj +snap-id: snap-id-2 +snap-size: 456 +snap-revision: 1 +developer-id: dev-id1 +revision: 1 +timestamp: 2015-11-30T20:00:00Z +body-length: 0 +sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij + +openpgp ... +` + + a, err := cs.cli.Known("snap-revision", nil, nil) + c.Assert(err, IsNil) + c.Check(a, HasLen, 2) + + c.Check(a[0].Type(), Equals, asserts.SnapRevisionType) +} + +func (cs *clientSuite) TestClientAssertsNoAssertions(c *C) { + cs.header = http.Header{} + cs.header.Add("X-Ubuntu-Assertions-Count", "0") + cs.rsp = "" + cs.status = 200 + a, err := cs.cli.Known("snap-revision", nil, nil) + c.Assert(err, IsNil) + c.Check(a, HasLen, 0) +} + +func (cs *clientSuite) TestClientAssertsMissingAssertions(c *C) { + cs.header = http.Header{} + cs.header.Add("X-Ubuntu-Assertions-Count", "4") + cs.rsp = "" + cs.status = 200 + _, err := cs.cli.Known("snap-build", nil, nil) + c.Assert(err, ErrorMatches, "response did not have the expected number of assertions") +} + +func (cs *clientSuite) TestStoreAccount(c *C) { + cs.header = http.Header{} + cs.header.Add("X-Ubuntu-Assertions-Count", "1") + cs.rsp = `type: account +authority-id: canonical +account-id: canonicalID +display-name: canonicalDisplay +timestamp: 2016-04-01T00:00:00.0Z +username: canonicalUser +validation: certified +sign-key-sha3-384: -CvQKAwRQ5h3Ffn10FILJoEZUXOv6km9FwA80-Rcj-f-6jadQ89VRswHNiEB9Lxk + +AcLDXAQAAQoABgUCV7UYzwAKCRDUpVvql9g3IK7uH/4udqNOurx5WYVknzXdwekp0ovHCQJ0iBPw +TSFxEVr9faZSzb7eqJ1WicHsShf97PYS3ClRYAiluFsjRA8Y03kkSVJHjC+sIwGFubsnkmgflt6D +WEmYIl0UBmeaEDS8uY4Xvp9NsLTzNEj2kvzy/52gKaTc1ZSl5RDL9ppMav+0V9iBYpiDPBWH2rJ+ +aDSD8Rkyygm0UscfAKyDKH4lrvZ0WkYyi1YVNPrjQ/AtBySh6Q4iJ3LifzKa9woIyAuJET/4/FPY +oirqHAfuvNod36yNQIyNqEc20AvTvZNH0PSsg4rq3DLjIPzv5KbJO9lhsasNJK1OdL6x8Yqrdsbk +ldZp4qkzfjV7VOMQKaadfcZPRaVVeJWOBnBiaukzkhoNlQi1sdCdkBB/AJHZF8QXw6c7vPDcfnCV +1lW7ddQ2p8IsJbT6LzpJu3GW/P4xhNgCjtCJ1AJm9a9RqLwQYgdLZwwDa9iCRtqTbRXBlfy3apps +1VjbQ3h5iCd0hNfwDBnGVm1rhLKHCD1DUdNE43oN2ZlE7XGyh0HFV6vKlpqoW3eoXCIxWu+HBY96 ++LSl/jQgCkb0nxYyzEYK4Reb31D0mYw1Nji5W+MIF5E09+DYZoOT0UvR05YMwMEOeSdI/hLWg/5P +k+GDK+/KopMmpd4D1+jjtF7ZvqDpmAV98jJGB2F88RyVb4gcjmFFyTi4Kv6vzz/oLpbm0qrizC0W +HLGDN/ymGA5sHzEgEx7U540vz/q9VX60FKqL2YZr/DcyY9GKX5kCG4sNqIIHbcJneZ4frM99oVDu +7Jv+DIx/Di6D1ULXol2XjxbbJLKHFtHksR97ceaFvcZwTogC61IYUBJCvvMoqdXAWMhEXCr0QfQ5 +Xbi31XW2d4/lF/zWlAkRnGTzufIXFni7+nEuOK0SQEzO3/WaRedK1SGOOtTDjB8/3OJeW96AUYK5 +oTIynkYkEyHWMNCXALg+WQW6L4/YO7aUjZ97zOWIugd7Xy63aT3r/EHafqaY2nacOhLfkeKZ830b +o/ezjoZQAxbh6ce7JnXRgE9ELxjdAhBTpGjmmmN2sYrJ7zP9bOgly0BnEPXGSQfFA+NNNw1FADx1 +MUY8q9DBjmVtgqY+1KGTV5X8KvQCBMODZIf/XJPHdCRAHxMd8COypcwgL2vDIIXpOFbi1J/B0GF+ +eklxk9wzBA8AecBMCwCzIRHDNpD1oa2we38bVFrOug6e/VId1k1jYFJjiLyLCDmV8IMYwEllHSXp +LQAdm3xZ7t4WnxYC8YSCk9mXf3CZg59SpmnV5Q5Z6A5Pl7Nc3sj7hcsMBZEsOMPzNC9dPsBnZvjs +WpPUffJzEdhHBFhvYMuD4Vqj6ejUv9l3oTrjQWVC +` + + account, err := cs.cli.StoreAccount("canonicalID") + c.Assert(err, IsNil) + c.Check(cs.req.Method, Equals, "GET") + c.Check(cs.req.URL.Query(), HasLen, 1) + c.Check(cs.req.URL.Query().Get("account-id"), Equals, "canonicalID") + c.Assert(account, DeepEquals, &snap.StoreAccount{ + ID: "canonicalID", + Username: "canonicalUser", + DisplayName: "canonicalDisplay", + Validation: "verified", + }) +} + +func (cs *clientSuite) TestStoreAccountNoAssertionFound(c *C) { + cs.header = http.Header{} + cs.header.Add("X-Ubuntu-Assertions-Count", "0") + cs.rsp = "" + + _, err := cs.cli.StoreAccount("canonicalID") + c.Assert(err, ErrorMatches, "no assertion found for account-id canonicalID") +} + +func (cs *clientSuite) TestClientAssertTypesErrIsWrapped(c *C) { + cs.err = errors.New("boom") + _, err := cs.cli.AssertionTypes() + var e xerrors.Wrapper + c.Assert(err, Implements, &e) +} + +func (cs *clientSuite) TestClientKnownErrIsWrapped(c *C) { + cs.err = errors.New("boom") + _, err := cs.cli.Known("foo", nil, nil) + var e xerrors.Wrapper + c.Assert(err, Implements, &e) +} diff --git a/client/buy.go b/client/buy.go new file mode 100644 index 00000000..3a410d6b --- /dev/null +++ b/client/buy.go @@ -0,0 +1,63 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client + +import ( + "bytes" + "encoding/json" +) + +// BuyOptions specifies parameters to buy from the store. +type BuyOptions struct { + SnapID string `json:"snap-id"` + Price float64 `json:"price"` + Currency string `json:"currency"` // ISO 4217 code as string +} + +// BuyResult holds the state of a buy attempt. +type BuyResult struct { + State string `json:"state,omitempty"` +} + +func (client *Client) Buy(opts *BuyOptions) (*BuyResult, error) { + if opts == nil { + opts = &BuyOptions{} + } + + var body bytes.Buffer + if err := json.NewEncoder(&body).Encode(opts); err != nil { + return nil, err + } + + var result BuyResult + _, err := client.doSync("POST", "/v2/buy", nil, nil, &body, &result) + + if err != nil { + return nil, err + } + + return &result, nil +} + +func (client *Client) ReadyToBuy() error { + var result bool + _, err := client.doSync("GET", "/v2/buy/ready", nil, nil, nil, &result) + return err +} diff --git a/client/change.go b/client/change.go new file mode 100644 index 00000000..e0a35b7c --- /dev/null +++ b/client/change.go @@ -0,0 +1,164 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client + +import ( + "bytes" + "encoding/json" + "fmt" + "net/url" + "time" +) + +// A Change is a modification to the system state. +type Change struct { + ID string `json:"id"` + Kind string `json:"kind"` + Summary string `json:"summary"` + Status string `json:"status"` + Tasks []*Task `json:"tasks,omitempty"` + Ready bool `json:"ready"` + Err string `json:"err,omitempty"` + + SpawnTime time.Time `json:"spawn-time,omitempty"` + ReadyTime time.Time `json:"ready-time,omitempty"` + + data map[string]*json.RawMessage +} + +var ErrNoData = fmt.Errorf("data entry not found") + +// Get unmarshals into value the kind-specific data with the provided key. +func (c *Change) Get(key string, value interface{}) error { + raw := c.data[key] + if raw == nil { + return ErrNoData + } + return json.Unmarshal([]byte(*raw), value) +} + +// A Task is an operation done to change the system's state. +type Task struct { + ID string `json:"id"` + Kind string `json:"kind"` + Summary string `json:"summary"` + Status string `json:"status"` + Log []string `json:"log,omitempty"` + Progress TaskProgress `json:"progress"` + + SpawnTime time.Time `json:"spawn-time,omitempty"` + ReadyTime time.Time `json:"ready-time,omitempty"` +} + +type TaskProgress struct { + Label string `json:"label"` + Done int `json:"done"` + Total int `json:"total"` +} + +type changeAndData struct { + Change + Data map[string]*json.RawMessage `json:"data"` +} + +// Change fetches information about a Change given its ID. +func (client *Client) Change(id string) (*Change, error) { + var chgd changeAndData + _, err := client.doSync("GET", "/v2/changes/"+id, nil, nil, nil, &chgd) + if err != nil { + return nil, err + } + + chgd.Change.data = chgd.Data + return &chgd.Change, nil +} + +// Abort attempts to abort a change that is in not yet ready. +func (client *Client) Abort(id string) (*Change, error) { + var postData struct { + Action string `json:"action"` + } + postData.Action = "abort" + + var body bytes.Buffer + if err := json.NewEncoder(&body).Encode(postData); err != nil { + return nil, err + } + + var chg Change + if _, err := client.doSync("POST", "/v2/changes/"+id, nil, nil, &body, &chg); err != nil { + return nil, err + } + + return &chg, nil +} + +type ChangeSelector uint8 + +func (c ChangeSelector) String() string { + switch c { + case ChangesInProgress: + return "in-progress" + case ChangesReady: + return "ready" + case ChangesAll: + return "all" + } + + panic(fmt.Sprintf("unknown ChangeSelector %d", c)) +} + +const ( + ChangesInProgress ChangeSelector = 1 << iota + ChangesReady + ChangesAll = ChangesReady | ChangesInProgress +) + +type ChangesOptions struct { + SnapName string // if empty, no filtering by name is done + Selector ChangeSelector +} + +func (client *Client) Changes(opts *ChangesOptions) ([]*Change, error) { + query := url.Values{} + if opts != nil { + if opts.Selector != 0 { + query.Set("select", opts.Selector.String()) + } + if opts.SnapName != "" { + query.Set("for", opts.SnapName) + } + } + + var chgds []changeAndData + _, err := client.doSync("GET", "/v2/changes", query, nil, nil, &chgds) + if err != nil { + return nil, err + } + + var chgs []*Change + for i := range chgds { + chgd := &chgds[i] + chgd.Change.data = chgd.Data + chgs = append(chgs, &chgd.Change) + } + + return chgs, err +} diff --git a/client/change_test.go b/client/change_test.go new file mode 100644 index 00000000..e0fe3231 --- /dev/null +++ b/client/change_test.go @@ -0,0 +1,234 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client_test + +import ( + "io" + "time" + + "gopkg.in/check.v1" + + "github.com/snapcore/snapd/client" +) + +func (cs *clientSuite) TestClientChange(c *check.C) { + cs.rsp = `{"type": "sync", "result": { + "id": "uno", + "kind": "foo", + "summary": "...", + "status": "Do", + "ready": false, + "spawn-time": "2016-04-21T01:02:03Z", + "ready-time": "2016-04-21T01:02:04Z", + "tasks": [{"kind": "bar", "summary": "...", "status": "Do", "progress": {"done": 0, "total": 1}, "spawn-time": "2016-04-21T01:02:03Z", "ready-time": "2016-04-21T01:02:04Z"}] +}}` + + chg, err := cs.cli.Change("uno") + c.Assert(err, check.IsNil) + c.Check(chg, check.DeepEquals, &client.Change{ + ID: "uno", + Kind: "foo", + Summary: "...", + Status: "Do", + Tasks: []*client.Task{{ + Kind: "bar", + Summary: "...", + Status: "Do", + Progress: client.TaskProgress{Done: 0, Total: 1}, + SpawnTime: time.Date(2016, 04, 21, 1, 2, 3, 0, time.UTC), + ReadyTime: time.Date(2016, 04, 21, 1, 2, 4, 0, time.UTC), + }}, + + SpawnTime: time.Date(2016, 04, 21, 1, 2, 3, 0, time.UTC), + ReadyTime: time.Date(2016, 04, 21, 1, 2, 4, 0, time.UTC), + }) +} + +func (cs *clientSuite) TestClientChangeData(c *check.C) { + cs.rsp = `{"type": "sync", "result": { + "id": "uno", + "kind": "foo", + "summary": "...", + "status": "Do", + "ready": false, + "data": {"n": 42} +}}` + + chg, err := cs.cli.Change("uno") + c.Assert(err, check.IsNil) + var n int + err = chg.Get("n", &n) + c.Assert(err, check.IsNil) + c.Assert(n, check.Equals, 42) + + err = chg.Get("missing", &n) + c.Assert(err, check.Equals, client.ErrNoData) +} + +func (cs *clientSuite) TestClientChangeRestartingState(c *check.C) { + cs.rsp = `{"type": "sync", "result": { + "id": "uno", + "kind": "foo", + "summary": "...", + "status": "Do", + "ready": false +}, + "maintenance": {"kind": "system-restart", "message": "system is restarting"} +}` + + chg, err := cs.cli.Change("uno") + c.Check(chg, check.NotNil) + c.Check(chg.ID, check.Equals, "uno") + c.Check(err, check.IsNil) + c.Check(cs.cli.Maintenance(), check.ErrorMatches, `system is restarting`) +} + +func (cs *clientSuite) TestClientChangeError(c *check.C) { + cs.rsp = `{"type": "sync", "result": { + "id": "uno", + "kind": "foo", + "summary": "...", + "status": "Error", + "ready": true, + "tasks": [{"kind": "bar", "summary": "...", "status": "Error", "progress": {"done": 1, "total": 1}, "log": ["ERROR: something broke"]}], + "err": "error message" +}}` + + chg, err := cs.cli.Change("uno") + c.Assert(err, check.IsNil) + c.Check(chg, check.DeepEquals, &client.Change{ + ID: "uno", + Kind: "foo", + Summary: "...", + Status: "Error", + Tasks: []*client.Task{{ + Kind: "bar", + Summary: "...", + Status: "Error", + Progress: client.TaskProgress{Done: 1, Total: 1}, + Log: []string{"ERROR: something broke"}, + }}, + Err: "error message", + Ready: true, + }) +} + +func (cs *clientSuite) TestClientChangesString(c *check.C) { + for k, v := range map[client.ChangeSelector]string{ + client.ChangesAll: "all", + client.ChangesReady: "ready", + client.ChangesInProgress: "in-progress", + } { + c.Check(k.String(), check.Equals, v) + } +} + +func (cs *clientSuite) TestClientChanges(c *check.C) { + cs.rsp = `{"type": "sync", "result": [{ + "id": "uno", + "kind": "foo", + "summary": "...", + "status": "Do", + "ready": false, + "tasks": [{"kind": "bar", "summary": "...", "status": "Do", "progress": {"done": 0, "total": 1}}] +}]}` + + for _, i := range []*client.ChangesOptions{ + {Selector: client.ChangesAll}, + {Selector: client.ChangesReady}, + {Selector: client.ChangesInProgress}, + {SnapName: "foo"}, + nil, + } { + chg, err := cs.cli.Changes(i) + c.Assert(err, check.IsNil) + c.Check(chg, check.DeepEquals, []*client.Change{{ + ID: "uno", + Kind: "foo", + Summary: "...", + Status: "Do", + Tasks: []*client.Task{{Kind: "bar", Summary: "...", Status: "Do", Progress: client.TaskProgress{Done: 0, Total: 1}}}, + }}) + if i == nil { + c.Check(cs.req.URL.RawQuery, check.Equals, "") + } else { + if i.Selector != 0 { + c.Check(cs.req.URL.RawQuery, check.Equals, "select="+i.Selector.String()) + } else { + c.Check(cs.req.URL.RawQuery, check.Equals, "for="+i.SnapName) + } + } + } + +} + +func (cs *clientSuite) TestClientChangesData(c *check.C) { + cs.rsp = `{"type": "sync", "result": [{ + "id": "uno", + "kind": "foo", + "summary": "...", + "status": "Do", + "ready": false, + "data": {"n": 42} +}]}` + + chgs, err := cs.cli.Changes(&client.ChangesOptions{Selector: client.ChangesAll}) + c.Assert(err, check.IsNil) + + chg := chgs[0] + var n int + err = chg.Get("n", &n) + c.Assert(err, check.IsNil) + c.Assert(n, check.Equals, 42) + + err = chg.Get("missing", &n) + c.Assert(err, check.Equals, client.ErrNoData) +} + +func (cs *clientSuite) TestClientAbort(c *check.C) { + cs.rsp = `{"type": "sync", "result": { + "id": "uno", + "kind": "foo", + "summary": "...", + "status": "Hold", + "ready": true, + "spawn-time": "2016-04-21T01:02:03Z", + "ready-time": "2016-04-21T01:02:04Z" +}}` + + chg, err := cs.cli.Abort("uno") + c.Assert(err, check.IsNil) + c.Check(cs.req.Method, check.Equals, "POST") + c.Check(chg, check.DeepEquals, &client.Change{ + ID: "uno", + Kind: "foo", + Summary: "...", + Status: "Hold", + Ready: true, + + SpawnTime: time.Date(2016, 04, 21, 1, 2, 3, 0, time.UTC), + ReadyTime: time.Date(2016, 04, 21, 1, 2, 4, 0, time.UTC), + }) + + body, err := io.ReadAll(cs.req.Body) + c.Assert(err, check.IsNil) + + c.Assert(string(body), check.Equals, "{\"action\":\"abort\"}\n") +} diff --git a/client/client.go b/client/client.go new file mode 100644 index 00000000..22385191 --- /dev/null +++ b/client/client.go @@ -0,0 +1,803 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2015-2024 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "path" + "strconv" + "time" + + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/features" + "github.com/snapcore/snapd/httputil" + "github.com/snapcore/snapd/jsonutil" +) + +func unixDialer(socketPath string) func(string, string) (net.Conn, error) { + if socketPath == "" { + socketPath = dirs.SnapdSocket + } + return func(_, _ string) (net.Conn, error) { + return net.Dial("unix", socketPath) + } +} + +type doer interface { + Do(*http.Request) (*http.Response, error) +} + +// Config allows to customize client behavior. +type Config struct { + // BaseURL contains the base URL where snappy daemon is expected to be. + // It can be empty for a default behavior of talking over a unix socket. + BaseURL string + + // DisableAuth controls whether the client should send an + // Authorization header from reading the auth.json data. + DisableAuth bool + + // Interactive controls whether the client runs in interactive mode. + // At present, this only affects whether interactive polkit + // authorisation is requested. + Interactive bool + + // Socket is the path to the unix socket to use + Socket string + + // DisableKeepAlive indicates whether the connections should not be kept + // alive for later reuse + DisableKeepAlive bool + + // User-Agent to sent to the snapd daemon + UserAgent string +} + +// A Client knows how to talk to the snappy daemon. +type Client struct { + baseURL url.URL + doer doer + + disableAuth bool + interactive bool + + maintenance error + + warningCount int + warningTimestamp time.Time + + userAgent string + + // SetMayLogBody controls whether a request or response's body may be logged + // if the appropriate environment variable is set + SetMayLogBody func(bool) +} + +// New returns a new instance of Client +func New(config *Config) *Client { + if config == nil { + config = &Config{} + } + + var baseURL *url.URL + var dial func(network, addr string) (net.Conn, error) + + // By default talk over an UNIX socket. + if config.BaseURL == "" { + dial = unixDialer(config.Socket) + baseURL = &url.URL{ + Scheme: "http", + Host: "localhost", + } + } else { + var err error + baseURL, err = url.Parse(config.BaseURL) + if err != nil { + panic(fmt.Sprintf("cannot parse server base URL: %q (%v)", config.BaseURL, err)) + } + } + + transport := &httputil.LoggedTransport{ + Transport: &http.Transport{ + Dial: dial, + DisableKeepAlives: config.DisableKeepAlive, + }, + Key: "SNAP_CLIENT_DEBUG_HTTP", + MayLogBody: true, + } + return &Client{ + baseURL: *baseURL, + doer: &http.Client{Transport: transport}, + disableAuth: config.DisableAuth, + interactive: config.Interactive, + userAgent: config.UserAgent, + SetMayLogBody: func(logBody bool) { + transport.MayLogBody = logBody + }, + } +} + +// Maintenance returns an error reflecting the daemon maintenance status or nil. +func (client *Client) Maintenance() error { + return client.maintenance +} + +// WarningsSummary returns the number of warnings that are ready to be shown to +// the user, and the timestamp of the most recently added warning (useful for +// silencing the warning alerts, and OKing the returned warnings). +func (client *Client) WarningsSummary() (count int, timestamp time.Time) { + return client.warningCount, client.warningTimestamp +} + +func (client *Client) WhoAmI() (string, error) { + user, err := readAuthData() + if os.IsNotExist(err) { + return "", nil + } + if err != nil { + return "", err + } + + return user.Email, nil +} + +func (client *Client) setAuthorization(req *http.Request) error { + user, err := readAuthData() + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + + var buf bytes.Buffer + fmt.Fprintf(&buf, `Macaroon root="%s"`, user.Macaroon) + for _, discharge := range user.Discharges { + fmt.Fprintf(&buf, `, discharge="%s"`, discharge) + } + req.Header.Set("Authorization", buf.String()) + return nil +} + +type RequestError struct{ error } + +func (e RequestError) Error() string { + return fmt.Sprintf("cannot build request: %v", e.error) +} + +type AuthorizationError struct{ Err error } + +func (e AuthorizationError) Error() string { + return fmt.Sprintf("cannot add authorization: %v", e.Err) +} + +func (e AuthorizationError) Is(target error) bool { + _, ok := target.(AuthorizationError) + return ok +} + +type ConnectionError struct{ Err error } + +func (e ConnectionError) Error() string { + var errStr string + switch e.Err { + case context.DeadlineExceeded: + errStr = "timeout exceeded while waiting for response" + case context.Canceled: + errStr = "request canceled" + default: + errStr = e.Err.Error() + } + return fmt.Sprintf("cannot communicate with server: %s", errStr) +} + +func (e ConnectionError) Unwrap() error { + return e.Err +} + +type InternalClientError struct{ Err error } + +func (e InternalClientError) Error() string { + return fmt.Sprintf("internal error: %s", e.Err.Error()) +} + +func (e InternalClientError) Is(target error) bool { + _, ok := target.(InternalClientError) + return ok +} + +// AllowInteractionHeader is the HTTP request header used to indicate +// that the client is willing to allow interaction. +const AllowInteractionHeader = "X-Allow-Interaction" + +// raw performs a request and returns the resulting http.Response and +// error. You usually only need to call this directly if you expect the +// response to not be JSON, otherwise you'd call Do(...) instead. +func (client *Client) raw(ctx context.Context, method, urlpath string, query url.Values, headers map[string]string, body io.Reader) (*http.Response, error) { + // fake a url to keep http.Client happy + u := client.baseURL + u.Path = path.Join(client.baseURL.Path, urlpath) + u.RawQuery = query.Encode() + req, err := http.NewRequest(method, u.String(), body) + if err != nil { + return nil, RequestError{err} + } + if client.userAgent != "" { + req.Header.Set("User-Agent", client.userAgent) + } + + for key, value := range headers { + req.Header.Set(key, value) + } + // Content-length headers are special and need to be set + // directly to the request. Just setting it to the header + // will be ignored by go http. + if clStr := req.Header.Get("Content-Length"); clStr != "" { + cl, err := strconv.ParseInt(clStr, 10, 64) + if err != nil { + return nil, err + } + req.ContentLength = cl + } + + if !client.disableAuth { + // set Authorization header if there are user's credentials + err = client.setAuthorization(req) + if err != nil { + return nil, AuthorizationError{err} + } + } + + if client.interactive { + req.Header.Set(AllowInteractionHeader, "true") + } + + if ctx != nil { + req = req.WithContext(ctx) + } + + rsp, err := client.doer.Do(req) + if err != nil { + return nil, ConnectionError{err} + } + + return rsp, nil +} + +// rawWithTimeout is like raw(), but sets a timeout based on opts for +// the whole of request and response (including rsp.Body() read) round +// trip. If opts is nil the default doTimeout is used. +// The caller is responsible for canceling the internal context +// to release the resources associated with the request by calling the +// returned cancel function. +func (client *Client) rawWithTimeout(ctx context.Context, method, urlpath string, query url.Values, headers map[string]string, body io.Reader, opts *doOptions) (*http.Response, context.CancelFunc, error) { + opts = ensureDoOpts(opts) + if opts.Timeout <= 0 { + return nil, nil, InternalClientError{fmt.Errorf("timeout not set in options for rawWithTimeout")} + } + + ctx, cancel := context.WithTimeout(ctx, opts.Timeout) + rsp, err := client.raw(ctx, method, urlpath, query, headers, body) + if err != nil && ctx.Err() != nil { + cancel() + return nil, nil, ConnectionError{ctx.Err()} + } + + return rsp, cancel, err +} + +var ( + doRetry = 250 * time.Millisecond + // snapd may need to reach out to the store, where it uses a fixed 10s + // timeout for the whole of a single request to complete, requests are + // retried for up to 38s in total, make sure that the client timeout is + // not shorter than that + doTimeout = 120 * time.Second +) + +// MockDoTimings mocks the delay used by the do retry loop and request timeout. +func MockDoTimings(retry, timeout time.Duration) (restore func()) { + oldRetry := doRetry + oldTimeout := doTimeout + doRetry = retry + doTimeout = timeout + return func() { + doRetry = oldRetry + doTimeout = oldTimeout + } +} + +type hijacked struct { + do func(*http.Request) (*http.Response, error) +} + +func (h hijacked) Do(req *http.Request) (*http.Response, error) { + return h.do(req) +} + +// Hijack lets the caller take over the raw http request +func (client *Client) Hijack(f func(*http.Request) (*http.Response, error)) { + client.doer = hijacked{f} +} + +type doOptions struct { + // Timeout is the overall request timeout + Timeout time.Duration + // Retry interval. + // Note for a request with a Timeout but without a retry, Retry should just + // be set to something larger than the Timeout. + Retry time.Duration +} + +func ensureDoOpts(opts *doOptions) *doOptions { + if opts == nil { + // defaults + opts = &doOptions{ + Timeout: doTimeout, + Retry: doRetry, + } + } + return opts +} + +// doNoTimeoutAndRetry can be passed to the do family to not have timeout +// nor retries. +var doNoTimeoutAndRetry = &doOptions{ + Timeout: time.Duration(-1), +} + +// do performs a request and decodes the resulting json into the given +// value. It's low-level, for testing/experimenting only; you should +// usually use a higher level interface that builds on this. +func (client *Client) do(method, path string, query url.Values, headers map[string]string, body io.Reader, v interface{}, opts *doOptions) (statusCode int, err error) { + opts = ensureDoOpts(opts) + + client.checkMaintenanceJSON() + + var rsp *http.Response + ctx := context.Background() + if opts.Timeout <= 0 { + // no timeout and retries + rsp, err = client.raw(ctx, method, path, query, headers, body) + } else { + if opts.Retry <= 0 { + return 0, InternalClientError{fmt.Errorf("retry setting %s invalid", opts.Retry)} + } + retry := time.NewTicker(opts.Retry) + defer retry.Stop() + timeout := time.NewTimer(opts.Timeout) + defer timeout.Stop() + + for { + var cancel context.CancelFunc + // use the same timeout as for the whole of the retry + // loop to error out the whole do() call when a single + // request exceeds the deadline + rsp, cancel, err = client.rawWithTimeout(ctx, method, path, query, headers, body, opts) + if err == nil { + defer cancel() + } + if err == nil || shouldNotRetryError(err) || method != "GET" { + break + } + select { + case <-retry.C: + continue + case <-timeout.C: + } + break + } + } + if err != nil { + return 0, err + } + defer rsp.Body.Close() + + if v != nil { + if err := decodeInto(rsp.Body, v); err != nil { + return rsp.StatusCode, err + } + } + + return rsp.StatusCode, nil +} + +func shouldNotRetryError(err error) bool { + return errors.Is(err, AuthorizationError{}) || + errors.Is(err, InternalClientError{}) +} + +func decodeInto(reader io.Reader, v interface{}) error { + dec := json.NewDecoder(reader) + if err := dec.Decode(v); err != nil { + r := dec.Buffered() + buf, err1 := io.ReadAll(r) + if err1 != nil { + buf = []byte(fmt.Sprintf("error reading buffered response body: %s", err1)) + } + return fmt.Errorf("cannot decode %q: %s", buf, err) + } + return nil +} + +// doSync performs a request to the given path using the specified HTTP method. +// It expects a "sync" response from the API and on success decodes the JSON +// response payload into the given value using the "UseNumber" json decoding +// which produces json.Numbers instead of float64 types for numbers. +func (client *Client) doSync(method, path string, query url.Values, headers map[string]string, body io.Reader, v interface{}) (*ResultInfo, error) { + return client.doSyncWithOpts(method, path, query, headers, body, v, nil) +} + +// checkMaintenanceJSON checks if there is a maintenance.json file written by +// snapd the daemon that positively identifies snapd as being unavailable due to +// maintenance, either for snapd restarting itself to update, or rebooting the +// system to update the kernel or base snap, etc. If there is ongoing +// maintenance, then the maintenance object on the client is set appropriately. +// note that currently checkMaintenanceJSON does not return errors, such that +// if the file is missing or corrupt or empty, nothing will happen and it will +// be silently ignored +func (client *Client) checkMaintenanceJSON() { + f, err := os.Open(dirs.SnapdMaintenanceFile) + // just continue if we can't read the maintenance file + if err != nil { + return + } + defer f.Close() + + // we have a maintenance file, try to read it + maintenance := &Error{} + + if err := json.NewDecoder(f).Decode(&maintenance); err != nil { + // if the json is malformed, just ignore it for now, we only use it for + // positive identification of snapd down for maintenance + return + } + + if maintenance != nil { + switch maintenance.Kind { + case ErrorKindDaemonRestart: + client.maintenance = maintenance + case ErrorKindSystemRestart: + client.maintenance = maintenance + } + // don't set maintenance for other kinds, as we don't know what it + // is yet + + // this also means an empty json object in maintenance.json doesn't get + // treated as a real maintenance downtime for example + } +} + +func (client *Client) doSyncWithOpts(method, path string, query url.Values, headers map[string]string, body io.Reader, v interface{}, opts *doOptions) (*ResultInfo, error) { + // first check maintenance.json to see if snapd is down for a restart, and + // set cli.maintenance as appropriate, then perform the request + // TODO: it would be a nice thing to skip the request if we know that snapd + // won't respond and return a specific error, but that's a big behavior + // change we probably shouldn't make right now, not to mention it probably + // requires adjustments in other areas too + client.checkMaintenanceJSON() + + var rsp response + statusCode, err := client.do(method, path, query, headers, body, &rsp, opts) + if err != nil { + return nil, err + } + if err := rsp.err(client, statusCode); err != nil { + return nil, err + } + if rsp.Type != "sync" { + return nil, fmt.Errorf("expected sync response, got %q", rsp.Type) + } + + if v != nil { + if err := jsonutil.DecodeWithNumber(bytes.NewReader(rsp.Result), v); err != nil { + return nil, fmt.Errorf("cannot unmarshal: %v", err) + } + } + + client.warningCount = rsp.WarningCount + client.warningTimestamp = rsp.WarningTimestamp + + return &rsp.ResultInfo, nil +} + +func (client *Client) doAsync(method, path string, query url.Values, headers map[string]string, body io.Reader) (changeID string, err error) { + _, changeID, err = client.doAsyncFull(method, path, query, headers, body, nil) + return +} + +func (client *Client) doAsyncFull(method, path string, query url.Values, headers map[string]string, body io.Reader, opts *doOptions) (result json.RawMessage, changeID string, err error) { + var rsp response + statusCode, err := client.do(method, path, query, headers, body, &rsp, opts) + if err != nil { + return nil, "", err + } + if err := rsp.err(client, statusCode); err != nil { + return nil, "", err + } + if rsp.Type != "async" { + return nil, "", fmt.Errorf("expected async response for %q on %q, got %q", method, path, rsp.Type) + } + if statusCode != 202 { + return nil, "", fmt.Errorf("operation not accepted") + } + if rsp.Change == "" { + return nil, "", fmt.Errorf("async response without change reference") + } + + return rsp.Result, rsp.Change, nil +} + +type ServerVersion struct { + Version string + Series string + OSID string + OSVersionID string + OnClassic bool + + KernelVersion string + Architecture string + Virtualization string +} + +func (client *Client) ServerVersion() (*ServerVersion, error) { + sysInfo, err := client.SysInfo() + if err != nil { + return nil, err + } + + return &ServerVersion{ + Version: sysInfo.Version, + Series: sysInfo.Series, + OSID: sysInfo.OSRelease.ID, + OSVersionID: sysInfo.OSRelease.VersionID, + OnClassic: sysInfo.OnClassic, + + KernelVersion: sysInfo.KernelVersion, + Architecture: sysInfo.Architecture, + Virtualization: sysInfo.Virtualization, + }, nil +} + +// A response produced by the REST API will usually fit in this +// (exceptions are the icons/ endpoints obvs) +type response struct { + Result json.RawMessage `json:"result"` + Type string `json:"type"` + Change string `json:"change"` + + WarningCount int `json:"warning-count"` + WarningTimestamp time.Time `json:"warning-timestamp"` + + ResultInfo + + Maintenance *Error `json:"maintenance"` +} + +// Error is the real value of response.Result when an error occurs. +type Error struct { + Kind ErrorKind `json:"kind"` + Value interface{} `json:"value"` + Message string `json:"message"` + + StatusCode int +} + +func (e *Error) Error() string { + return e.Message +} + +// IsRetryable returns true if the given error is an error +// that can be retried later. +func IsRetryable(err error) bool { + switch e := err.(type) { + case *Error: + return e.Kind == ErrorKindSnapChangeConflict + } + return false +} + +// IsTwoFactorError returns whether the given error is due to problems +// in two-factor authentication. +func IsTwoFactorError(err error) bool { + e, ok := err.(*Error) + if !ok || e == nil { + return false + } + + return e.Kind == ErrorKindTwoFactorFailed || e.Kind == ErrorKindTwoFactorRequired +} + +// IsInterfacesUnchangedError returns whether the given error means the requested +// change to interfaces was not made, because there was nothing to do. +func IsInterfacesUnchangedError(err error) bool { + e, ok := err.(*Error) + if !ok || e == nil { + return false + } + return e.Kind == ErrorKindInterfacesUnchanged +} + +// IsAssertionNotFoundError returns whether the given error means that the +// assertion wasn't found and thus the device isn't ready/seeded. +func IsAssertionNotFoundError(err error) bool { + e, ok := err.(*Error) + if !ok || e == nil { + return false + } + + return e.Kind == ErrorKindAssertionNotFound +} + +// OSRelease contains information about the system extracted from /etc/os-release. +type OSRelease struct { + ID string `json:"id"` + VersionID string `json:"version-id,omitempty"` +} + +// RefreshInfo contains information about refreshes. +type RefreshInfo struct { + // Timer contains the refresh.timer setting. + Timer string `json:"timer,omitempty"` + // Schedule contains the legacy refresh.schedule setting. + Schedule string `json:"schedule,omitempty"` + Last string `json:"last,omitempty"` + Hold string `json:"hold,omitempty"` + Next string `json:"next,omitempty"` +} + +// SysInfo holds system information +type SysInfo struct { + Series string `json:"series,omitempty"` + Version string `json:"version,omitempty"` + BuildID string `json:"build-id"` + OSRelease OSRelease `json:"os-release"` + OnClassic bool `json:"on-classic"` + Managed bool `json:"managed"` + + KernelVersion string `json:"kernel-version,omitempty"` + Architecture string `json:"architecture,omitempty"` + Virtualization string `json:"virtualization,omitempty"` + + Refresh RefreshInfo `json:"refresh,omitempty"` + Confinement string `json:"confinement"` + SandboxFeatures map[string][]string `json:"sandbox-features,omitempty"` + + Features map[string]features.FeatureInfo `json:"features,omitempty"` +} + +func (rsp *response) err(cli *Client, statusCode int) error { + if cli != nil { + maintErr := rsp.Maintenance + // avoid setting to (*client.Error)(nil) + if maintErr != nil { + cli.maintenance = maintErr + } else { + cli.maintenance = nil + } + } + if rsp.Type != "error" { + return nil + } + var resultErr Error + err := json.Unmarshal(rsp.Result, &resultErr) + if err != nil || resultErr.Message == "" { + return fmt.Errorf("server error: %q", http.StatusText(statusCode)) + } + resultErr.StatusCode = statusCode + + return &resultErr +} + +func parseError(r *http.Response) error { + var rsp response + if r.Header.Get("Content-Type") != "application/json" { + return fmt.Errorf("server error: %q", r.Status) + } + + dec := json.NewDecoder(r.Body) + if err := dec.Decode(&rsp); err != nil { + return fmt.Errorf("cannot unmarshal error: %v", err) + } + + err := rsp.err(nil, r.StatusCode) + if err == nil { + return fmt.Errorf("server error: %q", r.Status) + } + return err +} + +// SysInfo gets system information from the REST API. +func (client *Client) SysInfo() (*SysInfo, error) { + var sysInfo SysInfo + + opts := &doOptions{ + Timeout: 25 * time.Second, + Retry: doRetry, + } + if _, err := client.doSyncWithOpts("GET", "/v2/system-info", nil, nil, nil, &sysInfo, opts); err != nil { + return nil, fmt.Errorf("cannot obtain system details: %v", err) + } + + return &sysInfo, nil +} + +type debugAction struct { + Action string `json:"action"` + Params interface{} `json:"params,omitempty"` +} + +// Debug is only useful when writing test code, it will trigger +// an internal action with the given parameters. +func (client *Client) Debug(action string, params interface{}, result interface{}) error { + body, err := json.Marshal(debugAction{ + Action: action, + Params: params, + }) + if err != nil { + return err + } + + _, err = client.doSync("POST", "/v2/debug", nil, nil, bytes.NewReader(body), result) + return err +} + +func (client *Client) DebugGet(aspect string, result interface{}, params map[string]string) error { + urlParams := url.Values{"aspect": []string{aspect}} + for k, v := range params { + urlParams.Set(k, v) + } + _, err := client.doSync("GET", "/v2/debug", urlParams, nil, nil, &result) + return err +} + +type SystemRecoveryKeysResponse struct { + RecoveryKey string `json:"recovery-key"` + ReinstallKey string `json:"reinstall-key,omitempty"` +} + +func (client *Client) SystemRecoveryKeys(result interface{}) error { + _, err := client.doSync("GET", "/v2/system-recovery-keys", nil, nil, nil, &result) + return err +} + +func (c *Client) MigrateSnapHome(snaps []string) (changeID string, err error) { + body, err := json.Marshal(struct { + Action string `json:"action"` + Snaps []string `json:"snaps"` + }{ + Action: "migrate-home", + Snaps: snaps, + }) + if err != nil { + return "", err + } + + return c.doAsync("POST", "/v2/debug", nil, nil, bytes.NewReader(body)) +} diff --git a/client/client_test.go b/client/client_test.go new file mode 100644 index 00000000..ae7a50db --- /dev/null +++ b/client/client_test.go @@ -0,0 +1,731 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2015-2024 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client_test + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "time" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/client" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/features" + "github.com/snapcore/snapd/logger" + "github.com/snapcore/snapd/testutil" +) + +// Hook up check.v1 into the "go test" runner +func Test(t *testing.T) { TestingT(t) } + +type clientSuite struct { + testutil.BaseTest + + cli *client.Client + req *http.Request + reqs []*http.Request + rsp string + rsps []string + err error + doCalls int + header http.Header + status int + contentLength int64 + + countingCloser *countingCloser +} + +var _ = Suite(&clientSuite{}) + +func (cs *clientSuite) SetUpTest(c *C) { + os.Setenv(client.TestAuthFileEnvKey, filepath.Join(c.MkDir(), "auth.json")) + cs.AddCleanup(func() { os.Unsetenv(client.TestAuthFileEnvKey) }) + + cs.cli = client.New(nil) + cs.cli.SetDoer(cs) + cs.err = nil + cs.req = nil + cs.reqs = nil + cs.rsp = "" + cs.rsps = nil + cs.req = nil + cs.header = nil + cs.status = 200 + cs.doCalls = 0 + cs.contentLength = 0 + cs.countingCloser = nil + + dirs.SetRootDir(c.MkDir()) + cs.AddCleanup(func() { dirs.SetRootDir("") }) + + cs.AddCleanup(client.MockDoTimings(time.Millisecond, 100*time.Millisecond)) +} + +type countingCloser struct { + io.Reader + closeCalled int +} + +func (n *countingCloser) Close() error { + n.closeCalled++ + return nil +} + +func (cs *clientSuite) Do(req *http.Request) (*http.Response, error) { + cs.req = req + cs.reqs = append(cs.reqs, req) + body := cs.rsp + if cs.doCalls < len(cs.rsps) { + body = cs.rsps[cs.doCalls] + } + cs.countingCloser = &countingCloser{Reader: strings.NewReader(body)} + rsp := &http.Response{ + Body: cs.countingCloser, + Header: cs.header, + StatusCode: cs.status, + ContentLength: cs.contentLength, + } + cs.doCalls++ + return rsp, cs.err +} + +func (cs *clientSuite) TestNewPanics(c *C) { + c.Assert(func() { + client.New(&client.Config{BaseURL: ":"}) + }, PanicMatches, `cannot parse server base URL: ":" \(parse \"?:\"?: missing protocol scheme\)`) +} + +func (cs *clientSuite) TestClientDoReportsErrors(c *C) { + cs.err = errors.New("ouchie") + _, err := cs.cli.Do("GET", "/", nil, nil, nil, nil) + c.Check(err, ErrorMatches, "cannot communicate with server: ouchie") + if cs.doCalls < 2 { + c.Fatalf("do did not retry") + } +} + +func (cs *clientSuite) TestClientWorks(c *C) { + var v []int + cs.rsp = `[1,2]` + reqBody := io.NopCloser(strings.NewReader("")) + statusCode, err := cs.cli.Do("GET", "/this", nil, reqBody, &v, nil) + c.Check(err, IsNil) + c.Check(statusCode, Equals, 200) + c.Check(v, DeepEquals, []int{1, 2}) + c.Assert(cs.req, NotNil) + c.Assert(cs.req.URL, NotNil) + c.Check(cs.req.Method, Equals, "GET") + c.Check(cs.req.Body, Equals, reqBody) + c.Check(cs.req.URL.Path, Equals, "/this") +} + +func makeMaintenanceFile(c *C, b []byte) { + c.Assert(os.MkdirAll(filepath.Dir(dirs.SnapdMaintenanceFile), 0755), IsNil) + c.Assert(os.WriteFile(dirs.SnapdMaintenanceFile, b, 0644), IsNil) +} + +func (cs *clientSuite) TestClientSetMaintenanceForMaintenanceJSON(c *C) { + // write a maintenance.json that says snapd is down for a restart + maintErr := &client.Error{ + Kind: client.ErrorKindSystemRestart, + Message: "system is restarting", + } + b, err := json.Marshal(maintErr) + c.Assert(err, IsNil) + makeMaintenanceFile(c, b) + + // now after a Do(), we will have maintenance set to what we wrote + // originally + _, err = cs.cli.Do("GET", "/this", nil, nil, nil, nil) + c.Check(err, IsNil) + + returnedErr := cs.cli.Maintenance() + c.Assert(returnedErr, DeepEquals, maintErr) +} + +func (cs *clientSuite) TestClientIgnoresGarbageMaintenanceJSON(c *C) { + // write a garbage maintenance.json that can't be unmarshalled + makeMaintenanceFile(c, []byte("blah blah blah not json")) + + // after a Do(), no maintenance set and also no error returned from Do() + _, err := cs.cli.Do("GET", "/this", nil, nil, nil, nil) + c.Check(err, IsNil) + + returnedErr := cs.cli.Maintenance() + c.Assert(returnedErr, IsNil) +} + +func (cs *clientSuite) TestClientDoNoTimeoutIgnoresRetry(c *C) { + var v []int + cs.rsp = `[1,2]` + cs.err = fmt.Errorf("borken") + reqBody := io.NopCloser(strings.NewReader("")) + doOpts := &client.DoOptions{ + // Timeout is unset, thus 0, and thus we ignore the retry and only run + // once even though there is an error + Retry: time.Duration(time.Second), + } + _, err := cs.cli.Do("GET", "/this", nil, reqBody, &v, doOpts) + c.Check(err, ErrorMatches, "cannot communicate with server: borken") + c.Assert(cs.doCalls, Equals, 1) +} + +func (cs *clientSuite) TestClientDoRetryValidation(c *C) { + var v []int + cs.rsp = `[1,2]` + reqBody := io.NopCloser(strings.NewReader("")) + doOpts := &client.DoOptions{ + Retry: time.Duration(-1), + Timeout: time.Duration(time.Minute), + } + _, err := cs.cli.Do("GET", "/this", nil, reqBody, &v, doOpts) + c.Check(err, ErrorMatches, "internal error: retry setting.*invalid") + c.Assert(cs.req, IsNil) +} + +func (cs *clientSuite) TestClientDoRetryWorks(c *C) { + reqBody := io.NopCloser(strings.NewReader("")) + cs.err = fmt.Errorf("borken") + doOpts := &client.DoOptions{ + Retry: time.Duration(time.Millisecond), + Timeout: time.Duration(time.Second), + } + _, err := cs.cli.Do("GET", "/this", nil, reqBody, nil, doOpts) + c.Check(err, ErrorMatches, "cannot communicate with server: borken") + // best effort checking given that execution could be slow + // on some machines + c.Assert(cs.doCalls > 100, Equals, true, Commentf("got only %v calls", cs.doCalls)) + c.Assert(cs.doCalls < 1100, Equals, true, Commentf("got %v calls", cs.doCalls)) +} + +func (cs *clientSuite) TestClientOnlyRetryAppropriateErrors(c *C) { + reqBody := io.NopCloser(strings.NewReader("")) + doOpts := &client.DoOptions{ + Retry: time.Millisecond, + Timeout: 1 * time.Minute, + } + + for _, t := range []struct{ error }{ + {client.InternalClientError{Err: fmt.Errorf("boom")}}, + {client.AuthorizationError{Err: fmt.Errorf("boom")}}, + } { + cs.doCalls = 0 + cs.err = t.error + + _, err := cs.cli.Do("GET", "/this", nil, reqBody, nil, doOpts) + c.Check(err, ErrorMatches, fmt.Sprintf(".*%s", t.error.Error())) + c.Assert(cs.doCalls, Equals, 1) + } +} + +func (cs *clientSuite) TestClientUnderstandsStatusCode(c *C) { + var v []int + cs.status = 202 + cs.rsp = `[1,2]` + reqBody := io.NopCloser(strings.NewReader("")) + statusCode, err := cs.cli.Do("GET", "/this", nil, reqBody, &v, nil) + c.Check(err, IsNil) + c.Check(statusCode, Equals, 202) + c.Check(v, DeepEquals, []int{1, 2}) + c.Assert(cs.req, NotNil) + c.Assert(cs.req.URL, NotNil) + c.Check(cs.req.Method, Equals, "GET") + c.Check(cs.req.Body, Equals, reqBody) + c.Check(cs.req.URL.Path, Equals, "/this") +} + +func (cs *clientSuite) TestClientDefaultsToNoAuthorization(c *C) { + os.Setenv(client.TestAuthFileEnvKey, filepath.Join(c.MkDir(), "json")) + defer os.Unsetenv(client.TestAuthFileEnvKey) + + var v string + _, _ = cs.cli.Do("GET", "/this", nil, nil, &v, nil) + c.Assert(cs.req, NotNil) + authorization := cs.req.Header.Get("Authorization") + c.Check(authorization, Equals, "") +} + +func (cs *clientSuite) TestClientSetsAuthorization(c *C) { + os.Setenv(client.TestAuthFileEnvKey, filepath.Join(c.MkDir(), "json")) + defer os.Unsetenv(client.TestAuthFileEnvKey) + + mockUserData := client.User{ + Macaroon: "macaroon", + Discharges: []string{"discharge"}, + } + err := client.TestWriteAuth(mockUserData) + c.Assert(err, IsNil) + + var v string + _, _ = cs.cli.Do("GET", "/this", nil, nil, &v, nil) + authorization := cs.req.Header.Get("Authorization") + c.Check(authorization, Equals, `Macaroon root="macaroon", discharge="discharge"`) +} + +func (cs *clientSuite) TestClientHonorsDisableAuth(c *C) { + os.Setenv(client.TestAuthFileEnvKey, filepath.Join(c.MkDir(), "json")) + defer os.Unsetenv(client.TestAuthFileEnvKey) + + mockUserData := client.User{ + Macaroon: "macaroon", + Discharges: []string{"discharge"}, + } + err := client.TestWriteAuth(mockUserData) + c.Assert(err, IsNil) + + var v string + cli := client.New(&client.Config{DisableAuth: true}) + cli.SetDoer(cs) + _, _ = cli.Do("GET", "/this", nil, nil, &v, nil) + authorization := cs.req.Header.Get("Authorization") + c.Check(authorization, Equals, "") +} + +func (cs *clientSuite) TestClientHonorsInteractive(c *C) { + var v string + cli := client.New(&client.Config{Interactive: false}) + cli.SetDoer(cs) + _, _ = cli.Do("GET", "/this", nil, nil, &v, nil) + interactive := cs.req.Header.Get(client.AllowInteractionHeader) + c.Check(interactive, Equals, "") + + cli = client.New(&client.Config{Interactive: true}) + cli.SetDoer(cs) + _, _ = cli.Do("GET", "/this", nil, nil, &v, nil) + interactive = cs.req.Header.Get(client.AllowInteractionHeader) + c.Check(interactive, Equals, "true") +} + +func (cs *clientSuite) TestClientWhoAmINobody(c *C) { + email, err := cs.cli.WhoAmI() + c.Assert(err, IsNil) + c.Check(email, Equals, "") +} + +func (cs *clientSuite) TestClientWhoAmIRubbish(c *C) { + c.Assert(os.WriteFile(client.TestStoreAuthFilename(os.Getenv("HOME")), []byte("rubbish"), 0644), IsNil) + + email, err := cs.cli.WhoAmI() + c.Check(err, NotNil) + c.Check(email, Equals, "") +} + +func (cs *clientSuite) TestClientWhoAmISomebody(c *C) { + mockUserData := client.User{ + Email: "foo@example.com", + } + c.Assert(client.TestWriteAuth(mockUserData), IsNil) + + email, err := cs.cli.WhoAmI() + c.Check(err, IsNil) + c.Check(email, Equals, "foo@example.com") +} + +func (cs *clientSuite) TestClientSysInfo(c *C) { + cs.rsp = `{ + "type": "sync", + "result": { + "series": "16", + "version": "2", + "os-release": {"id": "ubuntu", "version-id": "16.04"}, + "on-classic": true, + "build-id": "1234", + "confinement": "strict", + "architecture": "TI-99/4A", + "virtualization": "MESS", + "sandbox-features": {"backend": ["feature-1", "feature-2"]}, + "features": { + "foo": {"supported": false, "unsupported-reason": "too foo", "enabled": false}, + "bar": {"supported": false, "unsupported-reason": "not bar enough", "enabled": true}, + "baz": {"supported": true, "enabled": false}, + "buzz": {"supported": true, "enabled": true} + } + } +}` + sysInfo, err := cs.cli.SysInfo() + c.Check(err, IsNil) + c.Check(sysInfo, DeepEquals, &client.SysInfo{ + Version: "2", + Series: "16", + OSRelease: client.OSRelease{ + ID: "ubuntu", + VersionID: "16.04", + }, + OnClassic: true, + Confinement: "strict", + SandboxFeatures: map[string][]string{ + "backend": {"feature-1", "feature-2"}, + }, + BuildID: "1234", + Architecture: "TI-99/4A", + Virtualization: "MESS", + Features: map[string]features.FeatureInfo{ + "foo": {Supported: false, UnsupportedReason: "too foo", Enabled: false}, + "bar": {Supported: false, UnsupportedReason: "not bar enough", Enabled: true}, + "baz": {Supported: true, Enabled: false}, + "buzz": {Supported: true, Enabled: true}, + }, + }) +} + +func (cs *clientSuite) TestServerVersion(c *C) { + cs.rsp = `{"type": "sync", "result": + {"series": "16", + "version": "2", + "os-release": {"id": "zyggy", "version-id": "123"}, + "architecture": "m32", + "virtualization": "qemu" +}}}` + version, err := cs.cli.ServerVersion() + c.Check(err, IsNil) + c.Check(version, DeepEquals, &client.ServerVersion{ + Version: "2", + Series: "16", + OSID: "zyggy", + OSVersionID: "123", + Architecture: "m32", + Virtualization: "qemu", + }) +} + +func (cs *clientSuite) TestSnapdClientIntegration(c *C) { + c.Assert(os.MkdirAll(filepath.Dir(dirs.SnapdSocket), 0755), IsNil) + l, err := net.Listen("unix", dirs.SnapdSocket) + if err != nil { + c.Fatalf("unable to listen on %q: %v", dirs.SnapdSocket, err) + } + + f := func(w http.ResponseWriter, r *http.Request) { + c.Check(r.URL.Path, Equals, "/v2/system-info") + c.Check(r.URL.RawQuery, Equals, "") + + fmt.Fprintln(w, `{"type":"sync", "result":{"series":"42"}}`) + } + + srv := &httptest.Server{ + Listener: l, + Config: &http.Server{Handler: http.HandlerFunc(f)}, + } + srv.Start() + defer srv.Close() + + cli := client.New(nil) + si, err := cli.SysInfo() + c.Assert(err, IsNil) + c.Check(si.Series, Equals, "42") +} + +func (cs *clientSuite) TestSnapClientIntegration(c *C) { + c.Assert(os.MkdirAll(filepath.Dir(dirs.SnapSocket), 0755), IsNil) + l, err := net.Listen("unix", dirs.SnapSocket) + if err != nil { + c.Fatalf("unable to listen on %q: %v", dirs.SnapSocket, err) + } + + f := func(w http.ResponseWriter, r *http.Request) { + c.Check(r.URL.Path, Equals, "/v2/snapctl") + c.Check(r.URL.RawQuery, Equals, "") + + fmt.Fprintln(w, `{"type":"sync", "result":{"stdout":"test stdout","stderr":"test stderr"}}`) + } + + srv := &httptest.Server{ + Listener: l, + Config: &http.Server{Handler: http.HandlerFunc(f)}, + } + srv.Start() + defer srv.Close() + + cli := client.New(&client.Config{ + Socket: dirs.SnapSocket, + }) + options := &client.SnapCtlOptions{ + ContextID: "foo", + Args: []string{"bar", "--baz"}, + } + + stdout, stderr, err := cli.RunSnapctl(options, nil) + c.Check(err, IsNil) + c.Check(string(stdout), Equals, "test stdout") + c.Check(string(stderr), Equals, "test stderr") +} + +func (cs *clientSuite) TestClientReportsOpError(c *C) { + cs.status = 500 + cs.rsp = `{"type": "error"}` + _, err := cs.cli.SysInfo() + c.Check(err, ErrorMatches, `.*server error: "Internal Server Error"`) +} + +func (cs *clientSuite) TestClientReportsOpErrorStr(c *C) { + cs.status = 400 + cs.rsp = `{ + "result": {}, + "status": "Bad Request", + "status-code": 400, + "type": "error" + }` + _, err := cs.cli.SysInfo() + c.Check(err, ErrorMatches, `.*server error: "Bad Request"`) +} + +func (cs *clientSuite) TestClientReportsBadType(c *C) { + cs.rsp = `{"type": "what"}` + _, err := cs.cli.SysInfo() + c.Check(err, ErrorMatches, `.*expected sync response, got "what"`) +} + +func (cs *clientSuite) TestClientReportsOuterJSONError(c *C) { + cs.rsp = "this isn't really json is it" + _, err := cs.cli.SysInfo() + c.Check(err, ErrorMatches, `.*invalid character .*`) +} + +func (cs *clientSuite) TestClientReportsInnerJSONError(c *C) { + cs.rsp = `{"type": "sync", "result": "this isn't really json is it"}` + _, err := cs.cli.SysInfo() + c.Check(err, ErrorMatches, `.*cannot unmarshal.*`) +} + +func (cs *clientSuite) TestClientMaintenance(c *C) { + cs.rsp = `{"type":"sync", "result":{"series":"42"}, "maintenance": {"kind": "system-restart", "message": "system is restarting"}}` + _, err := cs.cli.SysInfo() + c.Assert(err, IsNil) + c.Check(cs.cli.Maintenance().(*client.Error), DeepEquals, &client.Error{ + Kind: client.ErrorKindSystemRestart, + Message: "system is restarting", + }) + + cs.rsp = `{"type":"sync", "result":{"series":"42"}}` + _, err = cs.cli.SysInfo() + c.Assert(err, IsNil) + c.Check(cs.cli.Maintenance(), Equals, error(nil)) +} + +func (cs *clientSuite) TestClientAsyncOpMaintenance(c *C) { + cs.status = 202 + cs.rsp = `{"type":"async", "status-code": 202, "change": "42", "maintenance": {"kind": "system-restart", "message": "system is restarting"}}` + _, err := cs.cli.Install("foo", nil) + c.Assert(err, IsNil) + c.Check(cs.cli.Maintenance().(*client.Error), DeepEquals, &client.Error{ + Kind: client.ErrorKindSystemRestart, + Message: "system is restarting", + }) + + cs.rsp = `{"type":"async", "status-code": 202, "change": "42"}` + _, err = cs.cli.Install("foo", nil) + c.Assert(err, IsNil) + c.Check(cs.cli.Maintenance(), Equals, error(nil)) +} + +func (cs *clientSuite) TestParseError(c *C) { + resp := &http.Response{ + Status: "404 Not Found", + } + err := client.ParseErrorInTest(resp) + c.Check(err, ErrorMatches, `server error: "404 Not Found"`) + + h := http.Header{} + h.Add("Content-Type", "application/json") + resp = &http.Response{ + Status: "400 Bad Request", + Header: h, + Body: io.NopCloser(strings.NewReader(`{ + "status-code": 400, + "type": "error", + "result": { + "message": "invalid" + } + }`)), + } + err = client.ParseErrorInTest(resp) + c.Check(err, ErrorMatches, "invalid") + + resp = &http.Response{ + Status: "400 Bad Request", + Header: h, + Body: io.NopCloser(strings.NewReader("{}")), + } + err = client.ParseErrorInTest(resp) + c.Check(err, ErrorMatches, `server error: "400 Bad Request"`) +} + +func (cs *clientSuite) TestIsTwoFactor(c *C) { + c.Check(client.IsTwoFactorError(&client.Error{Kind: client.ErrorKindTwoFactorRequired}), Equals, true) + c.Check(client.IsTwoFactorError(&client.Error{Kind: client.ErrorKindTwoFactorFailed}), Equals, true) + c.Check(client.IsTwoFactorError(&client.Error{Kind: "some other kind"}), Equals, false) + c.Check(client.IsTwoFactorError(errors.New("test")), Equals, false) + c.Check(client.IsTwoFactorError(nil), Equals, false) + c.Check(client.IsTwoFactorError((*client.Error)(nil)), Equals, false) +} + +func (cs *clientSuite) TestIsRetryable(c *C) { + // unhappy + c.Check(client.IsRetryable(nil), Equals, false) + c.Check(client.IsRetryable(errors.New("some-error")), Equals, false) + c.Check(client.IsRetryable(&client.Error{Kind: "something-else"}), Equals, false) + // happy + c.Check(client.IsRetryable(&client.Error{Kind: client.ErrorKindSnapChangeConflict}), Equals, true) +} + +func (cs *clientSuite) TestUserAgent(c *C) { + cli := client.New(&client.Config{UserAgent: "some-agent/9.87"}) + cli.SetDoer(cs) + + var v string + _, _ = cli.Do("GET", "/", nil, nil, &v, nil) + c.Assert(cs.req, NotNil) + c.Check(cs.req.Header.Get("User-Agent"), Equals, "some-agent/9.87") +} + +func (cs *clientSuite) TestDebugEnsureStateSoon(c *C) { + cs.rsp = `{"type": "sync", "result":true}` + err := cs.cli.Debug("ensure-state-soon", nil, nil) + c.Check(err, IsNil) + c.Check(cs.reqs, HasLen, 1) + c.Check(cs.reqs[0].Method, Equals, "POST") + c.Check(cs.reqs[0].URL.Path, Equals, "/v2/debug") + data, err := io.ReadAll(cs.reqs[0].Body) + c.Assert(err, IsNil) + c.Check(data, DeepEquals, []byte(`{"action":"ensure-state-soon"}`)) +} + +func (cs *clientSuite) TestDebugGeneric(c *C) { + cs.rsp = `{"type": "sync", "result":["res1","res2"]}` + + var result []string + err := cs.cli.Debug("do-something", []string{"param1", "param2"}, &result) + c.Check(err, IsNil) + c.Check(result, DeepEquals, []string{"res1", "res2"}) + c.Check(cs.reqs, HasLen, 1) + c.Check(cs.reqs[0].Method, Equals, "POST") + c.Check(cs.reqs[0].URL.Path, Equals, "/v2/debug") + data, err := io.ReadAll(cs.reqs[0].Body) + c.Assert(err, IsNil) + c.Check(string(data), DeepEquals, `{"action":"do-something","params":["param1","param2"]}`) +} + +func (cs *clientSuite) TestDebugGet(c *C) { + cs.rsp = `{"type": "sync", "result":["res1","res2"]}` + + var result []string + err := cs.cli.DebugGet("do-something", &result, map[string]string{"foo": "bar"}) + c.Check(err, IsNil) + c.Check(result, DeepEquals, []string{"res1", "res2"}) + c.Check(cs.reqs, HasLen, 1) + c.Check(cs.reqs[0].Method, Equals, "GET") + c.Check(cs.reqs[0].URL.Path, Equals, "/v2/debug") + c.Check(cs.reqs[0].URL.Query(), DeepEquals, url.Values{"aspect": []string{"do-something"}, "foo": []string{"bar"}}) +} + +func (cs *clientSuite) TestDebugMigrateHome(c *C) { + cs.status = 202 + cs.rsp = `{"type": "async", "status-code": 202, "change": "123"}` + + snaps := []string{"foo", "bar"} + changeID, err := cs.cli.MigrateSnapHome(snaps) + c.Check(err, IsNil) + c.Check(changeID, Equals, "123") + + c.Check(cs.reqs, HasLen, 1) + c.Check(cs.reqs[0].Method, Equals, "POST") + c.Check(cs.reqs[0].URL.Path, Equals, "/v2/debug") + data, err := io.ReadAll(cs.reqs[0].Body) + c.Assert(err, IsNil) + c.Check(string(data), Equals, `{"action":"migrate-home","snaps":["foo","bar"]}`) +} + +type integrationSuite struct{} + +var _ = Suite(&integrationSuite{}) + +func (cs *integrationSuite) TestClientTimeoutLP1837804(c *C) { + restore := client.MockDoTimings(time.Millisecond, 5*time.Millisecond) + defer restore() + + testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { + time.Sleep(25 * time.Millisecond) + })) + defer func() { testServer.Close() }() + + cli := client.New(&client.Config{BaseURL: testServer.URL}) + _, err := cli.Do("GET", "/", nil, nil, nil, nil) + c.Assert(err, ErrorMatches, `.* timeout exceeded while waiting for response`) + + _, err = cli.Do("POST", "/", nil, nil, nil, nil) + c.Assert(err, ErrorMatches, `.* timeout exceeded while waiting for response`) +} + +func (cs *clientSuite) TestClientSystemRecoveryKeys(c *C) { + cs.rsp = `{"type":"sync", "result":{"recovery-key":"42"}}` + + var key client.SystemRecoveryKeysResponse + err := cs.cli.SystemRecoveryKeys(&key) + c.Assert(err, IsNil) + c.Check(cs.reqs, HasLen, 1) + c.Check(cs.reqs[0].Method, Equals, "GET") + c.Check(cs.reqs[0].URL.Path, Equals, "/v2/system-recovery-keys") + c.Check(key.RecoveryKey, Equals, "42") +} + +func (cs *clientSuite) TestClientDebugEnvVar(c *C) { + buf, restore := logger.MockLogger() + defer restore() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + io.WriteString(w, `bar`) + })) + defer srv.Close() + + debugValue, ok := os.LookupEnv("SNAP_CLIENT_DEBUG_HTTP") + defer func() { + if ok { + os.Setenv("SNAP_CLIENT_DEBUG_HTTP", debugValue) + } else { + os.Unsetenv("SNAP_CLIENT_DEBUG_HTTP") + } + }() + + os.Setenv("SNAP_CLIENT_DEBUG_HTTP", "7") + + cli := client.New(&client.Config{BaseURL: srv.URL}) + c.Assert(cli, NotNil) + _, err := cli.Do("GET", "/", nil, strings.NewReader("foo"), nil, nil) + c.Assert(err, IsNil) + + // check request + c.Assert(buf.String(), testutil.Contains, `logger.go:67: DEBUG: > "GET`) + // check response + c.Assert(buf.String(), testutil.Contains, `logger.go:74: DEBUG: < "HTTP/1.1 200 OK`) + // check bodies + c.Assert(buf.String(), testutil.Contains, "foo") + c.Assert(buf.String(), testutil.Contains, "bar") +} diff --git a/client/clientutil/modelinfo.go b/client/clientutil/modelinfo.go new file mode 100644 index 00000000..91474e53 --- /dev/null +++ b/client/clientutil/modelinfo.go @@ -0,0 +1,448 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package clientutil + +import ( + "encoding/json" + "fmt" + "strings" + "text/tabwriter" + "time" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/strutil" + "github.com/snapcore/snapd/timeutil" +) + +var ( + // this list is a "nice" "human" "readable" "ordering" of headers to print. + // it also contains both serial and model assertion headers, but we + // follow the same code path for both assertion types and some of the + // headers are shared between the two, so it still works out correctly + niceOrdering = [...]string{ + "architecture", + "base", + "classic", + "display-name", + "gadget", + "kernel", + "revision", + "store", + "system-user-authority", + "timestamp", + "required-snaps", // for uc16 and uc18 models + "snaps", // for uc20 models + "device-key-sha3-384", + "device-key", + } +) + +// ModelAssertJSON is used to represent a model assertion as-is in JSON. +type ModelAssertJSON struct { + Headers map[string]interface{} `json:"headers,omitempty"` + Body string `json:"body,omitempty"` +} + +// ModelFormatter is a helper interface to format special model elements +// like the publisher, which needs additional formatting. The formatting +// varies based on where this code needs to be used, which is why this +// interface is defined. +type ModelFormatter interface { + // LongPublisher returns the publisher as a nicely formatted string. + LongPublisher(storeAccountID string) string + // GetEscapedDash returns either a double dash which is YAML safe, or the + // special unicode dash character. + GetEscapedDash() string +} + +type PrintModelAssertionOptions struct { + // TermWidth is the width of the terminal for the output. This is used to format + // the device keys in a more readable way. + TermWidth int + // AbsTime determines how the timestamps are formatted, if set the timestamp + // will be formatted as RFC3339, otherwise as a human readable time. + AbsTime bool + // Verbose prints additional information about the provided assertion, + // which includes most of the assertion headers. This is implicitly always + // true when printing in JSON. + Verbose bool + // Assertion controls whether the provided assertion will be serialized + // without any prior processing, which means if set, it will serialize + // the entire assertion as-is. + Assertion bool +} + +func fmtTime(t time.Time, abs bool) string { + if abs { + return t.Format(time.RFC3339) + } + return timeutil.Human(t) +} + +func formatInvalidTypeErr(headers ...string) error { + return fmt.Errorf("invalid type for %q header", strings.Join(headers, "/")) +} + +func printVerboseSnapsList(w *tabwriter.Writer, snaps []interface{}) error { + printModes := func(snapName string, members map[string]interface{}) error { + modes, ok := members["modes"] + if !ok { + return nil + } + + modesSlice, ok := modes.([]interface{}) + if !ok { + return formatInvalidTypeErr("snaps", snapName, "modes") + } + + if len(modesSlice) == 0 { + return nil + } + + modeStrSlice := make([]string, 0, len(modesSlice)) + for _, mode := range modesSlice { + modeStr, ok := mode.(string) + if !ok { + return formatInvalidTypeErr("snaps", snapName, "modes") + } + modeStrSlice = append(modeStrSlice, modeStr) + } + modesSliceYamlStr := "[" + strings.Join(modeStrSlice, ", ") + "]" + fmt.Fprintf(w, " modes:\t%s\n", modesSliceYamlStr) + return nil + } + + for _, sn := range snaps { + snMap, ok := sn.(map[string]interface{}) + if !ok { + return formatInvalidTypeErr("snaps") + } + + // Print all the desired keys in the map in a stable, visually + // appealing ordering + // first do snap name, which will always be present since we + // parsed a valid assertion + name := snMap["name"].(string) + fmt.Fprintf(w, " - name:\t%s\n", name) + + // the rest of these may be absent, but they are all still + // simple strings + for _, snKey := range []string{"id", "type", "default-channel", "presence"} { + snValue, ok := snMap[snKey] + if !ok { + continue + } + snStrValue, ok := snValue.(string) + if !ok { + return formatInvalidTypeErr("snaps", snKey) + } + if snStrValue != "" { + fmt.Fprintf(w, " %s:\t%s\n", snKey, snStrValue) + } + } + + // finally handle "modes" which is a list + if err := printModes(name, snMap); err != nil { + return err + } + } + return nil +} + +func printVerboseModelAssertionHeaders(w *tabwriter.Writer, assertion asserts.Assertion, opts PrintModelAssertionOptions) error { + allHeadersMap := assertion.Headers() + for _, headerName := range niceOrdering { + headerValue, ok := allHeadersMap[headerName] + // make sure the header is in the map + if !ok { + continue + } + + // switch on which header it is to handle some special cases + switch headerName { + // list of scalars + case "required-snaps", "system-user-authority": + headerIfaceList, ok := headerValue.([]interface{}) + if !ok { + // system-user-authority can also appear as string + headerString, ok := headerValue.(string) + if ok { + fmt.Fprintf(w, "%s:\t%s\n", headerName, headerString) + continue + } + return formatInvalidTypeErr(headerName) + } + if len(headerIfaceList) == 0 { + continue + } + + fmt.Fprintf(w, "%s:\t\n", headerName) + for _, elem := range headerIfaceList { + headerStringElem, ok := elem.(string) + if !ok { + return formatInvalidTypeErr(headerName) + } + // note we don't wrap these, since for now this is + // specifically just required-snaps and so all of these + // will be snap names which are required to be short + fmt.Fprintf(w, " - %s\n", headerStringElem) + } + + // timestamp needs to be formatted in an identical manner to how fmtTime works + // from timeMixin package in cmd/snap + case "timestamp": + timestamp, ok := headerValue.(string) + if !ok { + return formatInvalidTypeErr(headerName) + } + + // parse the time string as RFC3339, which is what the format is + // always in for assertions + t, err := time.Parse(time.RFC3339, timestamp) + if err != nil { + return err + } + fmt.Fprintf(w, "timestamp:\t%s\n", fmtTime(t, opts.AbsTime)) + + // long string key we don't want to rewrap but can safely handle + // on "reasonable" width terminals + case "device-key-sha3-384": + // also flush the writer before continuing so the previous keys + // don't try to align with this key + w.Flush() + headerString, ok := headerValue.(string) + if !ok { + return formatInvalidTypeErr(headerName) + } + + switch { + case opts.TermWidth > 86: + fmt.Fprintf(w, "device-key-sha3-384: %s\n", headerString) + case opts.TermWidth > 66: + fmt.Fprintln(w, "device-key-sha3-384: |") + strutil.WordWrapPadded(w, []rune(headerString), " ", opts.TermWidth) + } + case "snaps": + // also flush the writer before continuing so the previous keys + // don't try to align with this key + w.Flush() + snapsHeader, ok := headerValue.([]interface{}) + if !ok { + return formatInvalidTypeErr(headerName) + } + if len(snapsHeader) == 0 { + // unexpected why this is an empty list, but just ignore for + // now + continue + } + + fmt.Fprintf(w, "snaps:\n") + if err := printVerboseSnapsList(w, snapsHeader); err != nil { + return err + } + + // long base64 key we can rewrap safely + case "device-key": + headerString, ok := headerValue.(string) + if !ok { + return formatInvalidTypeErr(headerName) + } + // the string value here has newlines inserted as part of the + // raw assertion, but base64 doesn't care about whitespace, so + // it's safe to replace the newlines + headerString = strings.ReplaceAll(headerString, "\n", "") + fmt.Fprintln(w, "device-key: |") + strutil.WordWrapPadded(w, []rune(headerString), " ", opts.TermWidth) + + // The rest of the values should be single strings + default: + headerString, ok := headerValue.(string) + if !ok { + return formatInvalidTypeErr(headerName) + } + fmt.Fprintf(w, "%s:\t%s\n", headerName, headerString) + } + } + return w.Flush() +} + +// PrintModelAssertion will format the provided serial or model assertion based on the parameters given in +// YAML format, or serialize it raw if Assertion is set. The output will be written to the provided io.Writer. +func PrintModelAssertion(w *tabwriter.Writer, modelAssertion asserts.Model, serialAssertion *asserts.Serial, modelFormatter ModelFormatter, opts PrintModelAssertionOptions) error { + // if assertion was requested we want it raw + if opts.Assertion { + _, err := w.Write(asserts.Encode(&modelAssertion)) + return err + } + + // the rest of this function is the main flow for outputting either the + // model or serial assertion in normal or verbose mode + + // for the `snap model` case with no options, we don't want colons, we want + // to be like `snap version` + separator := ":" + if !opts.Verbose { + separator = "" + } + + // ordering of the primary keys for model: brand, model, serial + brandIDHeader := modelAssertion.HeaderString("brand-id") + modelHeader := modelAssertion.HeaderString("model") + + // for the serial header, if there's no serial yet, it's not an error for + // model (and we already handled the serial error above) but need to add a + // parenthetical about the device not being registered yet + var serial string + if serialAssertion == nil { + if opts.Verbose { + // verbose and serial are yamlish, so we need to escape the dash + serial = modelFormatter.GetEscapedDash() + } else { + serial = "-" + } + serial += " (device not registered yet)" + } else { + serial = serialAssertion.HeaderString("serial") + } + + // handle brand/brand-id and model/model + display-name differently on just + // `snap model` w/o opts + if opts.Verbose { + fmt.Fprintf(w, "brand-id:\t%s\n", brandIDHeader) + fmt.Fprintf(w, "model:\t%s\n", modelHeader) + } else { + publisher := modelFormatter.LongPublisher(brandIDHeader) + + // use the longPublisher helper to format the brand store account + // like we do in `snap info` + fmt.Fprintf(w, "brand%s\t%s\n", separator, publisher) + + // for model, if there's a display-name, we show that first with the + // real model in parenthesis + if displayName := modelAssertion.HeaderString("display-name"); displayName != "" { + modelHeader = fmt.Sprintf("%s (%s)", displayName, modelHeader) + } + fmt.Fprintf(w, "model%s\t%s\n", separator, modelHeader) + } + + grade := modelAssertion.HeaderString("grade") + if grade != "" { + fmt.Fprintf(w, "grade%s\t%s\n", separator, grade) + } + + storageSafety := modelAssertion.HeaderString("storage-safety") + if storageSafety != "" { + fmt.Fprintf(w, "storage-safety%s\t%s\n", separator, storageSafety) + } + + fmt.Fprintf(w, "serial%s\t%s\n", separator, serial) + + if opts.Verbose { + if err := printVerboseModelAssertionHeaders(w, &modelAssertion, opts); err != nil { + return err + } + } + return w.Flush() +} + +// PrintModelAssertionYAML will format the provided serial or model assertion based on the parameters given in +// YAML format. The output will be written to the provided io.Writer. +func PrintSerialAssertionYAML(w *tabwriter.Writer, serialAssertion asserts.Serial, modelFormatter ModelFormatter, opts PrintModelAssertionOptions) error { + // if assertion was requested we want it raw + if opts.Assertion { + _, err := w.Write(asserts.Encode(&serialAssertion)) + return err + } + + // the rest of this function is the main flow for outputting either the + // serial assertion in normal or verbose mode + + // ordering of primary keys for serial is brand-id, model, serial + brandIDHeader := serialAssertion.HeaderString("brand-id") + modelHeader := serialAssertion.HeaderString("model") + serial := serialAssertion.HeaderString("serial") + + fmt.Fprintf(w, "brand-id:\t%s\n", brandIDHeader) + fmt.Fprintf(w, "model:\t%s\n", modelHeader) + fmt.Fprintf(w, "serial:\t%s\n", serial) + + if opts.Verbose { + if err := printVerboseModelAssertionHeaders(w, &serialAssertion, opts); err != nil { + return err + } + } + return w.Flush() +} + +// PrintModelAssertionJSON will format the provided serial or model assertion based on the parameters given in +// JSON format. The output will be written to the provided io.Writer. +func PrintModelAssertionJSON(w *tabwriter.Writer, modelAssertion asserts.Model, serialAssertion *asserts.Serial, opts PrintModelAssertionOptions) error { + serializeJSON := func(v interface{}) error { + marshalled, err := json.MarshalIndent(v, "", " ") + if err != nil { + return err + } + + _, err = w.Write(marshalled) + if err != nil { + return err + } + return w.Flush() + } + + if opts.Assertion { + modelJSON := ModelAssertJSON{} + modelJSON.Headers = modelAssertion.Headers() + modelJSON.Body = string(modelAssertion.Body()) + return serializeJSON(modelJSON) + } + + modelData := make(map[string]interface{}) + modelData["brand-id"] = modelAssertion.HeaderString("brand-id") + modelData["model"] = modelAssertion.HeaderString("model") + + grade := modelAssertion.HeaderString("grade") + if grade != "" { + modelData["grade"] = grade + } + + storageSafety := modelAssertion.HeaderString("storage-safety") + if storageSafety != "" { + modelData["storage-safety"] = storageSafety + } + + if serialAssertion != nil { + modelData["serial"] = serialAssertion.HeaderString("serial") + } else { + modelData["serial"] = nil + } + allHeadersMap := modelAssertion.Headers() + + // always print extra information for JSON + for _, headerName := range niceOrdering { + headerValue, ok := allHeadersMap[headerName] + if !ok { + continue + } + modelData[headerName] = headerValue + } + + return serializeJSON(modelData) +} diff --git a/client/clientutil/modelinfo_test.go b/client/clientutil/modelinfo_test.go new file mode 100644 index 00000000..97c10a2e --- /dev/null +++ b/client/clientutil/modelinfo_test.go @@ -0,0 +1,502 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package clientutil_test + +import ( + "bytes" + "fmt" + "strings" + "text/tabwriter" + "time" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/asserts/assertstest" + "github.com/snapcore/snapd/client/clientutil" + "github.com/snapcore/snapd/strutil" + "github.com/snapcore/snapd/testutil" + "github.com/snapcore/snapd/timeutil" +) + +const ( + modelExample = "type: model\n" + + "authority-id: brand-id1\n" + + "series: 16\n" + + "brand-id: brand-id1\n" + + "model: baz-3000\n" + + "display-name: Baz 3000\n" + + "architecture: amd64\n" + + "gadget: brand-gadget\n" + + "base: core18\n" + + "kernel: baz-linux\n" + + "store: brand-store\n" + + "serial-authority:\n - generic\n" + + "system-user-authority: *\n" + + "required-snaps:\n - foo\n - bar\n" + + "TSLINE" + + "body-length: 0\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==" + + core20ModelExample = `type: model +authority-id: brand-id1 +series: 16 +brand-id: brand-id1 +model: baz-3000 +display-name: Baz 3000 +architecture: amd64 +system-user-authority: + - partner + - brand-id1 +base: core20 +store: brand-store +grade: dangerous +storage-safety: prefer-unencrypted +snaps: + - + name: baz-linux + id: bazlinuxidididididididididididid + type: kernel + default-channel: 20 + - + name: brand-gadget + id: brandgadgetdidididididididididid + type: gadget + - + name: other-base + id: otherbasedididididididididididid + type: base + modes: + - run + presence: required + - + name: nm + id: nmididididididididididididididid + modes: + - ephemeral + - run + default-channel: 1.0 + - + name: myapp + id: myappdididididididididididididid + type: app + default-channel: 2.0 + - + name: myappopt + id: myappoptidididididididididididid + type: app + presence: optional + grade: secured + storage-safety: encrypted +` + "TSLINE" + + "body-length: 0\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij" + + "\n\n" + + "AXNpZw==" + + serialExample = "type: serial\n" + + "authority-id: brand-id1\n" + + "brand-id: brand-id1\n" + + "model: baz-3000\n" + + "serial: 2700\n" + + "device-key:\n DEVICEKEY\n" + + "device-key-sha3-384: KEYID\n" + + "TSLINE" + + "body-length: 2\n" + + "sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij\n\n" + + "HW" + + "\n\n" + + "AXNpZw==" +) + +type testFormatter struct { +} + +func (tf testFormatter) GetEscapedDash() string { + return "--" +} + +func (tf testFormatter) LongPublisher(storeAccountID string) string { + return storeAccountID +} + +type modelInfoSuite struct { + testutil.BaseTest + ts time.Time + tsLine string + deviceKey asserts.PrivateKey + encodedDevKey string + formatter testFormatter +} + +var testPrivKey2, _ = assertstest.GenerateKey(752) + +var _ = Suite(&modelInfoSuite{}) + +func (s *modelInfoSuite) SetUpTest(c *C) { + s.BaseTest.SetUpTest(c) + s.ts = time.Now().Truncate(time.Second).UTC() + s.tsLine = "timestamp: " + s.ts.Format(time.RFC3339) + "\n" + + s.deviceKey = testPrivKey2 + encodedPubKey, err := asserts.EncodePublicKey(s.deviceKey.PublicKey()) + c.Assert(err, IsNil) + s.encodedDevKey = string(encodedPubKey) +} + +func (s *modelInfoSuite) getModel(c *C, modelText string) asserts.Model { + encoded := strings.Replace(modelText, "TSLINE", s.tsLine, 1) + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + c.Check(a.Type(), Equals, asserts.ModelType) + model := a.(*asserts.Model) + return *model +} + +func (s *modelInfoSuite) getDeviceKey(padding string, termWidth int) string { + var buffer bytes.Buffer + w := tabwriter.NewWriter(&buffer, 0, 5, 4, ' ', 0) + key := strings.Join(strings.Split(s.encodedDevKey, "\n"), "") + strutil.WordWrapPadded(w, []rune(key), padding, termWidth) + return buffer.String() +} + +func (s *modelInfoSuite) getSerial(c *C, serialText string) asserts.Serial { + encoded := strings.Replace(serialText, "TSLINE", s.tsLine, 1) + encoded = strings.Replace(encoded, "DEVICEKEY", strings.Replace(s.encodedDevKey, "\n", "\n ", -1), 1) + encoded = strings.Replace(encoded, "KEYID", s.deviceKey.PublicKey().ID(), 1) + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + c.Check(a.Type(), Equals, asserts.SerialType) + serial := a.(*asserts.Serial) + return *serial +} + +func (s *modelInfoSuite) TestPrintModelYAML(c *C) { + var buffer bytes.Buffer + w := tabwriter.NewWriter(&buffer, 0, 5, 4, ' ', 0) + model := s.getModel(c, modelExample) + + options := clientutil.PrintModelAssertionOptions{ + TermWidth: 80, + Verbose: false, + AbsTime: false, + Assertion: false, + } + err := clientutil.PrintModelAssertion(w, model, nil, s.formatter, options) + c.Assert(err, IsNil) + c.Check(buffer.String(), Equals, `brand brand-id1 +model Baz 3000 (baz-3000) +serial - (device not registered yet) +`) +} + +func (s *modelInfoSuite) TestPrintModelWithSerialYAML(c *C) { + var buffer bytes.Buffer + w := tabwriter.NewWriter(&buffer, 0, 5, 4, ' ', 0) + model := s.getModel(c, modelExample) + serial := s.getSerial(c, serialExample) + + options := clientutil.PrintModelAssertionOptions{ + TermWidth: 80, + Verbose: false, + AbsTime: false, + Assertion: false, + } + err := clientutil.PrintModelAssertion(w, model, &serial, s.formatter, options) + c.Assert(err, IsNil) + c.Check(buffer.String(), Equals, `brand brand-id1 +model Baz 3000 (baz-3000) +serial 2700 +`) +} + +func (s *modelInfoSuite) TestPrintModelYAMLVerbose(c *C) { + var buffer bytes.Buffer + w := tabwriter.NewWriter(&buffer, 0, 5, 4, ' ', 0) + model := s.getModel(c, core20ModelExample) + + options := clientutil.PrintModelAssertionOptions{ + TermWidth: 80, + Verbose: true, + AbsTime: false, + Assertion: false, + } + err := clientutil.PrintModelAssertion(w, model, nil, s.formatter, options) + c.Assert(err, IsNil) + c.Check(buffer.String(), Equals, fmt.Sprintf(`brand-id: brand-id1 +model: baz-3000 +grade: dangerous +storage-safety: prefer-unencrypted +serial: -- (device not registered yet) +architecture: amd64 +base: core20 +display-name: Baz 3000 +store: brand-store +system-user-authority: + - partner + - brand-id1 +timestamp: %s +snaps: + - name: baz-linux + id: bazlinuxidididididididididididid + type: kernel + default-channel: 20 + - name: brand-gadget + id: brandgadgetdidididididididididid + type: gadget + - name: other-base + id: otherbasedididididididididididid + type: base + presence: required + modes: [run] + - name: nm + id: nmididididididididididididididid + default-channel: 1.0 + modes: [ephemeral, run] + - name: myapp + id: myappdididididididididididididid + type: app + default-channel: 2.0 + - name: myappopt + id: myappoptidididididididididididid + type: app + presence: optional +`, timeutil.Human(s.ts))) +} + +func (s *modelInfoSuite) TestPrintModelAssertion(c *C) { + var buffer bytes.Buffer + w := tabwriter.NewWriter(&buffer, 0, 5, 4, ' ', 0) + model := s.getModel(c, modelExample) + + options := clientutil.PrintModelAssertionOptions{ + TermWidth: 80, + Verbose: false, + AbsTime: false, + Assertion: true, + } + err := clientutil.PrintModelAssertion(w, model, nil, s.formatter, options) + c.Assert(err, IsNil) + c.Check(buffer.String(), Equals, fmt.Sprintf(`type: model +authority-id: brand-id1 +series: 16 +brand-id: brand-id1 +model: baz-3000 +display-name: Baz 3000 +architecture: amd64 +gadget: brand-gadget +base: core18 +kernel: baz-linux +store: brand-store +serial-authority: + - generic +system-user-authority: * +required-snaps: + - foo + - bar +timestamp: %s +body-length: 0 +sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij + +`, s.ts.Format(time.RFC3339))) +} + +func (s *modelInfoSuite) TestPrintSerialYAML(c *C) { + var buffer bytes.Buffer + w := tabwriter.NewWriter(&buffer, 0, 5, 4, ' ', 0) + serial := s.getSerial(c, serialExample) + + options := clientutil.PrintModelAssertionOptions{ + TermWidth: 80, + Verbose: false, + AbsTime: false, + Assertion: false, + } + err := clientutil.PrintSerialAssertionYAML(w, serial, s.formatter, options) + c.Assert(err, IsNil) + c.Check(buffer.String(), Equals, `brand-id: brand-id1 +model: baz-3000 +serial: 2700 +`) +} + +func (s *modelInfoSuite) TestPrintSerialAssertion(c *C) { + var buffer bytes.Buffer + w := tabwriter.NewWriter(&buffer, 0, 5, 4, ' ', 0) + serial := s.getSerial(c, serialExample) + + options := clientutil.PrintModelAssertionOptions{ + TermWidth: 80, + Verbose: false, + AbsTime: false, + Assertion: true, + } + err := clientutil.PrintSerialAssertionYAML(w, serial, s.formatter, options) + c.Assert(err, IsNil) + c.Check(buffer.String(), Equals, fmt.Sprintf(`type: serial +authority-id: brand-id1 +brand-id: brand-id1 +model: baz-3000 +serial: 2700 +device-key: +%sdevice-key-sha3-384: %s +timestamp: %s +body-length: 2 +sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij + +HW + +`, s.getDeviceKey(" ", options.TermWidth), s.deviceKey.PublicKey().ID(), s.ts.Format(time.RFC3339))) +} + +func (s *modelInfoSuite) TestPrintSerialVerboseYAML(c *C) { + var buffer bytes.Buffer + w := tabwriter.NewWriter(&buffer, 0, 5, 4, ' ', 0) + serial := s.getSerial(c, serialExample) + + options := clientutil.PrintModelAssertionOptions{ + TermWidth: 80, + Verbose: true, + AbsTime: false, + Assertion: false, + } + err := clientutil.PrintSerialAssertionYAML(w, serial, s.formatter, options) + c.Assert(err, IsNil) + c.Check(buffer.String(), Equals, fmt.Sprintf(`brand-id: brand-id1 +model: baz-3000 +serial: 2700 +timestamp: %s +device-key-sha3-384: | + %s +device-key: | +%s`, timeutil.Human(s.ts), s.deviceKey.PublicKey().ID(), s.getDeviceKey(" ", options.TermWidth))) +} + +func (s *modelInfoSuite) TestPrintModelJSON(c *C) { + var buffer bytes.Buffer + w := tabwriter.NewWriter(&buffer, 0, 5, 4, ' ', 0) + model := s.getModel(c, modelExample) + + // For JSON verbose is always implictly true + options := clientutil.PrintModelAssertionOptions{ + TermWidth: 80, + Verbose: true, + AbsTime: false, + Assertion: false, + } + err := clientutil.PrintModelAssertionJSON(w, model, nil, options) + c.Assert(err, IsNil) + c.Check(buffer.String(), Equals, fmt.Sprintf(`{ + "architecture": "amd64", + "base": "core18", + "brand-id": "brand-id1", + "display-name": "Baz 3000", + "gadget": "brand-gadget", + "kernel": "baz-linux", + "model": "baz-3000", + "required-snaps": [ + "foo", + "bar" + ], + "serial": null, + "store": "brand-store", + "system-user-authority": "*", + "timestamp": "%s" +}`, s.ts.Format(time.RFC3339))) +} + +func (s *modelInfoSuite) TestPrintModelWithSerialJSON(c *C) { + var buffer bytes.Buffer + w := tabwriter.NewWriter(&buffer, 0, 5, 4, ' ', 0) + model := s.getModel(c, modelExample) + serial := s.getSerial(c, serialExample) + + // For JSON verbose is always implictly true + options := clientutil.PrintModelAssertionOptions{ + TermWidth: 80, + Verbose: true, + AbsTime: false, + Assertion: false, + } + err := clientutil.PrintModelAssertionJSON(w, model, &serial, options) + c.Assert(err, IsNil) + c.Check(buffer.String(), Equals, fmt.Sprintf(`{ + "architecture": "amd64", + "base": "core18", + "brand-id": "brand-id1", + "display-name": "Baz 3000", + "gadget": "brand-gadget", + "kernel": "baz-linux", + "model": "baz-3000", + "required-snaps": [ + "foo", + "bar" + ], + "serial": "2700", + "store": "brand-store", + "system-user-authority": "*", + "timestamp": "%s" +}`, s.ts.Format(time.RFC3339))) +} + +func (s *modelInfoSuite) TestPrintModelJSONAssertion(c *C) { + var buffer bytes.Buffer + w := tabwriter.NewWriter(&buffer, 0, 5, 4, ' ', 0) + model := s.getModel(c, modelExample) + + // For JSON verbose is always implictly true + options := clientutil.PrintModelAssertionOptions{ + TermWidth: 80, + Verbose: true, + AbsTime: false, + Assertion: true, + } + err := clientutil.PrintModelAssertionJSON(w, model, nil, options) + c.Assert(err, IsNil) + c.Check(buffer.String(), Equals, fmt.Sprintf(`{ + "headers": { + "architecture": "amd64", + "authority-id": "brand-id1", + "base": "core18", + "body-length": "0", + "brand-id": "brand-id1", + "display-name": "Baz 3000", + "gadget": "brand-gadget", + "kernel": "baz-linux", + "model": "baz-3000", + "required-snaps": [ + "foo", + "bar" + ], + "serial-authority": [ + "generic" + ], + "series": "16", + "sign-key-sha3-384": "Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij", + "store": "brand-store", + "system-user-authority": "*", + "timestamp": "%s", + "type": "model" + } +}`, s.ts.Format(time.RFC3339))) +} diff --git a/client/clientutil/service_scope.go b/client/clientutil/service_scope.go new file mode 100644 index 00000000..c6d860d0 --- /dev/null +++ b/client/clientutil/service_scope.go @@ -0,0 +1,99 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2024 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package clientutil + +import ( + "fmt" + + "github.com/snapcore/snapd/client" + "github.com/snapcore/snapd/i18n" + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/strutil" +) + +// ServiceScopeOptions represents shared options between service operations +// that change the scope of services affected. +type ServiceScopeOptions struct { + System bool `long:"system"` + User bool `long:"user"` + Usernames string `long:"users"` +} + +func (us *ServiceScopeOptions) Validate() error { + switch { + case us.System && us.User: + return fmt.Errorf("--system and --user cannot be used in conjunction with each other") + case us.Usernames != "" && us.User: + return fmt.Errorf("--user and --users cannot be used in conjunction with each other") + case us.Usernames != "" && us.Usernames != "all": + return fmt.Errorf("only \"all\" is supported as a value for --users") + } + return nil +} + +func (us *ServiceScopeOptions) Scope() client.ScopeSelector { + switch { + case (us.User || us.Usernames != "") && !us.System: + return client.ScopeSelector([]string{"user"}) + case !(us.User || us.Usernames != "") && us.System: + return client.ScopeSelector([]string{"system"}) + } + return nil +} + +func (us *ServiceScopeOptions) Users() client.UserSelector { + switch { + case us.User: + return client.UserSelector{ + Selector: client.UserSelectionSelf, + } + case us.Usernames == "all": + return client.UserSelector{ + Selector: client.UserSelectionAll, + } + } + // Currently not reachable as us.Usernames can only be 'all' for now, but when + // we introduce support for lists of usernames, this will be hit. + return client.UserSelector{ + Selector: client.UserSelectionList, + Names: strutil.CommaSeparatedList(us.Usernames), + } +} + +// FmtServiceStatus formats a given service application into the following string +// +// To keep output persistent between snapctl and snap cmd. +func FmtServiceStatus(svc *client.AppInfo, isGlobal bool) string { + startup := i18n.G("disabled") + if svc.Enabled { + startup = i18n.G("enabled") + } + + // When requesting global service status, we don't have any active + // information available for user daemons. + current := i18n.G("inactive") + if svc.DaemonScope == snap.UserDaemon && isGlobal { + current = "-" + } else if svc.Active { + current = i18n.G("active") + } + + return fmt.Sprintf("%s.%s\t%s\t%s\t%s", svc.Snap, svc.Name, startup, current, ClientAppInfoNotes(svc)) +} diff --git a/client/clientutil/service_scope_test.go b/client/clientutil/service_scope_test.go new file mode 100644 index 00000000..7aa94bf2 --- /dev/null +++ b/client/clientutil/service_scope_test.go @@ -0,0 +1,82 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2024 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package clientutil_test + +import ( + "github.com/snapcore/snapd/client" + "github.com/snapcore/snapd/client/clientutil" + . "gopkg.in/check.v1" +) + +type serviceScopeSuite struct{} + +var _ = Suite(&serviceScopeSuite{}) + +func (s *serviceScopeSuite) TestScopes(c *C) { + tests := []struct { + opts clientutil.ServiceScopeOptions + expected client.ScopeSelector + }{ + // when expected is nil it means both scopes + {clientutil.ServiceScopeOptions{}, nil}, + {clientutil.ServiceScopeOptions{User: true}, client.ScopeSelector{"user"}}, + {clientutil.ServiceScopeOptions{Usernames: "all"}, client.ScopeSelector{"user"}}, + {clientutil.ServiceScopeOptions{System: true}, client.ScopeSelector{"system"}}, + {clientutil.ServiceScopeOptions{User: true, System: true}, nil}, + {clientutil.ServiceScopeOptions{Usernames: "all", System: true}, nil}, + } + + for _, t := range tests { + c.Check(t.opts.Scope(), DeepEquals, t.expected) + } +} + +func (s *serviceScopeSuite) TestUsers(c *C) { + tests := []struct { + opts clientutil.ServiceScopeOptions + expected client.UserSelector + }{ + {clientutil.ServiceScopeOptions{}, client.UserSelector{Names: []string{}, Selector: client.UserSelectionList}}, + {clientutil.ServiceScopeOptions{User: true}, client.UserSelector{Selector: client.UserSelectionSelf}}, + {clientutil.ServiceScopeOptions{Usernames: "all"}, client.UserSelector{Selector: client.UserSelectionAll}}, + {clientutil.ServiceScopeOptions{System: true}, client.UserSelector{Names: []string{}, Selector: client.UserSelectionList}}, + {clientutil.ServiceScopeOptions{User: true, System: true}, client.UserSelector{Selector: client.UserSelectionSelf}}, + {clientutil.ServiceScopeOptions{Usernames: "all", System: true}, client.UserSelector{Selector: client.UserSelectionAll}}, + } + + for _, t := range tests { + c.Check(t.opts.Users(), DeepEquals, t.expected) + } +} + +func (s *serviceScopeSuite) TestInvalidOptions(c *C) { + tests := []struct { + opts clientutil.ServiceScopeOptions + expected string + }{ + {clientutil.ServiceScopeOptions{Usernames: "foo"}, `only "all" is supported as a value for --users`}, + {clientutil.ServiceScopeOptions{User: true, System: true}, `--system and --user cannot be used in conjunction with each other`}, + {clientutil.ServiceScopeOptions{Usernames: "all", User: true}, `--user and --users cannot be used in conjunction with each other`}, + } + + for _, t := range tests { + c.Check(t.opts.Validate(), ErrorMatches, t.expected) + } +} diff --git a/client/clientutil/snapinfo.go b/client/clientutil/snapinfo.go new file mode 100644 index 00000000..6c8d7509 --- /dev/null +++ b/client/clientutil/snapinfo.go @@ -0,0 +1,160 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2018-2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +// Package clientutil offers utilities to turn snap.Info and related +// structs into client structs and to work with the latter. +package clientutil + +import ( + "sort" + "strings" + + "github.com/snapcore/snapd/client" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/snap" +) + +// A StatusDecorator is able to decorate client.AppInfos with service status. +type StatusDecorator interface { + DecorateWithStatus(appInfo *client.AppInfo, snapApp *snap.AppInfo) error +} + +// ClientSnapFromSnapInfo returns a client.Snap derived from snap.Info. +// If an optional StatusDecorator is provided it will be used to +// add service status information. +func ClientSnapFromSnapInfo(snapInfo *snap.Info, decorator StatusDecorator) (*client.Snap, error) { + var publisher *snap.StoreAccount + if snapInfo.Publisher.Username != "" { + publisher = &snapInfo.Publisher + } + + confinement := snapInfo.Confinement + if confinement == "" { + confinement = snap.StrictConfinement + } + + snapapps := make([]*snap.AppInfo, 0, len(snapInfo.Apps)) + for _, app := range snapInfo.Apps { + snapapps = append(snapapps, app) + } + sort.Sort(snap.AppInfoBySnapApp(snapapps)) + + apps, err := ClientAppInfosFromSnapAppInfos(snapapps, decorator) + result := &client.Snap{ + Description: snapInfo.Description(), + Developer: snapInfo.Publisher.Username, + Publisher: publisher, + Icon: snapInfo.Media.IconURL(), + ID: snapInfo.ID(), + InstallDate: snapInfo.InstallDate(), + Name: snapInfo.InstanceName(), + Revision: snapInfo.Revision, + Summary: snapInfo.Summary(), + Type: string(snapInfo.Type()), + Base: snapInfo.Base, + Version: snapInfo.Version, + Channel: snapInfo.Channel, + Private: snapInfo.Private, + Confinement: string(confinement), + Apps: apps, + Broken: snapInfo.Broken, + Title: snapInfo.Title(), + License: snapInfo.License, + Media: snapInfo.Media, + Prices: snapInfo.Prices, + Channels: snapInfo.Channels, + Tracks: snapInfo.Tracks, + CommonIDs: snapInfo.CommonIDs, + Links: snapInfo.Links(), + Contact: snapInfo.Contact(), + Website: snapInfo.Website(), + StoreURL: snapInfo.StoreURL, + Categories: snapInfo.Categories, + } + + return result, err +} + +func ClientAppInfoNotes(app *client.AppInfo) string { + if !app.IsService() { + return "-" + } + + var notes = make([]string, 0, 4) + if app.DaemonScope == snap.UserDaemon { + notes = append(notes, "user") + } + var seenTimer, seenSocket, seenDbus bool + for _, act := range app.Activators { + switch act.Type { + case "timer": + seenTimer = true + case "socket": + seenSocket = true + case "dbus": + seenDbus = true + } + } + if seenTimer { + notes = append(notes, "timer-activated") + } + if seenSocket { + notes = append(notes, "socket-activated") + } + if seenDbus { + notes = append(notes, "dbus-activated") + } + if len(notes) == 0 { + return "-" + } + return strings.Join(notes, ",") +} + +// ClientAppInfosFromSnapAppInfos returns client.AppInfos derived from +// the given snap.AppInfos. +// If an optional StatusDecorator is provided it will be used to add +// service status information as well, this will be done only if the +// snap is active and when the app is a service. +func ClientAppInfosFromSnapAppInfos(apps []*snap.AppInfo, decorator StatusDecorator) ([]client.AppInfo, error) { + out := make([]client.AppInfo, 0, len(apps)) + for _, app := range apps { + appInfo := client.AppInfo{ + Snap: app.Snap.InstanceName(), + Name: app.Name, + CommonID: app.CommonID, + } + if fn := app.DesktopFile(); osutil.FileExists(fn) { + appInfo.DesktopFile = fn + } + + appInfo.Daemon = app.Daemon + appInfo.DaemonScope = app.DaemonScope + if !app.IsService() || decorator == nil || !app.Snap.IsActive() { + out = append(out, appInfo) + continue + } + + if err := decorator.DecorateWithStatus(&appInfo, app); err != nil { + return nil, err + } + out = append(out, appInfo) + } + + return out, nil +} diff --git a/client/clientutil/snapinfo_test.go b/client/clientutil/snapinfo_test.go new file mode 100644 index 00000000..67c5469d --- /dev/null +++ b/client/clientutil/snapinfo_test.go @@ -0,0 +1,328 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2018-2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package clientutil_test + +import ( + "os" + "path/filepath" + "reflect" + "testing" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/client" + "github.com/snapcore/snapd/client/clientutil" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/testutil" +) + +func Test(t *testing.T) { TestingT(t) } + +type cmdSuite struct { + testutil.BaseTest +} + +func (s *cmdSuite) SetUpTest(c *C) { + s.BaseTest.SetUpTest(c) + dirs.SetRootDir(c.MkDir()) + s.AddCleanup(func() { dirs.SetRootDir("/") }) +} + +var _ = Suite(&cmdSuite{}) + +func (*cmdSuite) TestClientSnapFromSnapInfo(c *C) { + si := &snap.Info{ + SnapType: snap.TypeApp, + SuggestedName: "", + InstanceKey: "insta", + Version: "v1", + Confinement: snap.StrictConfinement, + License: "Proprietary", + Publisher: snap.StoreAccount{ + ID: "ZvtzsxbsHivZLdvzrt0iqW529riGLfXJ", + Username: "thingyinc", + DisplayName: "Thingy Inc.", + Validation: "unproven", + }, + Base: "core18", + SideInfo: snap.SideInfo{ + RealName: "the-snap", + SnapID: "snapidid", + Revision: snap.R(99), + EditedTitle: "the-title", + EditedSummary: "the-summary", + EditedDescription: "the-description", + Channel: "latest/stable", + EditedLinks: map[string][]string{ + "contact": {"https://thingy.com"}, + "website": {"http://example.com/thingy"}, + }, + LegacyEditedContact: "https://thingy.com", + Private: true, + }, + Channels: map[string]*snap.ChannelSnapInfo{}, + Tracks: []string{}, + Prices: map[string]float64{}, + Media: []snap.MediaInfo{ + {Type: "icon", URL: "https://dashboard.snapcraft.io/site_media/appmedia/2017/12/Thingy.png"}, + {Type: "screenshot", URL: "https://dashboard.snapcraft.io/site_media/appmedia/2018/01/Thingy_01.png"}, + {Type: "screenshot", URL: "https://dashboard.snapcraft.io/site_media/appmedia/2018/01/Thingy_02.png", Width: 600, Height: 200}, + }, + CommonIDs: []string{"org.thingy"}, + StoreURL: "https://snapcraft.io/thingy", + Broken: "broken", + Categories: []snap.CategoryInfo{ + {Featured: true, Name: "featured"}, + {Featured: false, Name: "productivity"}, + }, + } + // valid InstallDate + err := os.MkdirAll(si.MountDir(), 0755) + c.Assert(err, IsNil) + err = os.Symlink(si.Revision.String(), filepath.Join(filepath.Dir(si.MountDir()), "current")) + c.Assert(err, IsNil) + + ci, err := clientutil.ClientSnapFromSnapInfo(si, nil) + c.Check(err, IsNil) + + // check that fields are filled + // see daemon/snap.go for fields filled after this + expectedZeroFields := []string{ + "Screenshots", // unused nowadays + "DownloadSize", + "InstalledSize", + "Health", + "Status", + "TrackingChannel", + "IgnoreValidation", + "CohortKey", + "DevMode", + "TryMode", + "JailMode", + "MountedFrom", + "Hold", + "GatingHold", + "RefreshInhibit", + } + var checker func(string, reflect.Value) + checker = func(pfx string, x reflect.Value) { + t := x.Type() + for i := 0; i < x.NumField(); i++ { + f := t.Field(i) + if f.PkgPath != "" { + // not exported, ignore + continue + } + v := x.Field(i) + if f.Anonymous { + checker(pfx+f.Name+".", v) + continue + } + if reflect.DeepEqual(v.Interface(), reflect.Zero(f.Type).Interface()) { + name := pfx + f.Name + c.Check(expectedZeroFields, testutil.Contains, name, Commentf("%s not set", name)) + } + } + } + x := reflect.ValueOf(ci).Elem() + checker("", x) + + // check some values + c.Check(ci.Name, Equals, "the-snap_insta") + c.Check(ci.Type, Equals, "app") + c.Check(ci.ID, Equals, si.ID()) + c.Check(ci.Revision, Equals, snap.R(99)) + c.Check(ci.Version, Equals, "v1") + c.Check(ci.Title, Equals, "the-title") + c.Check(ci.Summary, Equals, "the-summary") + c.Check(ci.Description, Equals, "the-description") + c.Check(ci.Icon, Equals, si.Media.IconURL()) + c.Check(ci.Links, DeepEquals, si.Links()) + c.Check(ci.Links, DeepEquals, si.EditedLinks) + c.Check(ci.Contact, Equals, si.Contact()) + c.Check(ci.Website, Equals, si.Website()) + c.Check(ci.StoreURL, Equals, si.StoreURL) + c.Check(ci.Developer, Equals, "thingyinc") + c.Check(ci.Publisher, DeepEquals, &si.Publisher) + c.Check(ci.Categories, DeepEquals, si.Categories) +} + +type testStatusDecorator struct { + calls int +} + +func (sd *testStatusDecorator) DecorateWithStatus(appInfo *client.AppInfo, app *snap.AppInfo) error { + sd.calls++ + if appInfo.Snap != app.Snap.InstanceName() || appInfo.Name != app.Name { + panic("mismatched") + } + appInfo.Enabled = true + appInfo.Active = true + return nil +} + +func (*cmdSuite) TestClientSnapFromSnapInfoAppsInactive(c *C) { + si := &snap.Info{ + SnapType: snap.TypeApp, + SuggestedName: "", + InstanceKey: "insta", + SideInfo: snap.SideInfo{ + RealName: "the-snap", + SnapID: "snapidid", + Revision: snap.R(99), + }, + } + si.Apps = map[string]*snap.AppInfo{ + "svc": {Snap: si, Name: "svc", Daemon: "simple", DaemonScope: snap.SystemDaemon}, + "app": {Snap: si, Name: "app", CommonID: "common.id"}, + } + // validity + c.Check(si.IsActive(), Equals, false) + // desktop file + df := si.Apps["app"].DesktopFile() + err := os.MkdirAll(filepath.Dir(df), 0755) + c.Assert(err, IsNil) + err = os.WriteFile(df, nil, 0644) + c.Assert(err, IsNil) + + sd := &testStatusDecorator{} + ci, err := clientutil.ClientSnapFromSnapInfo(si, sd) + c.Check(err, IsNil) + + c.Check(ci.Name, Equals, "the-snap_insta") + c.Check(ci.Apps, DeepEquals, []client.AppInfo{ + { + Snap: "the-snap_insta", + Name: "app", + CommonID: "common.id", + DesktopFile: df, + }, + { + Snap: "the-snap_insta", + Name: "svc", + Daemon: "simple", + DaemonScope: snap.SystemDaemon, + }, + }) + // not called on inactive snaps + c.Check(sd.calls, Equals, 0) +} + +func (*cmdSuite) TestClientSnapFromSnapInfoAppsActive(c *C) { + si := &snap.Info{ + SnapType: snap.TypeApp, + SuggestedName: "", + InstanceKey: "insta", + SideInfo: snap.SideInfo{ + RealName: "the-snap", + SnapID: "snapidid", + Revision: snap.R(99), + }, + } + si.Apps = map[string]*snap.AppInfo{ + "svc": {Snap: si, Name: "svc", Daemon: "simple", DaemonScope: snap.SystemDaemon}, + } + // make it active + err := os.MkdirAll(si.MountDir(), 0755) + c.Assert(err, IsNil) + err = os.Symlink(si.Revision.String(), filepath.Join(filepath.Dir(si.MountDir()), "current")) + c.Assert(err, IsNil) + c.Check(si.IsActive(), Equals, true) + + sd := &testStatusDecorator{} + ci, err := clientutil.ClientSnapFromSnapInfo(si, sd) + c.Check(err, IsNil) + // ... service status + c.Check(ci.Name, Equals, "the-snap_insta") + c.Check(ci.Apps, DeepEquals, []client.AppInfo{ + { + Snap: "the-snap_insta", + Name: "svc", + Daemon: "simple", + DaemonScope: snap.SystemDaemon, + Enabled: true, + Active: true, + }, + }) + + c.Check(sd.calls, Equals, 1) +} + +func (*cmdSuite) TestAppStatusNotes(c *C) { + ai := client.AppInfo{} + c.Check(clientutil.ClientAppInfoNotes(&ai), Equals, "-") + + ai = client.AppInfo{ + Daemon: "oneshot", + } + c.Check(clientutil.ClientAppInfoNotes(&ai), Equals, "-") + + ai = client.AppInfo{ + Daemon: "simple", + DaemonScope: snap.UserDaemon, + } + c.Check(clientutil.ClientAppInfoNotes(&ai), Equals, "user") + + ai = client.AppInfo{ + Daemon: "oneshot", + Activators: []client.AppActivator{ + {Type: "timer"}, + }, + } + c.Check(clientutil.ClientAppInfoNotes(&ai), Equals, "timer-activated") + + ai = client.AppInfo{ + Daemon: "oneshot", + Activators: []client.AppActivator{ + {Type: "socket"}, + }, + } + c.Check(clientutil.ClientAppInfoNotes(&ai), Equals, "socket-activated") + + ai = client.AppInfo{ + Daemon: "oneshot", + Activators: []client.AppActivator{ + {Type: "dbus"}, + }, + } + c.Check(clientutil.ClientAppInfoNotes(&ai), Equals, "dbus-activated") + + // check that the output is stable regardless of the order of activators + ai = client.AppInfo{ + Daemon: "oneshot", + Activators: []client.AppActivator{ + {Type: "timer"}, + {Type: "socket"}, + {Type: "dbus"}, + }, + } + c.Check(clientutil.ClientAppInfoNotes(&ai), Equals, "timer-activated,socket-activated,dbus-activated") + ai = client.AppInfo{ + Daemon: "oneshot", + DaemonScope: snap.UserDaemon, + Activators: []client.AppActivator{ + {Type: "dbus"}, + {Type: "socket"}, + {Type: "timer"}, + }, + } + c.Check(clientutil.ClientAppInfoNotes(&ai), Equals, "user,timer-activated,socket-activated,dbus-activated") +} diff --git a/client/cohort.go b/client/cohort.go new file mode 100644 index 00000000..657cfadd --- /dev/null +++ b/client/cohort.go @@ -0,0 +1,50 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client + +import ( + "bytes" + "encoding/json" + "fmt" + + "golang.org/x/xerrors" +) + +type CohortAction struct { + Action string `json:"action"` + Snaps []string `json:"snaps"` +} + +func (client *Client) CreateCohorts(snaps []string) (map[string]string, error) { + data, err := json.Marshal(&CohortAction{Action: "create", Snaps: snaps}) + if err != nil { + return nil, fmt.Errorf("cannot request cohorts: %v", err) + } + + var cohorts map[string]string + + if _, err := client.doSync("POST", "/v2/cohorts", nil, nil, bytes.NewReader(data), &cohorts); err != nil { + fmt := "cannot create cohorts: %w" + return nil, xerrors.Errorf(fmt, err) + } + + return cohorts, nil + +} diff --git a/client/cohort_test.go b/client/cohort_test.go new file mode 100644 index 00000000..564c84a8 --- /dev/null +++ b/client/cohort_test.go @@ -0,0 +1,76 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client_test + +import ( + "encoding/json" + "errors" + "io" + + "golang.org/x/xerrors" + "gopkg.in/check.v1" +) + +func (cs *clientSuite) TestClientCreateCohortsEndpoint(c *check.C) { + cs.cli.CreateCohorts([]string{"foo", "bar"}) + c.Check(cs.req.Method, check.Equals, "POST") + c.Check(cs.req.URL.Path, check.Equals, "/v2/cohorts") + + body, err := io.ReadAll(cs.req.Body) + c.Assert(err, check.IsNil) + var jsonBody map[string]interface{} + err = json.Unmarshal(body, &jsonBody) + c.Assert(err, check.IsNil) + c.Check(jsonBody, check.DeepEquals, map[string]interface{}{ + "action": "create", + "snaps": []interface{}{"foo", "bar"}, + }) +} + +func (cs *clientSuite) TestClientCreateCohorts(c *check.C) { + cs.rsp = `{ + "type": "sync", + "status-code": 200, + "result": {"foo": "xyzzy", "bar": "what-what"} + }` + cohorts, err := cs.cli.CreateCohorts([]string{"foo", "bar"}) + c.Assert(err, check.IsNil) + c.Check(cohorts, check.DeepEquals, map[string]string{ + "foo": "xyzzy", + "bar": "what-what", + }) + + body, err := io.ReadAll(cs.req.Body) + c.Assert(err, check.IsNil) + var jsonBody map[string]interface{} + err = json.Unmarshal(body, &jsonBody) + c.Assert(err, check.IsNil) + c.Check(jsonBody, check.DeepEquals, map[string]interface{}{ + "action": "create", + "snaps": []interface{}{"foo", "bar"}, + }) +} + +func (cs *clientSuite) TestClientCreateCohortsErrIsWrapped(c *check.C) { + cs.err = errors.New("boom") + _, err := cs.cli.CreateCohorts([]string{"foo", "bar"}) + var e xerrors.Wrapper + c.Assert(err, check.Implements, &e) +} diff --git a/client/conf.go b/client/conf.go new file mode 100644 index 00000000..e7a67530 --- /dev/null +++ b/client/conf.go @@ -0,0 +1,52 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client + +import ( + "bytes" + "encoding/json" + "net/url" + "strings" +) + +// SetConf requests a snap to apply the provided patch to the configuration. +func (client *Client) SetConf(snapName string, patch map[string]interface{}) (changeID string, err error) { + b, err := json.Marshal(patch) + if err != nil { + return "", err + } + return client.doAsync("PUT", "/v2/snaps/"+snapName+"/conf", nil, nil, bytes.NewReader(b)) +} + +// Conf asks for a snap's current configuration. +// +// Note that the configuration may include json.Numbers. +func (client *Client) Conf(snapName string, keys []string) (configuration map[string]interface{}, err error) { + // Prepare query + query := url.Values{} + query.Set("keys", strings.Join(keys, ",")) + + _, err = client.doSync("GET", "/v2/snaps/"+snapName+"/conf", query, nil, nil, &configuration) + if err != nil { + return nil, err + } + + return configuration, nil +} diff --git a/client/conf_test.go b/client/conf_test.go new file mode 100644 index 00000000..88e44039 --- /dev/null +++ b/client/conf_test.go @@ -0,0 +1,105 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client_test + +import ( + "encoding/json" + + "gopkg.in/check.v1" +) + +func (cs *clientSuite) TestClientSetConfCallsEndpoint(c *check.C) { + cs.cli.SetConf("snap-name", map[string]interface{}{"key": "value"}) + c.Check(cs.req.Method, check.Equals, "PUT") + c.Check(cs.req.URL.Path, check.Equals, "/v2/snaps/snap-name/conf") +} + +func (cs *clientSuite) TestClientGetConfCallsEndpoint(c *check.C) { + cs.cli.Conf("snap-name", []string{"test-key"}) + c.Check(cs.req.Method, check.Equals, "GET") + c.Check(cs.req.URL.Path, check.Equals, "/v2/snaps/snap-name/conf") + c.Check(cs.req.URL.Query().Get("keys"), check.Equals, "test-key") +} + +func (cs *clientSuite) TestClientGetConfCallsEndpointMultipleKeys(c *check.C) { + cs.cli.Conf("snap-name", []string{"test-key1", "test-key2"}) + c.Check(cs.req.Method, check.Equals, "GET") + c.Check(cs.req.URL.Path, check.Equals, "/v2/snaps/snap-name/conf") + c.Check(cs.req.URL.Query().Get("keys"), check.Equals, "test-key1,test-key2") +} + +func (cs *clientSuite) TestClientSetConf(c *check.C) { + cs.status = 202 + cs.rsp = `{ + "type": "async", + "status-code": 202, + "result": { }, + "change": "foo" + }` + id, err := cs.cli.SetConf("snap-name", map[string]interface{}{"key": "value"}) + c.Assert(err, check.IsNil) + c.Check(id, check.Equals, "foo") + var body map[string]interface{} + decoder := json.NewDecoder(cs.req.Body) + err = decoder.Decode(&body) + c.Check(err, check.IsNil) + c.Check(body, check.DeepEquals, map[string]interface{}{ + "key": "value", + }) +} + +func (cs *clientSuite) TestClientGetConf(c *check.C) { + cs.rsp = `{ + "type": "sync", + "status-code": 200, + "result": {"test-key": "test-value"} + }` + value, err := cs.cli.Conf("snap-name", []string{"test-key"}) + c.Assert(err, check.IsNil) + c.Check(value, check.DeepEquals, map[string]interface{}{"test-key": "test-value"}) +} + +func (cs *clientSuite) TestClientGetConfBigInt(c *check.C) { + cs.rsp = `{ + "type": "sync", + "status-code": 200, + "result": {"test-key": 1234567890} + }` + value, err := cs.cli.Conf("snap-name", []string{"test-key"}) + c.Assert(err, check.IsNil) + c.Check(value, check.DeepEquals, map[string]interface{}{"test-key": json.Number("1234567890")}) +} + +func (cs *clientSuite) TestClientGetConfMultipleKeys(c *check.C) { + cs.rsp = `{ + "type": "sync", + "status-code": 200, + "result": { + "test-key1": "test-value1", + "test-key2": "test-value2" + } + }` + value, err := cs.cli.Conf("snap-name", []string{"test-key1", "test-key2"}) + c.Assert(err, check.IsNil) + c.Check(value, check.DeepEquals, map[string]interface{}{ + "test-key1": "test-value1", + "test-key2": "test-value2", + }) +} diff --git a/client/connections.go b/client/connections.go new file mode 100644 index 00000000..28d829c0 --- /dev/null +++ b/client/connections.go @@ -0,0 +1,81 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client + +import ( + "net/url" +) + +// Connection describes a connection between a plug and a slot. +type Connection struct { + Slot SlotRef `json:"slot"` + Plug PlugRef `json:"plug"` + Interface string `json:"interface"` + // Manual is set for connections that were established manually. + Manual bool `json:"manual"` + // Gadget is set for connections that were enabled by the gadget snap. + Gadget bool `json:"gadget"` + // SlotAttrs is the list of attributes of the slot side of the connection. + SlotAttrs map[string]interface{} `json:"slot-attrs,omitempty"` + // PlugAttrs is the list of attributes of the plug side of the connection. + PlugAttrs map[string]interface{} `json:"plug-attrs,omitempty"` +} + +// Connections contains information about connections, as well as related plugs +// and slots. +type Connections struct { + // Established is the list of connections that are currently present. + Established []Connection `json:"established"` + // Undersired is a list of connections that are manually denied. + Undesired []Connection `json:"undesired"` + Plugs []Plug `json:"plugs"` + Slots []Slot `json:"slots"` +} + +// ConnectionOptions contains criteria for selecting matching connections, plugs +// and slots. +type ConnectionOptions struct { + // Snap selects connections with the snap on one of the sides, as well + // as plugs and slots of a given snap. + Snap string + // Interface selects connections, plugs or slots using given interface. + Interface string + // All when true, selects established and undesired connections as well + // as all disconnected plugs and slots. + All bool +} + +// Connections returns matching plugs, slots and their connections. Unless +// specified by matching options, returns established connections. +func (client *Client) Connections(opts *ConnectionOptions) (Connections, error) { + var conns Connections + query := url.Values{} + if opts != nil && opts.Snap != "" { + query.Set("snap", opts.Snap) + } + if opts != nil && opts.Interface != "" { + query.Set("interface", opts.Interface) + } + if opts != nil && opts.All { + query.Set("select", "all") + } + _, err := client.doSync("GET", "/v2/connections", query, nil, nil, &conns) + return conns, err +} diff --git a/client/connections_test.go b/client/connections_test.go new file mode 100644 index 00000000..aff55a85 --- /dev/null +++ b/client/connections_test.go @@ -0,0 +1,270 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client_test + +import ( + "net/url" + + "gopkg.in/check.v1" + + "github.com/snapcore/snapd/client" +) + +func (cs *clientSuite) TestClientConnectionsCallsEndpoint(c *check.C) { + _, _ = cs.cli.Connections(nil) + c.Check(cs.req.Method, check.Equals, "GET") + c.Check(cs.req.URL.Path, check.Equals, "/v2/connections") +} + +func (cs *clientSuite) TestClientConnectionsDefault(c *check.C) { + cs.rsp = `{ + "type": "sync", + "result": { + "established": [ + { + "slot": {"snap": "keyboard-lights", "slot": "capslock-led"}, + "plug": {"snap": "canonical-pi2", "plug": "pin-13"}, + "interface": "bool-file", + "gadget": true + } + ], + "plugs": [ + { + "snap": "canonical-pi2", + "plug": "pin-13", + "interface": "bool-file", + "label": "Pin 13", + "connections": [ + {"snap": "keyboard-lights", "slot": "capslock-led"} + ] + } + ], + "slots": [ + { + "snap": "keyboard-lights", + "slot": "capslock-led", + "interface": "bool-file", + "label": "Capslock indicator LED", + "connections": [ + {"snap": "canonical-pi2", "plug": "pin-13"} + ] + } + ] + } + }` + conns, err := cs.cli.Connections(nil) + c.Assert(err, check.IsNil) + c.Check(cs.req.URL.Path, check.Equals, "/v2/connections") + c.Check(conns, check.DeepEquals, client.Connections{ + Established: []client.Connection{ + { + Plug: client.PlugRef{Snap: "canonical-pi2", Name: "pin-13"}, + Slot: client.SlotRef{Snap: "keyboard-lights", Name: "capslock-led"}, + Interface: "bool-file", + Gadget: true, + }, + }, + Plugs: []client.Plug{ + { + Snap: "canonical-pi2", + Name: "pin-13", + Interface: "bool-file", + Label: "Pin 13", + Connections: []client.SlotRef{ + { + Snap: "keyboard-lights", + Name: "capslock-led", + }, + }, + }, + }, + Slots: []client.Slot{ + { + Snap: "keyboard-lights", + Name: "capslock-led", + Interface: "bool-file", + Label: "Capslock indicator LED", + Connections: []client.PlugRef{ + { + Snap: "canonical-pi2", + Name: "pin-13", + }, + }, + }, + }, + }) +} + +func (cs *clientSuite) TestClientConnectionsAll(c *check.C) { + cs.rsp = `{ + "type": "sync", + "result": { + "established": [ + { + "slot": {"snap": "keyboard-lights", "slot": "capslock-led"}, + "plug": {"snap": "canonical-pi2", "plug": "pin-13"}, + "interface": "bool-file", + "gadget": true + } + ], + "undesired": [ + { + "slot": {"snap": "keyboard-lights", "slot": "numlock-led"}, + "plug": {"snap": "canonical-pi2", "plug": "pin-14"}, + "interface": "bool-file", + "gadget": true, + "manual": true + } + ], + "plugs": [ + { + "snap": "canonical-pi2", + "plug": "pin-13", + "interface": "bool-file", + "label": "Pin 13", + "connections": [ + {"snap": "keyboard-lights", "slot": "capslock-led"} + ] + }, + { + "snap": "canonical-pi2", + "plug": "pin-14", + "interface": "bool-file", + "label": "Pin 14" + } + ], + "slots": [ + { + "snap": "keyboard-lights", + "slot": "capslock-led", + "interface": "bool-file", + "label": "Capslock indicator LED", + "connections": [ + {"snap": "canonical-pi2", "plug": "pin-13"} + ] + }, + { + "snap": "keyboard-lights", + "slot": "numlock-led", + "interface": "bool-file", + "label": "Numlock LED" + } + ] + } + }` + conns, err := cs.cli.Connections(&client.ConnectionOptions{All: true}) + c.Assert(err, check.IsNil) + c.Check(cs.req.URL.Path, check.Equals, "/v2/connections") + c.Check(cs.req.URL.RawQuery, check.Equals, "select=all") + c.Check(conns, check.DeepEquals, client.Connections{ + Established: []client.Connection{ + { + Plug: client.PlugRef{Snap: "canonical-pi2", Name: "pin-13"}, + Slot: client.SlotRef{Snap: "keyboard-lights", Name: "capslock-led"}, + Interface: "bool-file", + Gadget: true, + }, + }, + Undesired: []client.Connection{ + { + Plug: client.PlugRef{Snap: "canonical-pi2", Name: "pin-14"}, + Slot: client.SlotRef{Snap: "keyboard-lights", Name: "numlock-led"}, + Interface: "bool-file", + Gadget: true, + Manual: true, + }, + }, + Plugs: []client.Plug{ + { + Snap: "canonical-pi2", + Name: "pin-13", + Interface: "bool-file", + Label: "Pin 13", + Connections: []client.SlotRef{ + { + Snap: "keyboard-lights", + Name: "capslock-led", + }, + }, + }, + { + Snap: "canonical-pi2", + Name: "pin-14", + Interface: "bool-file", + Label: "Pin 14", + }, + }, + Slots: []client.Slot{ + { + Snap: "keyboard-lights", + Name: "capslock-led", + Interface: "bool-file", + Label: "Capslock indicator LED", + Connections: []client.PlugRef{ + { + Snap: "canonical-pi2", + Name: "pin-13", + }, + }, + }, + { + Snap: "keyboard-lights", + Name: "numlock-led", + Interface: "bool-file", + Label: "Numlock LED", + }, + }, + }) +} + +func (cs *clientSuite) TestClientConnectionsFilter(c *check.C) { + cs.rsp = `{ + "type": "sync", + "result": { + "established": [], + "plugs": [], + "slots": [] + } + }` + + _, err := cs.cli.Connections(&client.ConnectionOptions{All: true}) + c.Assert(err, check.IsNil) + c.Check(cs.req.URL.Path, check.Equals, "/v2/connections") + c.Check(cs.req.URL.RawQuery, check.Equals, "select=all") + + _, err = cs.cli.Connections(&client.ConnectionOptions{Snap: "foo"}) + c.Assert(err, check.IsNil) + c.Check(cs.req.URL.Path, check.Equals, "/v2/connections") + c.Check(cs.req.URL.RawQuery, check.Equals, "snap=foo") + + _, err = cs.cli.Connections(&client.ConnectionOptions{Interface: "test"}) + c.Assert(err, check.IsNil) + c.Check(cs.req.URL.Path, check.Equals, "/v2/connections") + c.Check(cs.req.URL.RawQuery, check.Equals, "interface=test") + + _, err = cs.cli.Connections(&client.ConnectionOptions{All: true, Snap: "foo", Interface: "test"}) + c.Assert(err, check.IsNil) + query := cs.req.URL.Query() + c.Check(query, check.DeepEquals, url.Values{ + "select": []string{"all"}, + "interface": []string{"test"}, + "snap": []string{"foo"}, + }) +} diff --git a/client/console_conf.go b/client/console_conf.go new file mode 100644 index 00000000..d07899ce --- /dev/null +++ b/client/console_conf.go @@ -0,0 +1,44 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client + +import "time" + +// InternalConsoleConfStartResponse is the response from console-conf start +// support +type InternalConsoleConfStartResponse struct { + ActiveAutoRefreshChanges []string `json:"active-auto-refreshes,omitempty"` + ActiveAutoRefreshSnaps []string `json:"active-auto-refresh-snaps,omitempty"` +} + +// InternalConsoleConfStart invokes the dedicated console-conf start support +// to handle intervening auto-refreshes. +// Not for general use. +func (client *Client) InternalConsoleConfStart() ([]string, []string, error) { + resp := &InternalConsoleConfStartResponse{} + // do the post with a short timeout so that if snapd is not available due to + // maintenance we will return very quickly so the caller can handle that + opts := &doOptions{ + Timeout: 2 * time.Second, + Retry: 1 * time.Hour, + } + _, err := client.doSyncWithOpts("POST", "/v2/internal/console-conf-start", nil, nil, nil, resp, opts) + return resp.ActiveAutoRefreshChanges, resp.ActiveAutoRefreshSnaps, err +} diff --git a/client/console_conf_test.go b/client/console_conf_test.go new file mode 100644 index 00000000..09aab5ff --- /dev/null +++ b/client/console_conf_test.go @@ -0,0 +1,63 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client_test + +import ( + . "gopkg.in/check.v1" +) + +func (cs *clientSuite) TestClientInternalConsoleConfEndpointEmpty(c *C) { + // no changes and no snaps + cs.status = 200 + cs.rsp = `{ + "type": "sync", + "status-code": 200, + "result": {} + }` + + chgs, snaps, err := cs.cli.InternalConsoleConfStart() + c.Assert(chgs, HasLen, 0) + c.Assert(snaps, HasLen, 0) + c.Assert(err, IsNil) + c.Check(cs.req.Method, Equals, "POST") + c.Check(cs.req.URL.Path, Equals, "/v2/internal/console-conf-start") + c.Check(cs.doCalls, Equals, 1) +} + +func (cs *clientSuite) TestClientInternalConsoleConfEndpoint(c *C) { + // some changes and snaps + cs.status = 200 + cs.rsp = `{ + "type": "sync", + "status-code": 200, + "result": { + "active-auto-refreshes": ["1"], + "active-auto-refresh-snaps": ["pc-kernel"] + } + }` + + chgs, snaps, err := cs.cli.InternalConsoleConfStart() + c.Assert(err, IsNil) + c.Assert(chgs, DeepEquals, []string{"1"}) + c.Assert(snaps, DeepEquals, []string{"pc-kernel"}) + c.Check(cs.req.Method, Equals, "POST") + c.Check(cs.req.URL.Path, Equals, "/v2/internal/console-conf-start") + c.Check(cs.doCalls, Equals, 1) +} diff --git a/client/errors.go b/client/errors.go new file mode 100644 index 00000000..87769074 --- /dev/null +++ b/client/errors.go @@ -0,0 +1,155 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client + +// ErrorKind distinguishes kind of errors. +type ErrorKind string + +// error kind const value doc comments here have a non-default, +// specialized style (to help docs/error-kind.go): +// +// // ErrorKind...: DESCRIPTION . +// +// Note the mandatory dot at the end. +// `code-like` quoting should be used when meaningful. + +// Error kinds. Keep https://forum.snapcraft.io/t/using-the-rest-api/18603#heading--errors in sync using doc/error-kinds.go. +const ( + // ErrorKindTwoFactorRequired: the client needs to retry the + // `login` command including an OTP. + ErrorKindTwoFactorRequired ErrorKind = "two-factor-required" + // ErrorKindTwoFactorFailed: the OTP provided wasn't recognised. + ErrorKindTwoFactorFailed ErrorKind = "two-factor-failed" + // ErrorKindLoginRequired: the requested operation cannot be + // performed without an authenticated user. This is the kind + // of any other 401 Unauthorized response. + ErrorKindLoginRequired ErrorKind = "login-required" + // ErrorKindInvalidAuthData: the authentication data provided + // failed to validate (e.g. a malformed email address). The + // `value` of the error is an object with a key per failed field + // and a list of the failures on each field. + ErrorKindInvalidAuthData ErrorKind = "invalid-auth-data" + // ErrorKindPasswordPolicy: provided password doesn't meet + // system policy. + ErrorKindPasswordPolicy ErrorKind = "password-policy" + // ErrorKindAuthCancelled: authentication was cancelled by the user. + ErrorKindAuthCancelled ErrorKind = "auth-cancelled" + + // ErrorKindTermsNotAccepted: deprecated, do not document. + ErrorKindTermsNotAccepted ErrorKind = "terms-not-accepted" + // ErrorKindNoPaymentMethods: deprecated, do not document. + ErrorKindNoPaymentMethods ErrorKind = "no-payment-methods" + // ErrorKindPaymentDeclined: deprecated, do not document. + ErrorKindPaymentDeclined ErrorKind = "payment-declined" + + // ErrorKindSnapAlreadyInstalled: the requested snap is + // already installed. + ErrorKindSnapAlreadyInstalled ErrorKind = "snap-already-installed" + // ErrorKindSnapNotInstalled: the requested snap is not installed. + ErrorKindSnapNotInstalled ErrorKind = "snap-not-installed" + // ErrorKindSnapNotFound: the requested snap couldn't be found. + ErrorKindSnapNotFound ErrorKind = "snap-not-found" + // ErrorKindAppNotFound: the requested app couldn't be found. + ErrorKindAppNotFound ErrorKind = "app-not-found" + // ErrorKindSnapLocal: cannot perform operation on local snap. + ErrorKindSnapLocal ErrorKind = "snap-local" + // ErrorKindSnapNeedsDevMode: the requested snap needs devmode + // to be installed. + ErrorKindSnapNeedsDevMode ErrorKind = "snap-needs-devmode" + // ErrorKindSnapNeedsClassic: the requested snap needs classic + // confinement to be installed. + ErrorKindSnapNeedsClassic ErrorKind = "snap-needs-classic" + // ErrorKindSnapNeedsClassicSystem: the requested snap can't + // be installed on the current non-classic system. + ErrorKindSnapNeedsClassicSystem ErrorKind = "snap-needs-classic-system" + // ErrorKindSnapNotClassic: snap not compatible with classic mode. + ErrorKindSnapNotClassic ErrorKind = "snap-not-classic" + // ErrorKindSnapNoUpdateAvailable: the requested snap does not + // have an update available. + ErrorKindSnapNoUpdateAvailable ErrorKind = "snap-no-update-available" + // ErrorKindSnapRevisionNotAvailable: no snap revision available + // as specified. + ErrorKindSnapRevisionNotAvailable ErrorKind = "snap-revision-not-available" + // ErrorKindSnapChannelNotAvailable: no snap revision on specified + // channel. The `value` of the error is a rich object with + // requested `snap-name`, `action`, `channel`, `architecture`, and + // actually available `releases` as list of + // `{"architecture":... , "channel": ...}` objects. + ErrorKindSnapChannelNotAvailable ErrorKind = "snap-channel-not-available" + // ErrorKindSnapArchitectureNotAvailable: no snap revision on + // specified architecture. Value has the same format as for + // `snap-channel-not-available`. + ErrorKindSnapArchitectureNotAvailable ErrorKind = "snap-architecture-not-available" + + // ErrorKindSnapChangeConflict: the requested operation would + // conflict with currently ongoing change. This is a temporary + // error. The error `value` is an object with optional fields + // `snap-name`, `change-kind` of the ongoing change. + ErrorKindSnapChangeConflict ErrorKind = "snap-change-conflict" + + // ErrorKindQuotaChangeConflict: the requested operation would + // conflict with a currently ongoing change affecting the quota + // group. This is a temporary error. The error `value` is an + // object with optional fields `quota-name`, `change-kind` of the + // ongoing change. + ErrorKindQuotaChangeConflict ErrorKind = "quota-change-conflict" + + // ErrorKindNotSnap: the given snap or directory does not + // look like a snap. + ErrorKindNotSnap ErrorKind = "snap-not-a-snap" + + // ErrorKindInterfacesUnchanged: the requested interfaces' + // operation would have no effect. + ErrorKindInterfacesUnchanged ErrorKind = "interfaces-unchanged" + + // ErrorKindBadQuery: a bad query was provided. + ErrorKindBadQuery ErrorKind = "bad-query" + // ErrorKindConfigNoSuchOption: the given configuration option + // does not exist. + ErrorKindConfigNoSuchOption ErrorKind = "option-not-found" + + // ErrorKindAssertionNotFound: assertion can not be found. + ErrorKindAssertionNotFound ErrorKind = "assertion-not-found" + + // ErrorKindUnsuccessful: snapctl command was unsuccessful. + ErrorKindUnsuccessful ErrorKind = "unsuccessful" + + // ErrorKindNetworkTimeout: a timeout occurred during the request. + ErrorKindNetworkTimeout ErrorKind = "network-timeout" + + // ErrorKindDNSFailure: DNS not responding. + ErrorKindDNSFailure ErrorKind = "dns-failure" + + // ErrorKindInsufficientDiskSpace: not enough disk space to perform the request. + ErrorKindInsufficientDiskSpace ErrorKind = "insufficient-disk-space" + + // ErrorKindValidationSetNotFound: validation set cannot be found. + ErrorKindValidationSetNotFound ErrorKind = "validation-set-not-found" +) + +// Maintenance error kinds. +// These are used only inside the maintenance field of responses. +// Keep https://forum.snapcraft.io/t/using-the-rest-api/18603#heading--maint-errors in sync using doc/error-kinds.go. +const ( + // ErrorKindDaemonRestart: daemon is restarting. + ErrorKindDaemonRestart ErrorKind = "daemon-restart" + // ErrorKindSystemRestart: system is restarting. + ErrorKindSystemRestart ErrorKind = "system-restart" +) diff --git a/client/export_test.go b/client/export_test.go new file mode 100644 index 00000000..acceb148 --- /dev/null +++ b/client/export_test.go @@ -0,0 +1,63 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2015 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client + +import ( + "encoding/json" + "io" + "net/url" +) + +// SetDoer sets the client's doer to the given one +func (client *Client) SetDoer(d doer) { + client.doer = d +} + +type DoOptions = doOptions + +// Do does do. +func (client *Client) Do(method, path string, query url.Values, body io.Reader, v interface{}, opts *DoOptions) (statusCode int, err error) { + return client.do(method, path, query, nil, body, v, opts) +} + +// expose parseError for testing +var ParseErrorInTest = parseError + +// expose read and write auth helpers for testing +var TestWriteAuth = writeAuthData +var TestReadAuth = readAuthData +var TestStoreAuthFilename = storeAuthDataFilename + +var TestAuthFileEnvKey = authFileEnvKey + +func UnmarshalSnapshotAction(body io.Reader) (act snapshotAction, err error) { + err = json.NewDecoder(body).Decode(&act) + return +} + +type DownloadAction = downloadAction + +func MockStdinReadLimit(new int64) (restore func()) { + oldStdinReadLimit := stdinReadLimit + stdinReadLimit = new + return func() { + stdinReadLimit = oldStdinReadLimit + } +} diff --git a/client/icons.go b/client/icons.go new file mode 100644 index 00000000..9c63f809 --- /dev/null +++ b/client/icons.go @@ -0,0 +1,72 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client + +import ( + "context" + "fmt" + "io" + "regexp" + + "golang.org/x/xerrors" +) + +// Icon represents the icon of an installed snap +type Icon struct { + Filename string + Content []byte +} + +var contentDispositionMatcher = regexp.MustCompile(`attachment; filename=(.+)`).FindStringSubmatch + +// Icon returns the Icon belonging to an installed snap +func (c *Client) Icon(pkgID string) (*Icon, error) { + const errPrefix = "cannot retrieve icon" + + response, cancel, err := c.rawWithTimeout(context.Background(), "GET", fmt.Sprintf("/v2/icons/%s/icon", pkgID), nil, nil, nil, nil) + if err != nil { + fmt := "%s: failed to communicate with server: %w" + return nil, xerrors.Errorf(fmt, errPrefix, err) + } + defer cancel() + defer response.Body.Close() + + if response.StatusCode != 200 { + return nil, fmt.Errorf("%s: Not Found", errPrefix) + } + + matches := contentDispositionMatcher(response.Header.Get("Content-Disposition")) + + if matches == nil || matches[1] == "" { + return nil, fmt.Errorf("%s: cannot determine filename", errPrefix) + } + + content, err := io.ReadAll(response.Body) + if err != nil { + return nil, fmt.Errorf("%s: %s", errPrefix, err) + } + + icon := &Icon{ + Filename: matches[1], + Content: content, + } + + return icon, nil +} diff --git a/client/icons_test.go b/client/icons_test.go new file mode 100644 index 00000000..eb965e7d --- /dev/null +++ b/client/icons_test.go @@ -0,0 +1,73 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client_test + +import ( + "errors" + "fmt" + "net/http" + + "golang.org/x/xerrors" + . "gopkg.in/check.v1" +) + +const ( + pkgID = "chatroom.ogra" +) + +func (cs *clientSuite) TestClientIconCallsEndpoint(c *C) { + _, _ = cs.cli.Icon(pkgID) + c.Assert(cs.req.Method, Equals, "GET") + c.Assert(cs.req.URL.Path, Equals, fmt.Sprintf("/v2/icons/%s/icon", pkgID)) +} + +func (cs *clientSuite) TestClientIconHttpError(c *C) { + cs.err = errors.New("fail") + _, err := cs.cli.Icon(pkgID) + c.Assert(err, ErrorMatches, ".*server: fail") +} + +func (cs *clientSuite) TestClientIconResponseNotFound(c *C) { + cs.status = 404 + _, err := cs.cli.Icon(pkgID) + c.Assert(err, ErrorMatches, `.*Not Found`) +} + +func (cs *clientSuite) TestClientIconInvalidContentDisposition(c *C) { + cs.header = http.Header{"Content-Disposition": {"invalid"}} + _, err := cs.cli.Icon(pkgID) + c.Assert(err, ErrorMatches, `.*cannot determine filename`) +} + +func (cs *clientSuite) TestClientIcon(c *C) { + cs.rsp = "pixels" + cs.header = http.Header{"Content-Disposition": {"attachment; filename=myicon.png"}} + icon, err := cs.cli.Icon(pkgID) + c.Assert(err, IsNil) + c.Assert(icon.Filename, Equals, "myicon.png") + c.Assert(icon.Content, DeepEquals, []byte("pixels")) +} + +func (cs *clientSuite) TestClientIconErrIsWrapped(c *C) { + cs.err = errors.New("boom") + _, err := cs.cli.Icon("something") + var e xerrors.Wrapper + c.Assert(err, Implements, &e) +} diff --git a/client/interfaces.go b/client/interfaces.go new file mode 100644 index 00000000..f9968f04 --- /dev/null +++ b/client/interfaces.go @@ -0,0 +1,149 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client + +import ( + "bytes" + "encoding/json" + "net/url" + "strings" +) + +// Plug represents the potential of a given snap to connect to a slot. +type Plug struct { + Snap string `json:"snap"` + Name string `json:"plug"` + Interface string `json:"interface,omitempty"` + Attrs map[string]interface{} `json:"attrs,omitempty"` + Apps []string `json:"apps,omitempty"` + Label string `json:"label,omitempty"` + Connections []SlotRef `json:"connections,omitempty"` +} + +// PlugRef is a reference to a plug. +type PlugRef struct { + Snap string `json:"snap"` + Name string `json:"plug"` +} + +// Slot represents a capacity offered by a snap. +type Slot struct { + Snap string `json:"snap"` + Name string `json:"slot"` + Interface string `json:"interface,omitempty"` + Attrs map[string]interface{} `json:"attrs,omitempty"` + Apps []string `json:"apps,omitempty"` + Label string `json:"label,omitempty"` + Connections []PlugRef `json:"connections,omitempty"` +} + +// SlotRef is a reference to a slot. +type SlotRef struct { + Snap string `json:"snap"` + Name string `json:"slot"` +} + +// Interface holds information about a given interface and its instances. +type Interface struct { + Name string `json:"name,omitempty"` + Summary string `json:"summary,omitempty"` + DocURL string `json:"doc-url,omitempty"` + Plugs []Plug `json:"plugs,omitempty"` + Slots []Slot `json:"slots,omitempty"` +} + +// InterfaceAction represents an action performed on the interface system. +type InterfaceAction struct { + Action string `json:"action"` + Forget bool `json:"forget,omitempty"` + Plugs []Plug `json:"plugs,omitempty"` + Slots []Slot `json:"slots,omitempty"` +} + +// InterfaceOptions represents opt-in elements include in responses. +type InterfaceOptions struct { + Names []string + Doc bool + Plugs bool + Slots bool + Connected bool +} + +// DisconnectOptions represents extra options for disconnect op +type DisconnectOptions struct { + Forget bool +} + +func (client *Client) Interfaces(opts *InterfaceOptions) ([]*Interface, error) { + query := url.Values{} + if opts != nil && len(opts.Names) > 0 { + query.Set("names", strings.Join(opts.Names, ",")) // Return just those specific interfaces. + } + if opts != nil { + if opts.Doc { + query.Set("doc", "true") // Return documentation of each selected interface. + } + if opts.Plugs { + query.Set("plugs", "true") // Return plugs of each selected interface. + } + if opts.Slots { + query.Set("slots", "true") // Return slots of each selected interface. + } + } + // NOTE: Presence of "select" triggers the use of the new response format. + if opts != nil && opts.Connected { + query.Set("select", "connected") // Return just the connected interfaces. + } else { + query.Set("select", "all") // Return all interfaces. + } + var interfaces []*Interface + _, err := client.doSync("GET", "/v2/interfaces", query, nil, nil, &interfaces) + + return interfaces, err +} + +// performInterfaceAction performs a single action on the interface system. +func (client *Client) performInterfaceAction(sa *InterfaceAction) (changeID string, err error) { + b, err := json.Marshal(sa) + if err != nil { + return "", err + } + return client.doAsync("POST", "/v2/interfaces", nil, nil, bytes.NewReader(b)) +} + +// Connect establishes a connection between a plug and a slot. +// The plug and the slot must have the same interface. +func (client *Client) Connect(plugSnapName, plugName, slotSnapName, slotName string) (changeID string, err error) { + return client.performInterfaceAction(&InterfaceAction{ + Action: "connect", + Plugs: []Plug{{Snap: plugSnapName, Name: plugName}}, + Slots: []Slot{{Snap: slotSnapName, Name: slotName}}, + }) +} + +// Disconnect breaks the connection between a plug and a slot. +func (client *Client) Disconnect(plugSnapName, plugName, slotSnapName, slotName string, opts *DisconnectOptions) (changeID string, err error) { + return client.performInterfaceAction(&InterfaceAction{ + Action: "disconnect", + Forget: opts != nil && opts.Forget, + Plugs: []Plug{{Snap: plugSnapName, Name: plugName}}, + Slots: []Slot{{Snap: slotSnapName, Name: slotName}}, + }) +} diff --git a/client/interfaces_test.go b/client/interfaces_test.go new file mode 100644 index 00000000..31bf9c5c --- /dev/null +++ b/client/interfaces_test.go @@ -0,0 +1,268 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client_test + +import ( + "encoding/json" + + "gopkg.in/check.v1" + + "github.com/snapcore/snapd/client" +) + +func (cs *clientSuite) TestClientInterfacesOptionEncoding(c *check.C) { + // Choose some options + _, _ = cs.cli.Interfaces(&client.InterfaceOptions{ + Names: []string{"a", "b"}, + Doc: true, + Plugs: true, + Slots: true, + Connected: true, + }) + c.Check(cs.req.Method, check.Equals, "GET") + c.Check(cs.req.URL.Path, check.Equals, "/v2/interfaces") + c.Check(cs.req.URL.RawQuery, check.Equals, + "doc=true&names=a%2Cb&plugs=true&select=connected&slots=true") +} + +func (cs *clientSuite) TestClientInterfacesAll(c *check.C) { + // Ask for a summary of all interfaces. + cs.rsp = `{ + "type": "sync", + "result": [ + {"name": "iface-a", "summary": "the A iface"}, + {"name": "iface-b", "summary": "the B iface"}, + {"name": "iface-c", "summary": "the C iface"} + ] + }` + ifaces, err := cs.cli.Interfaces(nil) + c.Check(cs.req.Method, check.Equals, "GET") + c.Check(cs.req.URL.Path, check.Equals, "/v2/interfaces") + // This uses the select=all query option to indicate that new response + // format should be used. The same API endpoint is used by the Interfaces + // and by the Connections functions an the absence or presence of the + // select query option decides what kind of result should be returned + // (legacy or modern). + c.Check(cs.req.URL.RawQuery, check.Equals, "select=all") + c.Assert(err, check.IsNil) + c.Check(ifaces, check.DeepEquals, []*client.Interface{ + {Name: "iface-a", Summary: "the A iface"}, + {Name: "iface-b", Summary: "the B iface"}, + {Name: "iface-c", Summary: "the C iface"}, + }) +} + +func (cs *clientSuite) TestClientInterfacesConnected(c *check.C) { + // Ask for a summary of connected interfaces. + cs.rsp = `{ + "type": "sync", + "result": [ + {"name": "iface-a", "summary": "the A iface"}, + {"name": "iface-c", "summary": "the C iface"} + ] + }` + ifaces, err := cs.cli.Interfaces(&client.InterfaceOptions{ + Connected: true, + }) + c.Check(cs.req.URL.Path, check.Equals, "/v2/interfaces") + // This uses select=connected to ignore interfaces that just sit on some + // snap but are not connected to anything. + c.Check(cs.req.URL.RawQuery, check.Equals, "select=connected") + c.Assert(err, check.IsNil) + c.Check(ifaces, check.DeepEquals, []*client.Interface{ + {Name: "iface-a", Summary: "the A iface"}, + // interface b was not connected so it doesn't get listed. + {Name: "iface-c", Summary: "the C iface"}, + }) +} + +func (cs *clientSuite) TestClientInterfacesSelectedDetails(c *check.C) { + // Ask for single element and request docs, plugs and slots. + cs.rsp = `{ + "type": "sync", + "result": [ + { + "name": "iface-a", + "summary": "the A iface", + "doc-url": "http://example.org/ifaces/a", + "plugs": [{ + "snap": "consumer", + "plug": "plug", + "interface": "iface-a" + }], + "slots": [{ + "snap": "producer", + "slot": "slot", + "interface": "iface-a" + }] + } + ] + }` + opts := &client.InterfaceOptions{Names: []string{"iface-a"}, Doc: true, Plugs: true, Slots: true} + ifaces, err := cs.cli.Interfaces(opts) + c.Check(cs.req.Method, check.Equals, "GET") + c.Check(cs.req.URL.Path, check.Equals, "/v2/interfaces") + // This enables documentation, plugs, slots, chooses a specific interface + // (iface-a), and uses select=all to indicate that new response is desired. + c.Check(cs.req.URL.RawQuery, check.Equals, + "doc=true&names=iface-a&plugs=true&select=all&slots=true") + c.Assert(err, check.IsNil) + c.Check(ifaces, check.DeepEquals, []*client.Interface{ + { + Name: "iface-a", + Summary: "the A iface", + DocURL: "http://example.org/ifaces/a", + Plugs: []client.Plug{{Snap: "consumer", Name: "plug", Interface: "iface-a"}}, + Slots: []client.Slot{{Snap: "producer", Name: "slot", Interface: "iface-a"}}, + }, + }) +} + +func (cs *clientSuite) TestClientInterfacesMultiple(c *check.C) { + // Ask for multiple interfaces. + cs.rsp = `{ + "type": "sync", + "result": [ + {"name": "iface-a", "summary": "the A iface"}, + {"name": "iface-b", "summary": "the B iface"} + ] + }` + ifaces, err := cs.cli.Interfaces(&client.InterfaceOptions{Names: []string{"iface-a", "iface-b"}}) + c.Check(cs.req.Method, check.Equals, "GET") + c.Check(cs.req.URL.Path, check.Equals, "/v2/interfaces") + // This chooses a specific interfaces (iface-a, iface-b) + c.Check(cs.req.URL.RawQuery, check.Equals, "names=iface-a%2Ciface-b&select=all") + c.Assert(err, check.IsNil) + c.Check(ifaces, check.DeepEquals, []*client.Interface{ + {Name: "iface-a", Summary: "the A iface"}, + {Name: "iface-b", Summary: "the B iface"}, + }) +} + +func (cs *clientSuite) TestClientConnectCallsEndpoint(c *check.C) { + cs.cli.Connect("producer", "plug", "consumer", "slot") + c.Check(cs.req.Method, check.Equals, "POST") + c.Check(cs.req.URL.Path, check.Equals, "/v2/interfaces") +} + +func (cs *clientSuite) TestClientConnect(c *check.C) { + cs.status = 202 + cs.rsp = `{ + "type": "async", + "status-code": 202, + "result": { }, + "change": "foo" + }` + id, err := cs.cli.Connect("producer", "plug", "consumer", "slot") + c.Assert(err, check.IsNil) + c.Check(id, check.Equals, "foo") + var body map[string]interface{} + decoder := json.NewDecoder(cs.req.Body) + err = decoder.Decode(&body) + c.Check(err, check.IsNil) + c.Check(body, check.DeepEquals, map[string]interface{}{ + "action": "connect", + "plugs": []interface{}{ + map[string]interface{}{ + "snap": "producer", + "plug": "plug", + }, + }, + "slots": []interface{}{ + map[string]interface{}{ + "snap": "consumer", + "slot": "slot", + }, + }, + }) +} + +func (cs *clientSuite) TestClientDisconnectCallsEndpoint(c *check.C) { + cs.cli.Disconnect("producer", "plug", "consumer", "slot", nil) + c.Check(cs.req.Method, check.Equals, "POST") + c.Check(cs.req.URL.Path, check.Equals, "/v2/interfaces") +} + +func (cs *clientSuite) TestClientDisconnect(c *check.C) { + cs.status = 202 + cs.rsp = `{ + "type": "async", + "status-code": 202, + "result": { }, + "change": "42" + }` + opts := &client.DisconnectOptions{Forget: false} + id, err := cs.cli.Disconnect("producer", "plug", "consumer", "slot", opts) + c.Assert(err, check.IsNil) + c.Check(id, check.Equals, "42") + var body map[string]interface{} + decoder := json.NewDecoder(cs.req.Body) + err = decoder.Decode(&body) + c.Check(err, check.IsNil) + c.Check(body, check.DeepEquals, map[string]interface{}{ + "action": "disconnect", + "plugs": []interface{}{ + map[string]interface{}{ + "snap": "producer", + "plug": "plug", + }, + }, + "slots": []interface{}{ + map[string]interface{}{ + "snap": "consumer", + "slot": "slot", + }, + }, + }) +} + +func (cs *clientSuite) TestClientDisconnectForget(c *check.C) { + cs.status = 202 + cs.rsp = `{ + "type": "async", + "status-code": 202, + "result": { }, + "change": "42" + }` + opts := &client.DisconnectOptions{Forget: true} + id, err := cs.cli.Disconnect("producer", "plug", "consumer", "slot", opts) + c.Assert(err, check.IsNil) + c.Check(id, check.Equals, "42") + var body map[string]interface{} + decoder := json.NewDecoder(cs.req.Body) + err = decoder.Decode(&body) + c.Check(err, check.IsNil) + c.Check(body, check.DeepEquals, map[string]interface{}{ + "action": "disconnect", + "forget": true, + "plugs": []interface{}{ + map[string]interface{}{ + "snap": "producer", + "plug": "plug", + }, + }, + "slots": []interface{}{ + map[string]interface{}{ + "snap": "consumer", + "slot": "slot", + }, + }, + }) +} diff --git a/client/login.go b/client/login.go new file mode 100644 index 00000000..21edcaca --- /dev/null +++ b/client/login.go @@ -0,0 +1,186 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "os/user" + "path/filepath" + + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/osutil/sys" +) + +// User holds logged in user information. +type User struct { + ID int `json:"id,omitempty"` + Username string `json:"username,omitempty"` + Email string `json:"email,omitempty"` + + Macaroon string `json:"macaroon,omitempty"` + Discharges []string `json:"discharges,omitempty"` +} + +type loginData struct { + Email string `json:"email,omitempty"` + Password string `json:"password,omitempty"` + Otp string `json:"otp,omitempty"` +} + +// Login logs user in. +func (client *Client) Login(email, password, otp string) (*User, error) { + postData := loginData{ + Email: email, + Password: password, + Otp: otp, + } + var body bytes.Buffer + if err := json.NewEncoder(&body).Encode(postData); err != nil { + return nil, err + } + + var user User + if _, err := client.doSync("POST", "/v2/login", nil, nil, &body, &user); err != nil { + return nil, err + } + + if err := writeAuthData(user); err != nil { + return nil, fmt.Errorf("cannot persist login information: %v", err) + } + return &user, nil +} + +// Logout logs the user out. +func (client *Client) Logout() error { + _, err := client.doSync("POST", "/v2/logout", nil, nil, nil, nil) + if err != nil { + return err + } + return removeAuthData() +} + +// LoggedInUser returns the logged in User or nil +func (client *Client) LoggedInUser() *User { + u, err := readAuthData() + if err != nil { + return nil + } + return u +} + +const authFileEnvKey = "SNAPD_AUTH_DATA_FILENAME" + +func storeAuthDataFilename(homeDir string) string { + if fn := os.Getenv(authFileEnvKey); fn != "" { + return fn + } + + if homeDir == "" { + real, err := osutil.UserMaybeSudoUser() + if err != nil { + panic(err) + } + homeDir = real.HomeDir + } + + return filepath.Join(homeDir, ".snap", "auth.json") +} + +// realUidGid finds the real user when the command is run +// via sudo. It returns the users record and uid,gid. +func realUidGid() (*user.User, sys.UserID, sys.GroupID, error) { + real, err := osutil.UserMaybeSudoUser() + if err != nil { + return nil, 0, 0, err + } + + uid, gid, err := osutil.UidGid(real) + if err != nil { + return nil, 0, 0, err + } + return real, uid, gid, err +} + +// writeAuthData saves authentication details for later reuse through ReadAuthData +func writeAuthData(user User) error { + real, uid, gid, err := realUidGid() + if err != nil { + return err + } + + targetFile := storeAuthDataFilename(real.HomeDir) + + out, err := json.Marshal(user) + if err != nil { + return err + } + + return sys.RunAsUidGid(uid, gid, func() error { + if err := os.MkdirAll(filepath.Dir(targetFile), 0700); err != nil { + return err + } + + return osutil.AtomicWriteFile(targetFile, out, 0600, 0) + }) +} + +// readAuthData reads previously written authentication details +func readAuthData() (*User, error) { + _, uid, _, err := realUidGid() + if err != nil { + return nil, err + } + + var user User + sourceFile := storeAuthDataFilename("") + + if err := sys.RunAsUidGid(uid, sys.FlagID, func() error { + f, err := os.Open(sourceFile) + if err != nil { + return err + } + defer f.Close() + + dec := json.NewDecoder(f) + + return dec.Decode(&user) + }); err != nil { + return nil, err + } + + return &user, nil +} + +// removeAuthData removes any previously written authentication details. +func removeAuthData() error { + _, uid, _, err := realUidGid() + if err != nil { + return err + } + + filename := storeAuthDataFilename("") + + return sys.RunAsUidGid(uid, sys.FlagID, func() error { + return os.Remove(filename) + }) +} diff --git a/client/login_test.go b/client/login_test.go new file mode 100644 index 00000000..e5267fbb --- /dev/null +++ b/client/login_test.go @@ -0,0 +1,162 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client_test + +import ( + "os" + "path/filepath" + + "gopkg.in/check.v1" + + "github.com/snapcore/snapd/client" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/testutil" +) + +func (cs *clientSuite) TestClientLogin(c *check.C) { + cs.rsp = `{"type": "sync", "result": + {"username": "the-user-name", + "macaroon": "the-root-macaroon", + "discharges": ["discharge-macaroon"]}}` + + outfile := filepath.Join(c.MkDir(), "json") + os.Setenv(client.TestAuthFileEnvKey, outfile) + defer os.Unsetenv(client.TestAuthFileEnvKey) + + c.Assert(cs.cli.LoggedInUser(), check.IsNil) + + user, err := cs.cli.Login("username", "pass", "") + c.Check(err, check.IsNil) + c.Check(user, check.DeepEquals, &client.User{ + Username: "the-user-name", + Macaroon: "the-root-macaroon", + Discharges: []string{"discharge-macaroon"}}) + + c.Assert(cs.cli.LoggedInUser(), check.Not(check.IsNil)) + + c.Check(osutil.FileExists(outfile), check.Equals, true) + c.Check(outfile, testutil.FileEquals, `{"username":"the-user-name","macaroon":"the-root-macaroon","discharges":["discharge-macaroon"]}`) +} + +func (cs *clientSuite) TestClientLoginWhenLoggedIn(c *check.C) { + cs.rsp = `{"type": "sync", "result": + {"username": "the-user-name", + "email": "zed@bar.com", + "macaroon": "the-root-macaroon", + "discharges": ["discharge-macaroon"]}}` + + outfile := filepath.Join(c.MkDir(), "json") + os.Setenv(client.TestAuthFileEnvKey, outfile) + defer os.Unsetenv(client.TestAuthFileEnvKey) + + err := os.WriteFile(outfile, []byte(`{"email":"foo@bar.com","macaroon":"macaroon"}`), 0600) + c.Assert(err, check.IsNil) + c.Assert(cs.cli.LoggedInUser(), check.DeepEquals, &client.User{ + Email: "foo@bar.com", + Macaroon: "macaroon", + }) + + user, err := cs.cli.Login("username", "pass", "") + expected := &client.User{ + Email: "zed@bar.com", + Username: "the-user-name", + Macaroon: "the-root-macaroon", + Discharges: []string{"discharge-macaroon"}, + } + c.Check(err, check.IsNil) + c.Check(user, check.DeepEquals, expected) + c.Check(cs.req.Header.Get("Authorization"), check.Matches, `Macaroon root="macaroon"`) + + c.Assert(cs.cli.LoggedInUser(), check.DeepEquals, expected) + + c.Check(osutil.FileExists(outfile), check.Equals, true) + c.Check(outfile, testutil.FileEquals, `{"username":"the-user-name","email":"zed@bar.com","macaroon":"the-root-macaroon","discharges":["discharge-macaroon"]}`) +} + +func (cs *clientSuite) TestClientLoginError(c *check.C) { + cs.rsp = `{ + "result": {}, + "status": "Bad Request", + "status-code": 400, + "type": "error" + }` + + outfile := filepath.Join(c.MkDir(), "json") + os.Setenv(client.TestAuthFileEnvKey, outfile) + defer os.Unsetenv(client.TestAuthFileEnvKey) + + user, err := cs.cli.Login("username", "pass", "") + + c.Check(user, check.IsNil) + c.Check(err, check.NotNil) + + c.Check(osutil.FileExists(outfile), check.Equals, false) +} + +func (cs *clientSuite) TestClientLogout(c *check.C) { + cs.rsp = `{"type": "sync", "result": {}}` + + outfile := filepath.Join(c.MkDir(), "json") + os.Setenv(client.TestAuthFileEnvKey, outfile) + defer os.Unsetenv(client.TestAuthFileEnvKey) + + err := os.WriteFile(outfile, []byte(`{"macaroon":"macaroon","discharges":["discharged"]}`), 0600) + c.Assert(err, check.IsNil) + + err = cs.cli.Logout() + c.Assert(err, check.IsNil) + c.Check(cs.req.Method, check.Equals, "POST") + c.Check(cs.req.URL.Path, check.Equals, "/v2/logout") + + c.Check(osutil.FileExists(outfile), check.Equals, false) +} + +func (cs *clientSuite) TestWriteAuthData(c *check.C) { + outfile := filepath.Join(c.MkDir(), "json") + os.Setenv(client.TestAuthFileEnvKey, outfile) + defer os.Unsetenv(client.TestAuthFileEnvKey) + + authData := client.User{ + Macaroon: "macaroon", + Discharges: []string{"discharge"}, + } + err := client.TestWriteAuth(authData) + c.Assert(err, check.IsNil) + + c.Check(osutil.FileExists(outfile), check.Equals, true) + c.Check(outfile, testutil.FileEquals, `{"macaroon":"macaroon","discharges":["discharge"]}`) +} + +func (cs *clientSuite) TestReadAuthData(c *check.C) { + outfile := filepath.Join(c.MkDir(), "json") + os.Setenv(client.TestAuthFileEnvKey, outfile) + defer os.Unsetenv(client.TestAuthFileEnvKey) + + authData := client.User{ + Macaroon: "macaroon", + Discharges: []string{"discharge"}, + } + err := client.TestWriteAuth(authData) + c.Assert(err, check.IsNil) + + readUser, err := client.TestReadAuth() + c.Assert(err, check.IsNil) + c.Check(readUser, check.DeepEquals, &authData) +} diff --git a/client/model.go b/client/model.go new file mode 100644 index 00000000..930ad911 --- /dev/null +++ b/client/model.go @@ -0,0 +1,227 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/textproto" + "net/url" + "os" + "path/filepath" + + "golang.org/x/xerrors" + + "github.com/snapcore/snapd/asserts" +) + +type remodelData struct { + NewModel string `json:"new-model"` + Offline bool `json:"offline,omitempty"` +} + +// RemodelOpts defines options to be used when remodeling the system. +type RemodelOpts struct { + // Offline indicates whether the remodel should be done offline. If true, + // the remodel will be attempted to be done without contacting the store. + Offline bool +} + +// Remodel tries to remodel the system with the given assertion data +func (client *Client) Remodel(b []byte, opts RemodelOpts) (changeID string, err error) { + data, err := json.Marshal(&remodelData{ + NewModel: string(b), + Offline: opts.Offline, + }) + if err != nil { + return "", fmt.Errorf("cannot marshal remodel data: %v", err) + } + headers := map[string]string{ + "Content-Type": "application/json", + } + + return client.doAsync("POST", "/v2/model", nil, headers, bytes.NewReader(data)) +} + +// RemodelWithLocalSnaps tries to remodel the system with the given model +// assertion and local snaps and assertion files. Remodeling using this method +// will ensure that snapd does not contact the store. +func (client *Client) RemodelWithLocalSnaps( + model []byte, snapPaths, assertPaths []string) (changeID string, err error) { + + // Check if all files exist before starting the go routine + snapFiles, err := checkAndOpenFiles(snapPaths) + if err != nil { + return "", err + } + assertsFiles, err := checkAndOpenFiles(assertPaths) + if err != nil { + return "", err + } + + pr, pw := io.Pipe() + mw := multipart.NewWriter(pw) + go sendRemodelFiles(model, snapPaths, snapFiles, assertsFiles, pw, mw) + + headers := map[string]string{ + "Content-Type": mw.FormDataContentType(), + } + + _, changeID, err = client.doAsyncFull("POST", "/v2/model", nil, headers, pr, doNoTimeoutAndRetry) + return changeID, err +} + +func checkAndOpenFiles(paths []string) ([]*os.File, error) { + var files []*os.File + for _, path := range paths { + f, err := os.Open(path) + if err != nil { + for _, openFile := range files { + openFile.Close() + } + return nil, fmt.Errorf("cannot open %q: %w", path, err) + } + + files = append(files, f) + } + + return files, nil +} + +func createAssertionPart(name string, mw *multipart.Writer) (io.Writer, error) { + h := make(textproto.MIMEHeader) + h.Set("Content-Disposition", + fmt.Sprintf(`form-data; name="%s"`, name)) + h.Set("Content-Type", asserts.MediaType) + return mw.CreatePart(h) +} + +func sendRemodelFiles(model []byte, paths []string, files, assertFiles []*os.File, pw *io.PipeWriter, mw *multipart.Writer) { + defer func() { + for _, f := range files { + f.Close() + } + }() + + w, err := createAssertionPart("new-model", mw) + if err != nil { + pw.CloseWithError(err) + return + } + _, err = w.Write(model) + if err != nil { + pw.CloseWithError(err) + return + } + + for _, file := range assertFiles { + if err := sendPartFromFile(file, + func() (io.Writer, error) { + return createAssertionPart("assertion", mw) + }); err != nil { + pw.CloseWithError(err) + return + } + } + + for i, file := range files { + if err := sendPartFromFile(file, + func() (io.Writer, error) { + return mw.CreateFormFile("snap", filepath.Base(paths[i])) + }); err != nil { + pw.CloseWithError(err) + return + } + } + + mw.Close() + pw.Close() +} + +func sendPartFromFile(file *os.File, writeHeader func() (io.Writer, error)) error { + fw, err := writeHeader() + if err != nil { + return err + } + + _, err = io.Copy(fw, file) + if err != nil { + return err + } + + return nil +} + +// CurrentModelAssertion returns the current model assertion +func (client *Client) CurrentModelAssertion() (*asserts.Model, error) { + assert, err := currentAssertion(client, "/v2/model") + if err != nil { + return nil, err + } + modelAssert, ok := assert.(*asserts.Model) + if !ok { + return nil, fmt.Errorf("unexpected assertion type (%s) returned", assert.Type().Name) + } + return modelAssert, nil +} + +// CurrentSerialAssertion returns the current serial assertion +func (client *Client) CurrentSerialAssertion() (*asserts.Serial, error) { + assert, err := currentAssertion(client, "/v2/model/serial") + if err != nil { + return nil, err + } + serialAssert, ok := assert.(*asserts.Serial) + if !ok { + return nil, fmt.Errorf("unexpected assertion type (%s) returned", assert.Type().Name) + } + return serialAssert, nil +} + +// helper function for getting assertions from the daemon via a REST path +func currentAssertion(client *Client, path string) (asserts.Assertion, error) { + q := url.Values{} + + response, cancel, err := client.rawWithTimeout(context.Background(), "GET", path, q, nil, nil, nil) + if err != nil { + fmt := "failed to query current assertion: %w" + return nil, xerrors.Errorf(fmt, err) + } + defer cancel() + defer response.Body.Close() + if response.StatusCode != 200 { + return nil, parseError(response) + } + + dec := asserts.NewDecoder(response.Body) + + // only decode a single assertion - we can't ever get more than a single + // assertion through these endpoints by design + assert, err := dec.Decode() + if err != nil { + return nil, fmt.Errorf("failed to decode assertions: %v", err) + } + + return assert, nil +} diff --git a/client/model_test.go b/client/model_test.go new file mode 100644 index 00000000..5c562472 --- /dev/null +++ b/client/model_test.go @@ -0,0 +1,292 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client_test + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + + "golang.org/x/xerrors" + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/client" + "github.com/snapcore/snapd/dirs" +) + +const happyModelAssertionResponse = `type: model +authority-id: mememe +series: 16 +brand-id: mememe +model: test-model +architecture: amd64 +base: core18 +gadget: pc=18 +kernel: pc-kernel=18 +required-snaps: + - core + - hello-world +timestamp: 2017-07-27T00:00:00.0Z +sign-key-sha3-384: 8B3Wmemeu3H6i4dEV4Q85Q4gIUCHIBCNMHq49e085QeLGHi7v27l3Cqmemer4__t + +AcLBcwQAAQoAHRYhBMbX+t6MbKGH5C3nnLZW7+q0g6ELBQJdTdwTAAoJELZW7+q0g6ELEvgQAI3j +jXTqR6kKOqvw94pArwdMDUaZ++tebASAZgso8ejrW2DQGWSc0Q7SQICIR8bvHxqS1GtupQswOzwS +U8hjDTv7WEchH1jylyTj/1W1GernmitTKycecRlEkSOE+EpuqBFgTtj6PdA1Fj3CiCRi1rLMhgF2 +luCOitBLaP+E8P3fuATsLqqDLYzt1VY4Y14MU75hMn+CxAQdnOZTI+NzGMasPsldmOYCPNaN/b3N +6/fDLU47RtNlMJ3K0Tz8kj0bqRbegKlD0RdNbAgo9iZwNmrr5E9WCu9f/0rUor/NIxO77H2ExIll +zhmsZ7E6qlxvAgBmzKgAXrn68gGrBkIb0eXKiCaKy/i2ApvjVZ9HkOzA6Ldd+SwNJv/iA8rdiMsq +p2BfKV5f3ju5b6+WktHxAakJ8iqQmj9Yh7piHjsOAUf1PEJd2s2nqQ+pEEn1F0B23gVCY/Fa9YRQ +iKtWVeL3rBw4dSAaK9rpTMqlNcr+yrdXfTK5YzkCC6RU4yzc5MW0hKeseeSiEDSaRYxvftjFfVNa +ZaVXKg8Lu+cHtCJDeYXEkPIDQzXswdBO1M8Mb9D0mYxQwHxwvsWv1DByB+Otq08EYgPh4kyHo7ag +85yK2e/NQ/fxSwQJMhBF74jM1z9arq6RMiE/KOleFAOraKn2hcROKnEeinABW+sOn6vNuMVv +` + +// note: this serial assertion was generated by adding print statements to the +// test in api_model_test.go that generate a fake serial assertion +const happySerialAssertionResponse = `type: serial +authority-id: my-brand +brand-id: my-brand +model: my-old-model +serial: serialserial +device-key: + AcZrBFaFwYABAvCgEOrrLA6FKcreHxCcOoTgBUZ+IRG7Nb8tzmEAklaQPGpv7skapUjwD1luE2go + mTcoTssVHrfLpBoSDV1aBs44rg3NK40ZKPJP7d2zkds1GxUo1Ea5vfet3SJ4h3aRABEBAAE= +device-key-sha3-384: iqLo9doLzK8De9925UrdUyuvPbBad72OTWVE9YJXqd6nz9dKvwJ_lHP5bVxrl3VO +timestamp: 2019-08-26T16:34:21-05:00 +sign-key-sha3-384: anCEGC2NYq7DzDEi6y7OafQCVeVLS90XlLt9PNjrRl9sim5rmRHDDNFNO7ODcWQW + +AcJwBAABCgAGBQJdZFBdAADCLALwR6Sy24wm9PffwbvUhOEXneyY3BnxKC0+NgdHu1gU8go9vEP1 +i+Flh5uoS70+MBIO+nmF8T+9JWIx2QWFDDxvcuFosnIhvUajCEQohauys5FMz/H/WvB0vrbTBpvK +eg==` + +const noModelAssertionYetResponse = ` +{ + "type": "error", + "status-code": 404, + "status": "Not Found", + "result": { + "message": "no model assertion yet", + "kind": "assertion-not-found", + "value": "model" + } +}` + +const noSerialAssertionYetResponse = ` +{ + "type": "error", + "status-code": 404, + "status": "Not Found", + "result": { + "message": "no serial assertion yet", + "kind": "assertion-not-found", + "value": "serial" + } +}` + +func (cs *clientSuite) TestClientRemodelEndpoint(c *C) { + cs.cli.Remodel([]byte(`{"new-model": "some-model"}`), client.RemodelOpts{}) + c.Check(cs.req.Method, Equals, "POST") + c.Check(cs.req.URL.Path, Equals, "/v2/model") +} + +func (cs *clientSuite) TestClientRemodel(c *C) { + cs.status = 202 + cs.rsp = `{ + "type": "async", + "status-code": 202, + "result": {}, + "change": "d728" + }` + remodelJsonData := []byte(`{"new-model": "some-model"}`) + id, err := cs.cli.Remodel(remodelJsonData, client.RemodelOpts{}) + c.Assert(err, IsNil) + c.Check(id, Equals, "d728") + c.Assert(cs.req.Header.Get("Content-Type"), Equals, "application/json") + + body, err := io.ReadAll(cs.req.Body) + c.Assert(err, IsNil) + jsonBody := make(map[string]interface{}) + err = json.Unmarshal(body, &jsonBody) + c.Assert(err, IsNil) + c.Check(jsonBody, HasLen, 1) + c.Check(jsonBody["new-model"], Equals, string(remodelJsonData)) + c.Check(jsonBody["offline"], IsNil) +} + +func (cs *clientSuite) TestClientRemodelOffline(c *C) { + cs.status = 202 + cs.rsp = `{ + "type": "async", + "status-code": 202, + "result": {}, + "change": "d728" + }` + remodelJsonData := []byte(`{"new-model": "some-model"}`) + id, err := cs.cli.Remodel(remodelJsonData, client.RemodelOpts{Offline: true}) + c.Assert(err, IsNil) + c.Check(id, Equals, "d728") + c.Assert(cs.req.Header.Get("Content-Type"), Equals, "application/json") + + body, err := io.ReadAll(cs.req.Body) + c.Assert(err, IsNil) + jsonBody := make(map[string]interface{}) + err = json.Unmarshal(body, &jsonBody) + c.Assert(err, IsNil) + c.Check(jsonBody, HasLen, 2) + c.Check(jsonBody["new-model"], Equals, string(remodelJsonData)) + c.Check(jsonBody["offline"], Equals, true) +} + +func (cs *clientSuite) TestClientGetModelHappy(c *C) { + cs.status = 200 + cs.rsp = happyModelAssertionResponse + modelAssertion, err := cs.cli.CurrentModelAssertion() + c.Assert(err, IsNil) + expectedAssert, err := asserts.Decode([]byte(happyModelAssertionResponse)) + c.Assert(err, IsNil) + c.Assert(modelAssertion, DeepEquals, expectedAssert) +} + +func (cs *clientSuite) TestClientGetModelNoModel(c *C) { + cs.status = 404 + cs.rsp = noModelAssertionYetResponse + cs.header = http.Header{} + cs.header.Add("Content-Type", "application/json") + _, err := cs.cli.CurrentModelAssertion() + c.Assert(err, ErrorMatches, "no model assertion yet") +} + +func (cs *clientSuite) TestClientGetModelNoSerial(c *C) { + cs.status = 404 + cs.rsp = noSerialAssertionYetResponse + cs.header = http.Header{} + cs.header.Add("Content-Type", "application/json") + _, err := cs.cli.CurrentSerialAssertion() + c.Assert(err, ErrorMatches, "no serial assertion yet") +} + +func (cs *clientSuite) TestClientGetSerialHappy(c *C) { + cs.status = 200 + cs.rsp = happySerialAssertionResponse + serialAssertion, err := cs.cli.CurrentSerialAssertion() + c.Assert(err, IsNil) + expectedAssert, err := asserts.Decode([]byte(happySerialAssertionResponse)) + c.Assert(err, IsNil) + c.Assert(serialAssertion, DeepEquals, expectedAssert) +} + +func (cs *clientSuite) TestClientCurrentModelAssertionErrIsWrapped(c *C) { + cs.err = errors.New("boom") + _, err := cs.cli.CurrentModelAssertion() + var e xerrors.Wrapper + c.Assert(err, Implements, &e) +} + +func (cs *clientSuite) TestClientOfflineRemodel(c *C) { + cs.status = 202 + cs.rsp = `{ + "type": "async", + "status-code": 202, + "result": {}, + "change": "d728" + }` + rawModel := []byte(`some-model`) + + var err error + snapPaths := []string{filepath.Join(dirs.GlobalRootDir, "snap1.snap")} + err = os.WriteFile(snapPaths[0], []byte("snap1"), 0644) + c.Assert(err, IsNil) + assertsPaths := []string{filepath.Join(dirs.GlobalRootDir, "f1.asserts")} + err = os.WriteFile(assertsPaths[0], []byte("asserts1"), 0644) + c.Assert(err, IsNil) + + id, err := cs.cli.RemodelWithLocalSnaps(rawModel, snapPaths, assertsPaths) + c.Assert(err, IsNil) + c.Check(id, Equals, "d728") + contentTypeReStr := "^multipart/form-data; boundary=([A-Za-z0-9]*)$" + contentType := cs.req.Header.Get("Content-Type") + c.Assert(contentType, Matches, contentTypeReStr) + contentTypeRe := regexp.MustCompile(contentTypeReStr) + matches := contentTypeRe.FindStringSubmatch(contentType) + c.Assert(len(matches), Equals, 2) + boundary := "--" + matches[1] + + body, err := io.ReadAll(cs.req.Body) + c.Assert(err, IsNil) + expected := boundary + ` +Content-Disposition: form-data; name="new-model" +Content-Type: application/x.ubuntu.assertion + +some-model +` + boundary + ` +Content-Disposition: form-data; name="assertion" +Content-Type: application/x.ubuntu.assertion + +asserts1 +` + boundary + ` +Content-Disposition: form-data; name="snap"; filename="snap1.snap" +Content-Type: application/octet-stream + +snap1 +` + boundary + `-- +` + expected = strings.Replace(expected, "\n", "\r\n", -1) + c.Assert(string(body), Equals, expected) +} + +func (cs *clientSuite) TestClientOfflineRemodelServerError(c *C) { + cs.status = 404 + cs.rsp = noSerialAssertionYetResponse + rawModel := []byte(`some-model`) + + var err error + snapPaths := []string{filepath.Join(dirs.GlobalRootDir, "snap1.snap")} + err = os.WriteFile(snapPaths[0], []byte("snap1"), 0644) + c.Assert(err, IsNil) + assertsPaths := []string{filepath.Join(dirs.GlobalRootDir, "f1.asserts")} + err = os.WriteFile(assertsPaths[0], []byte("asserts1"), 0644) + c.Assert(err, IsNil) + + id, err := cs.cli.RemodelWithLocalSnaps(rawModel, snapPaths, assertsPaths) + c.Assert(err.Error(), Equals, "no serial assertion yet") + c.Check(id, Equals, "") +} + +func (cs *clientSuite) TestClientOfflineRemodelNoFile(c *C) { + rawModel := []byte(`some-model`) + + paths := []string{filepath.Join(dirs.GlobalRootDir, "snap1.snap")} + + // No snap file + id, err := cs.cli.RemodelWithLocalSnaps(rawModel, paths, nil) + c.Assert(err, ErrorMatches, `cannot open .*: no such file or directory`) + c.Assert(id, Equals, "") + + // No assertions file + id, err = cs.cli.RemodelWithLocalSnaps(rawModel, nil, paths) + c.Assert(err, ErrorMatches, `cannot open .*: no such file or directory`) + c.Assert(id, Equals, "") +} diff --git a/client/notices.go b/client/notices.go new file mode 100644 index 00000000..fb369d6d --- /dev/null +++ b/client/notices.go @@ -0,0 +1,64 @@ +// Copyright (c) 2024 Canonical Ltd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 3 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package client + +import ( + "bytes" + "encoding/json" +) + +type NotifyOptions struct { + // Type is the notice's type. Currently only notices of type CustomNotice + // can be added. + Type NoticeType + + // Key is the notice's key. For "custom" notices, this must be in + // "domain.com/key" format. + Key string +} + +// Notify records an occurrence of a notice with the specified options, +// returning the notice ID. +func (client *Client) Notify(opts *NotifyOptions) (string, error) { + var payload = struct { + Action string `json:"action"` + Type string `json:"type"` + Key string `json:"key"` + }{ + Action: "add", + Type: string(opts.Type), + Key: opts.Key, + } + var body bytes.Buffer + if err := json.NewEncoder(&body).Encode(&payload); err != nil { + return "", err + } + + result := struct { + ID string `json:"id"` + }{} + _, err := client.doSync("POST", "/v2/notices", nil, nil, &body, &result) + if err != nil { + return "", err + } + return result.ID, err +} + +type NoticeType string + +const ( + // SnapRunInhibitNotice is recorded when "snap run" is inhibited due refresh. + SnapRunInhibitNotice NoticeType = "snap-run-inhibit" +) diff --git a/client/notices_test.go b/client/notices_test.go new file mode 100644 index 00000000..dc489262 --- /dev/null +++ b/client/notices_test.go @@ -0,0 +1,50 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2024 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client_test + +import ( + "encoding/json" + "io" + + "github.com/snapcore/snapd/client" + . "gopkg.in/check.v1" +) + +func (cs *clientSuite) TestNotify(c *C) { + cs.rsp = `{"type": "sync", "result": {"id": "7"}}` + noticeID, err := cs.cli.Notify(&client.NotifyOptions{ + Type: client.SnapRunInhibitNotice, + Key: "snap-name", + }) + c.Assert(err, IsNil) + c.Check(noticeID, Equals, "7") + c.Assert(cs.req.URL.Path, Equals, "/v2/notices") + + body, err := io.ReadAll(cs.req.Body) + c.Assert(err, IsNil) + var m map[string]any + err = json.Unmarshal(body, &m) + c.Assert(err, IsNil) + c.Assert(m, DeepEquals, map[string]any{ + "action": "add", + "type": "snap-run-inhibit", + "key": "snap-name", + }) +} diff --git a/client/packages.go b/client/packages.go new file mode 100644 index 00000000..9a543ef6 --- /dev/null +++ b/client/packages.go @@ -0,0 +1,296 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2015-2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client + +import ( + "errors" + "fmt" + "net/url" + "strings" + "time" + + "golang.org/x/xerrors" + + "github.com/snapcore/snapd/snap" +) + +// Snap holds the data for a snap as obtained from snapd. +type Snap struct { + ID string `json:"id"` + Title string `json:"title,omitempty"` + Summary string `json:"summary"` + Description string `json:"description"` + DownloadSize int64 `json:"download-size,omitempty"` + Icon string `json:"icon,omitempty"` + InstalledSize int64 `json:"installed-size,omitempty"` + InstallDate *time.Time `json:"install-date,omitempty"` + Name string `json:"name"` + Publisher *snap.StoreAccount `json:"publisher,omitempty"` + StoreURL string `json:"store-url,omitempty"` + // Developer is also the publisher's username for historic reasons. + Developer string `json:"developer"` + Status string `json:"status"` + Type string `json:"type"` + Base string `json:"base,omitempty"` + Version string `json:"version"` + Channel string `json:"channel"` + TrackingChannel string `json:"tracking-channel,omitempty"` + IgnoreValidation bool `json:"ignore-validation"` + Revision snap.Revision `json:"revision"` + Confinement string `json:"confinement"` + Private bool `json:"private"` + DevMode bool `json:"devmode"` + JailMode bool `json:"jailmode"` + TryMode bool `json:"trymode,omitempty"` + Apps []AppInfo `json:"apps,omitempty"` + Broken string `json:"broken,omitempty"` + License string `json:"license,omitempty"` + CommonIDs []string `json:"common-ids,omitempty"` + MountedFrom string `json:"mounted-from,omitempty"` + CohortKey string `json:"cohort-key,omitempty"` + + Links map[string][]string `json:"links,omitempy"` + + // legacy fields before we had links + Contact string `json:"contact"` + Website string `json:"website,omitempty"` + + Prices map[string]float64 `json:"prices,omitempty"` + Screenshots []snap.ScreenshotInfo `json:"screenshots,omitempty"` + Media snap.MediaInfos `json:"media,omitempty"` + Categories []snap.CategoryInfo `json:"categories,omitempty"` + + // The flattended channel map with $track/$risk + Channels map[string]*snap.ChannelSnapInfo `json:"channels,omitempty"` + + // The ordered list of tracks that contains channels + Tracks []string `json:"tracks,omitempty"` + + Health *SnapHealth `json:"health,omitempty"` + + // Hold is the time until which the snap's refreshes are held by the user. + Hold *time.Time `json:"hold,omitempty"` + // GatingHold is the time until which the snap's refreshes are held by a snap. + GatingHold *time.Time `json:"gating-hold,omitempty"` + // if RefreshInhibit is nil, then there is no pending refresh. + RefreshInhibit *SnapRefreshInhibit `json:"refresh-inhibit,omitempty"` +} + +type SnapHealth struct { + Revision snap.Revision `json:"revision"` + Timestamp time.Time `json:"timestamp"` + Status string `json:"status"` + Message string `json:"message,omitempty"` + Code string `json:"code,omitempty"` +} + +type SnapRefreshInhibit struct { + // ProceedTime is the time after which a pending refresh is forced for a + // running snap in the next auto-refresh. + // + // NOTE: ProceedTime may be in the past, if a refresh is still pending and + // the snap applications are being monitored. + ProceedTime time.Time `json:"proceed-time"` +} + +// Statuses and types a snap may have. +const ( + StatusAvailable = "available" + StatusInstalled = "installed" + StatusActive = "active" + StatusRemoved = "removed" + StatusPriced = "priced" + + TypeApp = "app" + TypeKernel = "kernel" + TypeGadget = "gadget" + TypeOS = "os" + + StrictConfinement = "strict" + DevModeConfinement = "devmode" + ClassicConfinement = "classic" +) + +type ResultInfo struct { + SuggestedCurrency string `json:"suggested-currency"` +} + +// FindOptions supports exactly one of the following options: +// - Refresh: only return snaps that are refreshable +// - Private: return snaps that are private +// - Query: only return snaps that match the query string +type FindOptions struct { + // Query is a term to search by or a prefix (if Prefix is true) + Query string + Prefix bool + + CommonID string + + Category string + // Section is deprecated, use Category instead. + Section string + Private bool + Scope string + + Refresh bool +} + +var ErrNoSnapsInstalled = errors.New("no snaps installed") + +type ListOptions struct { + All bool +} + +// Information about a category +type Category struct { + Name string `json:"name"` +} + +// List returns the list of all snaps installed on the system +// with names in the given list; if the list is empty, all snaps. +func (client *Client) List(names []string, opts *ListOptions) ([]*Snap, error) { + if opts == nil { + opts = &ListOptions{} + } + + q := make(url.Values) + if opts.All { + q.Add("select", "all") + } + if len(names) > 0 { + q.Add("snaps", strings.Join(names, ",")) + } + + snaps, _, err := client.snapsFromPath("/v2/snaps", q) + if err != nil { + return nil, err + } + + if len(snaps) == 0 { + return nil, ErrNoSnapsInstalled + } + + return snaps, nil +} + +// Sections returns the list of existing snap sections in the store +// This is deprecated, use Categories() instead. +func (client *Client) Sections() ([]string, error) { + var sections []string + _, err := client.doSync("GET", "/v2/sections", nil, nil, nil, §ions) + if err != nil { + fmt := "cannot get snap sections: %w" + return nil, xerrors.Errorf(fmt, err) + } + return sections, nil +} + +// Categories returns the list of existing snap categories in the store +func (client *Client) Categories() ([]*Category, error) { + var categories []*Category + _, err := client.doSync("GET", "/v2/categories", nil, nil, nil, &categories) + if err != nil { + return nil, fmt.Errorf("cannot get snap categories: %w", err) + } + return categories, nil +} + +// Find returns a list of snaps available for install from the +// store for this system and that match the query +func (client *Client) Find(opts *FindOptions) ([]*Snap, *ResultInfo, error) { + if opts == nil { + opts = &FindOptions{} + } + + q := url.Values{} + if opts.Prefix { + q.Set("name", opts.Query+"*") + } else { + if opts.CommonID != "" { + q.Set("common-id", opts.CommonID) + } + if opts.Query != "" { + q.Set("q", opts.Query) + } + } + + switch { + case opts.Refresh && opts.Private: + return nil, nil, fmt.Errorf("cannot specify refresh and private together") + case opts.Refresh: + q.Set("select", "refresh") + case opts.Private: + q.Set("select", "private") + } + if opts.Category != "" { + q.Set("category", opts.Category) + } + if opts.Section != "" { + q.Set("section", opts.Section) + } + if opts.Scope != "" { + q.Set("scope", opts.Scope) + } + + return client.snapsFromPath("/v2/find", q) +} + +func (client *Client) FindOne(name string) (*Snap, *ResultInfo, error) { + q := url.Values{} + q.Set("name", name) + + snaps, ri, err := client.snapsFromPath("/v2/find", q) + if err != nil { + fmt := "cannot find snap %q: %w" + return nil, nil, xerrors.Errorf(fmt, name, err) + } + + if len(snaps) == 0 { + return nil, nil, fmt.Errorf("cannot find snap %q", name) + } + + return snaps[0], ri, nil +} + +func (client *Client) snapsFromPath(path string, query url.Values) ([]*Snap, *ResultInfo, error) { + var snaps []*Snap + ri, err := client.doSync("GET", path, query, nil, nil, &snaps) + if e, ok := err.(*Error); ok { + return nil, nil, e + } + if err != nil { + fmt := "cannot list snaps: %w" + return nil, nil, xerrors.Errorf(fmt, err) + } + return snaps, ri, nil +} + +// Snap returns the most recently published revision of the snap with the +// provided name. +func (client *Client) Snap(name string) (*Snap, *ResultInfo, error) { + var snap *Snap + path := fmt.Sprintf("/v2/snaps/%s", name) + ri, err := client.doSync("GET", path, nil, nil, nil, &snap) + if err != nil { + fmt := "cannot retrieve snap %q: %w" + return nil, nil, xerrors.Errorf(fmt, name, err) + } + return snap, ri, nil +} diff --git a/client/packages_test.go b/client/packages_test.go new file mode 100644 index 00000000..bd89e63e --- /dev/null +++ b/client/packages_test.go @@ -0,0 +1,470 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2015-2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client_test + +import ( + "encoding/json" + "errors" + "fmt" + "net/url" + "os" + "time" + + "golang.org/x/xerrors" + "gopkg.in/check.v1" + + "github.com/snapcore/snapd/client" + "github.com/snapcore/snapd/snap" +) + +func (cs *clientSuite) TestClientSnapsCallsEndpoint(c *check.C) { + _, _ = cs.cli.List(nil, nil) + c.Check(cs.req.Method, check.Equals, "GET") + c.Check(cs.req.URL.Path, check.Equals, "/v2/snaps") + c.Check(cs.req.URL.Query(), check.DeepEquals, url.Values{}) +} + +func (cs *clientSuite) TestClientFindRefreshSetsQuery(c *check.C) { + _, _, _ = cs.cli.Find(&client.FindOptions{ + Refresh: true, + }) + c.Check(cs.req.Method, check.Equals, "GET") + c.Check(cs.req.URL.Path, check.Equals, "/v2/find") + c.Check(cs.req.URL.Query(), check.DeepEquals, url.Values{ + "select": []string{"refresh"}, + }) +} + +func (cs *clientSuite) TestClientFindRefreshSetsQueryWithSec(c *check.C) { + _, _, _ = cs.cli.Find(&client.FindOptions{ + Refresh: true, + Section: "mysection", + }) + c.Check(cs.req.Method, check.Equals, "GET") + c.Check(cs.req.URL.Path, check.Equals, "/v2/find") + c.Check(cs.req.URL.Query(), check.DeepEquals, url.Values{ + "section": []string{"mysection"}, "select": []string{"refresh"}, + }) +} + +func (cs *clientSuite) TestClientFindWithSectionSetsQuery(c *check.C) { + _, _, _ = cs.cli.Find(&client.FindOptions{ + Section: "mysection", + }) + c.Check(cs.req.Method, check.Equals, "GET") + c.Check(cs.req.URL.Path, check.Equals, "/v2/find") + c.Check(cs.req.URL.Query(), check.DeepEquals, url.Values{ + "section": []string{"mysection"}, + }) +} + +func (cs *clientSuite) TestClientFindRefreshSetsQueryWithCategory(c *check.C) { + _, _, _ = cs.cli.Find(&client.FindOptions{ + Refresh: true, + Category: "mycategory", + }) + c.Check(cs.req.Method, check.Equals, "GET") + c.Check(cs.req.URL.Path, check.Equals, "/v2/find") + c.Check(cs.req.URL.Query(), check.DeepEquals, url.Values{ + "category": []string{"mycategory"}, "select": []string{"refresh"}, + }) +} + +func (cs *clientSuite) TestClientFindWithCategorySetsQuery(c *check.C) { + _, _, _ = cs.cli.Find(&client.FindOptions{ + Category: "mycategory", + }) + c.Check(cs.req.Method, check.Equals, "GET") + c.Check(cs.req.URL.Path, check.Equals, "/v2/find") + c.Check(cs.req.URL.Query(), check.DeepEquals, url.Values{ + "category": []string{"mycategory"}, + }) +} + +func (cs *clientSuite) TestClientFindPrivateSetsQuery(c *check.C) { + _, _, _ = cs.cli.Find(&client.FindOptions{ + Private: true, + }) + c.Check(cs.req.Method, check.Equals, "GET") + c.Check(cs.req.URL.Path, check.Equals, "/v2/find") + + c.Check(cs.req.URL.Query().Get("select"), check.Equals, "private") +} + +func (cs *clientSuite) TestClientFindWithScopeSetsQuery(c *check.C) { + _, _, _ = cs.cli.Find(&client.FindOptions{ + Scope: "mouthwash", + }) + c.Check(cs.req.Method, check.Equals, "GET") + c.Check(cs.req.URL.Path, check.Equals, "/v2/find") + c.Check(cs.req.URL.Query(), check.DeepEquals, url.Values{ + "scope": []string{"mouthwash"}, + }) +} + +func (cs *clientSuite) TestClientSnapsInvalidSnapsJSON(c *check.C) { + cs.rsp = `{ + "type": "sync", + "result": "not a list of snaps" + }` + _, err := cs.cli.List(nil, nil) + c.Check(err, check.ErrorMatches, `.*cannot unmarshal.*`) +} + +func (cs *clientSuite) TestClientNoSnaps(c *check.C) { + cs.rsp = `{ + "type": "sync", + "result": [], + "suggested-currency": "GBP" + }` + _, err := cs.cli.List(nil, nil) + c.Check(err, check.Equals, client.ErrNoSnapsInstalled) + _, err = cs.cli.List([]string{"foo"}, nil) + c.Check(err, check.Equals, client.ErrNoSnapsInstalled) +} + +func (cs *clientSuite) TestClientSnaps(c *check.C) { + healthTimestamp, err := time.Parse(time.RFC3339Nano, "2019-05-13T16:27:01.475851677+01:00") + c.Assert(err, check.IsNil) + + // TODO: update this JSON as it's ancient + cs.rsp = `{ + "type": "sync", + "result": [{ + "id": "funky-snap-id", + "title": "Title", + "summary": "salutation snap", + "description": "hello-world", + "download-size": 22212, + "health": { + "revision": "29", + "timestamp": "2019-05-13T16:27:01.475851677+01:00", + "status": "okay" + }, + "icon": "https://myapps.developer.ubuntu.com/site_media/appmedia/2015/03/hello.svg_NZLfWbh.png", + "installed-size": -1, + "license": "GPL-3.0", + "name": "hello-world", + "developer": "canonical", + "publisher": { + "id": "canonical", + "username": "canonical", + "display-name": "Canonical", + "validation": "verified" + }, + "resource": "/v2/snaps/hello-world.canonical", + "status": "available", + "type": "app", + "version": "1.0.18", + "confinement": "strict", + "private": true, + "common-ids": ["org.funky.snap"] + }], + "suggested-currency": "GBP" + }` + applications, err := cs.cli.List(nil, nil) + c.Check(err, check.IsNil) + c.Check(applications, check.DeepEquals, []*client.Snap{{ + ID: "funky-snap-id", + Title: "Title", + Summary: "salutation snap", + Description: "hello-world", + DownloadSize: 22212, + Icon: "https://myapps.developer.ubuntu.com/site_media/appmedia/2015/03/hello.svg_NZLfWbh.png", + Health: &client.SnapHealth{ + Revision: snap.R(29), + Timestamp: healthTimestamp, + Status: "okay", + }, + InstalledSize: -1, + License: "GPL-3.0", + Name: "hello-world", + Developer: "canonical", + Publisher: &snap.StoreAccount{ + ID: "canonical", + Username: "canonical", + DisplayName: "Canonical", + Validation: "verified", + }, + Status: client.StatusAvailable, + Type: client.TypeApp, + Version: "1.0.18", + Confinement: client.StrictConfinement, + Private: true, + DevMode: false, + CommonIDs: []string{"org.funky.snap"}, + }}) +} + +func (cs *clientSuite) TestClientFilterSnaps(c *check.C) { + _, _, _ = cs.cli.Find(&client.FindOptions{Query: "foo"}) + c.Check(cs.req.URL.Path, check.Equals, "/v2/find") + c.Check(cs.req.URL.RawQuery, check.Equals, "q=foo") +} + +func (cs *clientSuite) TestClientFindPrefix(c *check.C) { + _, _, _ = cs.cli.Find(&client.FindOptions{Query: "foo", Prefix: true}) + c.Check(cs.req.URL.Path, check.Equals, "/v2/find") + c.Check(cs.req.URL.RawQuery, check.Equals, "name=foo%2A") // 2A is `*` +} + +func (cs *clientSuite) TestClientFindCommonID(c *check.C) { + _, _, _ = cs.cli.Find(&client.FindOptions{CommonID: "org.kde.ktuberling.desktop"}) + c.Check(cs.req.URL.Path, check.Equals, "/v2/find") + c.Check(cs.req.URL.RawQuery, check.Equals, "common-id=org.kde.ktuberling.desktop") +} + +func (cs *clientSuite) TestClientFindOne(c *check.C) { + _, _, _ = cs.cli.FindOne("foo") + c.Check(cs.req.URL.Path, check.Equals, "/v2/find") + c.Check(cs.req.URL.RawQuery, check.Equals, "name=foo") +} + +const ( + pkgName = "chatroom" +) + +func (cs *clientSuite) testClientSnap(c *check.C, refreshInhibited bool) { + // example data obtained via + // printf "GET /v2/find?name=test-snapd-tools HTTP/1.0\r\n\r\n" | nc -U -q 1 /run/snapd.socket|grep '{'|python3 -m json.tool + // XXX: update / sync with what daemon is actually putting out + cs.rsp = `{ + "type": "sync", + "result": { + "id": "funky-snap-id", + "title": "Title", + "summary": "bla bla", + "description": "WebRTC Video chat server for Snappy", + "download-size": 6930947, + "icon": "/v2/icons/chatroom.ogra/icon", + "installed-size": 18976651, + "install-date": "2016-01-02T15:04:05Z", + "license": "GPL-3.0", + "name": "chatroom", + "developer": "ogra", + "publisher": { + "id": "ogra-id", + "username": "ogra", + "display-name": "Ogra", + "validation": "unproven" + }, + "resource": "/v2/snaps/chatroom.ogra", + "status": "active", + "type": "app", + "version": "0.1-8", + "revision": 42, + "confinement": "strict", + "private": true, + "devmode": true, + "trymode": true, + "screenshots": [ + {"url":"http://example.com/shot1.png", "width":640, "height":480}, + {"url":"http://example.com/shot2.png"} + ], + "media": [ + {"type": "icon", "url":"http://example.com/icon.png"}, + {"type": "screenshot", "url":"http://example.com/shot1.png", "width":640, "height":480}, + {"type": "screenshot", "url":"http://example.com/shot2.png"} + ], + "cohort-key": "some-long-cohort-key", + "links": { + "website": ["http://example.com/funky"] + }, + "website": "http://example.com/funky", + "common-ids": ["org.funky.snap"],` + if refreshInhibited { + cs.rsp += ` + "store-url": "https://snapcraft.io/chatroom", + "refresh-inhibit": { "proceed-time": "2024-02-09T15:04:05Z" }` + } else { + cs.rsp += ` + "store-url": "https://snapcraft.io/chatroom"` + } + cs.rsp += ` + } + }` + pkg, _, err := cs.cli.Snap(pkgName) + c.Assert(cs.req.Method, check.Equals, "GET") + c.Assert(cs.req.URL.Path, check.Equals, fmt.Sprintf("/v2/snaps/%s", pkgName)) + c.Assert(err, check.IsNil) + + c.Assert(pkg.InstallDate.Equal(time.Date(2016, 1, 2, 15, 4, 5, 0, time.UTC)), check.Equals, true) + pkg.InstallDate = nil + + var expectedSnapRefreshInhibit *client.SnapRefreshInhibit + if refreshInhibited { + expectedSnapRefreshInhibit = &client.SnapRefreshInhibit{ + ProceedTime: time.Date(2024, 2, 9, 15, 4, 5, 0, time.UTC), + } + } + c.Assert(pkg, check.DeepEquals, &client.Snap{ + ID: "funky-snap-id", + Summary: "bla bla", + Title: "Title", + Description: "WebRTC Video chat server for Snappy", + DownloadSize: 6930947, + Icon: "/v2/icons/chatroom.ogra/icon", + InstalledSize: 18976651, + License: "GPL-3.0", + Name: "chatroom", + Developer: "ogra", + Publisher: &snap.StoreAccount{ + ID: "ogra-id", + Username: "ogra", + DisplayName: "Ogra", + Validation: "unproven", + }, + Status: client.StatusActive, + Type: client.TypeApp, + Version: "0.1-8", + Revision: snap.R(42), + Confinement: client.StrictConfinement, + Private: true, + DevMode: true, + TryMode: true, + Screenshots: []snap.ScreenshotInfo{ + {URL: "http://example.com/shot1.png", Width: 640, Height: 480}, + {URL: "http://example.com/shot2.png"}, + }, + Media: []snap.MediaInfo{ + {Type: "icon", URL: "http://example.com/icon.png"}, + {Type: "screenshot", URL: "http://example.com/shot1.png", Width: 640, Height: 480}, + {Type: "screenshot", URL: "http://example.com/shot2.png"}, + }, + CommonIDs: []string{"org.funky.snap"}, + CohortKey: "some-long-cohort-key", + Links: map[string][]string{ + "website": {"http://example.com/funky"}, + }, + Website: "http://example.com/funky", + StoreURL: "https://snapcraft.io/chatroom", + RefreshInhibit: expectedSnapRefreshInhibit, + }) +} + +func (cs *clientSuite) TestClientSnap(c *check.C) { + const refreshInhibited = false + cs.testClientSnap(c, refreshInhibited) +} + +func (cs *clientSuite) TestClientSnapRefreshInhibited(c *check.C) { + const refreshInhibited = true + cs.testClientSnap(c, refreshInhibited) +} + +func (cs *clientSuite) TestAppInfoNoServiceNoDaemon(c *check.C) { + buf, err := json.MarshalIndent(client.AppInfo{Name: "hello"}, "\t", "\t") + c.Assert(err, check.IsNil) + c.Check(string(buf), check.Equals, `{ + "name": "hello" + }`) +} + +func (cs *clientSuite) TestAppInfoServiceDaemon(c *check.C) { + buf, err := json.MarshalIndent(client.AppInfo{ + Snap: "foo", + Name: "hello", + Daemon: "daemon", + Enabled: true, + Active: false, + }, "\t", "\t") + c.Assert(err, check.IsNil) + c.Check(string(buf), check.Equals, `{ + "snap": "foo", + "name": "hello", + "daemon": "daemon", + "enabled": true + }`) +} + +func (cs *clientSuite) TestAppInfoNilNotService(c *check.C) { + var app *client.AppInfo + c.Check(app.IsService(), check.Equals, false) +} + +func (cs *clientSuite) TestAppInfoNoDaemonNotService(c *check.C) { + var app *client.AppInfo + c.Assert(json.Unmarshal([]byte(`{"name": "hello"}`), &app), check.IsNil) + c.Check(app.Name, check.Equals, "hello") + c.Check(app.IsService(), check.Equals, false) +} + +func (cs *clientSuite) TestAppInfoEmptyDaemonNotService(c *check.C) { + var app *client.AppInfo + c.Assert(json.Unmarshal([]byte(`{"name": "hello", "daemon": ""}`), &app), check.IsNil) + c.Check(app.Name, check.Equals, "hello") + c.Check(app.IsService(), check.Equals, false) +} + +func (cs *clientSuite) TestAppInfoDaemonIsService(c *check.C) { + var app *client.AppInfo + + c.Assert(json.Unmarshal([]byte(`{"name": "hello", "daemon": "x"}`), &app), check.IsNil) + c.Check(app.Name, check.Equals, "hello") + c.Check(app.IsService(), check.Equals, true) +} + +func (cs *clientSuite) TestClientSectionsErrIsWrapped(c *check.C) { + cs.err = errors.New("boom") + _, err := cs.cli.Sections() + var e xerrors.Wrapper + c.Assert(err, check.Implements, &e) +} + +func (cs *clientSuite) TestClientCategoriesErrIsWrapped(c *check.C) { + cs.err = errors.New("boom") + _, err := cs.cli.Categories() + var e xerrors.Wrapper + c.Assert(err, check.Implements, &e) +} + +func (cs *clientSuite) TestClientFindOneErrIsWrapped(c *check.C) { + cs.err = errors.New("boom") + _, _, err := cs.cli.FindOne("snap") + var e xerrors.Wrapper + c.Assert(err, check.Implements, &e) +} + +func (cs *clientSuite) TestClientSnapErrIsWrapped(c *check.C) { + // setting cs.err will trigger a "client.ClientError" + cs.err = errors.New("boom") + _, _, err := cs.cli.Snap("snap") + var e xerrors.Wrapper + c.Assert(err, check.Implements, &e) +} + +func (cs *clientSuite) TestClientFindFromPathErrIsWrapped(c *check.C) { + var e client.AuthorizationError + + // this will trigger a "client.AuthorizationError" + err := os.WriteFile(client.TestStoreAuthFilename(os.Getenv("HOME")), []byte("rubbish"), 0644) + c.Assert(err, check.IsNil) + + // check that all the functions that use snapsFromPath() get a + // wrapped error + _, _, err = cs.cli.FindOne("snap") + c.Assert(xerrors.As(err, &e), check.Equals, true) + + _, _, err = cs.cli.Find(nil) + c.Assert(xerrors.As(err, &e), check.Equals, true) + + _, err = cs.cli.List([]string{"snap"}, nil) + c.Assert(xerrors.As(err, &e), check.Equals, true) +} diff --git a/client/quota.go b/client/quota.go new file mode 100644 index 00000000..129e1ec1 --- /dev/null +++ b/client/quota.go @@ -0,0 +1,164 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2021 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client + +import ( + "bytes" + "encoding/json" + "fmt" + "time" + + "github.com/snapcore/snapd/gadget/quantity" +) + +type postQuotaData struct { + Action string `json:"action"` + GroupName string `json:"group-name"` + Parent string `json:"parent,omitempty"` + Snaps []string `json:"snaps,omitempty"` + Services []string `json:"services,omitempty"` + Constraints *QuotaValues `json:"constraints,omitempty"` +} + +type QuotaGroupResult struct { + GroupName string `json:"group-name"` + Parent string `json:"parent,omitempty"` + Subgroups []string `json:"subgroups,omitempty"` + Snaps []string `json:"snaps,omitempty"` + Services []string `json:"services,omitempty"` + Constraints *QuotaValues `json:"constraints,omitempty"` + Current *QuotaValues `json:"current,omitempty"` +} + +type QuotaCPUValues struct { + Count int `json:"count,omitempty"` + Percentage int `json:"percentage,omitempty"` +} + +type QuotaCPUSetValues struct { + CPUs []int `json:"cpus,omitempty"` +} + +type QuotaJournalRate struct { + RateCount int `json:"rate-count"` + RatePeriod time.Duration `json:"rate-period"` +} + +type QuotaJournalValues struct { + Size quantity.Size `json:"size,omitempty"` + *QuotaJournalRate +} + +type QuotaValues struct { + Memory quantity.Size `json:"memory,omitempty"` + CPU *QuotaCPUValues `json:"cpu,omitempty"` + CPUSet *QuotaCPUSetValues `json:"cpu-set,omitempty"` + Threads int `json:"threads,omitempty"` + Journal *QuotaJournalValues `json:"journal,omitempty"` +} + +type EnsureQuotaOptions struct { + // Parent is used to assign a Parent quota group + Parent string + // Snaps that should be added to the quota group + Snaps []string + // Services that should be added to the quota group + Services []string + // Constraints are the resource limits that should be applied to the quota group, + // these are added or modified, not removed. + Constraints *QuotaValues +} + +// EnsureQuota creates a quota group or updates an existing group with the options +// provided. +func (client *Client) EnsureQuota(groupName string, opts *EnsureQuotaOptions) (changeID string, err error) { + if groupName == "" { + return "", fmt.Errorf("cannot create or update quota group without a name") + } + if opts == nil { + return "", fmt.Errorf("cannot create or update quota group without any options") + } + + // TODO: use naming.ValidateQuotaGroup() + + data := &postQuotaData{ + Action: "ensure", + GroupName: groupName, + Parent: opts.Parent, + Snaps: opts.Snaps, + Services: opts.Services, + Constraints: opts.Constraints, + } + + var body bytes.Buffer + if err := json.NewEncoder(&body).Encode(data); err != nil { + return "", err + } + chgID, err := client.doAsync("POST", "/v2/quotas", nil, nil, &body) + + if err != nil { + return "", err + } + return chgID, nil +} + +func (client *Client) GetQuotaGroup(groupName string) (*QuotaGroupResult, error) { + if groupName == "" { + return nil, fmt.Errorf("cannot get quota group without a name") + } + + var res *QuotaGroupResult + path := fmt.Sprintf("/v2/quotas/%s", groupName) + if _, err := client.doSync("GET", path, nil, nil, nil, &res); err != nil { + return nil, err + } + + return res, nil +} + +func (client *Client) RemoveQuotaGroup(groupName string) (changeID string, err error) { + if groupName == "" { + return "", fmt.Errorf("cannot remove quota group without a name") + } + data := &postQuotaData{ + Action: "remove", + GroupName: groupName, + } + + var body bytes.Buffer + if err := json.NewEncoder(&body).Encode(data); err != nil { + return "", err + } + chgID, err := client.doAsync("POST", "/v2/quotas", nil, nil, &body) + if err != nil { + return "", fmt.Errorf("cannot remove quota group: %w", err) + } + + return chgID, nil +} + +func (client *Client) Quotas() ([]*QuotaGroupResult, error) { + var res []*QuotaGroupResult + if _, err := client.doSync("GET", "/v2/quotas", nil, nil, nil, &res); err != nil { + return nil, err + } + + return res, nil +} diff --git a/client/quota_test.go b/client/quota_test.go new file mode 100644 index 00000000..579844bb --- /dev/null +++ b/client/quota_test.go @@ -0,0 +1,194 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2021 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client_test + +import ( + "bytes" + "encoding/json" + "io" + "time" + + "gopkg.in/check.v1" + + "github.com/snapcore/snapd/client" + "github.com/snapcore/snapd/gadget/quantity" + "github.com/snapcore/snapd/jsonutil" +) + +func (cs *clientSuite) TestCreateQuotaGroupInvalidName(c *check.C) { + _, err := cs.cli.EnsureQuota("", nil) + c.Check(err, check.ErrorMatches, `cannot create or update quota group without a name`) +} + +func (cs *clientSuite) TestCreateQuotaGroupInvalidOptions(c *check.C) { + _, err := cs.cli.EnsureQuota("foo", nil) + c.Check(err, check.ErrorMatches, `cannot create or update quota group without any options`) +} + +func (cs *clientSuite) TestEnsureQuotaGroup(c *check.C) { + cs.status = 202 + cs.rsp = `{ + "type": "async", + "status-code": 202, + "change": "42" + }` + + quotaValues := &client.QuotaValues{ + Memory: quantity.Size(1001), + CPU: &client.QuotaCPUValues{ + Count: 1, + Percentage: 50, + }, + CPUSet: &client.QuotaCPUSetValues{ + CPUs: []int{0}, + }, + Threads: 32, + Journal: &client.QuotaJournalValues{ + Size: quantity.SizeMiB, + QuotaJournalRate: &client.QuotaJournalRate{ + RateCount: 150, + RatePeriod: time.Minute, + }, + }, + } + + chgID, err := cs.cli.EnsureQuota("foo", &client.EnsureQuotaOptions{ + Parent: "bar", + Snaps: []string{"snap-a", "snap-b"}, + Services: []string{"snap-a.svc1", "snap-b.svc1"}, + Constraints: quotaValues, + }) + c.Assert(err, check.IsNil) + c.Assert(chgID, check.Equals, "42") + c.Check(cs.req.Method, check.Equals, "POST") + c.Check(cs.req.URL.Path, check.Equals, "/v2/quotas") + body, err := io.ReadAll(cs.req.Body) + c.Assert(err, check.IsNil) + var req map[string]interface{} + err = jsonutil.DecodeWithNumber(bytes.NewReader(body), &req) + c.Assert(err, check.IsNil) + c.Assert(req, check.DeepEquals, map[string]interface{}{ + "action": "ensure", + "group-name": "foo", + "parent": "bar", + "snaps": []interface{}{"snap-a", "snap-b"}, + "services": []interface{}{"snap-a.svc1", "snap-b.svc1"}, + "constraints": map[string]interface{}{ + "memory": json.Number("1001"), + "cpu": map[string]interface{}{ + "count": json.Number("1"), + "percentage": json.Number("50"), + }, + "cpu-set": map[string]interface{}{ + "cpus": []interface{}{json.Number("0")}, + }, + "threads": json.Number("32"), + "journal": map[string]interface{}{ + "size": json.Number("1048576"), + "rate-count": json.Number("150"), + "rate-period": json.Number("60000000000"), + }, + }, + }) +} + +func (cs *clientSuite) TestEnsureQuotaGroupError(c *check.C) { + cs.status = 500 + cs.rsp = `{"type": "error"}` + _, err := cs.cli.EnsureQuota("foo", &client.EnsureQuotaOptions{ + Parent: "bar", + Snaps: []string{"snap-a"}, + Constraints: &client.QuotaValues{Memory: quantity.Size(1)}, + }) + c.Check(err, check.ErrorMatches, `server error: "Internal Server Error"`) +} + +func (cs *clientSuite) TestGetQuotaGroupInvalidName(c *check.C) { + _, err := cs.cli.GetQuotaGroup("") + c.Assert(err, check.ErrorMatches, `cannot get quota group without a name`) +} + +func (cs *clientSuite) TestGetQuotaGroup(c *check.C) { + cs.rsp = `{ + "type": "sync", + "status-code": 200, + "result": { + "group-name":"foo", + "parent":"bar", + "subgroups":["foo-subgrp"], + "snaps":["snap-a"], + "services":["snap-a.svc1"], + "constraints": { "memory": 999 }, + "current": { "memory": 450 } + } + }` + + grp, err := cs.cli.GetQuotaGroup("foo") + c.Assert(err, check.IsNil) + c.Check(cs.req.Method, check.Equals, "GET") + c.Check(cs.req.URL.Path, check.Equals, "/v2/quotas/foo") + c.Check(grp, check.DeepEquals, &client.QuotaGroupResult{ + GroupName: "foo", + Parent: "bar", + Subgroups: []string{"foo-subgrp"}, + Constraints: &client.QuotaValues{Memory: quantity.Size(999)}, + Current: &client.QuotaValues{Memory: quantity.Size(450)}, + Snaps: []string{"snap-a"}, + Services: []string{"snap-a.svc1"}, + }) +} + +func (cs *clientSuite) TestGetQuotaGroupError(c *check.C) { + cs.status = 500 + cs.rsp = `{"type": "error"}` + _, err := cs.cli.GetQuotaGroup("foo") + c.Check(err, check.ErrorMatches, `server error: "Internal Server Error"`) +} + +func (cs *clientSuite) TestRemoveQuotaGroup(c *check.C) { + cs.status = 202 + cs.rsp = `{ + "type": "async", + "status-code": 202, + "change": "42" + }` + + chgID, err := cs.cli.RemoveQuotaGroup("foo") + c.Assert(err, check.IsNil) + c.Assert(chgID, check.Equals, "42") + c.Check(cs.req.Method, check.Equals, "POST") + c.Check(cs.req.URL.Path, check.Equals, "/v2/quotas") + body, err := io.ReadAll(cs.req.Body) + c.Assert(err, check.IsNil) + var req map[string]interface{} + err = json.Unmarshal(body, &req) + c.Assert(err, check.IsNil) + c.Assert(req, check.DeepEquals, map[string]interface{}{ + "action": "remove", + "group-name": "foo", + }) +} + +func (cs *clientSuite) TestRemoveQuotaGroupError(c *check.C) { + cs.status = 500 + cs.rsp = `{"type": "error"}` + _, err := cs.cli.RemoveQuotaGroup("foo") + c.Check(err, check.ErrorMatches, `cannot remove quota group: server error: "Internal Server Error"`) +} diff --git a/client/snap_op.go b/client/snap_op.go new file mode 100644 index 00000000..1512b697 --- /dev/null +++ b/client/snap_op.go @@ -0,0 +1,502 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016-2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "os" + "path/filepath" +) + +// TransactionType says whether we want to treat each snap separately +// (the transaction is per snap) or whether to consider the call a +// single transaction so everything is reverted if it fails for just +// one snap. This applies to installs and updates, which can be done +// for multiple snaps in the same API call. +type TransactionType string + +const ( + TransactionAllSnaps TransactionType = "all-snaps" + TransactionPerSnap TransactionType = "per-snap" +) + +type SnapOptions struct { + Channel string `json:"channel,omitempty"` + Revision string `json:"revision,omitempty"` + CohortKey string `json:"cohort-key,omitempty"` + LeaveCohort bool `json:"leave-cohort,omitempty"` + DevMode bool `json:"devmode,omitempty"` + JailMode bool `json:"jailmode,omitempty"` + Classic bool `json:"classic,omitempty"` + Dangerous bool `json:"dangerous,omitempty"` + IgnoreValidation bool `json:"ignore-validation,omitempty"` + IgnoreRunning bool `json:"ignore-running,omitempty"` + Unaliased bool `json:"unaliased,omitempty"` + Prefer bool `json:"prefer,omitempty"` + Purge bool `json:"purge,omitempty"` + Amend bool `json:"amend,omitempty"` + Transaction TransactionType `json:"transaction,omitempty"` + QuotaGroupName string `json:"quota-group,omitempty"` + ValidationSets []string `json:"validation-sets,omitempty"` + Time string `json:"time,omitempty"` + HoldLevel string `json:"hold-level,omitempty"` + + Users []string `json:"users,omitempty"` +} + +func writeFieldBool(mw *multipart.Writer, key string, val bool) error { + if !val { + return nil + } + return mw.WriteField(key, "true") +} + +type field struct { + field string + value bool +} + +func writeFields(mw *multipart.Writer, fields []field) error { + for _, fd := range fields { + if err := writeFieldBool(mw, fd.field, fd.value); err != nil { + return err + } + } + + return nil +} + +func (opts *SnapOptions) writeModeFields(mw *multipart.Writer) error { + fields := []field{ + {"devmode", opts.DevMode}, + {"classic", opts.Classic}, + {"jailmode", opts.JailMode}, + {"dangerous", opts.Dangerous}, + } + return writeFields(mw, fields) +} + +func (opts *SnapOptions) writeOptionFields(mw *multipart.Writer) error { + fields := []field{ + {"ignore-running", opts.IgnoreRunning}, + {"unaliased", opts.Unaliased}, + {"prefer", opts.Prefer}, + } + if opts.Transaction != "" { + if err := mw.WriteField("transaction", string(opts.Transaction)); err != nil { + return err + } + } + if opts.QuotaGroupName != "" { + if err := mw.WriteField("quota-group", opts.QuotaGroupName); err != nil { + return err + } + } + return writeFields(mw, fields) +} + +type actionData struct { + Action string `json:"action"` + Name string `json:"name,omitempty"` + SnapPath string `json:"snap-path,omitempty"` + *SnapOptions +} + +type multiActionData struct { + Action string `json:"action"` + Snaps []string `json:"snaps,omitempty"` + Users []string `json:"users,omitempty"` + Transaction TransactionType `json:"transaction,omitempty"` + IgnoreRunning bool `json:"ignore-running,omitempty"` + Purge bool `json:"purge,omitempty"` + ValidationSets []string `json:"validation-sets,omitempty"` + Time string `json:"time,omitempty"` + HoldLevel string `json:"hold-level,omitempty"` +} + +// Install adds the snap with the given name from the given channel (or +// the system default channel if not). +func (client *Client) Install(name string, options *SnapOptions) (changeID string, err error) { + return client.doSnapAction("install", name, options) +} + +func (client *Client) InstallMany(names []string, options *SnapOptions) (changeID string, err error) { + return client.doMultiSnapAction("install", names, options) +} + +// Remove removes the snap with the given name. +func (client *Client) Remove(name string, options *SnapOptions) (changeID string, err error) { + return client.doSnapAction("remove", name, options) +} + +func (client *Client) RemoveMany(names []string, options *SnapOptions) (changeID string, err error) { + return client.doMultiSnapAction("remove", names, options) +} + +// Refresh refreshes the snap with the given name (switching it to track +// the given channel if given). +func (client *Client) Refresh(name string, options *SnapOptions) (changeID string, err error) { + return client.doSnapAction("refresh", name, options) +} + +func (client *Client) RefreshMany(names []string, options *SnapOptions) (changeID string, err error) { + return client.doMultiSnapAction("refresh", names, options) +} + +func (client *Client) HoldRefreshes(name string, options *SnapOptions) (changeID string, err error) { + return client.doSnapAction("hold", name, options) +} + +func (client *Client) HoldRefreshesMany(names []string, options *SnapOptions) (changeID string, err error) { + return client.doMultiSnapAction("hold", names, options) +} + +func (client *Client) UnholdRefreshes(name string, options *SnapOptions) (changeID string, err error) { + return client.doSnapAction("unhold", name, options) +} + +func (client *Client) UnholdRefreshesMany(names []string, options *SnapOptions) (changeID string, err error) { + return client.doMultiSnapAction("unhold", names, options) +} + +func (client *Client) Enable(name string, options *SnapOptions) (changeID string, err error) { + return client.doSnapAction("enable", name, options) +} + +func (client *Client) Disable(name string, options *SnapOptions) (changeID string, err error) { + return client.doSnapAction("disable", name, options) +} + +// Revert rolls the snap back to the previous on-disk state +func (client *Client) Revert(name string, options *SnapOptions) (changeID string, err error) { + return client.doSnapAction("revert", name, options) +} + +// Switch moves the snap to a different channel without a refresh +func (client *Client) Switch(name string, options *SnapOptions) (changeID string, err error) { + return client.doSnapAction("switch", name, options) +} + +// SnapshotMany snapshots many snaps (all, if names empty) for many users (all, if users is empty). +func (client *Client) SnapshotMany(names []string, users []string) (setID uint64, changeID string, err error) { + result, changeID, err := client.doMultiSnapActionFull("snapshot", names, &SnapOptions{Users: users}) + if err != nil { + return 0, "", err + } + if len(result) == 0 { + return 0, "", fmt.Errorf("server result does not contain snapshot set identifier") + } + var x struct { + SetID uint64 `json:"set-id"` + } + if err := json.Unmarshal(result, &x); err != nil { + return 0, "", err + } + return x.SetID, changeID, nil +} + +var ErrDangerousNotApplicable = fmt.Errorf("dangerous option only meaningful when installing from a local file") + +func (client *Client) doSnapAction(actionName string, snapName string, options *SnapOptions) (changeID string, err error) { + if options != nil && options.Dangerous { + return "", ErrDangerousNotApplicable + } + action := actionData{ + Action: actionName, + SnapOptions: options, + } + data, err := json.Marshal(&action) + if err != nil { + return "", fmt.Errorf("cannot marshal snap action: %s", err) + } + path := fmt.Sprintf("/v2/snaps/%s", snapName) + + headers := map[string]string{ + "Content-Type": "application/json", + } + + return client.doAsync("POST", path, nil, headers, bytes.NewBuffer(data)) +} + +func (client *Client) doMultiSnapAction(actionName string, snaps []string, options *SnapOptions) (changeID string, err error) { + _, changeID, err = client.doMultiSnapActionFull(actionName, snaps, options) + + return changeID, err +} + +func (client *Client) doMultiSnapActionFull(actionName string, snaps []string, options *SnapOptions) (result json.RawMessage, changeID string, err error) { + action := multiActionData{ + Action: actionName, + Snaps: snaps, + } + if options != nil { + // TODO: consider returning error when options.Dangerous is set + action.Users = options.Users + action.Transaction = options.Transaction + action.IgnoreRunning = options.IgnoreRunning + action.Purge = options.Purge + action.ValidationSets = options.ValidationSets + action.Time = options.Time + action.HoldLevel = options.HoldLevel + } + + data, err := json.Marshal(&action) + if err != nil { + return nil, "", fmt.Errorf("cannot marshal multi-snap action: %s", err) + } + + headers := map[string]string{ + "Content-Type": "application/json", + } + + return client.doAsyncFull("POST", "/v2/snaps", nil, headers, bytes.NewBuffer(data), nil) +} + +// InstallPath sideloads the snap with the given path under optional provided name, +// returning the UUID of the background operation upon success. +func (client *Client) InstallPath(path, name string, options *SnapOptions) (changeID string, err error) { + f, err := os.Open(path) + if err != nil { + return "", fmt.Errorf("cannot open %q: %w", path, err) + } + + action := actionData{ + Action: "install", + Name: name, + SnapPath: path, + SnapOptions: options, + } + + return client.sendLocalSnaps([]string{path}, []*os.File{f}, action) +} + +// InstallPathMany sideloads the snaps with the given paths, +// returning the UUID of the background operation upon success. +func (client *Client) InstallPathMany(paths []string, options *SnapOptions) (changeID string, err error) { + action := actionData{ + Action: "install", + SnapOptions: options, + } + + var files []*os.File + for _, path := range paths { + f, err := os.Open(path) + if err != nil { + for _, openFile := range files { + openFile.Close() + } + return "", fmt.Errorf("cannot open %q: %w", path, err) + } + + files = append(files, f) + } + + return client.sendLocalSnaps(paths, files, action) +} + +func (client *Client) sendLocalSnaps(paths []string, files []*os.File, action actionData) (string, error) { + pr, pw := io.Pipe() + mw := multipart.NewWriter(pw) + go sendSnapFiles(paths, files, pw, mw, &action) + + headers := map[string]string{ + "Content-Type": mw.FormDataContentType(), + } + + _, changeID, err := client.doAsyncFull("POST", "/v2/snaps", nil, headers, pr, doNoTimeoutAndRetry) + return changeID, err +} + +// Try +func (client *Client) Try(path string, options *SnapOptions) (changeID string, err error) { + if options == nil { + options = &SnapOptions{} + } + if options.Dangerous { + return "", ErrDangerousNotApplicable + } + + buf := bytes.NewBuffer(nil) + mw := multipart.NewWriter(buf) + mw.WriteField("action", "try") + mw.WriteField("snap-path", path) + options.writeModeFields(mw) + mw.Close() + + headers := map[string]string{ + "Content-Type": mw.FormDataContentType(), + } + + return client.doAsync("POST", "/v2/snaps", nil, headers, buf) +} + +func sendSnapFiles(paths []string, files []*os.File, pw *io.PipeWriter, mw *multipart.Writer, action *actionData) { + defer func() { + for _, f := range files { + f.Close() + } + }() + + if action.SnapOptions == nil { + action.SnapOptions = &SnapOptions{} + } + + type field struct { + name string + value string + } + + fields := []field{{"action", action.Action}} + if len(paths) == 1 { + fields = append(fields, []field{ + {"name", action.Name}, + {"snap-path", action.SnapPath}, + {"channel", action.Channel}}...) + } + + for _, s := range fields { + if s.value == "" { + continue + } + if err := mw.WriteField(s.name, s.value); err != nil { + pw.CloseWithError(err) + return + } + } + + if err := action.writeModeFields(mw); err != nil { + pw.CloseWithError(err) + return + } + + if err := action.writeOptionFields(mw); err != nil { + pw.CloseWithError(err) + return + } + + for i, file := range files { + path := paths[i] + fw, err := mw.CreateFormFile("snap", filepath.Base(path)) + if err != nil { + pw.CloseWithError(err) + return + } + + _, err = io.Copy(fw, file) + if err != nil { + pw.CloseWithError(err) + return + } + } + + mw.Close() + pw.Close() +} + +type snapRevisionOptions struct { + Channel string `json:"channel,omitempty"` + Revision string `json:"revision,omitempty"` + CohortKey string `json:"cohort-key,omitempty"` +} + +type downloadAction struct { + SnapName string `json:"snap-name"` + + snapRevisionOptions + + HeaderPeek bool `json:"header-peek,omitempty"` + ResumeToken string `json:"resume-token,omitempty"` +} + +type DownloadInfo struct { + SuggestedFileName string + Size int64 + Sha3_384 string + ResumeToken string +} + +type DownloadOptions struct { + SnapOptions + + HeaderPeek bool + ResumeToken string + Resume int64 +} + +// Download will stream the given snap to the client +func (client *Client) Download(name string, options *DownloadOptions) (dlInfo *DownloadInfo, r io.ReadCloser, err error) { + if options == nil { + options = &DownloadOptions{} + } + action := downloadAction{ + SnapName: name, + snapRevisionOptions: snapRevisionOptions{ + Channel: options.Channel, + CohortKey: options.CohortKey, + Revision: options.Revision, + }, + HeaderPeek: options.HeaderPeek, + ResumeToken: options.ResumeToken, + } + data, err := json.Marshal(&action) + if err != nil { + return nil, nil, fmt.Errorf("cannot marshal snap action: %s", err) + } + headers := map[string]string{ + "Content-Type": "application/json", + } + if options.Resume > 0 { + headers["range"] = fmt.Sprintf("bytes: %d-", options.Resume) + } + + // no deadline for downloads + ctx := context.Background() + rsp, err := client.raw(ctx, "POST", "/v2/download", nil, headers, bytes.NewBuffer(data)) + if err != nil { + return nil, nil, err + } + + if rsp.StatusCode != 200 { + var r response + defer rsp.Body.Close() + if err := decodeInto(rsp.Body, &r); err != nil { + return nil, nil, err + } + return nil, nil, r.err(client, rsp.StatusCode) + } + matches := contentDispositionMatcher(rsp.Header.Get("Content-Disposition")) + if matches == nil || matches[1] == "" { + return nil, nil, fmt.Errorf("cannot determine filename") + } + + dlInfo = &DownloadInfo{ + SuggestedFileName: matches[1], + Size: rsp.ContentLength, + Sha3_384: rsp.Header.Get("Snap-Sha3-384"), + ResumeToken: rsp.Header.Get("Snap-Download-Token"), + } + + return dlInfo, rsp.Body, nil +} diff --git a/client/snap_op_test.go b/client/snap_op_test.go new file mode 100644 index 00000000..cb4d6140 --- /dev/null +++ b/client/snap_op_test.go @@ -0,0 +1,916 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016-2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client_test + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "mime" + "mime/multipart" + "net/http" + "os" + "path/filepath" + + "gopkg.in/check.v1" + + "github.com/snapcore/snapd/client" + "github.com/snapcore/snapd/testutil" +) + +var chanName = "achan" + +var ops = []struct { + op func(*client.Client, string, *client.SnapOptions) (string, error) + action string +}{ + {(*client.Client).Install, "install"}, + {(*client.Client).Refresh, "refresh"}, + {(*client.Client).Remove, "remove"}, + {(*client.Client).Revert, "revert"}, + {(*client.Client).Enable, "enable"}, + {(*client.Client).Disable, "disable"}, + {(*client.Client).Switch, "switch"}, + {(*client.Client).HoldRefreshes, "hold"}, + {(*client.Client).UnholdRefreshes, "unhold"}, +} + +var multiOps = []struct { + op func(*client.Client, []string, *client.SnapOptions) (string, error) + action string +}{ + {(*client.Client).RefreshMany, "refresh"}, + {(*client.Client).InstallMany, "install"}, + {(*client.Client).RemoveMany, "remove"}, + {(*client.Client).HoldRefreshesMany, "hold"}, + {(*client.Client).UnholdRefreshesMany, "unhold"}, +} + +func (cs *clientSuite) TestClientOpSnapServerError(c *check.C) { + cs.err = errors.New("fail") + for _, s := range ops { + _, err := s.op(cs.cli, pkgName, nil) + c.Check(err, check.ErrorMatches, `.*fail`, check.Commentf(s.action)) + } +} + +func (cs *clientSuite) TestClientMultiOpSnapServerError(c *check.C) { + cs.err = errors.New("fail") + for _, s := range multiOps { + _, err := s.op(cs.cli, nil, nil) + c.Check(err, check.ErrorMatches, `.*fail`, check.Commentf(s.action)) + } + _, _, err := cs.cli.SnapshotMany(nil, nil) + c.Check(err, check.ErrorMatches, `.*fail`) +} + +func (cs *clientSuite) TestClientOpSnapResponseError(c *check.C) { + cs.status = 400 + cs.rsp = `{"type": "error"}` + for _, s := range ops { + _, err := s.op(cs.cli, pkgName, nil) + c.Check(err, check.ErrorMatches, `.*server error: "Bad Request"`, check.Commentf(s.action)) + } +} + +func (cs *clientSuite) TestClientMultiOpSnapResponseError(c *check.C) { + cs.status = 500 + cs.rsp = `{"type": "error"}` + for _, s := range multiOps { + _, err := s.op(cs.cli, nil, nil) + c.Check(err, check.ErrorMatches, `.*server error: "Internal Server Error"`, check.Commentf(s.action)) + } + _, _, err := cs.cli.SnapshotMany(nil, nil) + c.Check(err, check.ErrorMatches, `.*server error: "Internal Server Error"`) +} + +func (cs *clientSuite) TestClientOpSnapBadType(c *check.C) { + cs.rsp = `{"type": "what"}` + for _, s := range ops { + _, err := s.op(cs.cli, pkgName, nil) + c.Check(err, check.ErrorMatches, `.*expected async response for "POST" on "/v2/snaps/`+pkgName+`", got "what"`, check.Commentf(s.action)) + } +} + +func (cs *clientSuite) TestClientOpSnapNotAccepted(c *check.C) { + cs.rsp = `{ + "status-code": 200, + "type": "async" + }` + for _, s := range ops { + _, err := s.op(cs.cli, pkgName, nil) + c.Check(err, check.ErrorMatches, `.*operation not accepted`, check.Commentf(s.action)) + } +} + +func (cs *clientSuite) TestClientOpSnapNoChange(c *check.C) { + cs.status = 202 + cs.rsp = `{ + "status-code": 202, + "type": "async" + }` + for _, s := range ops { + _, err := s.op(cs.cli, pkgName, nil) + c.Assert(err, check.ErrorMatches, `.*response without change reference.*`, check.Commentf(s.action)) + } +} + +func (cs *clientSuite) TestClientOpSnap(c *check.C) { + cs.status = 202 + cs.rsp = `{ + "change": "d728", + "status-code": 202, + "type": "async" + }` + for _, s := range ops { + id, err := s.op(cs.cli, pkgName, &client.SnapOptions{Channel: chanName}) + c.Assert(err, check.IsNil) + + c.Assert(cs.req.Header.Get("Content-Type"), check.Equals, "application/json", check.Commentf(s.action)) + + _, ok := cs.req.Context().Deadline() + c.Check(ok, check.Equals, true) + + body, err := io.ReadAll(cs.req.Body) + c.Assert(err, check.IsNil, check.Commentf(s.action)) + jsonBody := make(map[string]string) + err = json.Unmarshal(body, &jsonBody) + c.Assert(err, check.IsNil, check.Commentf(s.action)) + c.Check(jsonBody["action"], check.Equals, s.action, check.Commentf(s.action)) + c.Check(jsonBody["channel"], check.Equals, chanName, check.Commentf(s.action)) + c.Check(jsonBody, check.HasLen, 2, check.Commentf(s.action)) + + c.Check(cs.req.URL.Path, check.Equals, fmt.Sprintf("/v2/snaps/%s", pkgName), check.Commentf(s.action)) + c.Check(id, check.Equals, "d728", check.Commentf(s.action)) + } +} + +func (cs *clientSuite) TestClientMultiOpSnap(c *check.C) { + cs.status = 202 + cs.rsp = `{ + "change": "d728", + "status-code": 202, + "type": "async" + }` + for _, s := range multiOps { + // Note body is essentially the same as TestClientMultiSnapshot; keep in sync + id, err := s.op(cs.cli, []string{pkgName}, nil) + c.Assert(err, check.IsNil) + + c.Assert(cs.req.Header.Get("Content-Type"), check.Equals, "application/json", check.Commentf(s.action)) + + body, err := io.ReadAll(cs.req.Body) + c.Assert(err, check.IsNil, check.Commentf(s.action)) + jsonBody := make(map[string]interface{}) + err = json.Unmarshal(body, &jsonBody) + c.Assert(err, check.IsNil, check.Commentf(s.action)) + c.Check(jsonBody["action"], check.Equals, s.action, check.Commentf(s.action)) + c.Check(jsonBody["snaps"], check.DeepEquals, []interface{}{pkgName}, check.Commentf(s.action)) + c.Check(jsonBody, check.HasLen, 2, check.Commentf(s.action)) + + c.Check(cs.req.URL.Path, check.Equals, "/v2/snaps", check.Commentf(s.action)) + c.Check(id, check.Equals, "d728", check.Commentf(s.action)) + } +} + +func (cs *clientSuite) TestClientMultiOpSnapTransactional(c *check.C) { + cs.status = 202 + cs.rsp = `{ + "change": "d728", + "status-code": 202, + "type": "async" + }` + for _, s := range multiOps { + // Note body is essentially the same as TestClientMultiSnapshot; keep in sync + id, err := s.op(cs.cli, []string{pkgName}, + &client.SnapOptions{Transaction: client.TransactionAllSnaps}) + c.Assert(err, check.IsNil) + + c.Assert(cs.req.Header.Get("Content-Type"), check.Equals, "application/json", check.Commentf(s.action)) + + body, err := io.ReadAll(cs.req.Body) + c.Assert(err, check.IsNil, check.Commentf(s.action)) + jsonBody := make(map[string]interface{}) + err = json.Unmarshal(body, &jsonBody) + c.Assert(err, check.IsNil, check.Commentf(s.action)) + c.Check(jsonBody["action"], check.Equals, s.action, check.Commentf(s.action)) + c.Check(jsonBody["snaps"], check.DeepEquals, []interface{}{pkgName}, check.Commentf(s.action)) + c.Check(jsonBody["transaction"], check.Equals, string(client.TransactionAllSnaps), + check.Commentf(s.action)) + c.Check(jsonBody, check.HasLen, 3, check.Commentf(s.action)) + + c.Check(cs.req.URL.Path, check.Equals, "/v2/snaps", check.Commentf(s.action)) + c.Check(id, check.Equals, "d728", check.Commentf(s.action)) + } +} + +func (cs *clientSuite) TestClientMultiOpSnapIgnoreRunning(c *check.C) { + cs.status = 202 + cs.rsp = `{ + "change": "d728", + "status-code": 202, + "type": "async" + }` + for _, s := range multiOps { + // Note body is essentially the same as TestClientMultiSnapshot; keep in sync + id, err := s.op(cs.cli, []string{pkgName}, + &client.SnapOptions{IgnoreRunning: true}) + c.Assert(err, check.IsNil) + + c.Assert(cs.req.Header.Get("Content-Type"), check.Equals, "application/json", check.Commentf(s.action)) + + body, err := io.ReadAll(cs.req.Body) + c.Assert(err, check.IsNil, check.Commentf(s.action)) + jsonBody := make(map[string]interface{}) + err = json.Unmarshal(body, &jsonBody) + c.Assert(err, check.IsNil, check.Commentf(s.action)) + c.Check(jsonBody["action"], check.Equals, s.action, check.Commentf(s.action)) + c.Check(jsonBody["snaps"], check.DeepEquals, []interface{}{pkgName}, check.Commentf(s.action)) + c.Check(jsonBody["ignore-running"], check.Equals, true, + check.Commentf(s.action)) + c.Check(jsonBody, check.HasLen, 3, check.Commentf(s.action)) + + c.Check(cs.req.URL.Path, check.Equals, "/v2/snaps", check.Commentf(s.action)) + c.Check(id, check.Equals, "d728", check.Commentf(s.action)) + } +} + +func (cs *clientSuite) TestClientMultiSnapshot(c *check.C) { + // Note body is essentially the same as TestClientMultiOpSnap; keep in sync + cs.status = 202 + cs.rsp = `{ + "result": {"set-id": 42}, + "change": "d728", + "status-code": 202, + "type": "async" + }` + setID, changeID, err := cs.cli.SnapshotMany([]string{pkgName}, nil) + c.Assert(err, check.IsNil) + c.Check(cs.req.Header.Get("Content-Type"), check.Equals, "application/json") + + body, err := io.ReadAll(cs.req.Body) + c.Assert(err, check.IsNil) + jsonBody := make(map[string]interface{}) + err = json.Unmarshal(body, &jsonBody) + c.Assert(err, check.IsNil) + c.Check(jsonBody["action"], check.Equals, "snapshot") + c.Check(jsonBody["snaps"], check.DeepEquals, []interface{}{pkgName}) + c.Check(jsonBody, check.HasLen, 2) + c.Check(cs.req.URL.Path, check.Equals, "/v2/snaps") + c.Check(setID, check.Equals, uint64(42)) + c.Check(changeID, check.Equals, "d728") +} + +func (cs *clientSuite) TestClientOpInstallPath(c *check.C) { + cs.status = 202 + cs.rsp = `{ + "change": "66b3", + "status-code": 202, + "type": "async" + }` + bodyData := []byte("snap-data") + + snap := filepath.Join(c.MkDir(), "foo.snap") + err := os.WriteFile(snap, bodyData, 0644) + c.Assert(err, check.IsNil) + + id, err := cs.cli.InstallPath(snap, "", nil) + c.Assert(err, check.IsNil) + + body, err := io.ReadAll(cs.req.Body) + c.Assert(err, check.IsNil) + + c.Assert(string(body), testutil.Contains, "\r\nsnap-data\r\n") + c.Assert(string(body), testutil.Contains, "Content-Disposition: form-data; name=\"action\"\r\n\r\ninstall\r\n") + + c.Check(cs.req.Method, check.Equals, "POST") + c.Check(cs.req.URL.Path, check.Equals, "/v2/snaps") + c.Assert(cs.req.Header.Get("Content-Type"), testutil.Contains, "multipart/form-data; boundary=") + _, ok := cs.req.Context().Deadline() + c.Assert(ok, check.Equals, false) + c.Check(id, check.Equals, "66b3") +} + +func (cs *clientSuite) TestClientOpInstallPathIgnoreRunning(c *check.C) { + cs.status = 202 + cs.rsp = `{ + "change": "66b3", + "status-code": 202, + "type": "async" + }` + bodyData := []byte("snap-data") + + snap := filepath.Join(c.MkDir(), "foo.snap") + err := os.WriteFile(snap, bodyData, 0644) + c.Assert(err, check.IsNil) + + id, err := cs.cli.InstallPath(snap, "", &client.SnapOptions{IgnoreRunning: true}) + c.Assert(err, check.IsNil) + + body, err := io.ReadAll(cs.req.Body) + c.Assert(err, check.IsNil) + + c.Assert(string(body), check.Matches, "(?s).*\r\nsnap-data\r\n.*") + c.Assert(string(body), check.Matches, "(?s).*Content-Disposition: form-data; name=\"action\"\r\n\r\ninstall\r\n.*") + c.Assert(string(body), check.Matches, "(?s).*Content-Disposition: form-data; name=\"ignore-running\"\r\n\r\ntrue\r\n.*") + + c.Check(cs.req.Method, check.Equals, "POST") + c.Check(cs.req.URL.Path, check.Equals, "/v2/snaps") + c.Assert(cs.req.Header.Get("Content-Type"), check.Matches, "multipart/form-data; boundary=.*") + _, ok := cs.req.Context().Deadline() + c.Assert(ok, check.Equals, false) + c.Check(id, check.Equals, "66b3") +} + +func (cs *clientSuite) TestClientOpInstallPathInstance(c *check.C) { + cs.status = 202 + cs.rsp = `{ + "change": "66b3", + "status-code": 202, + "type": "async" + }` + bodyData := []byte("snap-data") + + snap := filepath.Join(c.MkDir(), "foo.snap") + err := os.WriteFile(snap, bodyData, 0644) + c.Assert(err, check.IsNil) + + id, err := cs.cli.InstallPath(snap, "foo_bar", nil) + c.Assert(err, check.IsNil) + + body, err := io.ReadAll(cs.req.Body) + c.Assert(err, check.IsNil) + + c.Assert(string(body), check.Matches, "(?s).*\r\nsnap-data\r\n.*") + c.Assert(string(body), check.Matches, "(?s).*Content-Disposition: form-data; name=\"action\"\r\n\r\ninstall\r\n.*") + c.Assert(string(body), check.Matches, "(?s).*Content-Disposition: form-data; name=\"name\"\r\n\r\nfoo_bar\r\n.*") + + c.Check(cs.req.Method, check.Equals, "POST") + c.Check(cs.req.URL.Path, check.Equals, "/v2/snaps") + c.Assert(cs.req.Header.Get("Content-Type"), check.Matches, "multipart/form-data; boundary=.*") + c.Check(id, check.Equals, "66b3") +} + +func (cs *clientSuite) TestClientOpInstallPathMany(c *check.C) { + cs.status = 202 + cs.rsp = `{ + "change": "66b3", + "status-code": 202, + "type": "async" + }` + + var paths []string + names := []string{"foo.snap", "bar.snap"} + for _, name := range names { + path := filepath.Join(c.MkDir(), name) + paths = append(paths, path) + c.Assert(os.WriteFile(path, []byte("snap-data"), 0644), check.IsNil) + } + + id, err := cs.cli.InstallPathMany(paths, nil) + c.Assert(err, check.IsNil) + + body, err := io.ReadAll(cs.req.Body) + c.Assert(err, check.IsNil) + + for _, name := range names { + c.Assert(string(body), check.Matches, fmt.Sprintf(`(?s).*Content-Disposition: form-data; name="snap"; filename="%s"\r\nContent-Type: application/octet-stream\r\n\r\nsnap-data\r\n.*`, name)) + + } + c.Assert(string(body), check.Matches, `(?s).*Content-Disposition: form-data; name="action"\r\n\r\ninstall\r\n.*`) + + c.Check(cs.req.Method, check.Equals, "POST") + c.Check(cs.req.URL.Path, check.Equals, "/v2/snaps") + c.Assert(cs.req.Header.Get("Content-Type"), check.Matches, "multipart/form-data; boundary=.*") + + _, ok := cs.req.Context().Deadline() + c.Assert(ok, check.Equals, false) + c.Check(id, check.Equals, "66b3") +} + +func (cs *clientSuite) TestClientOpInstallPathManyTransactionally(c *check.C) { + cs.status = 202 + cs.rsp = `{ + "change": "66b3", + "status-code": 202, + "type": "async" + }` + + var paths []string + names := []string{"foo.snap", "bar.snap"} + for _, name := range names { + path := filepath.Join(c.MkDir(), name) + paths = append(paths, path) + c.Assert(os.WriteFile(path, []byte("snap-data"), 0644), check.IsNil) + } + + id, err := cs.cli.InstallPathMany(paths, &client.SnapOptions{Transaction: client.TransactionAllSnaps}) + c.Assert(err, check.IsNil) + + body, err := io.ReadAll(cs.req.Body) + c.Assert(err, check.IsNil) + + for _, name := range names { + c.Assert(string(body), check.Matches, fmt.Sprintf(`(?s).*Content-Disposition: form-data; name="snap"; filename="%s"\r\nContent-Type: application/octet-stream\r\n\r\nsnap-data\r\n.*`, name)) + + } + c.Assert(string(body), check.Matches, `(?s).*Content-Disposition: form-data; name="action"\r\n\r\ninstall\r\n.*`) + c.Assert(string(body), check.Matches, `(?s).*Content-Disposition: form-data; name="transaction"\r\n\r\nall-snaps\r\n.*`) + + c.Check(cs.req.Method, check.Equals, "POST") + c.Check(cs.req.URL.Path, check.Equals, "/v2/snaps") + c.Assert(cs.req.Header.Get("Content-Type"), check.Matches, "multipart/form-data; boundary=.*") + + _, ok := cs.req.Context().Deadline() + c.Assert(ok, check.Equals, false) + c.Check(id, check.Equals, "66b3") +} + +func (cs *clientSuite) TestClientOpInstallPathManyWithOptions(c *check.C) { + cs.status = 202 + cs.rsp = `{ + "change": "66b3", + "status-code": 202, + "type": "async" + }` + + var paths []string + for _, name := range []string{"foo.snap", "bar.snap"} { + path := filepath.Join(c.MkDir(), name) + paths = append(paths, path) + c.Assert(os.WriteFile(path, []byte("snap-data"), 0644), check.IsNil) + } + + // InstallPathMany supports opts + _, err := cs.cli.InstallPathMany(paths, &client.SnapOptions{ + Dangerous: true, + DevMode: true, + Classic: true, + }) + + c.Assert(err, check.IsNil) + + body, err := io.ReadAll(cs.req.Body) + c.Assert(err, check.IsNil) + + c.Check(string(body), check.Matches, `(?s).*Content-Disposition: form-data; name="dangerous"\r\n\r\ntrue\r\n.*`) + c.Check(string(body), check.Matches, `(?s).*Content-Disposition: form-data; name="devmode"\r\n\r\ntrue\r\n.*`) + c.Check(string(body), check.Matches, `(?s).*Content-Disposition: form-data; name="classic"\r\n\r\ntrue\r\n.*`) +} + +func (cs *clientSuite) TestClientOpInstallPathManyWithQuotaGroup(c *check.C) { + cs.status = 202 + cs.rsp = `{ + "change": "66b3", + "status-code": 202, + "type": "async" + }` + + var paths []string + for _, name := range []string{"foo.snap", "bar.snap"} { + path := filepath.Join(c.MkDir(), name) + paths = append(paths, path) + c.Assert(os.WriteFile(path, []byte("snap-data"), 0644), check.IsNil) + } + + // Verify that the quota group option is serialized as a part of multipart form. + _, err := cs.cli.InstallPathMany(paths, &client.SnapOptions{ + Dangerous: true, + QuotaGroupName: "foo-group", + }) + + c.Assert(err, check.IsNil) + + body, err := io.ReadAll(cs.req.Body) + c.Assert(err, check.IsNil) + + c.Check(string(body), check.Matches, `(?s).*Content-Disposition: form-data; name="dangerous"\r\n\r\ntrue\r\n.*`) + c.Check(string(body), check.Matches, `(?s).*Content-Disposition: form-data; name="quota-group"\r\n\r\nfoo-group\r\n.*`) +} + +func (cs *clientSuite) TestClientOpInstallDangerous(c *check.C) { + cs.status = 202 + cs.rsp = `{ + "change": "66b3", + "status-code": 202, + "type": "async" + }` + bodyData := []byte("snap-data") + + snap := filepath.Join(c.MkDir(), "foo.snap") + err := os.WriteFile(snap, bodyData, 0644) + c.Assert(err, check.IsNil) + + opts := client.SnapOptions{ + Dangerous: true, + } + + // InstallPath takes Dangerous + _, err = cs.cli.InstallPath(snap, "", &opts) + c.Assert(err, check.IsNil) + + body, err := io.ReadAll(cs.req.Body) + c.Assert(err, check.IsNil) + + c.Assert(string(body), check.Matches, "(?s).*Content-Disposition: form-data; name=\"dangerous\"\r\n\r\ntrue\r\n.*") + + // Install does not (and gives us a clear error message) + _, err = cs.cli.Install("foo", &opts) + c.Assert(err, check.Equals, client.ErrDangerousNotApplicable) + + // InstallMany just ignores it without error for the moment + _, err = cs.cli.InstallMany([]string{"foo"}, &opts) + c.Assert(err, check.IsNil) +} + +func (cs *clientSuite) TestClientOpInstallUnaliased(c *check.C) { + cs.status = 202 + cs.rsp = `{ + "change": "66b3", + "status-code": 202, + "type": "async" + }` + bodyData := []byte("snap-data") + + snap := filepath.Join(c.MkDir(), "foo.snap") + err := os.WriteFile(snap, bodyData, 0644) + c.Assert(err, check.IsNil) + + opts := client.SnapOptions{ + Unaliased: true, + } + + _, err = cs.cli.Install("foo", &opts) + c.Assert(err, check.IsNil) + + body, err := io.ReadAll(cs.req.Body) + c.Assert(err, check.IsNil) + jsonBody := make(map[string]interface{}) + err = json.Unmarshal(body, &jsonBody) + c.Assert(err, check.IsNil, check.Commentf("body: %v", string(body))) + c.Check(jsonBody["unaliased"], check.Equals, true, check.Commentf("body: %v", string(body))) + + _, err = cs.cli.InstallPath(snap, "", &opts) + c.Assert(err, check.IsNil) + + body, err = io.ReadAll(cs.req.Body) + c.Assert(err, check.IsNil) + + c.Assert(string(body), check.Matches, "(?s).*Content-Disposition: form-data; name=\"unaliased\"\r\n\r\ntrue\r\n.*") +} + +func (cs *clientSuite) TestClientOpInstallTransactional(c *check.C) { + cs.status = 202 + cs.rsp = `{ + "change": "66b3", + "status-code": 202, + "type": "async" + }` + bodyData := []byte("snap-data") + + snap := filepath.Join(c.MkDir(), "foo.snap") + err := os.WriteFile(snap, bodyData, 0644) + c.Assert(err, check.IsNil) + + opts := client.SnapOptions{ + Transaction: client.TransactionAllSnaps, + } + + _, err = cs.cli.InstallMany([]string{"foo", "bar"}, &opts) + c.Assert(err, check.IsNil) + + body, err := io.ReadAll(cs.req.Body) + c.Assert(err, check.IsNil) + jsonBody := make(map[string]interface{}) + err = json.Unmarshal(body, &jsonBody) + c.Assert(err, check.IsNil, check.Commentf("body: %v", string(body))) + c.Check(jsonBody["transaction"], check.Equals, string(client.TransactionAllSnaps), + check.Commentf("body: %v", string(body))) + + _, err = cs.cli.InstallPath(snap, "", &opts) + c.Assert(err, check.IsNil) + + body, err = io.ReadAll(cs.req.Body) + c.Assert(err, check.IsNil) + + c.Assert(string(body), check.Matches, + "(?s).*Content-Disposition: form-data; name=\"transaction\"\r\n\r\nall-snaps\r\n.*") +} + +func (cs *clientSuite) TestClientOpInstallPrefer(c *check.C) { + cs.status = 202 + cs.rsp = `{ + "change": "66b3", + "status-code": 202, + "type": "async" + }` + bodyData := []byte("snap-data") + + snap := filepath.Join(c.MkDir(), "foo.snap") + err := os.WriteFile(snap, bodyData, 0644) + c.Assert(err, check.IsNil) + + opts := client.SnapOptions{ + Prefer: true, + } + + _, err = cs.cli.Install("foo", &opts) + c.Assert(err, check.IsNil) + + body, err := io.ReadAll(cs.req.Body) + c.Assert(err, check.IsNil) + var jsonBody map[string]interface{} + err = json.Unmarshal(body, &jsonBody) + c.Assert(err, check.IsNil, check.Commentf("body: %v", string(body))) + c.Check(jsonBody["prefer"], check.Equals, true, check.Commentf("body: %v", string(body))) + + _, err = cs.cli.InstallPath(snap, "", &opts) + c.Assert(err, check.IsNil) + + body, err = io.ReadAll(cs.req.Body) + c.Assert(err, check.IsNil) + + c.Assert(string(body), check.Matches, "(?s).*Content-Disposition: form-data; name=\"prefer\"\r\n\r\ntrue\r\n.*") +} + +func formToMap(c *check.C, mr *multipart.Reader) map[string]string { + formData := map[string]string{} + for { + p, err := mr.NextPart() + if err == io.EOF { + break + } + c.Assert(err, check.IsNil) + slurp, err := io.ReadAll(p) + c.Assert(err, check.IsNil) + formData[p.FormName()] = string(slurp) + } + return formData +} + +func (cs *clientSuite) TestClientOpTryMode(c *check.C) { + cs.status = 202 + cs.rsp = `{ + "change": "66b3", + "status-code": 202, + "type": "async" + }` + snapdir := filepath.Join(c.MkDir(), "/some/path") + + for _, opts := range []*client.SnapOptions{ + {Classic: false, DevMode: false, JailMode: false}, + {Classic: false, DevMode: false, JailMode: true}, + {Classic: false, DevMode: true, JailMode: true}, + {Classic: false, DevMode: true, JailMode: false}, + {Classic: true, DevMode: false, JailMode: false}, + {Classic: true, DevMode: false, JailMode: true}, + {Classic: true, DevMode: true, JailMode: true}, + {Classic: true, DevMode: true, JailMode: false}, + } { + comment := check.Commentf("when Classic:%t DevMode:%t JailMode:%t", opts.Classic, opts.DevMode, opts.JailMode) + id, err := cs.cli.Try(snapdir, opts) + c.Assert(err, check.IsNil) + + // ensure we send the right form-data + _, params, err := mime.ParseMediaType(cs.req.Header.Get("Content-Type")) + c.Assert(err, check.IsNil, comment) + mr := multipart.NewReader(cs.req.Body, params["boundary"]) + formData := formToMap(c, mr) + c.Check(formData["action"], check.Equals, "try", comment) + c.Check(formData["snap-path"], check.Equals, snapdir, comment) + expectedLength := 2 + if opts.Classic { + c.Check(formData["classic"], check.Equals, "true", comment) + expectedLength++ + } + if opts.DevMode { + c.Check(formData["devmode"], check.Equals, "true", comment) + expectedLength++ + } + if opts.JailMode { + c.Check(formData["jailmode"], check.Equals, "true", comment) + expectedLength++ + } + c.Check(len(formData), check.Equals, expectedLength) + + c.Check(cs.req.Method, check.Equals, "POST", comment) + c.Check(cs.req.URL.Path, check.Equals, "/v2/snaps", comment) + c.Assert(cs.req.Header.Get("Content-Type"), check.Matches, "multipart/form-data; boundary=.*", comment) + c.Check(id, check.Equals, "66b3", comment) + } +} + +func (cs *clientSuite) TestClientOpTryModeDangerous(c *check.C) { + snapdir := filepath.Join(c.MkDir(), "/some/path") + + _, err := cs.cli.Try(snapdir, &client.SnapOptions{Dangerous: true}) + c.Assert(err, check.Equals, client.ErrDangerousNotApplicable) +} + +func (cs *clientSuite) TestSnapOptionsSerialises(c *check.C) { + tests := map[string]client.SnapOptions{ + "{}": {}, + `{"channel":"edge"}`: {Channel: "edge"}, + `{"revision":"42"}`: {Revision: "42"}, + `{"cohort-key":"what"}`: {CohortKey: "what"}, + `{"leave-cohort":true}`: {LeaveCohort: true}, + `{"devmode":true}`: {DevMode: true}, + `{"jailmode":true}`: {JailMode: true}, + `{"classic":true}`: {Classic: true}, + `{"dangerous":true}`: {Dangerous: true}, + `{"ignore-validation":true}`: {IgnoreValidation: true}, + `{"unaliased":true}`: {Unaliased: true}, + `{"purge":true}`: {Purge: true}, + `{"amend":true}`: {Amend: true}, + `{"prefer":true}`: {Prefer: true}, + } + for expected, opts := range tests { + buf, err := json.Marshal(&opts) + c.Assert(err, check.IsNil, check.Commentf("%s", expected)) + c.Check(string(buf), check.Equals, expected) + } +} + +func (cs *clientSuite) TestClientOpDownload(c *check.C) { + cs.status = 200 + cs.header = http.Header{ + "Content-Disposition": {"attachment; filename=foo_2.snap"}, + "Snap-Sha3-384": {"sha3sha3sha3"}, + "Snap-Download-Token": {"some-token"}, + } + cs.contentLength = 1234 + + cs.rsp = `lots-of-foo-data` + + dlInfo, rc, err := cs.cli.Download("foo", &client.DownloadOptions{ + SnapOptions: client.SnapOptions{ + Revision: "2", + Channel: "edge", + }, + HeaderPeek: true, + }) + c.Check(err, check.IsNil) + c.Check(dlInfo, check.DeepEquals, &client.DownloadInfo{ + SuggestedFileName: "foo_2.snap", + Size: 1234, + Sha3_384: "sha3sha3sha3", + ResumeToken: "some-token", + }) + + // check we posted the right stuff + c.Assert(cs.req.Header.Get("Content-Type"), check.Equals, "application/json") + c.Assert(cs.req.Header.Get("range"), check.Equals, "") + body, err := io.ReadAll(cs.req.Body) + c.Assert(err, check.IsNil) + var jsonBody client.DownloadAction + err = json.Unmarshal(body, &jsonBody) + c.Assert(err, check.IsNil) + c.Check(jsonBody.SnapName, check.DeepEquals, "foo") + c.Check(jsonBody.Revision, check.Equals, "2") + c.Check(jsonBody.Channel, check.Equals, "edge") + c.Check(jsonBody.HeaderPeek, check.Equals, true) + + // ensure we can read the response + content, err := io.ReadAll(rc) + c.Assert(err, check.IsNil) + c.Check(string(content), check.Equals, cs.rsp) + // and we can close it + c.Check(rc.Close(), check.IsNil) +} + +func (cs *clientSuite) TestClientOpDownloadResume(c *check.C) { + cs.status = 200 + cs.header = http.Header{ + "Content-Disposition": {"attachment; filename=foo_2.snap"}, + "Snap-Sha3-384": {"sha3sha3sha3"}, + } + // we resume + cs.contentLength = 1234 - 64 + + cs.rsp = `lots-of-foo-data` + + dlInfo, rc, err := cs.cli.Download("foo", &client.DownloadOptions{ + SnapOptions: client.SnapOptions{ + Revision: "2", + Channel: "edge", + }, + HeaderPeek: true, + ResumeToken: "some-token", + Resume: 64, + }) + c.Check(err, check.IsNil) + c.Check(dlInfo, check.DeepEquals, &client.DownloadInfo{ + SuggestedFileName: "foo_2.snap", + Size: 1234 - 64, + Sha3_384: "sha3sha3sha3", + }) + + // check we posted the right stuff + c.Assert(cs.req.Header.Get("Content-Type"), check.Equals, "application/json") + c.Assert(cs.req.Header.Get("range"), check.Equals, "bytes: 64-") + body, err := io.ReadAll(cs.req.Body) + c.Assert(err, check.IsNil) + var jsonBody client.DownloadAction + err = json.Unmarshal(body, &jsonBody) + c.Assert(err, check.IsNil) + c.Check(jsonBody.SnapName, check.DeepEquals, "foo") + c.Check(jsonBody.Revision, check.Equals, "2") + c.Check(jsonBody.Channel, check.Equals, "edge") + c.Check(jsonBody.HeaderPeek, check.Equals, true) + c.Check(jsonBody.ResumeToken, check.Equals, "some-token") + + // ensure we can read the response + content, err := io.ReadAll(rc) + c.Assert(err, check.IsNil) + c.Check(string(content), check.Equals, cs.rsp) + // and we can close it + c.Check(rc.Close(), check.IsNil) +} + +func (cs *clientSuite) TestClientRefreshWithValidationSets(c *check.C) { + cs.status = 202 + cs.rsp = `{ + "change": "12", + "status-code": 202, + "type": "async" + }` + + sets := []string{"foo/bar=2", "foo/baz"} + chgID, err := cs.cli.RefreshMany(nil, &client.SnapOptions{ + ValidationSets: sets, + }) + c.Assert(err, check.IsNil) + c.Check(chgID, check.Equals, "12") + + type req struct { + ValidationSets []string `json:"validation-sets"` + Action string `json:"action"` + } + body, err := io.ReadAll(cs.req.Body) + c.Assert(err, check.IsNil) + + var decodedBody req + err = json.Unmarshal(body, &decodedBody) + c.Assert(err, check.IsNil) + + c.Check(decodedBody, check.DeepEquals, req{ + ValidationSets: sets, + Action: "refresh", + }) + c.Check(cs.req.Header["Content-Type"], check.DeepEquals, []string{"application/json"}) +} + +func (cs *clientSuite) TestClientHoldMany(c *check.C) { + cs.status = 202 + cs.rsp = `{ + "change": "12", + "status-code": 202, + "type": "async" + }` + + chgID, err := cs.cli.HoldRefreshesMany([]string{"foo", "bar"}, &client.SnapOptions{ + Time: "forever", + HoldLevel: "general", + }) + c.Assert(err, check.IsNil) + c.Check(chgID, check.Equals, "12") + + type req struct { + Action string `json:"action"` + Snaps []string `json:"snaps"` + Time string `json:"time"` + HoldLevel string `json:"hold-level"` + } + body, err := io.ReadAll(cs.req.Body) + c.Assert(err, check.IsNil) + + var decodedBody req + err = json.Unmarshal(body, &decodedBody) + c.Assert(err, check.IsNil) + + c.Check(decodedBody, check.DeepEquals, req{ + Action: "hold", + Snaps: []string{"foo", "bar"}, + Time: "forever", + HoldLevel: "general", + }) + c.Check(cs.req.Header["Content-Type"], check.DeepEquals, []string{"application/json"}) +} diff --git a/client/snapctl.go b/client/snapctl.go new file mode 100644 index 00000000..b1099582 --- /dev/null +++ b/client/snapctl.go @@ -0,0 +1,98 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client + +import ( + "bytes" + "encoding/json" + "fmt" + "io" +) + +// InternalSnapctlCmdNeedsStdin returns true if the given snapctl command +// needs data from stdin +func InternalSnapctlCmdNeedsStdin(name string) bool { + switch name { + case "fde-setup-result": + return true + default: + return false + } +} + +// SnapCtlOptions holds the various options with which snapctl is invoked. +type SnapCtlOptions struct { + // ContextID is a string used to determine the context of this call (e.g. + // which context and handler should be used, etc.) + ContextID string `json:"context-id"` + + // Args contains a list of parameters to use for this invocation. + Args []string `json:"args"` +} + +// SnapCtlPostData is the data posted to the daemon /v2/snapctl endpoint +// TODO: this can be removed again once we no longer need to pass stdin data +// but instead use a real stdin stream +type SnapCtlPostData struct { + SnapCtlOptions + + Stdin []byte `json:"stdin,omitempty"` +} + +type snapctlOutput struct { + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` +} + +// protect against too much data via stdin +var stdinReadLimit = int64(4 * 1000 * 1000) + +// RunSnapctl requests a snapctl run for the given options. +func (client *Client) RunSnapctl(options *SnapCtlOptions, stdin io.Reader) (stdout, stderr []byte, err error) { + // TODO: instead of reading all of stdin here we need to forward it to + // the daemon eventually + var stdinData []byte + if stdin != nil { + limitedStdin := &io.LimitedReader{R: stdin, N: stdinReadLimit + 1} + stdinData, err = io.ReadAll(limitedStdin) + if err != nil { + return nil, nil, fmt.Errorf("cannot read stdin: %v", err) + } + if limitedStdin.N <= 0 { + return nil, nil, fmt.Errorf("cannot read more than %v bytes of data from stdin", stdinReadLimit) + } + } + + b, err := json.Marshal(SnapCtlPostData{ + SnapCtlOptions: *options, + Stdin: stdinData, + }) + if err != nil { + return nil, nil, fmt.Errorf("cannot marshal options: %s", err) + } + + var output snapctlOutput + _, err = client.doSync("POST", "/v2/snapctl", nil, nil, bytes.NewReader(b), &output) + if err != nil { + return nil, nil, err + } + + return []byte(output.Stdout), []byte(output.Stderr), nil +} diff --git a/client/snapctl_test.go b/client/snapctl_test.go new file mode 100644 index 00000000..718cfdc0 --- /dev/null +++ b/client/snapctl_test.go @@ -0,0 +1,126 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client_test + +import ( + "bytes" + "encoding/base64" + "encoding/json" + + "gopkg.in/check.v1" + + "github.com/snapcore/snapd/client" +) + +func (cs *clientSuite) TestClientRunSnapctlCallsEndpoint(c *check.C) { + options := &client.SnapCtlOptions{ + ContextID: "1234ABCD", + Args: []string{"foo", "bar"}, + } + cs.cli.RunSnapctl(options, nil) + c.Check(cs.req.Method, check.Equals, "POST") + c.Check(cs.req.URL.Path, check.Equals, "/v2/snapctl") +} + +func (cs *clientSuite) TestClientRunSnapctl(c *check.C) { + cs.rsp = `{ + "type": "sync", + "status-code": 200, + "result": { + "stdout": "test stdout", + "stderr": "test stderr" + } + }` + + mockStdin := bytes.NewBufferString("some-input") + options := &client.SnapCtlOptions{ + ContextID: "1234ABCD", + Args: []string{"foo", "bar"}, + } + + stdout, stderr, err := cs.cli.RunSnapctl(options, mockStdin) + c.Assert(err, check.IsNil) + c.Check(string(stdout), check.Equals, "test stdout") + c.Check(string(stderr), check.Equals, "test stderr") + + var body map[string]interface{} + decoder := json.NewDecoder(cs.req.Body) + err = decoder.Decode(&body) + c.Check(err, check.IsNil) + c.Check(body, check.DeepEquals, map[string]interface{}{ + "context-id": "1234ABCD", + "args": []interface{}{"foo", "bar"}, + + // json byte-stream is b64 encoded + "stdin": base64.StdEncoding.EncodeToString([]byte("some-input")), + }) +} + +func (cs *clientSuite) TestInternalSnapctlCmdNeedsStdin(c *check.C) { + res := client.InternalSnapctlCmdNeedsStdin("fde-setup-result") + c.Check(res, check.Equals, true) + + for _, s := range []string{"help", "other"} { + res := client.InternalSnapctlCmdNeedsStdin(s) + c.Check(res, check.Equals, false) + } +} + +func (cs *clientSuite) TestClientRunSnapctlReadLimitOneTooMuch(c *check.C) { + cs.rsp = `{ + "type": "sync", + "status-code": 200, + "result": { + } + }` + + restore := client.MockStdinReadLimit(10) + defer restore() + + mockStdin := bytes.NewBufferString("12345678901") + options := &client.SnapCtlOptions{ + ContextID: "1234ABCD", + Args: []string{"foo", "bar"}, + } + + _, _, err := cs.cli.RunSnapctl(options, mockStdin) + c.Check(err, check.ErrorMatches, "cannot read more than 10 bytes of data from stdin") +} + +func (cs *clientSuite) TestClientRunSnapctlReadLimitExact(c *check.C) { + cs.rsp = `{ + "type": "sync", + "status-code": 200, + "result": { + } + }` + + restore := client.MockStdinReadLimit(10) + defer restore() + + mockStdin := bytes.NewBufferString("1234567890") + options := &client.SnapCtlOptions{ + ContextID: "1234ABCD", + Args: []string{"foo", "bar"}, + } + + _, _, err := cs.cli.RunSnapctl(options, mockStdin) + c.Check(err, check.IsNil) +} diff --git a/client/snapshot.go b/client/snapshot.go new file mode 100644 index 00000000..1d4f69d5 --- /dev/null +++ b/client/snapshot.go @@ -0,0 +1,282 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2018 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "io" + "net/url" + "sort" + "strconv" + "strings" + "time" + + "github.com/snapcore/snapd/snap" +) + +// SnapshotExportMediaType is the media type used to identify snapshot exports in the API. +const SnapshotExportMediaType = "application/x.snapd.snapshot" + +var ( + ErrSnapshotSetNotFound = errors.New("no snapshot set with the given ID") + ErrSnapshotSnapsNotFound = errors.New("no snapshot for the requested snaps found in the set with the given ID") +) + +// A snapshotAction is used to request an operation on a snapshot. +type snapshotAction struct { + SetID uint64 `json:"set"` + Action string `json:"action"` + Snaps []string `json:"snaps,omitempty"` + Users []string `json:"users,omitempty"` +} + +// A Snapshot is a collection of archives with a simple metadata json file +// (and hashsums of everything). +type Snapshot struct { + // SetID is the ID of the snapshot set (a snapshot set is the result of a "snap save" invocation) + SetID uint64 `json:"set"` + // the time this snapshot's data collection was started + Time time.Time `json:"time"` + + // information about the snap this data is for + Snap string `json:"snap"` + Revision snap.Revision `json:"revision"` + SnapID string `json:"snap-id,omitempty"` + Epoch snap.Epoch `json:"epoch,omitempty"` + Summary string `json:"summary"` + Version string `json:"version"` + + // the snap's configuration at snapshot time + Conf map[string]interface{} `json:"conf,omitempty"` + + // the hash of the archives' data, keyed by archive path + // (either 'archive.tgz' for the system archive, or + // user/.tgz for each user) + SHA3_384 map[string]string `json:"sha3-384"` + // the sum of the archive sizes + Size int64 `json:"size,omitempty"` + + // dynamic snapshot options + Options *snap.SnapshotOptions `json:"options,omitempty"` + + // if the snapshot failed to open this will be the reason why + Broken string `json:"broken,omitempty"` + + // set if the snapshot was created automatically on snap removal; + // note, this is only set inside actual snapshot file for old snapshots; + // newer snapd just updates this flag on the fly for snapshots + // returned by List(). + Auto bool `json:"auto,omitempty"` +} + +// IsValid checks whether the snapshot is missing information that +// should be there for a snapshot that's just been opened. +func (sh *Snapshot) IsValid() bool { + return !(sh == nil || sh.SetID == 0 || sh.Snap == "" || sh.Revision.Unset() || len(sh.SHA3_384) == 0 || sh.Time.IsZero()) +} + +// ContentHash returns a hash that can be used to identify the snapshot +// by its content, leaving out metadata like "time" or "set-id". +func (sh *Snapshot) ContentHash() ([]byte, error) { + sh2 := *sh + sh2.SetID = 0 + sh2.Time = time.Time{} + sh2.Auto = false + sh2.Options = nil + h := sha256.New() + enc := json.NewEncoder(h) + if err := enc.Encode(&sh2); err != nil { + return nil, err + } + return h.Sum(nil), nil +} + +// A SnapshotSet is a set of snapshots created by a single "snap save". +type SnapshotSet struct { + ID uint64 `json:"id"` + Snapshots []*Snapshot `json:"snapshots"` +} + +// Time returns the earliest time in the set. +func (ss SnapshotSet) Time() time.Time { + if len(ss.Snapshots) == 0 { + return time.Time{} + } + mint := ss.Snapshots[0].Time + for _, sh := range ss.Snapshots { + if sh.Time.Before(mint) { + mint = sh.Time + } + } + return mint +} + +// Size returns the sum of the set's sizes. +func (ss SnapshotSet) Size() int64 { + var sum int64 + for _, sh := range ss.Snapshots { + sum += sh.Size + } + return sum +} + +type bySnap []*Snapshot + +func (ss bySnap) Len() int { return len(ss) } +func (ss bySnap) Swap(i, j int) { ss[i], ss[j] = ss[j], ss[i] } +func (ss bySnap) Less(i, j int) bool { return ss[i].Snap < ss[j].Snap } + +// ContentHash returns a hash that can be used to identify the SnapshotSet by +// its content. +func (ss SnapshotSet) ContentHash() ([]byte, error) { + sortedSnapshots := make([]*Snapshot, len(ss.Snapshots)) + copy(sortedSnapshots, ss.Snapshots) + sort.Sort(bySnap(sortedSnapshots)) + + h := sha256.New() + for _, sh := range sortedSnapshots { + ch, err := sh.ContentHash() + if err != nil { + return nil, err + } + h.Write(ch) + } + return h.Sum(nil), nil +} + +// SnapshotSets lists the snapshot sets in the system that belong to the +// given set (if non-zero) and are for the given snaps (if non-empty). +func (client *Client) SnapshotSets(setID uint64, snapNames []string) ([]SnapshotSet, error) { + q := make(url.Values) + if setID > 0 { + q.Add("set", strconv.FormatUint(setID, 10)) + } + if len(snapNames) > 0 { + q.Add("snaps", strings.Join(snapNames, ",")) + } + + var snapshotSets []SnapshotSet + _, err := client.doSync("GET", "/v2/snapshots", q, nil, nil, &snapshotSets) + return snapshotSets, err +} + +// ForgetSnapshots permanently removes the snapshot set, limited to the +// given snaps (if non-empty). +func (client *Client) ForgetSnapshots(setID uint64, snaps []string) (changeID string, err error) { + return client.snapshotAction(&snapshotAction{ + SetID: setID, + Action: "forget", + Snaps: snaps, + }) +} + +// CheckSnapshots verifies the archive checksums in the given snapshot set. +// +// If snaps or users are non-empty, limit to checking only those +// archives of the snapshot. +func (client *Client) CheckSnapshots(setID uint64, snaps []string, users []string) (changeID string, err error) { + return client.snapshotAction(&snapshotAction{ + SetID: setID, + Action: "check", + Snaps: snaps, + Users: users, + }) +} + +// RestoreSnapshots extracts the given snapshot set. +// +// If snaps or users are non-empty, limit to checking only those +// archives of the snapshot. +func (client *Client) RestoreSnapshots(setID uint64, snaps []string, users []string) (changeID string, err error) { + return client.snapshotAction(&snapshotAction{ + SetID: setID, + Action: "restore", + Snaps: snaps, + Users: users, + }) +} + +func (client *Client) snapshotAction(action *snapshotAction) (changeID string, err error) { + data, err := json.Marshal(action) + if err != nil { + return "", fmt.Errorf("cannot marshal snapshot action: %v", err) + } + + headers := map[string]string{ + "Content-Type": "application/json", + } + + return client.doAsync("POST", "/v2/snapshots", nil, headers, bytes.NewBuffer(data)) +} + +// SnapshotExport streams the requested snapshot set. +// +// The return value includes the length of the returned stream. +func (client *Client) SnapshotExport(setID uint64) (stream io.ReadCloser, contentLength int64, err error) { + rsp, err := client.raw(context.Background(), "GET", fmt.Sprintf("/v2/snapshots/%v/export", setID), nil, nil, nil) + if err != nil { + return nil, 0, err + } + if rsp.StatusCode != 200 { + defer rsp.Body.Close() + + var r response + dec := json.NewDecoder(rsp.Body) + if err := dec.Decode(&r); err == nil { + specificErr := r.err(client, rsp.StatusCode) + if specificErr != nil { + return nil, 0, specificErr + } + } + return nil, 0, fmt.Errorf("unexpected status code: %v", rsp.Status) + } + contentType := rsp.Header.Get("Content-Type") + if contentType != SnapshotExportMediaType { + return nil, 0, fmt.Errorf("unexpected snapshot export content type %q", contentType) + } + + return rsp.Body, rsp.ContentLength, nil +} + +// SnapshotImportSet is a snapshot import created by a "snap import-snapshot". +type SnapshotImportSet struct { + ID uint64 `json:"set-id"` + Snaps []string `json:"snaps"` +} + +// SnapshotImport imports an exported snapshot set. +func (client *Client) SnapshotImport(exportStream io.Reader, size int64) (SnapshotImportSet, error) { + headers := map[string]string{ + "Content-Type": SnapshotExportMediaType, + "Content-Length": strconv.FormatInt(size, 10), + } + + var importSet SnapshotImportSet + if _, err := client.doSync("POST", "/v2/snapshots", nil, headers, exportStream, &importSet); err != nil { + return importSet, err + } + + return importSet, nil +} diff --git a/client/snapshot_test.go b/client/snapshot_test.go new file mode 100644 index 00000000..eed5f24b --- /dev/null +++ b/client/snapshot_test.go @@ -0,0 +1,317 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2018 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client_test + +import ( + "crypto/sha256" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "gopkg.in/check.v1" + + "github.com/snapcore/snapd/client" + "github.com/snapcore/snapd/snap" +) + +func (cs *clientSuite) TestClientSnapshotIsValid(c *check.C) { + now := time.Now() + revno := snap.R(1) + sums := map[string]string{"user/foo.tgz": "some long hash"} + c.Check((&client.Snapshot{ + SetID: 42, + Time: now, + Snap: "asnap", + Revision: revno, + SHA3_384: sums, + }).IsValid(), check.Equals, true) + + for desc, snapshot := range map[string]*client.Snapshot{ + "nil": nil, + "empty": {}, + "no id": { /*SetID: 42,*/ Time: now, Snap: "asnap", Revision: revno, SHA3_384: sums}, + "no time": {SetID: 42 /*Time: now,*/, Snap: "asnap", Revision: revno, SHA3_384: sums}, + "no snap": {SetID: 42, Time: now /*Snap: "asnap",*/, Revision: revno, SHA3_384: sums}, + "no rev": {SetID: 42, Time: now, Snap: "asnap" /*Revision: revno,*/, SHA3_384: sums}, + "no sums": {SetID: 42, Time: now, Snap: "asnap", Revision: revno /*SHA3_384: sums*/}, + } { + c.Check(snapshot.IsValid(), check.Equals, false, check.Commentf("%s", desc)) + } + +} + +func (cs *clientSuite) TestClientSnapshotSetTime(c *check.C) { + // if set is empty, it doesn't explode (and returns the zero time) + c.Check(client.SnapshotSet{}.Time().IsZero(), check.Equals, true) + // if not empty, returns the earliest one + c.Check(client.SnapshotSet{Snapshots: []*client.Snapshot{ + {Time: time.Unix(3, 0)}, + {Time: time.Unix(1, 0)}, + {Time: time.Unix(2, 0)}, + }}.Time(), check.DeepEquals, time.Unix(1, 0)) +} + +func (cs *clientSuite) TestClientSnapshotSetSize(c *check.C) { + // if set is empty, doesn't explode (and returns 0) + c.Check(client.SnapshotSet{}.Size(), check.Equals, int64(0)) + // if not empty, returns the sum + c.Check(client.SnapshotSet{Snapshots: []*client.Snapshot{ + {Size: 1}, + {Size: 2}, + {Size: 3}, + }}.Size(), check.DeepEquals, int64(6)) +} + +func (cs *clientSuite) TestClientSnapshotSets(c *check.C) { + cs.rsp = `{ + "type": "sync", + "result": [{"id": 1}, {"id":2}] +}` + sets, err := cs.cli.SnapshotSets(42, []string{"foo", "bar"}) + c.Assert(err, check.IsNil) + c.Check(sets, check.DeepEquals, []client.SnapshotSet{{ID: 1}, {ID: 2}}) + c.Check(cs.req.Method, check.Equals, "GET") + c.Check(cs.req.URL.Path, check.Equals, "/v2/snapshots") + c.Check(cs.req.URL.Query(), check.DeepEquals, url.Values{ + "set": []string{"42"}, + "snaps": []string{"foo,bar"}, + }) +} + +func (cs *clientSuite) testClientSnapshotActionFull(c *check.C, action string, users []string, f func() (string, error)) { + cs.status = 202 + cs.rsp = `{ + "status-code": 202, + "type": "async", + "change": "1too3" + }` + id, err := f() + c.Assert(err, check.IsNil) + c.Check(id, check.Equals, "1too3") + + c.Assert(cs.req.Header.Get("Content-Type"), check.Equals, "application/json") + + act, err := client.UnmarshalSnapshotAction(cs.req.Body) + c.Assert(err, check.IsNil) + c.Check(act.SetID, check.Equals, uint64(42)) + c.Check(act.Action, check.Equals, action) + c.Check(act.Snaps, check.DeepEquals, []string{"asnap", "bsnap"}) + c.Check(act.Users, check.DeepEquals, users) + + c.Check(cs.req.Method, check.Equals, "POST") + c.Check(cs.req.URL.Path, check.Equals, "/v2/snapshots") + c.Check(cs.req.URL.Query(), check.HasLen, 0) +} + +func (cs *clientSuite) TestClientForgetSnapshot(c *check.C) { + cs.testClientSnapshotActionFull(c, "forget", nil, func() (string, error) { + return cs.cli.ForgetSnapshots(42, []string{"asnap", "bsnap"}) + }) +} + +func (cs *clientSuite) testClientSnapshotAction(c *check.C, action string, f func(uint64, []string, []string) (string, error)) { + cs.testClientSnapshotActionFull(c, action, []string{"auser", "buser"}, func() (string, error) { + return f(42, []string{"asnap", "bsnap"}, []string{"auser", "buser"}) + }) +} + +func (cs *clientSuite) TestClientCheckSnapshots(c *check.C) { + cs.testClientSnapshotAction(c, "check", cs.cli.CheckSnapshots) +} + +func (cs *clientSuite) TestClientRestoreSnapshots(c *check.C) { + cs.testClientSnapshotAction(c, "restore", cs.cli.RestoreSnapshots) +} + +func (cs *clientSuite) TestClientExportSnapshotSpecificErr(c *check.C) { + content := `{"type":"error","status-code":400,"result":{"message":"boom","kind":"err-kind","value":"err-value"}}` + cs.contentLength = int64(len(content)) + cs.rsp = content + cs.status = 400 + cs.header = http.Header{"Content-Type": []string{"application/json"}} + _, _, err := cs.cli.SnapshotExport(42) + c.Check(err, check.ErrorMatches, "boom") +} + +func (cs *clientSuite) TestClientExportSnapshot(c *check.C) { + type tableT struct { + content string + contentType string + status int + } + + table := []tableT{ + {"test-export", client.SnapshotExportMediaType, 200}, + {"test-export", "application/x-tar", 400}, + {"", "", 400}, + } + + for i, t := range table { + comm := check.Commentf("%d: %q", i, t.content) + + cs.contentLength = int64(len(t.content)) + cs.header = http.Header{"Content-Type": []string{t.contentType}} + cs.rsp = t.content + cs.status = t.status + + r, size, err := cs.cli.SnapshotExport(42) + if t.status == 200 { + c.Assert(err, check.IsNil, comm) + c.Assert(cs.countingCloser.closeCalled, check.Equals, 0) + c.Assert(size, check.Equals, int64(len(t.content)), comm) + } else { + c.Assert(err.Error(), check.Equals, "unexpected status code: ") + c.Assert(cs.countingCloser.closeCalled, check.Equals, 1) + } + + if t.status == 200 { + buf, err := io.ReadAll(r) + c.Assert(err, check.IsNil) + c.Assert(string(buf), check.Equals, t.content) + } + } +} + +func (cs *clientSuite) TestClientSnapshotImport(c *check.C) { + type tableT struct { + rsp string + status int + setID uint64 + error string + } + table := []tableT{ + {`{"type": "sync", "result": {"set-id": 42, "snaps": ["baz", "bar", "foo"]}}`, 200, 42, ""}, + {`{"type": "error"}`, 400, 0, "server error: \"Bad Request\""}, + } + + for i, t := range table { + comm := check.Commentf("%d: %s", i, t.rsp) + + cs.rsp = t.rsp + cs.status = t.status + + fakeSnapshotData := "fake" + r := strings.NewReader(fakeSnapshotData) + importSet, err := cs.cli.SnapshotImport(r, int64(len(fakeSnapshotData))) + if t.error != "" { + c.Assert(err, check.NotNil, comm) + c.Check(err.Error(), check.Equals, t.error, comm) + continue + } + c.Assert(err, check.IsNil, comm) + c.Assert(cs.req.Header.Get("Content-Type"), check.Equals, client.SnapshotExportMediaType) + c.Assert(cs.req.Header.Get("Content-Length"), check.Equals, strconv.Itoa(len(fakeSnapshotData))) + c.Check(importSet.ID, check.Equals, t.setID, comm) + c.Check(importSet.Snaps, check.DeepEquals, []string{"baz", "bar", "foo"}, comm) + d, err := io.ReadAll(cs.req.Body) + c.Assert(err, check.IsNil) + c.Check(string(d), check.Equals, fakeSnapshotData) + } +} + +func (cs *clientSuite) TestClientSnapshotContentHash(c *check.C) { + now := time.Now() + revno := snap.R(1) + sums := map[string]string{"user/foo.tgz": "some long hash"} + + sh1 := &client.Snapshot{SetID: 1, Time: now, Snap: "asnap", Revision: revno, SHA3_384: sums} + // sh1, sh1_1 are the same except time + sh1_1 := &client.Snapshot{SetID: 1, Time: now.Add(10), Snap: "asnap", Revision: revno, SHA3_384: sums} + // sh1, sh2 are the same except setID + sh2 := &client.Snapshot{SetID: 2, Time: now, Snap: "asnap", Revision: revno, SHA3_384: sums} + + h1, err := sh1.ContentHash() + c.Assert(err, check.IsNil) + // content hash uses sha256 internally + c.Check(h1, check.HasLen, sha256.Size) + + // same except time means same hash + h1_1, err := sh1_1.ContentHash() + c.Assert(err, check.IsNil) + c.Check(h1, check.DeepEquals, h1_1) + + // same except set means same hash + h2, err := sh2.ContentHash() + c.Assert(err, check.IsNil) + c.Check(h1, check.DeepEquals, h2) + + // sh3 is different because of snap name + sh3 := &client.Snapshot{SetID: 1, Time: now, Snap: "other-snap", Revision: revno, SHA3_384: sums} + h3, err := sh3.ContentHash() + c.Assert(err, check.IsNil) + c.Check(h1, check.Not(check.DeepEquals), h3) + + // sh4 is different because of the sha3_384 sums + sums4 := map[string]string{"user/foo.tgz": "some other hash"} + sh4 := &client.Snapshot{SetID: 1, Time: now, Snap: "asnap", Revision: revno, SHA3_384: sums4} + // same except sha3_384 means different hash + h4, err := sh4.ContentHash() + c.Assert(err, check.IsNil) + c.Check(h4, check.Not(check.DeepEquals), h1) + + // same except options means same hash + sh5 := &client.Snapshot{SetID: 1, Time: now, Snap: "asnap", Revision: revno, SHA3_384: sums, Options: &snap.SnapshotOptions{Exclude: []string{"$SNAP_DATA/exclude"}}} + h5, err := sh5.ContentHash() + c.Assert(err, check.IsNil) + c.Check(h5, check.DeepEquals, h1) +} + +func (cs *clientSuite) TestClientSnapshotSetContentHash(c *check.C) { + sums := map[string]string{"user/foo.tgz": "some long hash"} + ss1 := client.SnapshotSet{Snapshots: []*client.Snapshot{ + {SetID: 1, Snap: "snap2", Size: 2, SHA3_384: sums}, + {SetID: 1, Snap: "snap1", Size: 1, SHA3_384: sums}, + {SetID: 1, Snap: "snap3", Size: 3, SHA3_384: sums}, + {SetID: 1, Snap: "snap4", Size: 4, SHA3_384: sums}, + }} + // ss2 is the same ss1 but in a different order with different setID, and in the last case + // ss2 is the same as ss1 except for snapshot options + ss2 := client.SnapshotSet{Snapshots: []*client.Snapshot{ + {SetID: 2, Snap: "snap3", Size: 3, SHA3_384: sums}, + {SetID: 2, Snap: "snap2", Size: 2, SHA3_384: sums}, + {SetID: 2, Snap: "snap1", Size: 1, SHA3_384: sums}, + {SetID: 2, Snap: "snap4", Size: 4, SHA3_384: sums, Options: &snap.SnapshotOptions{Exclude: []string{"$SNAP_DATA/exclude"}}}, + }} + + h1, err := ss1.ContentHash() + c.Assert(err, check.IsNil) + // content hash uses sha256 internally + c.Check(h1, check.HasLen, sha256.Size) + + // h1 and h2 have the same hash + h2, err := ss2.ContentHash() + c.Assert(err, check.IsNil) + c.Check(h2, check.DeepEquals, h1) + + // ss3 is different because the size of snap3 is different + ss3 := client.SnapshotSet{Snapshots: []*client.Snapshot{ + {SetID: 1, Snap: "snap2", Size: 2}, + {SetID: 1, Snap: "snap3", Size: 666666666}, + {SetID: 1, Snap: "snap1", Size: 1}, + }} + // h1 and h3 are different + h3, err := ss3.ContentHash() + c.Assert(err, check.IsNil) + c.Check(h3, check.Not(check.DeepEquals), h1) + +} diff --git a/client/systems.go b/client/systems.go new file mode 100644 index 00000000..47064270 --- /dev/null +++ b/client/systems.go @@ -0,0 +1,269 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020-2023 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client + +import ( + "bytes" + "encoding/json" + "fmt" + + "golang.org/x/xerrors" + + "github.com/snapcore/snapd/gadget" + "github.com/snapcore/snapd/snap" +) + +// SystemModelData contains information about the model +type SystemModelData struct { + // Model as the model assertion + Model string `json:"model,omitempty"` + // BrandID corresponds to brand-id in the model assertion + BrandID string `json:"brand-id,omitempty"` + // DisplayName is human friendly name, corresponds to display-name in + // the model assertion + DisplayName string `json:"display-name,omitempty"` +} + +type System struct { + // Current is true when the system running now was installed from that + // recovery seed + Current bool `json:"current,omitempty"` + // Label of the recovery system + Label string `json:"label,omitempty"` + // Model information + Model SystemModelData `json:"model,omitempty"` + // Brand information + Brand snap.StoreAccount `json:"brand,omitempty"` + // Actions available for this system + Actions []SystemAction `json:"actions,omitempty"` + // DefaultRecoverySystem is true when the system is the default recovery system + DefaultRecoverySystem bool `json:"default-recovery-system,omitempty"` +} + +type SystemAction struct { + // Title is a user presentable action description + Title string `json:"title,omitempty"` + // Mode given action can be executed in + Mode string `json:"mode,omitempty"` +} + +// ListSystems list all systems available for seeding or recovery. +func (client *Client) ListSystems() ([]System, error) { + type systemsResponse struct { + Systems []System `json:"systems,omitempty"` + } + + var rsp systemsResponse + + if _, err := client.doSync("GET", "/v2/systems", nil, nil, nil, &rsp); err != nil { + return nil, xerrors.Errorf("cannot list recovery systems: %v", err) + } + return rsp.Systems, nil +} + +// DoSystemAction issues a request to perform an action using the given seed +// system and its mode. +func (client *Client) DoSystemAction(systemLabel string, action *SystemAction) error { + if systemLabel == "" { + return fmt.Errorf("cannot request an action without the system") + } + if action == nil { + return fmt.Errorf("cannot request an action without one") + } + // deeper verification is done by the backend + + req := struct { + Action string `json:"action"` + *SystemAction + }{ + Action: "do", + SystemAction: action, + } + + var body bytes.Buffer + if err := json.NewEncoder(&body).Encode(&req); err != nil { + return err + } + if _, err := client.doSync("POST", "/v2/systems/"+systemLabel, nil, nil, &body, nil); err != nil { + return xerrors.Errorf("cannot request system action: %v", err) + } + return nil +} + +// RebootToSystem issues a request to reboot into system with the +// given label and the given mode. +// +// When called without a systemLabel and without a mode it will just +// trigger a regular reboot. +// +// When called without a systemLabel but with a mode it will use +// the current system to enter the given mode. +// +// Note that "recover" and "run" modes are only available for the +// current system. +func (client *Client) RebootToSystem(systemLabel, mode string) error { + // verification is done by the backend + + req := struct { + Action string `json:"action"` + Mode string `json:"mode"` + }{ + Action: "reboot", + Mode: mode, + } + + var body bytes.Buffer + if err := json.NewEncoder(&body).Encode(&req); err != nil { + return err + } + if _, err := client.doSync("POST", "/v2/systems/"+systemLabel, nil, nil, &body, nil); err != nil { + if systemLabel != "" { + return xerrors.Errorf("cannot request system reboot into %q: %v", systemLabel, err) + } + return xerrors.Errorf("cannot request system reboot: %v", err) + } + return nil +} + +type StorageEncryptionSupport string + +const ( + // forcefull disabled by the device + StorageEncryptionSupportDisabled = "disabled" + // encryption available and usable + StorageEncryptionSupportAvailable = "available" + // encryption unavailable but not required + StorageEncryptionSupportUnavailable = "unavailable" + // encryption unavailable and required, this is an error + StorageEncryptionSupportDefective = "defective" +) + +type StorageEncryption struct { + // Support describes the level of hardware support available. + Support StorageEncryptionSupport `json:"support"` + + // StorageSafety can have values of asserts.StorageSafety + StorageSafety string `json:"storage-safety,omitempty"` + + // Type has values of secboot.EncryptionType: "", "cryptsetup", + // "cryptsetup-with-inline-crypto-engine" + Type string `json:"encryption-type,omitempty"` + + // UnavailableReason describes why the encryption is not + // available in a human readable form. Depending on if + // encryption is required or not this should be presented to + // the user as either an error or as information. + UnavailableReason string `json:"unavailable-reason,omitempty"` +} + +type SystemDetails struct { + // First part is designed to look like `client.System` - the + // only difference is how the model is represented + Current bool `json:"current,omitempty"` + Label string `json:"label,omitempty"` + Model map[string]interface{} `json:"model,omitempty"` + Brand snap.StoreAccount `json:"brand,omitempty"` + Actions []SystemAction `json:"actions,omitempty"` + + // Volumes contains the volumes defined from the gadget snap + Volumes map[string]*gadget.Volume `json:"volumes,omitempty"` + + StorageEncryption *StorageEncryption `json:"storage-encryption,omitempty"` +} + +func (client *Client) SystemDetails(systemLabel string) (*SystemDetails, error) { + var rsp SystemDetails + + if _, err := client.doSync("GET", "/v2/systems/"+systemLabel, nil, nil, nil, &rsp); err != nil { + return nil, xerrors.Errorf("cannot get details for system %q: %v", systemLabel, err) + } + gadget.SetEnclosingVolumeInStructs(rsp.Volumes) + return &rsp, nil +} + +type InstallStep string + +const ( + // Creates a change to setup encryption for the partitions + // with system-{data,save} roles. The successful change has a + // created device mapper devices ready to use. + InstallStepSetupStorageEncryption InstallStep = "setup-storage-encryption" + + // Creates a change to finish the installation. The change + // ensures all volume structure content is written to disk, it + // sets up boot, kernel etc and when finished the installed + // system is ready for reboot. + InstallStepFinish InstallStep = "finish" +) + +type InstallSystemOptions struct { + // Step is the install step, either "setup-storage-encryption" + // or "finish". + Step InstallStep `json:"step,omitempty"` + + // OnVolumes is the volume description of the volumes that the + // given step should operate on. + OnVolumes map[string]*gadget.Volume `json:"on-volumes,omitempty"` +} + +// InstallSystem will perform the given install step for the given volumes +func (client *Client) InstallSystem(systemLabel string, opts *InstallSystemOptions) (changeID string, err error) { + if systemLabel == "" { + return "", fmt.Errorf("cannot install with an empty system label") + } + + // verification is done by the backend + req := struct { + Action string `json:"action"` + *InstallSystemOptions + }{ + Action: "install", + InstallSystemOptions: opts, + } + + var body bytes.Buffer + if err := json.NewEncoder(&body).Encode(&req); err != nil { + return "", err + } + chgID, err := client.doAsync("POST", "/v2/systems/"+systemLabel, nil, nil, &body) + if err != nil { + return "", xerrors.Errorf("cannot request system install for %q: %v", systemLabel, err) + } + return chgID, nil +} + +// CreateSystemOptions contains the options for creating a new recovery system. +type CreateSystemOptions struct { + // Label is the label of the new system. + Label string `json:"label,omitempty"` + // ValidationSets is a list of validation sets that snaps in the newly + // created system should be validated against. + ValidationSets []string `json:"validation-sets,omitempty"` + // TestSystem is true if the system should be tested by rebooting into the + // new system. + TestSystem bool `json:"test-system,omitempty"` + // MarkDefault is true if the system should be marked as the default + // recovery system. + MarkDefault bool `json:"mark-default,omitempty"` + // Offline is true if the system should be created without reaching out to + // the store. In the JSON variant of the API, only pre-installed + // snaps/assertions will be considered. + Offline bool `json:"offline,omitempty"` +} diff --git a/client/systems_test.go b/client/systems_test.go new file mode 100644 index 00000000..3a8ef1a5 --- /dev/null +++ b/client/systems_test.go @@ -0,0 +1,416 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client_test + +import ( + "encoding/json" + "io" + + "gopkg.in/check.v1" + + "github.com/snapcore/snapd/client" + "github.com/snapcore/snapd/gadget" + "github.com/snapcore/snapd/snap" +) + +func (cs *clientSuite) TestListSystemsSome(c *check.C) { + cs.rsp = `{ + "type": "sync", + "status-code": 200, + "result": { + "systems": [ + { + "current": true, + "label": "20200101", + "model": { + "model": "this-is-model-id", + "brand-id": "brand-id-1", + "display-name": "wonky model" + }, + "brand": { + "id": "brand-id-1", + "username": "brand", + "display-name": "wonky publishing" + }, + "actions": [ + {"title": "recover", "mode": "recover"}, + {"title": "reinstall", "mode": "install"} + ] + }, { + "label": "20200311", + "model": { + "model": "different-model-id", + "brand-id": "bulky-brand-id-1", + "display-name": "bulky model" + }, + "brand": { + "id": "bulky-brand-id-1", + "username": "bulky-brand", + "display-name": "bulky publishing" + }, + "actions": [ + {"title": "factory-reset", "mode": "install"} + ] + } + ] + } + }` + systems, err := cs.cli.ListSystems() + c.Assert(err, check.IsNil) + c.Check(cs.req.Method, check.Equals, "GET") + c.Check(cs.req.URL.Path, check.Equals, "/v2/systems") + c.Check(systems, check.DeepEquals, []client.System{ + { + Current: true, + Label: "20200101", + Model: client.SystemModelData{ + Model: "this-is-model-id", + BrandID: "brand-id-1", + DisplayName: "wonky model", + }, + Brand: snap.StoreAccount{ + ID: "brand-id-1", + Username: "brand", + DisplayName: "wonky publishing", + }, + Actions: []client.SystemAction{ + {Title: "recover", Mode: "recover"}, + {Title: "reinstall", Mode: "install"}, + }, + }, { + Label: "20200311", + Model: client.SystemModelData{ + Model: "different-model-id", + BrandID: "bulky-brand-id-1", + DisplayName: "bulky model", + }, + Brand: snap.StoreAccount{ + ID: "bulky-brand-id-1", + Username: "bulky-brand", + DisplayName: "bulky publishing", + }, + Actions: []client.SystemAction{ + {Title: "factory-reset", Mode: "install"}, + }, + }, + }) +} + +func (cs *clientSuite) TestListSystemsNone(c *check.C) { + cs.rsp = `{ + "type": "sync", + "status-code": 200, + "result": {} + }` + systems, err := cs.cli.ListSystems() + c.Assert(err, check.IsNil) + c.Check(cs.req.Method, check.Equals, "GET") + c.Check(cs.req.URL.Path, check.Equals, "/v2/systems") + c.Check(systems, check.HasLen, 0) +} + +func (cs *clientSuite) TestRequestSystemActionHappy(c *check.C) { + cs.rsp = `{ + "type": "sync", + "status-code": 200, + "result": {} + }` + err := cs.cli.DoSystemAction("1234", &client.SystemAction{ + Title: "reinstall", + Mode: "install", + }) + c.Assert(err, check.IsNil) + c.Check(cs.req.Method, check.Equals, "POST") + c.Check(cs.req.URL.Path, check.Equals, "/v2/systems/1234") + + body, err := io.ReadAll(cs.req.Body) + c.Assert(err, check.IsNil) + var req map[string]interface{} + err = json.Unmarshal(body, &req) + c.Assert(err, check.IsNil) + c.Assert(req, check.DeepEquals, map[string]interface{}{ + "action": "do", + "title": "reinstall", + "mode": "install", + }) +} + +func (cs *clientSuite) TestRequestSystemActionError(c *check.C) { + cs.rsp = `{ + "type": "error", + "status-code": 500, + "result": {"message": "failed"} + }` + err := cs.cli.DoSystemAction("1234", &client.SystemAction{Mode: "install"}) + c.Assert(err, check.ErrorMatches, "cannot request system action: failed") + c.Check(cs.req.Method, check.Equals, "POST") + c.Check(cs.req.URL.Path, check.Equals, "/v2/systems/1234") +} + +func (cs *clientSuite) TestRequestSystemActionInvalid(c *check.C) { + err := cs.cli.DoSystemAction("", &client.SystemAction{}) + c.Assert(err, check.ErrorMatches, "cannot request an action without the system") + err = cs.cli.DoSystemAction("1234", nil) + c.Assert(err, check.ErrorMatches, "cannot request an action without one") +} + +func (cs *clientSuite) TestRequestSystemRebootHappy(c *check.C) { + cs.rsp = `{ + "type": "sync", + "status-code": 200, + "result": {} + }` + err := cs.cli.RebootToSystem("20201212", "install") + c.Assert(err, check.IsNil) + c.Check(cs.req.Method, check.Equals, "POST") + c.Check(cs.req.URL.Path, check.Equals, "/v2/systems/20201212") + + body, err := io.ReadAll(cs.req.Body) + c.Assert(err, check.IsNil) + var req map[string]interface{} + err = json.Unmarshal(body, &req) + c.Assert(err, check.IsNil) + c.Assert(req, check.DeepEquals, map[string]interface{}{ + "action": "reboot", + "mode": "install", + }) +} + +func (cs *clientSuite) TestRequestSystemRebootErrorNoSystem(c *check.C) { + cs.rsp = `{ + "type": "error", + "status-code": 500, + "result": {"message": "failed"} + }` + err := cs.cli.RebootToSystem("", "install") + c.Assert(err, check.ErrorMatches, `cannot request system reboot: failed`) + c.Check(cs.req.Method, check.Equals, "POST") + c.Check(cs.req.URL.Path, check.Equals, "/v2/systems") +} + +func (cs *clientSuite) TestRequestSystemRebootErrorWithSystem(c *check.C) { + cs.rsp = `{ + "type": "error", + "status-code": 500, + "result": {"message": "failed"} + }` + err := cs.cli.RebootToSystem("1234", "install") + c.Assert(err, check.ErrorMatches, `cannot request system reboot into "1234": failed`) + c.Check(cs.req.Method, check.Equals, "POST") + c.Check(cs.req.URL.Path, check.Equals, "/v2/systems/1234") +} + +func (cs *clientSuite) TestSystemDetailsNone(c *check.C) { + cs.rsp = `{ + "type": "sync", + "status-code": 404, + "result": { + "kind": "assertion-not-found", + "value": "model" + } + }` + _, err := cs.cli.SystemDetails("20190102") + c.Assert(err, check.IsNil) + c.Check(cs.req.Method, check.Equals, "GET") + c.Check(cs.req.URL.Path, check.Equals, "/v2/systems/20190102") +} + +func (cs *clientSuite) TestSystemDetailsHappy(c *check.C) { + cs.rsp = `{ + "type": "sync", + "status-code": 200, + "result": { + "current": true, + "label": "20200101", + "model": { + "model": "this-is-model-id", + "brand-id": "brand-id-1", + "display-name": "wonky model" + }, + "brand": { + "id": "brand-id-1", + "username":"brand-username-1", + "display-name":"Brandy Display Name", + "validation":"validated" + }, + "actions": [ + {"title": "recover", "mode": "recover"}, + {"title": "reinstall", "mode": "install"} + ], + "storage-encryption": { + "support":"available", + "storage-safety":"prefer-encrypted", + "encryption-type":"cryptsetup" + }, + "volumes": { + "pc": { + "schema":"gpt", + "bootloader":"grub", + "structure":[{"name":"mbr","type":"mbr","size":440}] + } + } + } + }` + sys, err := cs.cli.SystemDetails("20190102") + c.Assert(err, check.IsNil) + c.Check(cs.req.Method, check.Equals, "GET") + c.Check(cs.req.URL.Path, check.Equals, "/v2/systems/20190102") + vols := map[string]*gadget.Volume{ + "pc": { + Schema: "gpt", + Bootloader: "grub", + Structure: []gadget.VolumeStructure{ + {Name: "mbr", Type: "mbr", Size: 440}, + }, + }} + gadget.SetEnclosingVolumeInStructs(vols) + c.Check(sys, check.DeepEquals, &client.SystemDetails{ + Current: true, + Label: "20200101", + Model: map[string]interface{}{ + "model": "this-is-model-id", + "brand-id": "brand-id-1", + "display-name": "wonky model", + }, + Brand: snap.StoreAccount{ + ID: "brand-id-1", + Username: "brand-username-1", + DisplayName: "Brandy Display Name", + Validation: "validated", + }, + Actions: []client.SystemAction{ + {Title: "recover", Mode: "recover"}, + {Title: "reinstall", Mode: "install"}, + }, + StorageEncryption: &client.StorageEncryption{ + Support: "available", + StorageSafety: "prefer-encrypted", + Type: "cryptsetup", + }, + Volumes: vols, + }) +} + +func (cs *clientSuite) TestRequestSystemInstallErrorNoSystem(c *check.C) { + cs.rsp = `{ + "type": "error", + "status-code": 500, + "result": {"message": "failed"} + }` + opts := &client.InstallSystemOptions{ + Step: client.InstallStepFinish, + } + _, err := cs.cli.InstallSystem("1234", opts) + c.Assert(err, check.ErrorMatches, `cannot request system install for "1234": failed`) + c.Check(cs.req.Method, check.Equals, "POST") + c.Check(cs.req.URL.Path, check.Equals, "/v2/systems/1234") +} + +func (cs *clientSuite) TestRequestSystemInstallEmptySystemLabel(c *check.C) { + cs.rsp = `{ + "type": "error", + "status-code": 500, + "result": {"message": "failed"} + }` + _, err := cs.cli.InstallSystem("", nil) + c.Assert(err, check.ErrorMatches, `cannot install with an empty system label`) + // no request was performed + c.Check(cs.req, check.IsNil) +} + +func (cs *clientSuite) TestRequestSystemInstallHappy(c *check.C) { + cs.status = 202 + cs.rsp = `{ + "type": "async", + "status-code": 202, + "change": "42" + }` + vols := map[string]*gadget.Volume{ + "pc": { + Schema: "dos", + Bootloader: "mbr", + ID: "id", + // Note that name is not exported as json + Name: "pc", + Structure: []gadget.VolumeStructure{ + { + Device: "/dev/sda1", + + Label: "label", + Name: "vol-name", + ID: "id", + MinSize: 1234, + Size: 1234, + Type: "type", + Filesystem: "fs", + Role: "system-boot", + // not exported to json + VolumeName: "vol-name", + }, + }, + }, + } + opts := &client.InstallSystemOptions{ + Step: client.InstallStepFinish, + OnVolumes: vols, + } + chgID, err := cs.cli.InstallSystem("1234", opts) + c.Assert(err, check.IsNil) + c.Assert(chgID, check.Equals, "42") + c.Check(cs.req.Method, check.Equals, "POST") + c.Check(cs.req.URL.Path, check.Equals, "/v2/systems/1234") + + body, err := io.ReadAll(cs.req.Body) + c.Assert(err, check.IsNil) + var req map[string]interface{} + err = json.Unmarshal(body, &req) + c.Assert(err, check.IsNil) + c.Assert(req, check.DeepEquals, map[string]interface{}{ + "action": "install", + "step": "finish", + "on-volumes": map[string]interface{}{ + "pc": map[string]interface{}{ + "schema": "dos", + "bootloader": "mbr", + "id": "id", + "structure": []interface{}{ + map[string]interface{}{ + "device": "/dev/sda1", + "filesystem-label": "label", + "name": "vol-name", + "id": "id", + "min-size": float64(1234), + "size": float64(1234), + "type": "type", + "filesystem": "fs", + "role": "system-boot", + "offset": nil, + "offset-write": nil, + "content": nil, + "update": map[string]interface{}{ + "edition": float64(0), + "preserve": nil, + }, + }, + }, + }, + }, + }) +} diff --git a/client/users.go b/client/users.go new file mode 100644 index 00000000..f1b15734 --- /dev/null +++ b/client/users.go @@ -0,0 +1,145 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2015-2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// CreateUserResult holds the result of a user creation. +type CreateUserResult struct { + Username string `json:"username"` + SSHKeys []string `json:"ssh-keys"` +} + +// CreateUserOptions holds options for creating a local system user. +// +// If Known is false, the provided email is used to query the store for +// username and SSH key details. +// +// If Known is true, the user will be created by looking through existing +// system-user assertions and looking for a matching email. If Email is +// empty then all such assertions are considered and multiple users may +// be created. +type CreateUserOptions struct { + Email string `json:"email,omitempty"` + Sudoer bool `json:"sudoer,omitempty"` + Known bool `json:"known,omitempty"` + ForceManaged bool `json:"force-managed,omitempty"` + // Automatic is for internal snapd use, behavior might evolve + Automatic bool `json:"automatic,omitempty"` +} + +// RemoveUserOptions holds options for removing a local system user. +type RemoveUserOptions struct { + // Username indicates which user to remove. + Username string `json:"username,omitempty"` +} + +type userAction struct { + Action string `json:"action"` + *CreateUserOptions + *RemoveUserOptions +} + +func (client *Client) doUserAction(act *userAction, result interface{}) error { + data, err := json.Marshal(act) + if err != nil { + return err + } + + _, err = client.doSync("POST", "/v2/users", nil, nil, bytes.NewReader(data), result) + return err +} + +// CreateUser creates a local system user. See CreateUserOptions for details. +func (client *Client) CreateUser(options *CreateUserOptions) (*CreateUserResult, error) { + if options == nil || options.Email == "" { + return nil, fmt.Errorf("cannot create a user without providing an email") + } + + var result []*CreateUserResult + err := client.doUserAction(&userAction{Action: "create", CreateUserOptions: options}, &result) + if err != nil { + return nil, fmt.Errorf("while creating user: %v", err) + } + return result[0], nil +} + +// CreateUsers creates multiple local system users. See CreateUserOptions for details. +// +// Results may be provided even if there are errors. +func (client *Client) CreateUsers(options []*CreateUserOptions) ([]*CreateUserResult, error) { + for _, opts := range options { + if opts == nil || (opts.Email == "" && !(opts.Known || opts.Automatic)) { + return nil, fmt.Errorf("cannot create user from store details without an email to query for") + } + } + + var results []*CreateUserResult + var errs []error + for _, opts := range options { + var result []*CreateUserResult + err := client.doUserAction(&userAction{Action: "create", CreateUserOptions: opts}, &result) + if err != nil { + errs = append(errs, err) + } else { + results = append(results, result...) + } + } + + if len(errs) == 1 { + return results, errs[0] + } + if len(errs) > 1 { + var buf bytes.Buffer + for _, err := range errs { + fmt.Fprintf(&buf, "\n- %s", err) + } + return results, fmt.Errorf("while creating users:%s", buf.Bytes()) + } + return results, nil +} + +// RemoveUser removes a local system user. +func (client *Client) RemoveUser(options *RemoveUserOptions) (removed []*User, err error) { + if options == nil || options.Username == "" { + return nil, fmt.Errorf("cannot remove a user without providing a username") + } + var result struct { + Removed []*User `json:"removed"` + } + if err := client.doUserAction(&userAction{Action: "remove", RemoveUserOptions: options}, &result); err != nil { + return nil, err + } + return result.Removed, nil +} + +// Users returns the local users. +func (client *Client) Users() ([]*User, error) { + var result []*User + + if _, err := client.doSync("GET", "/v2/users", nil, nil, nil, &result); err != nil { + return nil, fmt.Errorf("while getting users: %v", err) + } + return result, nil +} diff --git a/client/users_test.go b/client/users_test.go new file mode 100644 index 00000000..aef019e1 --- /dev/null +++ b/client/users_test.go @@ -0,0 +1,198 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2015-2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client_test + +import ( + "io" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/client" +) + +func (cs *clientSuite) TestClientRemoveUser(c *C) { + removed, err := cs.cli.RemoveUser(&client.RemoveUserOptions{}) + c.Assert(err, ErrorMatches, "cannot remove a user without providing a username") + c.Assert(removed, IsNil) + + cs.rsp = `{ + "type": "sync", + "result": { + "removed": [{"id": 11, "username": "one-user", "email": "user@test.com"}] + } + }` + removed, err = cs.cli.RemoveUser(&client.RemoveUserOptions{Username: "one-user"}) + c.Assert(cs.req.Method, Equals, "POST") + c.Assert(cs.req.URL.Path, Equals, "/v2/users") + c.Assert(err, IsNil) + c.Assert(removed, DeepEquals, []*client.User{ + {ID: 11, Username: "one-user", Email: "user@test.com"}, + }) + + body, err := io.ReadAll(cs.req.Body) + c.Assert(err, IsNil) + c.Assert(string(body), Equals, `{"action":"remove","username":"one-user"}`) +} + +func (cs *clientSuite) TestClientRemoveUserError(c *C) { + removed, err := cs.cli.RemoveUser(nil) + c.Assert(err, ErrorMatches, "cannot remove a user without providing a username") + c.Assert(removed, IsNil) + removed, err = cs.cli.RemoveUser(&client.RemoveUserOptions{}) + c.Assert(err, ErrorMatches, "cannot remove a user without providing a username") + c.Assert(removed, IsNil) + + cs.rsp = `{ + "type": "error", + "result": {"message": "no can do"} + }` + removed, err = cs.cli.RemoveUser(&client.RemoveUserOptions{Username: "one-user"}) + c.Assert(cs.req.Method, Equals, "POST") + c.Assert(cs.req.URL.Path, Equals, "/v2/users") + c.Assert(err, ErrorMatches, "no can do") + c.Assert(removed, IsNil) + + body, err := io.ReadAll(cs.req.Body) + c.Assert(err, IsNil) + c.Assert(string(body), Equals, `{"action":"remove","username":"one-user"}`) +} + +func (cs *clientSuite) TestClientCreateUser(c *C) { + _, err := cs.cli.CreateUser(nil) + c.Assert(err, ErrorMatches, "cannot create a user without providing an email") + _, err = cs.cli.CreateUser(&client.CreateUserOptions{}) + c.Assert(err, ErrorMatches, "cannot create a user without providing an email") + + cs.rsp = `{ + "type": "sync", + "result": [{ + "username": "karl", + "ssh-keys": ["one", "two"] + }] + }` + rsp, err := cs.cli.CreateUser(&client.CreateUserOptions{Email: "one@email.com", Sudoer: true, Known: true}) + c.Assert(cs.req.Method, Equals, "POST") + c.Assert(cs.req.URL.Path, Equals, "/v2/users") + c.Assert(err, IsNil) + + body, err := io.ReadAll(cs.req.Body) + c.Assert(err, IsNil) + c.Assert(string(body), Equals, `{"action":"create","email":"one@email.com","sudoer":true,"known":true}`) + + c.Assert(rsp, DeepEquals, &client.CreateUserResult{ + Username: "karl", + SSHKeys: []string{"one", "two"}, + }) +} + +var createUsersTests = []struct { + options []*client.CreateUserOptions + bodies []string + responses []string + results []*client.CreateUserResult + error string +}{{ + // nothing in -> nothing out + options: nil, +}, { + options: []*client.CreateUserOptions{nil}, + error: "cannot create user from store details without an email to query for", +}, { + options: []*client.CreateUserOptions{{}}, + error: "cannot create user from store details without an email to query for", +}, { + options: []*client.CreateUserOptions{{ + Email: "one@example.com", + Sudoer: true, + }, { + Known: true, + }}, + bodies: []string{ + `{"action":"create","email":"one@example.com","sudoer":true}`, + `{"action":"create","known":true}`, + }, + responses: []string{ + `{"type": "sync", "result": [{"username": "one", "ssh-keys":["a", "b"]}]}`, + `{"type": "sync", "result": [{"username": "two"}, {"username": "three"}]}`, + }, + results: []*client.CreateUserResult{{ + Username: "one", + SSHKeys: []string{"a", "b"}, + }, { + Username: "two", + }, { + Username: "three", + }, + }, +}, { + options: []*client.CreateUserOptions{{ + Automatic: true, + }}, + bodies: []string{ + `{"action":"create","automatic":true}`, + }, + responses: []string{ + // for automatic result can be empty + `{"type": "sync", "result": []}`, + }, +}, +} + +func (cs *clientSuite) TestClientCreateUsers(c *C) { + for _, test := range createUsersTests { + cs.reqs = nil + cs.rsps = test.responses + + results, err := cs.cli.CreateUsers(test.options) + if test.error != "" { + c.Assert(err, ErrorMatches, test.error) + } + c.Assert(results, DeepEquals, test.results) + + var bodies []string + for _, req := range cs.reqs { + c.Assert(req.Method, Equals, "POST") + c.Assert(req.URL.Path, Equals, "/v2/users") + data, err := io.ReadAll(req.Body) + c.Assert(err, IsNil) + bodies = append(bodies, string(data)) + } + + c.Assert(bodies, DeepEquals, test.bodies) + } +} + +func (cs *clientSuite) TestClientJSONError(c *C) { + cs.rsp = `some non-json error message` + _, err := cs.cli.SysInfo() + c.Assert(err, ErrorMatches, `cannot obtain system details: cannot decode "some non-json error message": invalid char.*`) +} + +func (cs *clientSuite) TestUsers(c *C) { + cs.rsp = `{"type": "sync", "result": + [{"username": "foo","email":"foo@example.com"}, + {"username": "bar","email":"bar@example.com"}]}` + users, err := cs.cli.Users() + c.Check(err, IsNil) + c.Check(users, DeepEquals, []*client.User{ + {Username: "foo", Email: "foo@example.com"}, + {Username: "bar", Email: "bar@example.com"}, + }) +} diff --git a/client/validate.go b/client/validate.go new file mode 100644 index 00000000..9a2fb5c7 --- /dev/null +++ b/client/validate.go @@ -0,0 +1,135 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client + +import ( + "bytes" + "encoding/json" + "fmt" + "net/url" + + "golang.org/x/xerrors" +) + +// ValidateApplyOptions carries options for ApplyValidationSet. +type ValidateApplyOptions struct { + Mode string + Sequence int +} + +// ValidationSetResult holds information about a single validation set. +type ValidationSetResult struct { + AccountID string `json:"account-id"` + Name string `json:"name"` + + Sequence int `json:"sequence,omitempty"` + PinnedAt int `json:"pinned-at,omitempty"` + + Mode string `json:"mode"` + Valid bool `json:"valid"` + // TODO: flags/states for notes column +} + +type postValidationSetData struct { + Action string `json:"action"` + Mode string `json:"mode,omitempty"` + Sequence int `json:"sequence,omitempty"` +} + +// ForgetValidationSet forgets the given validation set identified by account, +// name and optional sequence (if non-zero). +func (client *Client) ForgetValidationSet(accountID, name string, sequence int) error { + if accountID == "" || name == "" { + return xerrors.Errorf("cannot forget validation set without account ID and name") + } + + data := &postValidationSetData{ + Action: "forget", + Sequence: sequence, + } + + var body bytes.Buffer + if err := json.NewEncoder(&body).Encode(data); err != nil { + return err + } + path := fmt.Sprintf("/v2/validation-sets/%s/%s", accountID, name) + if _, err := client.doSync("POST", path, nil, nil, &body, nil); err != nil { + fmt := "cannot forget validation set: %w" + return xerrors.Errorf(fmt, err) + } + return nil +} + +// ApplyValidationSet applies the given validation set identified by account and name and returns +// the new validation set tracking info. For monitoring mode the returned res may indicate invalid +// state. +func (client *Client) ApplyValidationSet(accountID, name string, opts *ValidateApplyOptions) (res *ValidationSetResult, err error) { + if accountID == "" || name == "" { + return nil, xerrors.Errorf("cannot apply validation set without account ID and name") + } + + data := &postValidationSetData{ + Action: "apply", + Mode: opts.Mode, + Sequence: opts.Sequence, + } + + var body bytes.Buffer + if err := json.NewEncoder(&body).Encode(data); err != nil { + return nil, err + } + path := fmt.Sprintf("/v2/validation-sets/%s/%s", accountID, name) + + if _, err := client.doSync("POST", path, nil, nil, &body, &res); err != nil { + fmt := "cannot apply validation set: %w" + return nil, xerrors.Errorf(fmt, err) + } + return res, nil +} + +// ListValidationsSets queries all validation sets. +func (client *Client) ListValidationsSets() ([]*ValidationSetResult, error) { + var res []*ValidationSetResult + if _, err := client.doSync("GET", "/v2/validation-sets", nil, nil, nil, &res); err != nil { + fmt := "cannot list validation sets: %w" + return nil, xerrors.Errorf(fmt, err) + } + return res, nil +} + +// ValidationSet queries the given validation set identified by account/name. +func (client *Client) ValidationSet(accountID, name string, sequence int) (*ValidationSetResult, error) { + if accountID == "" || name == "" { + return nil, xerrors.Errorf("cannot query validation set without account ID and name") + } + + q := url.Values{} + if sequence != 0 { + q.Set("sequence", fmt.Sprintf("%d", sequence)) + } + + var res *ValidationSetResult + path := fmt.Sprintf("/v2/validation-sets/%s/%s", accountID, name) + if _, err := client.doSync("GET", path, q, nil, nil, &res); err != nil { + fmt := "cannot query validation set: %w" + return nil, xerrors.Errorf(fmt, err) + } + return res, nil +} diff --git a/client/validate_test.go b/client/validate_test.go new file mode 100644 index 00000000..233a251a --- /dev/null +++ b/client/validate_test.go @@ -0,0 +1,240 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client_test + +import ( + "encoding/json" + "io" + "net/url" + + "gopkg.in/check.v1" + + "github.com/snapcore/snapd/client" +) + +var errorResponseJSON = `{ + "type": "error", + "result": {"message": "failed"} +}` + +func (cs *clientSuite) TestListValidationsSetsNone(c *check.C) { + cs.rsp = `{ + "type": "sync", + "status-code": 200, + "result": [] + }` + + vsets, err := cs.cli.ListValidationsSets() + c.Assert(err, check.IsNil) + c.Check(cs.req.Method, check.Equals, "GET") + c.Check(cs.req.URL.Path, check.Equals, "/v2/validation-sets") + c.Check(vsets, check.HasLen, 0) +} + +func (cs *clientSuite) TestListValidationsSetsError(c *check.C) { + cs.status = 500 + cs.rsp = errorResponseJSON + + _, err := cs.cli.ListValidationsSets() + c.Assert(err, check.ErrorMatches, "cannot list validation sets: failed") + c.Check(cs.req.Method, check.Equals, "GET") + c.Check(cs.req.URL.Path, check.Equals, "/v2/validation-sets") +} + +func (cs *clientSuite) TestListValidationsSets(c *check.C) { + cs.rsp = `{ + "type": "sync", + "status-code": 200, + "result": [ + {"account-id": "abc", "name": "def", "mode": "monitor", "sequence": 0}, + {"account-id": "ghi", "name": "jkl", "mode": "enforce", "sequence": 2} + ] + }` + + vsets, err := cs.cli.ListValidationsSets() + c.Assert(err, check.IsNil) + c.Check(cs.req.Method, check.Equals, "GET") + c.Check(cs.req.URL.Path, check.Equals, "/v2/validation-sets") + c.Check(vsets, check.DeepEquals, []*client.ValidationSetResult{ + {AccountID: "abc", Name: "def", Mode: "monitor", Sequence: 0, Valid: false}, + {AccountID: "ghi", Name: "jkl", Mode: "enforce", Sequence: 2, Valid: false}, + }) +} + +func (cs *clientSuite) TestApplyValidationSetMonitor(c *check.C) { + cs.rsp = `{ + "type": "sync", + "status-code": 200, + "result": {"account-id": "foo", "name": "bar", "mode": "monitor", "sequence": 3, "valid": true} + }` + opts := &client.ValidateApplyOptions{Mode: "monitor", Sequence: 3} + vs, err := cs.cli.ApplyValidationSet("foo", "bar", opts) + c.Assert(err, check.IsNil) + c.Check(vs, check.DeepEquals, &client.ValidationSetResult{ + AccountID: "foo", + Name: "bar", + Mode: "monitor", + Sequence: 3, + Valid: true, + }) + c.Check(cs.req.Method, check.Equals, "POST") + c.Check(cs.req.URL.Path, check.Equals, "/v2/validation-sets/foo/bar") + body, err := io.ReadAll(cs.req.Body) + c.Assert(err, check.IsNil) + var req map[string]interface{} + err = json.Unmarshal(body, &req) + c.Assert(err, check.IsNil) + c.Assert(req, check.DeepEquals, map[string]interface{}{ + "action": "apply", + "mode": "monitor", + "sequence": float64(3), + }) +} + +func (cs *clientSuite) TestApplyValidationSetEnforce(c *check.C) { + cs.rsp = `{ + "type": "sync", + "status-code": 200, + "result": {"account-id": "foo", "name": "bar", "mode": "enforce", "sequence": 3, "valid": true} + }` + opts := &client.ValidateApplyOptions{Mode: "enforce", Sequence: 3} + vs, err := cs.cli.ApplyValidationSet("foo", "bar", opts) + c.Assert(err, check.IsNil) + c.Check(vs, check.DeepEquals, &client.ValidationSetResult{ + AccountID: "foo", + Name: "bar", + Mode: "enforce", + Sequence: 3, + Valid: true, + }) + c.Check(cs.req.Method, check.Equals, "POST") + c.Check(cs.req.URL.Path, check.Equals, "/v2/validation-sets/foo/bar") + body, err := io.ReadAll(cs.req.Body) + c.Assert(err, check.IsNil) + var req map[string]interface{} + err = json.Unmarshal(body, &req) + c.Assert(err, check.IsNil) + c.Assert(req, check.DeepEquals, map[string]interface{}{ + "action": "apply", + "mode": "enforce", + "sequence": float64(3), + }) +} + +func (cs *clientSuite) TestApplyValidationSetError(c *check.C) { + cs.status = 500 + cs.rsp = errorResponseJSON + opts := &client.ValidateApplyOptions{Mode: "monitor"} + _, err := cs.cli.ApplyValidationSet("foo", "bar", opts) + c.Assert(err, check.ErrorMatches, "cannot apply validation set: failed") + c.Check(cs.req.Method, check.Equals, "POST") + c.Check(cs.req.URL.Path, check.Equals, "/v2/validation-sets/foo/bar") +} + +func (cs *clientSuite) TestApplyValidationSetInvalidArgs(c *check.C) { + opts := &client.ValidateApplyOptions{} + _, err := cs.cli.ApplyValidationSet("", "bar", opts) + c.Assert(err, check.ErrorMatches, `cannot apply validation set without account ID and name`) + _, err = cs.cli.ApplyValidationSet("", "bar", opts) + c.Assert(err, check.ErrorMatches, `cannot apply validation set without account ID and name`) +} + +func (cs *clientSuite) TestForgetValidationSet(c *check.C) { + cs.rsp = `{ + "type": "sync", + "status-code": 200 + }` + c.Assert(cs.cli.ForgetValidationSet("foo", "bar", 3), check.IsNil) + c.Check(cs.req.Method, check.Equals, "POST") + c.Check(cs.req.URL.Path, check.Equals, "/v2/validation-sets/foo/bar") + body, err := io.ReadAll(cs.req.Body) + c.Assert(err, check.IsNil) + var req map[string]interface{} + err = json.Unmarshal(body, &req) + c.Assert(err, check.IsNil) + c.Assert(req, check.DeepEquals, map[string]interface{}{ + "action": "forget", + "sequence": float64(3), + }) +} + +func (cs *clientSuite) TestForgetValidationSetError(c *check.C) { + cs.status = 500 + cs.rsp = errorResponseJSON + err := cs.cli.ForgetValidationSet("foo", "bar", 0) + c.Assert(err, check.ErrorMatches, "cannot forget validation set: failed") + c.Check(cs.req.Method, check.Equals, "POST") + c.Check(cs.req.URL.Path, check.Equals, "/v2/validation-sets/foo/bar") +} + +func (cs *clientSuite) TestForgetValidationSetInvalidArgs(c *check.C) { + err := cs.cli.ForgetValidationSet("", "bar", 0) + c.Assert(err, check.ErrorMatches, `cannot forget validation set without account ID and name`) + err = cs.cli.ForgetValidationSet("", "bar", 0) + c.Assert(err, check.ErrorMatches, `cannot forget validation set without account ID and name`) +} + +func (cs *clientSuite) TestValidationSet(c *check.C) { + cs.rsp = `{ + "type": "sync", + "status-code": 200, + "result": {"account-id": "abc", "name": "def", "mode": "monitor", "sequence": 0} + }` + + vsets, err := cs.cli.ValidationSet("foo", "bar", 0) + c.Assert(err, check.IsNil) + c.Check(cs.req.Method, check.Equals, "GET") + c.Check(cs.req.URL.Path, check.Equals, "/v2/validation-sets/foo/bar") + c.Check(vsets, check.DeepEquals, &client.ValidationSetResult{ + AccountID: "abc", Name: "def", Mode: "monitor", Sequence: 0, Valid: false, + }) +} + +func (cs *clientSuite) TestValidationSetError(c *check.C) { + cs.status = 500 + cs.rsp = errorResponseJSON + + _, err := cs.cli.ValidationSet("foo", "bar", 0) + c.Assert(err, check.ErrorMatches, "cannot query validation set: failed") +} + +func (cs *clientSuite) TestValidationSetInvalidArgs(c *check.C) { + _, err := cs.cli.ValidationSet("foo", "", 0) + c.Assert(err, check.ErrorMatches, `cannot query validation set without account ID and name`) + _, err = cs.cli.ValidationSet("", "bar", 0) + c.Assert(err, check.ErrorMatches, `cannot query validation set without account ID and name`) +} + +func (cs *clientSuite) TestValidationSetWithSequence(c *check.C) { + cs.rsp = `{ + "type": "sync", + "status-code": 200, + "result": {"account-id": "abc", "name": "def", "mode": "monitor", "sequence": 9} + }` + + vsets, err := cs.cli.ValidationSet("foo", "bar", 9) + c.Assert(err, check.IsNil) + c.Check(cs.req.Method, check.Equals, "GET") + c.Check(cs.req.URL.Path, check.Equals, "/v2/validation-sets/foo/bar") + c.Check(cs.req.URL.Query(), check.DeepEquals, url.Values{"sequence": []string{"9"}}) + c.Check(vsets, check.DeepEquals, &client.ValidationSetResult{ + AccountID: "abc", Name: "def", Mode: "monitor", Sequence: 9, Valid: false, + }) +} diff --git a/client/warnings.go b/client/warnings.go new file mode 100644 index 00000000..32d0890a --- /dev/null +++ b/client/warnings.go @@ -0,0 +1,89 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2018 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client + +import ( + "bytes" + "encoding/json" + "net/url" + "time" +) + +// A Warning is a short messages that's meant to alert about system events. +// There'll only ever be one Warning with the same message, and it can be +// silenced for a while before repeating. After a (supposedly longer) while +// it'll go away on its own (unless it recurrs). +type Warning struct { + Message string `json:"message"` + FirstAdded time.Time `json:"first-added"` + LastAdded time.Time `json:"last-added"` + LastShown time.Time `json:"last-shown,omitempty"` + ExpireAfter time.Duration `json:"expire-after,omitempty"` + RepeatAfter time.Duration `json:"repeat-after,omitempty"` +} + +type jsonWarning struct { + Warning + ExpireAfter string `json:"expire-after,omitempty"` + RepeatAfter string `json:"repeat-after,omitempty"` +} + +// WarningsOptions contains options for querying snapd for warnings +// supported options: +// - All: return all warnings, instead of only the un-okayed ones. +type WarningsOptions struct { + All bool +} + +// Warnings returns the list of un-okayed warnings. +func (client *Client) Warnings(opts WarningsOptions) ([]*Warning, error) { + var jws []*jsonWarning + q := make(url.Values) + if opts.All { + q.Add("select", "all") + } + _, err := client.doSync("GET", "/v2/warnings", q, nil, nil, &jws) + + ws := make([]*Warning, len(jws)) + for i, jw := range jws { + ws[i] = &jw.Warning + ws[i].ExpireAfter, _ = time.ParseDuration(jw.ExpireAfter) + ws[i].RepeatAfter, _ = time.ParseDuration(jw.RepeatAfter) + } + + return ws, err +} + +type warningsAction struct { + Action string `json:"action"` + Timestamp time.Time `json:"timestamp"` +} + +// Okay asks snapd to chill about the warnings that would have been returned by +// Warnings at the given time. +func (client *Client) Okay(t time.Time) error { + var body bytes.Buffer + var op = warningsAction{Action: "okay", Timestamp: t} + if err := json.NewEncoder(&body).Encode(op); err != nil { + return err + } + _, err := client.doSync("POST", "/v2/warnings", nil, nil, &body, nil) + return err +} diff --git a/client/warnings_test.go b/client/warnings_test.go new file mode 100644 index 00000000..6eff5ee4 --- /dev/null +++ b/client/warnings_test.go @@ -0,0 +1,121 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2018 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client_test + +import ( + "encoding/json" + "time" + + "gopkg.in/check.v1" + + "github.com/snapcore/snapd/client" +) + +func (cs *clientSuite) testWarnings(c *check.C, all bool) { + t1 := time.Date(2018, 9, 19, 12, 41, 18, 505007495, time.UTC) + t2 := time.Date(2018, 9, 19, 12, 44, 19, 680362867, time.UTC) + cs.rsp = `{ + "result": [ + { + "expire-after": "672h0m0s", + "first-added": "2018-09-19T12:41:18.505007495Z", + "last-added": "2018-09-19T12:41:18.505007495Z", + "message": "hello world number one", + "repeat-after": "24h0m0s" + }, + { + "expire-after": "672h0m0s", + "first-added": "2018-09-19T12:44:19.680362867Z", + "last-added": "2018-09-19T12:44:19.680362867Z", + "message": "hello world number two", + "repeat-after": "24h0m0s" + } + ], + "status": "OK", + "status-code": 200, + "type": "sync", + "warning-count": 2, + "warning-timestamp": "2018-09-19T12:44:19.680362867Z" + }` + + ws, err := cs.cli.Warnings(client.WarningsOptions{All: all}) + c.Assert(err, check.IsNil) + c.Check(ws, check.DeepEquals, []*client.Warning{ + { + Message: "hello world number one", + FirstAdded: t1, + LastAdded: t1, + ExpireAfter: time.Hour * 24 * 28, + RepeatAfter: time.Hour * 24, + }, + { + Message: "hello world number two", + FirstAdded: t2, + LastAdded: t2, + ExpireAfter: time.Hour * 24 * 28, + RepeatAfter: time.Hour * 24, + }, + }) + c.Check(cs.req.Method, check.Equals, "GET") + c.Check(cs.req.URL.Path, check.Equals, "/v2/warnings") + query := cs.req.URL.Query() + if all { + c.Check(query, check.HasLen, 1) + c.Check(query.Get("select"), check.Equals, "all") + } else { + c.Check(query, check.HasLen, 0) + } + + // this could be done at the end of any sync method + count, stamp := cs.cli.WarningsSummary() + c.Check(count, check.Equals, 2) + c.Check(stamp, check.Equals, t2) +} + +func (cs *clientSuite) TestWarningsAll(c *check.C) { + cs.testWarnings(c, true) +} + +func (cs *clientSuite) TestWarnings(c *check.C) { + cs.testWarnings(c, false) +} + +func (cs *clientSuite) TestOkay(c *check.C) { + cs.rsp = `{ + "type": "sync", + "status-code": 200, + "result": { } + }` + t0 := time.Now() + err := cs.cli.Okay(t0) + c.Assert(err, check.IsNil) + c.Check(cs.req.Method, check.Equals, "POST") + c.Check(cs.req.URL.Query(), check.HasLen, 0) + var body map[string]interface{} + c.Assert(json.NewDecoder(cs.req.Body).Decode(&body), check.IsNil) + c.Check(body, check.HasLen, 2) + c.Check(body["action"], check.Equals, "okay") + c.Check(body["timestamp"], check.Equals, t0.Format(time.RFC3339Nano)) + + // note there's no warnings summary in the response + count, stamp := cs.cli.WarningsSummary() + c.Check(count, check.Equals, 0) + c.Check(stamp, check.Equals, time.Time{}) +} diff --git a/cmd/.clangd b/cmd/.clangd new file mode 100644 index 00000000..6d8b27ae --- /dev/null +++ b/cmd/.clangd @@ -0,0 +1,2 @@ +CompileFlags: + Add: [-I/usr/include/glib-2.0, -Wall, -Wextra, -Wmissing-prototypes, -Wstrict-prototypes, -Wno-missing-field-initializers, -Wno-unused-parameter] \ No newline at end of file diff --git a/cmd/.indent.pro b/cmd/.indent.pro new file mode 100644 index 00000000..b1e6b5a9 --- /dev/null +++ b/cmd/.indent.pro @@ -0,0 +1,35 @@ +-nbad +-bap +-nbc +-bbo +-hnl +-br +-brs +-c33 +-cd33 +-ncdb +-ce +-ci4 +-cli0 +-d0 +-di1 +-nfc1 +-i8 +-ip0 +-l80 +-lp +-npcs +-nprs +-npsl +-sai +-saf +-saw +-ncs +-nsc +-sob +-nfca +-cp33 +-ss +-ts8 +-il1 +-par diff --git a/cmd/Makefile.am b/cmd/Makefile.am new file mode 100644 index 00000000..55f821d6 --- /dev/null +++ b/cmd/Makefile.am @@ -0,0 +1,568 @@ + +EXTRA_DIST = VERSION snap-confine/PORTING +CLEANFILES = +TESTS = +libexec_PROGRAMS = +dist_man_MANS = +noinst_PROGRAMS = +noinst_LIBRARIES = + +AM_CFLAGS = $(CHECK_CFLAGS) + +if USE_INTERNAL_BPF_HEADERS +VENDOR_BPF_HEADERS_CFLAGS = -I$(srcdir)/libsnap-confine-private/bpf/vendor +endif + +subdirs = \ + libsnap-confine-private \ + snap-confine \ + snap-device-helper \ + snap-discard-ns \ + snap-gdb-shim \ + snap-update-ns \ + snapd-env-generator \ + snapd-generator \ + system-shutdown + +# Run check-syntax when checking +# TODO: conver those to autotools-style tests later +check: check-unit-tests + +# Force particular coding style on all source and header files. +.PHONY: check-syntax-c +check-syntax-c: + echo "WARNING: check-syntax-c produces different results for different version of indent" + echo "Your version of indent: `indent --version`" + @d=`mktemp -d`; \ + trap 'rm -rf $d' EXIT; \ + for f in $(foreach dir,$(subdirs),$(wildcard $(srcdir)/$(dir)/*.[ch])) ; do \ + out="$$d/`basename $$f.out`"; \ + echo "Checking $$f ... "; \ + HOME=$(srcdir) indent "$$f" -o "$$out"; \ + diff -Naur "$$f" "$$out" || exit 1; \ + done; + +.PHONY: check-unit-tests +if WITH_UNIT_TESTS +check-unit-tests: snap-confine/unit-tests system-shutdown/unit-tests libsnap-confine-private/unit-tests snap-device-helper/unit-tests + $(HAVE_VALGRIND) ./libsnap-confine-private/unit-tests + $(HAVE_VALGRIND) ./snap-confine/unit-tests + $(HAVE_VALGRIND) ./system-shutdown/unit-tests + $(HAVE_VALGRIND) ./snap-device-helper/unit-tests +else +check-unit-tests: + echo "unit tests are disabled (rebuild with --enable-unit-tests)" +endif + +new_format = \ + libsnap-confine-private/bpf-support.c \ + libsnap-confine-private/bpf-support.h \ + libsnap-confine-private/cgroup-support.c \ + libsnap-confine-private/cgroup-support.h \ + libsnap-confine-private/cgroup-support-test.c \ + libsnap-confine-private/device-cgroup-support.c \ + libsnap-confine-private/device-cgroup-support.h \ + libsnap-confine-private/infofile-test.c \ + libsnap-confine-private/infofile.c \ + libsnap-confine-private/infofile.h \ + libsnap-confine-private/panic-test.h \ + libsnap-confine-private/panic.c \ + libsnap-confine-private/panic.h \ + snap-confine/seccomp-support-ext.c \ + snap-confine/seccomp-support-ext.h \ + snap-confine/selinux-support.c \ + snap-confine/selinux-support.h \ + snap-confine/snap-confine-invocation-test.c \ + snap-confine/snap-confine-invocation.c \ + snap-confine/snap-confine-invocation.h \ + snap-device-helper/main.c \ + snap-device-helper/snap-device-helper.c \ + snap-device-helper/snap-device-helper.h \ + snap-device-helper/snap-device-helper-test.c \ + snap-discard-ns/snap-discard-ns.c \ + snap-gdb-shim/snap-gdb-shim.c \ + snap-gdb-shim/snap-gdbserver-shim.c + +# NOTE: clang-format is using project-wide .clang-format file. +.PHONY: fmt +fmt:: $(filter $(addprefix %,$(new_format)),$(foreach dir,$(subdirs),$(wildcard $(srcdir)/$(dir)/*.[ch]))) + clang-format -i $^ + +fmt:: $(filter-out $(addprefix %,$(new_format)),$(foreach dir,$(subdirs),$(wildcard $(srcdir)/$(dir)/*.[ch]))) + HOME=$(srcdir) indent $^ + +# The hack target helps developers work on snap-confine on their live system by +# installing a fresh copy of snap confine and the appropriate apparmor profile. +.PHONY: hack +hack: snap-confine/snap-confine-debug snap-confine/snap-confine.apparmor snap-update-ns/snap-update-ns snap-seccomp/snap-seccomp snap-discard-ns/snap-discard-ns snap-device-helper/snap-device-helper snapd-apparmor/snapd-apparmor + sudo install -D -m 4755 snap-confine/snap-confine-debug $(DESTDIR)$(libexecdir)/snap-confine + if [ -d /etc/apparmor.d ]; then sudo install -m 644 snap-confine/snap-confine.apparmor $(DESTDIR)/etc/apparmor.d/$(patsubst .%,%,$(subst /,.,$(libexecdir))).snap-confine.real; fi + sudo install -d -m 755 $(DESTDIR)/var/lib/snapd/apparmor/snap-confine/ + if [ "$$(command -v apparmor_parser)" != "" ]; then sudo apparmor_parser -r snap-confine/snap-confine.apparmor; fi + sudo install -m 755 snap-update-ns/snap-update-ns $(DESTDIR)$(libexecdir)/snap-update-ns + sudo install -m 755 snap-discard-ns/snap-discard-ns $(DESTDIR)$(libexecdir)/snap-discard-ns + sudo install -m 755 snap-seccomp/snap-seccomp $(DESTDIR)$(libexecdir)/snap-seccomp + sudo install -m 755 snap-device-helper/snap-device-helper $(DESTDIR)$(libexecdir)/snap-device-helper + sudo install -m 755 snapd-apparmor/snapd-apparmor $(DESTDIR)$(libexecdir)/snapd-apparmor + if [ "$$(command -v restorecon)" != "" ]; then sudo restorecon -R -v $(DESTDIR)$(libexecdir)/; fi + +# for the hack target also: +snap-update-ns/snap-update-ns: snap-update-ns/*.go snap-update-ns/*.[ch] + cd snap-update-ns && go build -ldflags='-extldflags=-static -linkmode=external' -v +snap-seccomp/snap-seccomp: snap-seccomp/*.go + cd snap-seccomp && go build -v +snapd-apparmor/snapd-apparmor: snapd-apparmor/*.go + cd snapd-apparmor && go build -v + +## +## libsnap-confine-private.a +## + +noinst_LIBRARIES += libsnap-confine-private.a + +libsnap_confine_private_a_SOURCES = \ + libsnap-confine-private/apparmor-support.c \ + libsnap-confine-private/apparmor-support.h \ + libsnap-confine-private/cgroup-freezer-support.c \ + libsnap-confine-private/cgroup-freezer-support.h \ + libsnap-confine-private/cgroup-support.c \ + libsnap-confine-private/cgroup-support.h \ + libsnap-confine-private/device-cgroup-support.c \ + libsnap-confine-private/device-cgroup-support.h \ + libsnap-confine-private/classic.c \ + libsnap-confine-private/classic.h \ + libsnap-confine-private/cleanup-funcs.c \ + libsnap-confine-private/cleanup-funcs.h \ + libsnap-confine-private/error.c \ + libsnap-confine-private/error.h \ + libsnap-confine-private/fault-injection.c \ + libsnap-confine-private/fault-injection.h \ + libsnap-confine-private/feature.c \ + libsnap-confine-private/feature.h \ + libsnap-confine-private/infofile.c \ + libsnap-confine-private/locking.c \ + libsnap-confine-private/locking.h \ + libsnap-confine-private/mount-opt.c \ + libsnap-confine-private/mount-opt.h \ + libsnap-confine-private/mountinfo.c \ + libsnap-confine-private/mountinfo.h \ + libsnap-confine-private/panic.c \ + libsnap-confine-private/panic.h \ + libsnap-confine-private/privs.c \ + libsnap-confine-private/privs.h \ + libsnap-confine-private/secure-getenv.c \ + libsnap-confine-private/secure-getenv.h \ + libsnap-confine-private/snap.c \ + libsnap-confine-private/snap.h \ + libsnap-confine-private/string-utils.c \ + libsnap-confine-private/string-utils.h \ + libsnap-confine-private/tool.c \ + libsnap-confine-private/tool.h \ + libsnap-confine-private/utils.c \ + libsnap-confine-private/utils.h +if ENABLE_BPF +libsnap_confine_private_a_SOURCES += \ + libsnap-confine-private/bpf-support.c \ + libsnap-confine-private/bpf-support.h +endif +libsnap_confine_private_a_CFLAGS = $(AM_CFLAGS) $(VENDOR_BPF_HEADERS_CFLAGS) + +noinst_LIBRARIES += libsnap-confine-private-debug.a +libsnap_confine_private_debug_a_SOURCES = $(libsnap_confine_private_a_SOURCES) +libsnap_confine_private_debug_a_CFLAGS = $(AM_CFLAGS) $(VENDOR_BPF_HEADERS_CFLAGS) -DSNAP_CONFINE_DEBUG_BUILD=1 + +if WITH_UNIT_TESTS +noinst_PROGRAMS += libsnap-confine-private/unit-tests +libsnap_confine_private_unit_tests_SOURCES = \ + libsnap-confine-private/cgroup-support-test.c \ + libsnap-confine-private/classic-test.c \ + libsnap-confine-private/cleanup-funcs-test.c \ + libsnap-confine-private/error-test.c \ + libsnap-confine-private/fault-injection-test.c \ + libsnap-confine-private/feature-test.c \ + libsnap-confine-private/infofile-test.c \ + libsnap-confine-private/locking-test.c \ + libsnap-confine-private/mount-opt-test.c \ + libsnap-confine-private/mountinfo-test.c \ + libsnap-confine-private/panic-test.c \ + libsnap-confine-private/privs-test.c \ + libsnap-confine-private/secure-getenv-test.c \ + libsnap-confine-private/snap-test.c \ + libsnap-confine-private/string-utils-test.c \ + libsnap-confine-private/test-utils-test.c \ + libsnap-confine-private/test-utils.c \ + libsnap-confine-private/unit-tests-main.c \ + libsnap-confine-private/unit-tests.c \ + libsnap-confine-private/unit-tests.h \ + libsnap-confine-private/utils-test.c + +libsnap_confine_private_unit_tests_CFLAGS = $(AM_CFLAGS) $(VENDOR_BPF_HEADERS_CFLAGS) $(GLIB_CFLAGS) +libsnap_confine_private_unit_tests_LDADD = $(GLIB_LIBS) +libsnap_confine_private_unit_tests_CFLAGS += -D_ENABLE_FAULT_INJECTION +libsnap_confine_private_unit_tests_STATIC = + +if STATIC_LIBCAP +libsnap_confine_private_unit_tests_STATIC += -lcap +else +libsnap_confine_private_unit_tests_LDADD += -lcap +endif # STATIC_LIBCAP + +# Use a hacked rule if we're doing static build. This allows us to inject the LIBS += .. rule below. +libsnap-confine-private/unit-tests$(EXEEXT): $(libsnap_confine_private_unit_tests_OBJECTS) $(libsnap_confine_private_unit_tests_DEPENDENCIES) $(EXTRA_libsnap_confine_private_unit_tests_DEPENDENCIES) libsnap-confine-private/$(am__dirstamp) + @rm -f libsnap-confine-private/unit-tests$(EXEEXT) + $(AM_V_CCLD)$(libsnap_confine_private_unit_tests_LINK) $(libsnap_confine_private_unit_tests_OBJECTS) $(libsnap_confine_private_unit_tests_LDADD) $(LIBS) + +libsnap-confine-private/unit-tests$(EXEEXT): LIBS += -Wl,-Bstatic $(libsnap_confine_private_unit_tests_STATIC) -Wl,-Bdynamic +endif # WITH_UNIT_TESTS + +## +## decode-mount-opts +## + +noinst_PROGRAMS += decode-mount-opts/decode-mount-opts + +decode_mount_opts_decode_mount_opts_SOURCES = \ + decode-mount-opts/decode-mount-opts.c +decode_mount_opts_decode_mount_opts_LDADD = libsnap-confine-private.a +decode_mount_opts_decode_mount_opts_STATIC = + +if STATIC_LIBCAP +decode_mount_opts_decode_mount_opts_STATIC += -lcap +else +decode_mount_opts_decode_mount_opts_LDADD += -lcap +endif # STATIC_LIBCAP + +# XXX: this makes automake generate decode_mount_opts_decode_mount_opts_LINK +decode_mount_opts_decode_mount_opts_CFLAGS = -D_fake + +# Use a hacked rule if we're doing static build. This allows us to inject the LIBS += .. rule below. +decode-mount-opts/decode-mount-opts$(EXEEXT): $(decode_mount_opts_decode_mount_opts_OBJECTS) $(decode_mount_opts_decode_mount_opts_DEPENDENCIES) $(EXTRA_decode_mount_opts_decode_mount_opts_DEPENDENCIES) libsnap-confine-private/$(am__dirstamp) + @rm -f decode-mount-opts/decode-mount-opts$(EXEEXT) + $(AM_V_CCLD)$(decode_mount_opts_decode_mount_opts_LINK) $(decode_mount_opts_decode_mount_opts_OBJECTS) $(decode_mount_opts_decode_mount_opts_LDADD) $(LIBS) + +decode-mount-opts/decode-mount-opts$(EXEEXT): LIBS += -Wl,-Bstatic $(decode_mount_opts_decode_mount_opts_STATIC) -Wl,-Bdynamic + +## +## snap-confine +## + +libexec_PROGRAMS += snap-confine/snap-confine +if HAVE_RST2MAN +dist_man_MANS += snap-confine/snap-confine.8 +CLEANFILES += snap-confine/snap-confine.8 +endif +EXTRA_DIST += snap-confine/snap-confine.rst +EXTRA_DIST += snap-confine/snap-confine.apparmor.in + +snap_confine_snap_confine_SOURCES = \ + snap-confine/cookie-support.c \ + snap-confine/cookie-support.h \ + snap-confine/mount-support-nvidia.c \ + snap-confine/mount-support-nvidia.h \ + snap-confine/mount-support.c \ + snap-confine/mount-support.h \ + snap-confine/ns-support.c \ + snap-confine/ns-support.h \ + snap-confine/seccomp-support-ext.c \ + snap-confine/seccomp-support-ext.h \ + snap-confine/seccomp-support.c \ + snap-confine/seccomp-support.h \ + snap-confine/snap-confine-args.c \ + snap-confine/snap-confine-args.h \ + snap-confine/snap-confine-invocation.c \ + snap-confine/snap-confine-invocation.h \ + snap-confine/snap-confine.c \ + snap-confine/udev-support.c \ + snap-confine/udev-support.h \ + snap-confine/user-support.c \ + snap-confine/user-support.h + +snap_confine_snap_confine_CFLAGS = $(AM_CFLAGS) -DLIBEXECDIR=\"$(libexecdir)\" -DNATIVE_LIBDIR=\"$(libdir)\" +snap_confine_snap_confine_LDFLAGS = $(AM_LDFLAGS) +snap_confine_snap_confine_LDADD = libsnap-confine-private.a +snap_confine_snap_confine_CFLAGS += $(LIBUDEV_CFLAGS) +snap_confine_snap_confine_LDADD += $(snap_confine_snap_confine_extra_libs) +# _STATIC is where we collect statically linked in libraries +snap_confine_snap_confine_STATIC = +# use a separate variable instead of snap_confine_snap_confine_LDADD to collect +# all external libraries, this way it can be reused in +# snap_confine_snap_confine_debug_LDADD withouth applying any text +# transformations +snap_confine_snap_confine_extra_libs = $(LIBUDEV_LIBS) -ldl + +if STATIC_LIBCAP +snap_confine_snap_confine_STATIC += -lcap +else +snap_confine_snap_confine_extra_libs += -lcap +endif # STATIC_LIBCAP + +# Use a hacked rule if we're doing static build. This allows us to inject the LIBS += .. rule below. +snap-confine/snap-confine$(EXEEXT): $(snap_confine_snap_confine_OBJECTS) $(snap_confine_snap_confine_DEPENDENCIES) $(EXTRA_snap_confine_snap_confine_DEPENDENCIES) libsnap-confine-private/$(am__dirstamp) + @rm -f snap-confine/snap-confine$(EXEEXT) + $(AM_V_CCLD)$(snap_confine_snap_confine_LINK) $(snap_confine_snap_confine_OBJECTS) $(snap_confine_snap_confine_LDADD) $(LIBS) + +snap-confine/snap-confine$(EXEEXT): LIBS += -Wl,-Bstatic $(snap_confine_snap_confine_STATIC) -Wl,-Bdynamic -pthread + +# This is here to help fix rpmlint hardening issue. +# https://en.opensuse.org/openSUSE:Packaging_checks#non-position-independent-executable +snap_confine_snap_confine_CFLAGS += $(SUID_CFLAGS) +snap_confine_snap_confine_LDFLAGS += $(SUID_LDFLAGS) + +if APPARMOR +snap_confine_snap_confine_CFLAGS += $(APPARMOR_CFLAGS) +if STATIC_LIBAPPARMOR +snap_confine_snap_confine_STATIC += $(shell $(PKG_CONFIG) --static --libs libapparmor) +else +snap_confine_snap_confine_extra_libs += $(APPARMOR_LIBS) +endif # STATIC_LIBAPPARMOR +endif # APPARMOR + +if SELINUX +snap_confine_snap_confine_SOURCES += \ + snap-confine/selinux-support.c \ + snap-confine/selinux-support.h +snap_confine_snap_confine_CFLAGS += $(SELINUX_CFLAGS) +if STATIC_LIBSELINUX +snap_confine_snap_confine_STATIC += $(shell $(PKG_CONFIG) --static --libs libselinux) +else +snap_confine_snap_confine_extra_libs += $(SELINUX_LIBS) +endif # STATIC_LIBSELINUX +endif # SELINUX + +# an extra build that has additional debugging enabled at compile time + +noinst_PROGRAMS += snap-confine/snap-confine-debug +snap_confine_snap_confine_debug_SOURCES = $(snap_confine_snap_confine_SOURCES) +snap_confine_snap_confine_debug_CFLAGS = $(snap_confine_snap_confine_CFLAGS) +snap_confine_snap_confine_debug_LDFLAGS = $(snap_confine_snap_confine_LDFLAGS) +snap_confine_snap_confine_debug_LDADD = libsnap-confine-private-debug.a $(snap_confine_snap_confine_extra_libs) +snap_confine_snap_confine_debug_CFLAGS += -DSNAP_CONFINE_DEBUG_BUILD=1 +snap_confine_snap_confine_debug_STATIC = $(snap_confine_snap_confine_STATIC) + +# Use a hacked rule if we're doing static build. This allows us to inject the LIBS += .. rule below. +snap-confine/snap-confine-debug$(EXEEXT): $(snap_confine_snap_confine_debug_OBJECTS) $(snap_confine_snap_confine_debug_DEPENDENCIES) $(EXTRA_snap_confine_snap_confine_debug_DEPENDENCIES) libsnap-confine-private/$(am__dirstamp) + @rm -f snap-confine/snap-confine-debug$(EXEEXT) + $(AM_V_CCLD)$(snap_confine_snap_confine_debug_LINK) $(snap_confine_snap_confine_debug_OBJECTS) $(snap_confine_snap_confine_debug_LDADD) $(LIBS) + +snap-confine/snap-confine-debug$(EXEEXT): LIBS += -Wl,-Bstatic $(snap_confine_snap_confine_debug_STATIC) -Wl,-Bdynamic -pthread + +if WITH_UNIT_TESTS +noinst_PROGRAMS += snap-confine/unit-tests +snap_confine_unit_tests_SOURCES = \ + libsnap-confine-private/test-utils.c \ + libsnap-confine-private/unit-tests-main.c \ + libsnap-confine-private/unit-tests.c \ + libsnap-confine-private/unit-tests.h \ + snap-confine/cookie-support-test.c \ + snap-confine/mount-support-test.c \ + snap-confine/ns-support-test.c \ + snap-confine/seccomp-support-test.c \ + snap-confine/snap-confine-args-test.c \ + snap-confine/snap-confine-invocation-test.c +snap_confine_unit_tests_CFLAGS = $(snap_confine_snap_confine_CFLAGS) $(GLIB_CFLAGS) +snap_confine_unit_tests_LDADD = $(snap_confine_snap_confine_LDADD) $(GLIB_LIBS) +snap_confine_unit_tests_LDFLAGS = $(snap_confine_snap_confine_LDFLAGS) +snap_confine_unit_tests_STATIC = $(snap_confine_snap_confine_STATIC) + +# Use a hacked rule if we're doing static build. This allows us to inject the LIBS += .. rule below. +snap-confine/unit-tests$(EXEEXT): $(snap_confine_unit_tests_OBJECTS) $(snap_confine_unit_tests_DEPENDENCIES) $(EXTRA_snap_confine_unit_tests_DEPENDENCIES) libsnap-confine-private/$(am__dirstamp) + @rm -f snap-confine/unit-tests$(EXEEXT) + $(AM_V_CCLD)$(snap_confine_unit_tests_LINK) $(snap_confine_unit_tests_OBJECTS) $(snap_confine_unit_tests_LDADD) $(LIBS) + +snap-confine/unit-tests$(EXEEXT): LIBS += -Wl,-Bstatic $(snap_confine_unit_tests_STATIC) -Wl,-Bdynamic -pthread +endif # WITH_UNIT_TESTS + +if HAVE_RST2MAN +%.8: %.rst + $(HAVE_RST2MAN) $^ > $@ +endif + +snap-confine/snap-confine.apparmor: snap-confine/snap-confine.apparmor.in Makefile + sed -e 's,[@]LIBEXECDIR[@],$(libexecdir),g' -e 's,[@]SNAP_MOUNT_DIR[@],$(SNAP_MOUNT_DIR),g' <$< >$@ + +# Install the apparmor profile +# +# NOTE: the funky make functions here just convert /foo/bar/froz into +# foo.bar.froz The inner subst replaces slashes with dots and the outer +# patsubst strips the leading dot +install-data-local:: snap-confine/snap-confine.apparmor +if APPARMOR + install -d -m 755 $(DESTDIR)/etc/apparmor.d/ + install -m 644 snap-confine/snap-confine.apparmor $(DESTDIR)/etc/apparmor.d/$(patsubst .%,%,$(subst /,.,$(libexecdir))).snap-confine +endif + install -d -m 755 $(DESTDIR)/var/lib/snapd/apparmor/snap-confine/ + +# NOTE: The 'void' directory *has to* be chmod 111 +install-data-local:: + install -d -m 111 $(DESTDIR)/var/lib/snapd/void + +install-exec-hook:: +# Ensure that snap-confine is u+s (setuid) + chmod 4755 $(DESTDIR)$(libexecdir)/snap-confine + +## +## snap-mgmt +## + +libexec_SCRIPTS = snap-mgmt/snap-mgmt +CLEANFILES += snap-mgmt/$(am__dirstamp) snap-mgmt/snap-mgmt + +snap-mgmt/$(am__dirstamp): + mkdir -p $$(dirname $@) + touch $@ + +snap-mgmt/snap-mgmt: snap-mgmt/snap-mgmt.sh.in Makefile snap-mgmt/$(am__dirstamp) + sed -e 's,[@]SNAP_MOUNT_DIR[@],$(SNAP_MOUNT_DIR),' <$< >$@ + +if SELINUX +## +## snap-mgmt-selinux +## + +libexec_SCRIPTS += snap-mgmt/snap-mgmt-selinux +CLEANFILES += snap-mgmt/$(am__dirstamp) snap-mgmt/snap-mgmt-selinux + +snap-mgmt/snap-mgmt-selinux: snap-mgmt/snap-mgmt-selinux.sh.in Makefile snap-mgmt/$(am__dirstamp) + sed -e 's,[@]SNAP_MOUNT_DIR[@],$(SNAP_MOUNT_DIR),' <$< >$@ +endif + +## +## ubuntu-core-launcher +## + +install-exec-hook:: + install -d -m 755 $(DESTDIR)$(bindir) + ln -sf $(libexecdir)/snap-confine $(DESTDIR)$(bindir)/ubuntu-core-launcher + +## +## snap-device-helper +## + +libexec_PROGRAMS += \ + snap-device-helper/snap-device-helper + +snap_device_helper_snap_device_helper_SOURCES = \ + snap-device-helper/main.c \ + snap-device-helper/snap-device-helper.c +snap_device_helper_snap_device_helper_LDFLAGS = $(AM_LDFLAGS) +snap_device_helper_snap_device_helper_LDADD = libsnap-confine-private.a + +if WITH_UNIT_TESTS +noinst_PROGRAMS += snap-device-helper/unit-tests +snap_device_helper_unit_tests_SOURCES = \ + libsnap-confine-private/test-utils.c \ + libsnap-confine-private/string-utils.c \ + libsnap-confine-private/utils.c \ + libsnap-confine-private/cleanup-funcs.c \ + libsnap-confine-private/panic.c \ + libsnap-confine-private/snap.c \ + libsnap-confine-private/error.c \ + libsnap-confine-private/unit-tests-main.c \ + libsnap-confine-private/unit-tests.c \ + libsnap-confine-private/unit-tests.h \ + snap-device-helper/snap-device-helper-test.c +snap_device_helper_unit_tests_CFLAGS = $(AM_CFLAGS) $(snap_device_helper_snap_device_helper_CFLAGS) $(GLIB_CFLAGS) +snap_device_helper_unit_tests_LDADD = $(GLIB_LIBS) +snap_device_helper_unit_tests_LDFLAGS =$(snap_device_helper_snap_device_helper_LDFLAGS) + +endif # WITH_UNIT_TESTS + +## +## snap-discard-ns +## + +libexec_PROGRAMS += snap-discard-ns/snap-discard-ns +if HAVE_RST2MAN +dist_man_MANS += snap-discard-ns/snap-discard-ns.8 +CLEANFILES += snap-discard-ns/snap-discard-ns.8 +endif +EXTRA_DIST += snap-discard-ns/snap-discard-ns.rst + +snap_discard_ns_snap_discard_ns_SOURCES = \ + snap-discard-ns/snap-discard-ns.c +snap_discard_ns_snap_discard_ns_LDFLAGS = $(AM_LDFLAGS) +snap_discard_ns_snap_discard_ns_LDADD = libsnap-confine-private.a +snap_discard_ns_snap_discard_ns_STATIC = + +# Use a hacked rule if we're doing static build. This allows us to inject the LIBS += .. rule below. +snap-discard-ns/snap-discard-ns$(EXEEXT): $(snap_discard_ns_snap_discard_ns_OBJECTS) $(snap_discard_ns_snap_discard_ns_DEPENDENCIES) $(EXTRA_snap_discard_ns_snap_discard_ns_DEPENDENCIES) snap-discard-ns/$(am__dirstamp) + @rm -f snap-discard-ns/snap-discard-ns$(EXEEXT) + $(AM_V_CCLD)$(snap_discard_ns_snap_discard_ns_LINK) $(snap_discard_ns_snap_discard_ns_OBJECTS) $(snap_discard_ns_snap_discard_ns_LDADD) $(LIBS) + +snap-discard-ns/snap-discard-ns$(EXEEXT): LIBS += -Wl,-Bstatic $(snap_discard_ns_snap_discard_ns_STATIC) -Wl,-Bdynamic -pthread + +## +## system-shutdown +## + +libexec_PROGRAMS += system-shutdown/system-shutdown + +system_shutdown_system_shutdown_SOURCES = \ + system-shutdown/system-shutdown-utils.c \ + system-shutdown/system-shutdown-utils.h \ + system-shutdown/system-shutdown.c +system_shutdown_system_shutdown_LDADD = libsnap-confine-private.a + +if WITH_UNIT_TESTS +noinst_PROGRAMS += system-shutdown/unit-tests +system_shutdown_unit_tests_SOURCES = \ + libsnap-confine-private/unit-tests-main.c \ + libsnap-confine-private/unit-tests.c \ + system-shutdown/system-shutdown-utils-test.c +system_shutdown_unit_tests_LDADD = libsnap-confine-private.a +system_shutdown_unit_tests_CFLAGS = $(AM_CFLAGS) $(GLIB_CFLAGS) +system_shutdown_unit_tests_LDADD += $(GLIB_LIBS) +endif + +## +## snap-gdb-shim +## + +libexec_PROGRAMS += snap-gdb-shim/snap-gdb-shim + +snap_gdb_shim_snap_gdb_shim_SOURCES = \ + snap-gdb-shim/snap-gdb-shim.c + +snap_gdb_shim_snap_gdb_shim_LDADD = libsnap-confine-private.a +snap_gdb_shim_snap_gdb_shim_LDFLAGS = -static + +## +## snap-gdbserver-shim +## + +libexec_PROGRAMS += snap-gdb-shim/snap-gdbserver-shim + +snap_gdb_shim_snap_gdbserver_shim_SOURCES = \ + snap-gdb-shim/snap-gdbserver-shim.c + +snap_gdb_shim_snap_gdbserver_shim_LDADD = libsnap-confine-private.a +snap_gdb_shim_snap_gdbserver_shim_LDFLAGS = -static + +## +## snapd-generator +## + +systemdsystemgeneratordir = $(SYSTEMD_SYSTEM_GENERATOR_DIR) +systemdsystemgenerator_PROGRAMS = snapd-generator/snapd-generator + +snapd_generator_snapd_generator_SOURCES = snapd-generator/main.c +snapd_generator_snapd_generator_LDADD = libsnap-confine-private.a + +## +## snapd-env-generator +## + +systemdsystemenvgeneratordir=$(SYSTEMD_SYSTEM_ENV_GENERATOR_DIR) +systemdsystemenvgenerator_PROGRAMS = snapd-env-generator/snapd-env-generator + +snapd_env_generator_snapd_env_generator_SOURCES = snapd-env-generator/main.c +snapd_env_generator_snapd_env_generator_LDADD = libsnap-confine-private.a +EXTRA_DIST += snapd-env-generator/snapd-env-generator.rst + +if HAVE_RST2MAN +dist_man_MANS += snapd-env-generator/snapd-env-generator.8 +CLEANFILES += snapd-env-generator/snapd-env-generator.8 +endif + +install-exec-local:: + install -d -m 755 $(DESTDIR)$(libexecdir) diff --git a/cmd/autogen.sh b/cmd/autogen.sh new file mode 100755 index 00000000..21cdebc1 --- /dev/null +++ b/cmd/autogen.sh @@ -0,0 +1,64 @@ +#!/bin/sh +# Welcome to the Happy Maintainer's Utility Script +# +# Set BUILD_DIR to the directory where the build will happen, otherwise $PWD +# will be used +set -eux + +BUILD_DIR=${BUILD_DIR:-.} +selfdir=$(dirname "$0") +SRC_DIR=$(readlink -f "$selfdir") + +# We need the VERSION file to configure +if [ ! -e VERSION ]; then + ( cd .. && ./mkversion.sh ) +fi + +# Precondition check, are we in the right directory? +test -f configure.ac + +# Regenerate the build system +rm -f config.status +autoreconf -i -f + +# Configure the build +extra_opts= +# shellcheck disable=SC1091 +. /etc/os-release +case "$ID" in + arch) + extra_opts="--libexecdir=/usr/lib/snapd --with-snap-mount-dir=/var/lib/snapd/snap --enable-apparmor --enable-nvidia-biarch --enable-merged-usr" + ;; + debian) + extra_opts="--libexecdir=/usr/lib/snapd" + ;; + gentoo) + extra_opts="--libexecdir=/usr/lib/snapd --with-snap-mount-dir=/var/lib/snapd/snap --enable-apparmor --enable-nvidia-biarch --enable-merged-usr" + ;; + ubuntu) + extra_opts="--libexecdir=/usr/lib/snapd --enable-nvidia-multiarch --enable-static-libcap --enable-static-libapparmor --with-host-arch-triplet=$(dpkg-architecture -qDEB_HOST_MULTIARCH)" + if [ "$(dpkg-architecture -qDEB_HOST_ARCH)" = "amd64" ]; then + extra_opts="$extra_opts --with-host-arch-32bit-triplet=$(dpkg-architecture -ai386 -qDEB_HOST_MULTIARCH)" + fi + ;; + fedora|centos|rhel) + extra_opts="--libexecdir=/usr/libexec/snapd --with-snap-mount-dir=/var/lib/snapd/snap --enable-merged-usr --disable-apparmor --enable-selinux" + ;; + opensuse-tumbleweed) + extra_opts="--libexecdir=/usr/libexec/snapd --enable-nvidia-biarch --with-32bit-libdir=/usr/lib --enable-merged-usr" + ;; + opensuse) + extra_opts="--libexecdir=/usr/lib/snapd --enable-nvidia-biarch --with-32bit-libdir=/usr/lib --enable-merged-usr" + ;; + solus) + extra_opts="--enable-nvidia-biarch" + ;; + altlinux) + extra_opts="--libexecdir=/usr/lib/snapd --with-snap-mount-dir=/var/lib/snapd/snap --disable-apparmor --enable-selinux --enable-nvidia-biarch --with-32bit-libdir=/usr/lib" + ;; +esac + +echo "Configuring in build directory $BUILD_DIR with: $extra_opts" +mkdir -p "$BUILD_DIR" && cd "$BUILD_DIR" +# shellcheck disable=SC2086 +"${SRC_DIR}/configure" --enable-maintainer-mode --prefix=/usr $extra_opts "$@" diff --git a/cmd/configure.ac b/cmd/configure.ac new file mode 100644 index 00000000..be450646 --- /dev/null +++ b/cmd/configure.ac @@ -0,0 +1,294 @@ +AC_PREREQ([2.69]) +AC_INIT([snap-confine], m4_esyscmd_s([cat VERSION]), [snapcraft@lists.ubuntu.com]) +AC_CONFIG_SRCDIR([snap-confine/snap-confine.c]) +AC_CONFIG_HEADERS([config.h]) +AC_USE_SYSTEM_EXTENSIONS +AM_INIT_AUTOMAKE([foreign subdir-objects]) +AM_MAINTAINER_MODE([enable]) + +# Checks for programs. +AC_PROG_CC_C99 +AC_PROG_CPP +AC_PROG_INSTALL +AC_PROG_MAKE_SET +AC_PROG_RANLIB + +AC_LANG([C]) +# Checks for libraries. + +# check for large file support +AC_SYS_LARGEFILE + +# Checks for header files. +AC_CHECK_HEADERS([fcntl.h limits.h stdlib.h string.h sys/mount.h unistd.h]) +AC_CHECK_HEADERS([sys/quota.h], [], [AC_MSG_ERROR(sys/quota.h unavailable)]) +AC_CHECK_HEADERS([xfs/xqm.h], [], [AC_MSG_ERROR(xfs/xqm.h unavailable)]) + +# Checks for typedefs, structures, and compiler characteristics. +AC_CHECK_HEADER_STDBOOL +AC_TYPE_UID_T +AC_TYPE_MODE_T +AC_TYPE_PID_T +AC_TYPE_SIZE_T + +# Checks for library functions. +AC_FUNC_CHOWN +AC_FUNC_ERROR_AT_LINE +AC_FUNC_FORK +AC_FUNC_STRNLEN +AC_CHECK_FUNCS([mkdir regcomp setenv strdup strerror secure_getenv]) + +AC_ARG_WITH([unit-tests], + AC_HELP_STRING([--without-unit-tests], [do not build unit test programs]), + [case "${withval}" in + yes) with_unit_tests=yes ;; + no) with_unit_tests=no ;; + *) AC_MSG_ERROR([bad value ${withval} for --without-unit-tests]) + esac], [with_unit_tests=yes]) +AM_CONDITIONAL([WITH_UNIT_TESTS], [test "x$with_unit_tests" = "xyes"]) + +# Allow to build without apparmor support by calling: +# ./configure --disable-apparmor +# This makes it possible to run snaps in devmode on almost any host, +# regardless of the kernel version. +AC_ARG_ENABLE([apparmor], + AS_HELP_STRING([--disable-apparmor], [Disable apparmor support]), + [case "${enableval}" in + yes) enable_apparmor=yes ;; + no) enable_apparmor=no ;; + *) AC_MSG_ERROR([bad value ${enableval} for --disable-apparmor]) + esac], [enable_apparmor=yes]) +AM_CONDITIONAL([APPARMOR], [test "x$enable_apparmor" = "xyes"]) + +# Allow to build with SELinux support by calling: +# ./configure --enable-selinux +AC_ARG_ENABLE([selinux], + AS_HELP_STRING([--enable-selinux], [Enable SELinux support]), + [case "${enableval}" in + yes) enable_selinux=yes ;; + no) enable_selinux=no ;; + *) AC_MSG_ERROR([bad value ${enableval} for --enable-selinux]) + esac], [enable_selinux=no]) +AM_CONDITIONAL([SELINUX], [test "x$enable_selinux" = "xyes"]) + +# Enable older tests only when confinement is enabled and we're building for PC +# The tests are of smaller value as we port more and more tests to spread. +AM_CONDITIONAL([CONFINEMENT_TESTS], [test "x$enable_apparmor" = "xyes" && ((test "x$host_cpu" = "xx86_64" && test "x$build_cpu" = "xx86_64") || (test "x$host_cpu" = "xi686" && test "x$build_cpu" = "xi686"))]) + +# Check for glib that we use for unit testing +AS_IF([test "x$with_unit_tests" = "xyes"], [ + PKG_CHECK_MODULES([GLIB], [glib-2.0]) +]) + +# Check if apparmor userspace library is available. +AS_IF([test "x$enable_apparmor" = "xyes"], [ + # Expect AppArmor3 when building as a snap under snapcraft + AS_IF([test "x$SNAPCRAFT_PROJECT_NAME" = "xsnapd"], [ + PKG_CHECK_MODULES([APPARMOR3], [libapparmor = 3.0.8], [ + AC_DEFINE([HAVE_APPARMOR], [1], [Build with apparmor3 support])], [ + AC_MSG_ERROR([unable to find apparmor3 for snap build of snapd])])], [ + PKG_CHECK_MODULES([APPARMOR], [libapparmor], [ + AC_DEFINE([HAVE_APPARMOR], [1], [Build with apparmor support])])]) +], [ + AC_MSG_WARN([ + XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX + X X + X Apparmor is disabled, all snaps will run in devmode X + X X + XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX]) +]) + +# Check if SELinux userspace library is available. +AS_IF([test "x$enable_selinux" = "xyes"], [ +PKG_CHECK_MODULES([SELINUX], [libselinux], [ +AC_DEFINE([HAVE_SELINUX], [1], [Build with SELinux support])]) +]) + +# Check if udev and libudev are available. +# Those are now used unconditionally even if apparmor is disabled. +PKG_CHECK_MODULES([LIBUDEV], [libudev]) +PKG_CHECK_MODULES([UDEV], [udev]) + +# Check if libcap is available. +# PKG_CHECK_MODULES([LIBCAP], [libcap]) + +# Enable special support for hosts with proprietary nvidia drivers on Ubuntu. +AC_ARG_ENABLE([nvidia-multiarch], + AS_HELP_STRING([--enable-nvidia-multiarch], [Support for proprietary nvidia drivers (Ubuntu/Debian)]), + [case "${enableval}" in + yes) enable_nvidia_multiarch=yes ;; + no) enable_nvidia_multiarch=no ;; + *) AC_MSG_ERROR([bad value ${enableval} for --enable-nvidia-multiarch]) + esac], [enable_nvidia_multiarch=no]) +AM_CONDITIONAL([NVIDIA_MULTIARCH], [test "x$enable_nvidia_multiarch" = "xyes"]) + +AS_IF([test "x$enable_nvidia_multiarch" = "xyes"], [ + AC_DEFINE([NVIDIA_MULTIARCH], [1], + [Support for proprietary nvidia drivers (Ubuntu/Debian)])]) + +# Enable special support for hosts with proprietary nvidia drivers on Arch. +AC_ARG_ENABLE([nvidia-biarch], + AS_HELP_STRING([--enable-nvidia-biarch], [Support for proprietary nvidia drivers (bi-arch distributions)]), + [case "${enableval}" in + yes) enable_nvidia_biarch=yes ;; + no) enable_nvidia_biarch=no ;; + *) AC_MSG_ERROR([bad value ${enableval} for --enable-nvidia-biarch]) + esac], [enable_nvidia_biarch=no]) +AM_CONDITIONAL([NVIDIA_BIARCH], [test "x$enable_nvidia_biarch" = "xyes"]) + +AS_IF([test "x$enable_nvidia_biarch" = "xyes"], [ + AC_DEFINE([NVIDIA_BIARCH], [1], + [Support for proprietary nvidia drivers (bi-arch distributions)])]) + +AC_ARG_ENABLE([merged-usr], + AS_HELP_STRING([--enable-merged-usr], [Enable support for merged /usr directory]), + [case "${enableval}" in + yes) enable_merged_usr=yes ;; + no) enable_merged_usr=no ;; + *) AC_MSG_ERROR([bad value ${enableval} for --enable-merged-usr]) + esac], [enable_merged_usr=no]) +AM_CONDITIONAL([MERGED_USR], [test "x$enable_merged_usr" = "xyes"]) + +AS_IF([test "x$enable_merged_usr" = "xyes"], [ + AC_DEFINE([MERGED_USR], [1], + [Support for merged /usr directory])]) + +SNAP_MOUNT_DIR="/snap" +AC_ARG_WITH([snap-mount-dir], + AS_HELP_STRING([--with-snap-mount-dir=DIR], [Use an alternate snap mount directory]), + [SNAP_MOUNT_DIR="$withval"]) +AC_SUBST(SNAP_MOUNT_DIR) +AC_DEFINE_UNQUOTED([SNAP_MOUNT_DIR], "${SNAP_MOUNT_DIR}", [Location of the snap mount points]) + +SNAP_MOUNT_DIR_SYSTEMD_UNIT="$(systemd-escape -p "$SNAP_MOUNT_DIR")" +AC_SUBST([SNAP_MOUNT_DIR_SYSTEMD_UNIT]) +AC_DEFINE_UNQUOTED([SNAP_MOUNT_DIR_SYSTEMD_UNIT], "${SNAP_MOUNT_DIR_SYSTEMD_UNIT}", [Systemd unit name for snap mount points location]) + +AC_PATH_PROGS([HAVE_RST2MAN],[rst2man rst2man.py]) +AS_IF([test "x$HAVE_RST2MAN" = "x"], [AC_MSG_WARN(["cannot find the rst2man tool, install python-docutils or similar"])]) +AM_CONDITIONAL([HAVE_RST2MAN], [test "x${HAVE_RST2MAN}" != "x"]) + +AC_PATH_PROG([HAVE_VALGRIND],[valgrind]) +AM_CONDITIONAL([HAVE_VALGRIND], [test "x${HAVE_VALGRIND}" != "x"]) +AS_IF([test "x$HAVE_VALGRIND" = "x"], [AC_MSG_WARN(["cannot find the valgrind tool, will not run unit tests through valgrind"])]) + +# Allow linking selected libraries statically for reexec. +AC_ARG_ENABLE([static-libcap], + AS_HELP_STRING([--enable-static-libcap], [Link libcap statically]), + [case "${enableval}" in + yes) enable_static_libcap=yes ;; + no) enable_static_libcap=no ;; + *) AC_MSG_ERROR([bad value ${enableval} for --enable-static-libcap]) + esac], [enable_static_libcap=no]) +AM_CONDITIONAL([STATIC_LIBCAP], [test "x$enable_static_libcap" = "xyes"]) + +AC_ARG_ENABLE([static-libapparmor], + AS_HELP_STRING([--enable-static-libapparmor], [Link libapparmor statically]), + [case "${enableval}" in + yes) enable_static_libapparmor=yes ;; + no) enable_static_libapparmor=no ;; + *) AC_MSG_ERROR([bad value ${enableval} for --enable-static-libapparmor]) + esac], [enable_static_libapparmor=no]) +AM_CONDITIONAL([STATIC_LIBAPPARMOR], [test "x$enable_static_libapparmor" = "xyes"]) + +AC_ARG_ENABLE([static-libselinux], +AS_HELP_STRING([--enable-static-libselinux], [Link libselinux statically]), +[case "${enableval}" in +yes) enable_static_libselinux=yes ;; +no) enable_static_libselinux=no ;; +*) AC_MSG_ERROR([bad value ${enableval} for --enable-static-libselinux]) +esac], [enable_static_libselinux=no]) +AM_CONDITIONAL([STATIC_LIBSELINUX], [test "x$enable_static_libselinux" = "xyes"]) + +LIB32_DIR="${prefix}/lib32" +AC_ARG_WITH([32bit-libdir], + AS_HELP_STRING([--with-32bit-libdir=DIR], [Use an alternate lib32 directory]), + [LIB32_DIR="$withval"]) +AC_SUBST(LIB32_DIR) +AC_DEFINE_UNQUOTED([LIB32_DIR], "${LIB32_DIR}", [Location of the lib32 directory]) + +AC_ARG_WITH([host-arch-triplet], + AS_HELP_STRING([--with-host-arch-triplet=triplet], [Arch triplet for host libraries]), + [HOST_ARCH_TRIPLET="$withval"]) +AC_SUBST(HOST_ARCH_TRIPLET) +AC_DEFINE_UNQUOTED([HOST_ARCH_TRIPLET], "${HOST_ARCH_TRIPLET}", [Arch triplet for host libraries]) + +AC_ARG_WITH([host-arch-32bit-triplet], + AS_HELP_STRING([--with-host-arch-32bit-triplet=triplet], [Arch triplet for 32bit libraries]), + [HOST_ARCH32_TRIPLET="$withval"]) +AC_SUBST(HOST_ARCH32_TRIPLET) +AC_DEFINE_UNQUOTED([HOST_ARCH32_TRIPLET], "${HOST_ARCH32_TRIPLET}", [Arch triplet for 32bit libraries]) + +SYSTEMD_SYSTEM_GENERATOR_DIR="$($PKG_CONFIG --variable=systemdsystemgeneratordir systemd)" +AS_IF([test "x$SYSTEMD_SYSTEM_GENERATOR_DIR" = "x"], [SYSTEMD_SYSTEM_GENERATOR_DIR=/lib/systemd/system-generators]) +AC_SUBST(SYSTEMD_SYSTEM_GENERATOR_DIR) + +# FIXME: get this via something like pkgconf once it is defined there +SYSTEMD_SYSTEM_ENV_GENERATOR_DIR="${prefix}/lib/systemd/system-environment-generators" +AC_SUBST(SYSTEMD_SYSTEM_ENV_GENERATOR_DIR) + +AC_ARG_ENABLE([bpf], +AS_HELP_STRING([--enable-bpf], [Enable BPF support]), +[case "${enableval}" in +yes) enable_bpf=yes ;; +no) enable_bpf=no ;; +*) AC_MSG_ERROR([bad value ${enableval} for --enable-bpf]) +esac], +[enable_bpf=yes]) +AM_CONDITIONAL([ENABLE_BPF], [test "x$enable_bpf" = "xyes"]) + +AS_IF([test "x$enable_bpf" = "xyes"], [ + AC_DEFINE([ENABLE_BPF], [1], [Enable BPF support]) + + AC_CACHE_CHECK([whether host BPF headers are usable], [snapd_cv_bpf_header_works], [ + AC_COMPILE_IFELSE( + [AC_LANG_SOURCE([[ +#include +void foo(enum bpf_attach_type type) {} +void bar() { struct bpf_cgroup_dev_ctx ctx = {0}; } +]])], + [snapd_cv_bpf_header_works=yes], + [snapd_cv_bpf_header_works=no]) + ]) + + AS_IF([test "x$snapd_cv_bpf_header_works" = "xno"], [ + use_internal_pbf_headers=yes + ]) +], [ + use_internal_pbf_headers=no +]) +AM_CONDITIONAL([USE_INTERNAL_BPF_HEADERS], [test "x$use_internal_pbf_headers" = "xyes"]) + +AC_CACHE_CHECK([whether -Wmissing-field-initializers is correct], [snapd_cv_missing_field_initializers_works], [ + save_CFLAGS="${CFLAGS}" + CFLAGS="${CFLAGS} -Wmissing-field-initializers -Werror" + AC_COMPILE_IFELSE( + [AC_LANG_SOURCE([[ +struct { int a; int b; } a = { 0 }; +struct { const char* a; int b; } b[] = { {.a = ""}, {} }; +]])], [ + snapd_cv_missing_field_initializers_works=yes + ], [ + snapd_cv_missing_field_initializers_works=no + ]) + CFLAGS="${save_CFLAGS}" +]) + +AX_APPEND_COMPILE_FLAGS([ dnl + -Wall dnl + -Wextra dnl + -Wmissing-prototypes dnl + -Wstrict-prototypes dnl + -Wno-unused-parameter dnl + ], [CHECK_CFLAGS]) + +AS_IF([test "x$snapd_cv_missing_field_initializers_works" = "xno"], [ + AX_APPEND_COMPILE_FLAGS([-Wno-missing-field-initializers], [CHECK_CFLAGS]) +]) + +AS_IF([test "x$with_unit_tests" = "xyes"], [ + AX_APPEND_COMPILE_FLAGS([-Werror], [CHECK_CFLAGS]) +]) + +AC_CONFIG_FILES([Makefile]) +AC_OUTPUT diff --git a/cmd/decode-mount-opts/decode-mount-opts.c b/cmd/decode-mount-opts/decode-mount-opts.c new file mode 100644 index 00000000..383aa03b --- /dev/null +++ b/cmd/decode-mount-opts/decode-mount-opts.c @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include +#include + +#include "../libsnap-confine-private/mount-opt.h" + +int main(int argc, char *argv[]) +{ + if (argc != 2) { + printf("usage: decode-mount-opts OPT\n"); + return 0; + } + char *end; + unsigned long mountflags = strtoul(argv[1], &end, 0); + if (*end != '\0') { + fprintf(stderr, "cannot parse given argument as a number\n"); + return 1; + } + char buf[1000] = {0}; + printf("%#lx is %s\n", mountflags, sc_mount_opt2str(buf, sizeof buf, mountflags)); + return 0; +} diff --git a/cmd/libsnap-confine-private/apparmor-support.c b/cmd/libsnap-confine-private/apparmor-support.c new file mode 100644 index 00000000..13d1b868 --- /dev/null +++ b/cmd/libsnap-confine-private/apparmor-support.c @@ -0,0 +1,164 @@ +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#ifdef HAVE_CONFIG_H +#include "config.h" +#endif + +#include "apparmor-support.h" +#include "string-utils.h" +#include "utils.h" + +#include +#include +#ifdef HAVE_APPARMOR +#include +#endif // ifdef HAVE_APPARMOR + +#include "../libsnap-confine-private/cleanup-funcs.h" +#include "../libsnap-confine-private/utils.h" + +// NOTE: Those constants map exactly what apparmor is returning and cannot be +// changed without breaking apparmor functionality. +#define SC_AA_ENFORCE_STR "enforce" +#define SC_AA_COMPLAIN_STR "complain" +#define SC_AA_MIXED_STR "mixed" +#define SC_AA_KILL_STR "kill" +#define SC_AA_UNCONFINED_STR "unconfined" + +void sc_init_apparmor_support(struct sc_apparmor *apparmor) +{ +#ifdef HAVE_APPARMOR + // Use aa_is_enabled() to see if apparmor is available in the kernel and + // enabled at boot time. If it isn't log a diagnostic message and assume + // we're not confined. + if (aa_is_enabled() != true) { + switch (errno) { + case ENOSYS: + debug + ("apparmor extensions to the system are not available"); + break; + case ECANCELED: + debug + ("apparmor is available on the system but has been disabled at boot"); + break; + case EPERM: + // NOTE: fall-through + case EACCES: + debug + ("insufficient permissions to determine if apparmor is enabled"); + // since snap-confine is setuid root this should + // never happen so likely someone is trying to + // manipulate our execution environment - fail hard + + // fall-through + case ENOENT: + case ENOMEM: + default: + // this shouldn't happen under normal usage so it + // is possible someone is trying to manipulate our + // execution environment - fail hard + die("aa_is_enabled() failed unexpectedly (%s)", + strerror(errno)); + break; + } + apparmor->is_confined = false; + apparmor->mode = SC_AA_NOT_APPLICABLE; + return; + } + // Use aa_getcon() to check the label of the current process and + // confinement type. Note that the returned label must be released with + // free() but the mode is a constant string that must not be freed. + char *label SC_CLEANUP(sc_cleanup_string) = NULL; + char *mode = NULL; + if (aa_getcon(&label, &mode) < 0) { + die("cannot query current apparmor profile"); + } + debug("apparmor label on snap-confine is: %s", label); + debug("apparmor mode is: %s", mode); + // expect to be confined by a profile with the name of a valid + // snap-confine binary since if not we may be executed under a + // profile with more permissions than expected + bool confined_mode = sc_streq(mode, SC_AA_ENFORCE_STR) + || sc_streq(mode, SC_AA_KILL_STR); + if (label != NULL && confined_mode && sc_is_expected_path(label)) { + apparmor->is_confined = true; + } else { + apparmor->is_confined = false; + } + // There are several possible results for the confinement type (mode) that + // are checked for below. + if (mode != NULL && strcmp(mode, SC_AA_COMPLAIN_STR) == 0) { + apparmor->mode = SC_AA_COMPLAIN; + } else if (mode != NULL && strcmp(mode, SC_AA_ENFORCE_STR) == 0) { + apparmor->mode = SC_AA_ENFORCE; + } else if (mode != NULL && strcmp(mode, SC_AA_MIXED_STR) == 0) { + apparmor->mode = SC_AA_MIXED; + } else if (mode != NULL && strcmp(mode, SC_AA_KILL_STR) == 0) { + apparmor->mode = SC_AA_KILL; + } else { + apparmor->mode = SC_AA_INVALID; + } +#else + apparmor->mode = SC_AA_NOT_APPLICABLE; + apparmor->is_confined = false; +#endif // ifdef HAVE_APPARMOR +} + +void +sc_maybe_aa_change_onexec(struct sc_apparmor *apparmor, const char *profile) +{ +#ifdef HAVE_APPARMOR + if (apparmor->mode == SC_AA_NOT_APPLICABLE) { + return; + } + debug("requesting changing of apparmor profile on next exec to %s", + profile); + if (aa_change_onexec(profile) < 0) { + /* Save errno because secure_getenv() can overwrite it */ + int aa_change_onexec_errno = errno; + if (secure_getenv("SNAPPY_LAUNCHER_INSIDE_TESTS") == NULL) { + errno = aa_change_onexec_errno; + if (errno == ENOENT) { + fprintf(stderr, "missing profile %s.\n" + "Please make sure that the snapd.apparmor service is enabled and started\n", + profile); + exit(1); + } else { + die("cannot change profile for the next exec call"); + } + } + } +#endif // ifdef HAVE_APPARMOR +} + +void +sc_maybe_aa_change_hat(struct sc_apparmor *apparmor, + const char *subprofile, unsigned long magic_token) +{ +#ifdef HAVE_APPARMOR + if (apparmor->mode == SC_AA_NOT_APPLICABLE) { + return; + } + if (apparmor->is_confined) { + debug("changing apparmor hat to %s", subprofile); + if (aa_change_hat(subprofile, magic_token) < 0) { + die("cannot change apparmor hat"); + } + } +#endif // ifdef HAVE_APPARMOR +} diff --git a/cmd/libsnap-confine-private/apparmor-support.h b/cmd/libsnap-confine-private/apparmor-support.h new file mode 100644 index 00000000..2d768607 --- /dev/null +++ b/cmd/libsnap-confine-private/apparmor-support.h @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#ifndef SNAP_CONFINE_APPARMOR_SUPPORT_H +#define SNAP_CONFINE_APPARMOR_SUPPORT_H + +#include + +/** + * Type of apparmor confinement. + **/ +enum sc_apparmor_mode { + // The enforcement mode was not recognized. + SC_AA_INVALID = -1, + // The enforcement mode is not applicable because apparmor is disabled. + SC_AA_NOT_APPLICABLE = 0, + // The enforcement mode is "enforcing" + SC_AA_ENFORCE = 1, + // The enforcement mode is "complain" + SC_AA_COMPLAIN, + // The enforcement mode is "mixed" + SC_AA_MIXED, + // The enforcement mode is "kill" + SC_AA_KILL, +}; + +/** + * Data required to manage apparmor wrapper. + **/ +struct sc_apparmor { + // The mode of enforcement. In addition to the two apparmor defined modes + // can be also SC_AA_INVALID (unknown mode reported by apparmor) and + // SC_AA_NOT_APPLICABLE (when we're not linked with apparmor). + enum sc_apparmor_mode mode; + // Flag indicating that the current process is confined. + bool is_confined; +}; + +/** + * Initialize apparmor support. + * + * This operation should be done even when apparmor support is disabled at + * compile time. Internally the supplied structure is initialized based on the + * information returned from aa_getcon(2) or if apparmor is disabled at compile + * time, with built-in constants. + * + * The main action performed here is to check if snap-confine is currently + * confined, this information is used later in sc_maybe_change_apparmor_hat() + * + * As with many functions in the snap-confine tree, all errors result in + * process termination. + **/ +void sc_init_apparmor_support(struct sc_apparmor *apparmor); + +/** + * Maybe call aa_change_onexec(2) + * + * This function does nothing when apparmor support is not enabled at compile + * time. If apparmor is enabled then profile change request is attempted. + * + * As with many functions in the snap-confine tree, all errors result in + * process termination. As an exception, when SNAPPY_LAUNCHER_INSIDE_TESTS + * environment variable is set then the process is not terminated. + **/ +void +sc_maybe_aa_change_onexec(struct sc_apparmor *apparmor, const char *profile); + +/** + * Maybe call aa_change_hat(2) + * + * This function does nothing when apparmor support is not enabled at compile + * time. If apparmor is enabled then hat change is attempted. + * + * As with many functions in the snap-confine tree, all errors result in + * process termination. + **/ +void +sc_maybe_aa_change_hat(struct sc_apparmor *apparmor, + const char *subprofile, unsigned long magic_token); + +#endif diff --git a/cmd/libsnap-confine-private/bpf-support.c b/cmd/libsnap-confine-private/bpf-support.c new file mode 100644 index 00000000..f5494e8b --- /dev/null +++ b/cmd/libsnap-confine-private/bpf-support.c @@ -0,0 +1,196 @@ +/* + * Copyright (C) 2021 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "bpf-support.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "utils.h" + +static int sys_bpf(enum bpf_cmd cmd, union bpf_attr *attr, size_t size) { +#ifdef SYS_bpf + return syscall(SYS_bpf, cmd, attr, size); +#else + errno = ENOSYS; + return -1; +#endif +} + +#define __ptr_as_u64(__x) ((uint64_t)(uintptr_t)__x) + +int bpf_create_map(enum bpf_map_type type, size_t key_size, size_t value_size, size_t max_entries) { + debug("create bpf map of type 0x%x, key size %zu, value size %zu, entries %zu", type, key_size, value_size, + max_entries); + union bpf_attr attr; + memset(&attr, 0, sizeof(attr)); + attr.map_type = type; + attr.key_size = key_size; + attr.value_size = value_size; + attr.max_entries = max_entries; + return sys_bpf(BPF_MAP_CREATE, &attr, sizeof(attr)); +} + +int bpf_update_map(int map_fd, const void *key, const void *value) { + union bpf_attr attr; + memset(&attr, 0, sizeof(attr)); + attr.map_fd = map_fd; + attr.key = __ptr_as_u64(key); + attr.value = __ptr_as_u64(value); + /* update or create an existing element */ + attr.flags = BPF_ANY; + return sys_bpf(BPF_MAP_UPDATE_ELEM, &attr, sizeof(attr)); +} + +int bpf_pin_to_path(int fd, const char *path) { + debug("pin bpf object %d to path %s", fd, path); + union bpf_attr attr; + memset(&attr, 0, sizeof(attr)); + attr.bpf_fd = fd; + /* pointer must be converted to a u64 */ + attr.pathname = __ptr_as_u64(path); + + return sys_bpf(BPF_OBJ_PIN, &attr, sizeof(attr)); +} + +int bpf_get_by_path(const char *path) { + debug("get bpf object at path %s", path); + union bpf_attr attr; + memset(&attr, 0, sizeof(attr)); + /* pointer must be converted to a u64 */ + attr.pathname = __ptr_as_u64(path); + + return sys_bpf(BPF_OBJ_GET, &attr, sizeof(attr)); +} + +int bpf_load_prog(enum bpf_prog_type type, const struct bpf_insn *insns, size_t insns_cnt, char *log_buf, + size_t log_buf_size) { + if (type == BPF_PROG_TYPE_UNSPEC) { + errno = EINVAL; + return -1; + } + debug("load program of type 0x%x, %zu instructions", type, insns_cnt); + union bpf_attr attr; + memset(&attr, 0, sizeof(attr)); + attr.prog_type = type; + attr.insns = __ptr_as_u64(insns); + attr.insn_cnt = (uint64_t)insns_cnt; + attr.license = __ptr_as_u64("GPL"); + if (log_buf != NULL) { + attr.log_buf = __ptr_as_u64(log_buf); + attr.log_size = log_buf_size; + attr.log_level = 1; + } + + /* XXX: libbpf does a while loop checking for EAGAIN */ + /* XXX: do we need to handle E2BIG? */ + return sys_bpf(BPF_PROG_LOAD, &attr, sizeof(attr)); +} + +int bpf_prog_attach(enum bpf_attach_type type, int cgroup_fd, int prog_fd) { + debug("attach type 0x%x program %d to cgroup %d", type, prog_fd, cgroup_fd); + union bpf_attr attr; + memset(&attr, 0, sizeof(attr)); + + attr.attach_type = type; + attr.target_fd = cgroup_fd; + attr.attach_bpf_fd = prog_fd; + + return sys_bpf(BPF_PROG_ATTACH, &attr, sizeof(attr)); +} + +int bpf_map_get_next_key(int map_fd, const void *key, void *next_key) { + debug("get next key for map %d", map_fd); + union bpf_attr attr; + memset(&attr, 0, sizeof(attr)); + + attr.map_fd = map_fd; + attr.key = __ptr_as_u64(key); + attr.next_key = __ptr_as_u64(next_key); + + return sys_bpf(BPF_MAP_GET_NEXT_KEY, &attr, sizeof(attr)); +} + +int bpf_map_delete_batch(int map_fd, const void *keys, size_t cnt) { +#if 0 +/* + * XXX: batch operations don't seem to work with 5.13.10, getting -EINVAL + * XXX: also batch operations are supported by recent kernels only + */ + debug("batch delete in map %d keys cnt %zu", map_fd, cnt); + union bpf_attr attr; + memset(&attr, 0, sizeof(attr)); + + attr.map_fd = map_fd; + attr.batch.keys = __ptr_as_u64(keys); + attr.batch.count = cnt; + /* TODO: getting EINVAL? */ + int ret = sys_bpf(BPF_MAP_DELETE_BATCH, &attr, sizeof(attr)); + debug("returned count %d", attr.batch.count); + return ret; +#endif + errno = ENOSYS; + return -1; +} + +int bpf_map_delete_elem(int map_fd, const void *key) { + debug("delete elem in map %d", map_fd); + union bpf_attr attr; + memset(&attr, 0, sizeof(attr)); + + attr.map_fd = map_fd; + attr.key = __ptr_as_u64(key); + + return sys_bpf(BPF_MAP_DELETE_ELEM, &attr, sizeof(attr)); +} + +#ifndef BPF_FS_MAGIC +#define BPF_FS_MAGIC 0xcafe4a11 +#endif + +bool bpf_path_is_bpffs(const char *path) { + struct statfs fs; + int res = statfs(path, &fs); + if (res < 0) { + if (errno == ENOENT) { + /* no path at all */ + return false; + } + die("cannot check filesystem type of %s", path); + } + /* see statfs(2) notes on __fsword_t */ + if ((unsigned int)fs.f_type == BPF_FS_MAGIC) { + return true; + } + return false; +} + +void bpf_mount_bpffs(const char *path) { + /* systemd and bpftool disagree as to the propagation mode of bpffs mounts, + * so go with the default which is a shared propagation and matches the + * state of a freshly booted system */ + int res = mount("bpf", path, "bpf", 0, "mode=0700"); + if (res < 0) { + die("cannot mount bpf filesystem under %s", path); + } +} diff --git a/cmd/libsnap-confine-private/bpf-support.h b/cmd/libsnap-confine-private/bpf-support.h new file mode 100644 index 00000000..d771d2de --- /dev/null +++ b/cmd/libsnap-confine-private/bpf-support.h @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2021 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#ifndef SNAP_CONFINE_BPF_SUPPORT_H +#define SNAP_CONFINE_BPF_SUPPORT_H + +#include +#include +#include + +/** + * bpf_pin_to_path pins an object referenced by fd to a path under a bpffs + * mount. + */ +int bpf_pin_to_path(int fd, const char *path); + +/** + * bpf_get_by_path obtains the file handle to the object referenced by a path + * under bpffs filesystem. The returned file descriptor has O_CLOEXEC flag set + * on it. + */ +int bpf_get_by_path(const char *path); + +/** + * bpf_load_prog loads a given BPF program and returns a file descriptor handle + * to it. + * + * The program is passed as an insns_cnt long array of BPF instructions. + * Passing non-NULL log buf, will populate the buffer with output from verifier + * if the program is found to be invalid. The returned file descriptor has + * O_CLOEXEC flag set on it. + */ +int bpf_load_prog(enum bpf_prog_type type, const struct bpf_insn *insns, size_t insns_cnt, char *log_buf, + size_t log_buf_size); + +int bpf_prog_attach(enum bpf_attach_type type, int cgroup_fd, int prog_fd); + +/** + * bf_create_map creates a BPF map and returns a file descriptor handle to it. + * The returned file descriptor has O_CLOEXEC flag set on it. + */ +int bpf_create_map(enum bpf_map_type type, size_t key_size, size_t value_size, size_t max_entries); + +/** + * bpf_update_map updates the value of element with a given key (or adds it to + * the map). + */ +int bpf_update_map(int map_fd, const void *key, const void *value); + +/** + * bpf_map_get_next_key iterates over keys of the map. + * + * When key does not match anything in the map, it is set to the first element + * of the map and next_key holds the next key. Subsequent calls will obtain the + * next_key following key. When an end if reached, -1 is returned and error is + * set to ENOENT. + */ +int bpf_map_get_next_key(int map_fd, const void *key, void *next_key); + +/** + * bpf_map_delete_batch performs a batch delete of elements with keys, where cnt + * is the number of keys. + */ +int bpf_map_delete_batch(int map_fd, const void *keys, size_t cnt); + +/** + * bpf_map_delete_elem deletes an element with a key from the map, returns -1 + * and ENOENT when the element did not exist. + */ +int bpf_map_delete_elem(int map_fd, const void *key); + +/** + * bpf_path_is_bpffs returns true when given path is a bpffs filesystem. + */ +bool bpf_path_is_bpffs(const char *path); + +/** + * bpf_mount_bpffs mounts a bpf filesystem at a given path. + */ +void bpf_mount_bpffs(const char *path); + +#endif /* SNAP_CONFINE_BPF_SUPPORT_H */ diff --git a/cmd/libsnap-confine-private/bpf/bpf-insn.h b/cmd/libsnap-confine-private/bpf/bpf-insn.h new file mode 100644 index 00000000..7125e7da --- /dev/null +++ b/cmd/libsnap-confine-private/bpf/bpf-insn.h @@ -0,0 +1,311 @@ +/* SPDX-License-Identifier: GPL-2.0 */ + +/* imported from the Linux kernel, commit + * 77d34a4683b053108ecd466cc7c4193b45805528 (v5.13-11855-g77d34a4683b0) */ + +#ifndef __BPF_INSN_H__ +#define __BPF_INSN_H__ + +#include + +/* ALU ops on registers, bpf_add|sub|...: dst_reg += src_reg */ + +#define BPF_ALU64_REG(OP, DST, SRC) \ + ((struct bpf_insn) { \ + .code = BPF_ALU64 | BPF_OP(OP) | BPF_X, \ + .dst_reg = DST, \ + .src_reg = SRC, \ + .off = 0, \ + .imm = 0 }) + +#define BPF_ALU32_REG(OP, DST, SRC) \ + ((struct bpf_insn) { \ + .code = BPF_ALU | BPF_OP(OP) | BPF_X, \ + .dst_reg = DST, \ + .src_reg = SRC, \ + .off = 0, \ + .imm = 0 }) + +/* ALU ops on immediates, bpf_add|sub|...: dst_reg += imm32 */ + +#define BPF_ALU64_IMM(OP, DST, IMM) \ + ((struct bpf_insn) { \ + .code = BPF_ALU64 | BPF_OP(OP) | BPF_K, \ + .dst_reg = DST, \ + .src_reg = 0, \ + .off = 0, \ + .imm = IMM }) + +#define BPF_ALU32_IMM(OP, DST, IMM) \ + ((struct bpf_insn) { \ + .code = BPF_ALU | BPF_OP(OP) | BPF_K, \ + .dst_reg = DST, \ + .src_reg = 0, \ + .off = 0, \ + .imm = IMM }) + +/* Endianess conversion, cpu_to_{l,b}e(), {l,b}e_to_cpu() */ + +#define BPF_ENDIAN(TYPE, DST, LEN) \ + ((struct bpf_insn) { \ + .code = BPF_ALU | BPF_END | BPF_SRC(TYPE), \ + .dst_reg = DST, \ + .src_reg = 0, \ + .off = 0, \ + .imm = LEN }) + +/* Short form of mov, dst_reg = src_reg */ + +#define BPF_MOV64_REG(DST, SRC) \ + ((struct bpf_insn) { \ + .code = BPF_ALU64 | BPF_MOV | BPF_X, \ + .dst_reg = DST, \ + .src_reg = SRC, \ + .off = 0, \ + .imm = 0 }) + +#define BPF_MOV32_REG(DST, SRC) \ + ((struct bpf_insn) { \ + .code = BPF_ALU | BPF_MOV | BPF_X, \ + .dst_reg = DST, \ + .src_reg = SRC, \ + .off = 0, \ + .imm = 0 }) + +/* Short form of mov, dst_reg = imm32 */ + +#define BPF_MOV64_IMM(DST, IMM) \ + ((struct bpf_insn) { \ + .code = BPF_ALU64 | BPF_MOV | BPF_K, \ + .dst_reg = DST, \ + .src_reg = 0, \ + .off = 0, \ + .imm = IMM }) + +#define BPF_MOV32_IMM(DST, IMM) \ + ((struct bpf_insn) { \ + .code = BPF_ALU | BPF_MOV | BPF_K, \ + .dst_reg = DST, \ + .src_reg = 0, \ + .off = 0, \ + .imm = IMM }) + +/* Special form of mov32, used for doing explicit zero extension on dst. */ +#define BPF_ZEXT_REG(DST) \ + ((struct bpf_insn) { \ + .code = BPF_ALU | BPF_MOV | BPF_X, \ + .dst_reg = DST, \ + .src_reg = DST, \ + .off = 0, \ + .imm = 1 }) + +/* BPF_LD_IMM64 macro encodes single 'load 64-bit immediate' insn */ +#define BPF_LD_IMM64(DST, IMM) \ + BPF_LD_IMM64_RAW(DST, 0, IMM) + +#define BPF_LD_IMM64_RAW(DST, SRC, IMM) \ + ((struct bpf_insn) { \ + .code = BPF_LD | BPF_DW | BPF_IMM, \ + .dst_reg = DST, \ + .src_reg = SRC, \ + .off = 0, \ + .imm = (__u32) (IMM) }), \ + ((struct bpf_insn) { \ + .code = 0, /* zero is reserved opcode */ \ + .dst_reg = 0, \ + .src_reg = 0, \ + .off = 0, \ + .imm = ((__u64) (IMM)) >> 32 }) + +/* pseudo BPF_LD_IMM64 insn used to refer to process-local map_fd */ +#define BPF_LD_MAP_FD(DST, MAP_FD) \ + BPF_LD_IMM64_RAW(DST, BPF_PSEUDO_MAP_FD, MAP_FD) + +/* Short form of mov based on type, BPF_X: dst_reg = src_reg, BPF_K: dst_reg = imm32 */ + +#define BPF_MOV64_RAW(TYPE, DST, SRC, IMM) \ + ((struct bpf_insn) { \ + .code = BPF_ALU64 | BPF_MOV | BPF_SRC(TYPE), \ + .dst_reg = DST, \ + .src_reg = SRC, \ + .off = 0, \ + .imm = IMM }) + +#define BPF_MOV32_RAW(TYPE, DST, SRC, IMM) \ + ((struct bpf_insn) { \ + .code = BPF_ALU | BPF_MOV | BPF_SRC(TYPE), \ + .dst_reg = DST, \ + .src_reg = SRC, \ + .off = 0, \ + .imm = IMM }) + +/* Direct packet access, R0 = *(uint *) (skb->data + imm32) */ + +#define BPF_LD_ABS(SIZE, IMM) \ + ((struct bpf_insn) { \ + .code = BPF_LD | BPF_SIZE(SIZE) | BPF_ABS, \ + .dst_reg = 0, \ + .src_reg = 0, \ + .off = 0, \ + .imm = IMM }) + +/* Indirect packet access, R0 = *(uint *) (skb->data + src_reg + imm32) */ + +#define BPF_LD_IND(SIZE, SRC, IMM) \ + ((struct bpf_insn) { \ + .code = BPF_LD | BPF_SIZE(SIZE) | BPF_IND, \ + .dst_reg = 0, \ + .src_reg = SRC, \ + .off = 0, \ + .imm = IMM }) + +/* Memory load, dst_reg = *(uint *) (src_reg + off16) */ + +#define BPF_LDX_MEM(SIZE, DST, SRC, OFF) \ + ((struct bpf_insn) { \ + .code = BPF_LDX | BPF_SIZE(SIZE) | BPF_MEM, \ + .dst_reg = DST, \ + .src_reg = SRC, \ + .off = OFF, \ + .imm = 0 }) + +/* Memory store, *(uint *) (dst_reg + off16) = src_reg */ + +#define BPF_STX_MEM(SIZE, DST, SRC, OFF) \ + ((struct bpf_insn) { \ + .code = BPF_STX | BPF_SIZE(SIZE) | BPF_MEM, \ + .dst_reg = DST, \ + .src_reg = SRC, \ + .off = OFF, \ + .imm = 0 }) + +/* + * Atomic operations: + * + * BPF_ADD *(uint *) (dst_reg + off16) += src_reg + * BPF_AND *(uint *) (dst_reg + off16) &= src_reg + * BPF_OR *(uint *) (dst_reg + off16) |= src_reg + * BPF_XOR *(uint *) (dst_reg + off16) ^= src_reg + * BPF_ADD | BPF_FETCH src_reg = atomic_fetch_add(dst_reg + off16, src_reg); + * BPF_AND | BPF_FETCH src_reg = atomic_fetch_and(dst_reg + off16, src_reg); + * BPF_OR | BPF_FETCH src_reg = atomic_fetch_or(dst_reg + off16, src_reg); + * BPF_XOR | BPF_FETCH src_reg = atomic_fetch_xor(dst_reg + off16, src_reg); + * BPF_XCHG src_reg = atomic_xchg(dst_reg + off16, src_reg) + * BPF_CMPXCHG r0 = atomic_cmpxchg(dst_reg + off16, r0, src_reg) + */ + +#define BPF_ATOMIC_OP(SIZE, OP, DST, SRC, OFF) \ + ((struct bpf_insn) { \ + .code = BPF_STX | BPF_SIZE(SIZE) | BPF_ATOMIC, \ + .dst_reg = DST, \ + .src_reg = SRC, \ + .off = OFF, \ + .imm = OP }) + +/* Legacy alias */ +#define BPF_STX_XADD(SIZE, DST, SRC, OFF) BPF_ATOMIC_OP(SIZE, BPF_ADD, DST, SRC, OFF) + +/* Memory store, *(uint *) (dst_reg + off16) = imm32 */ + +#define BPF_ST_MEM(SIZE, DST, OFF, IMM) \ + ((struct bpf_insn) { \ + .code = BPF_ST | BPF_SIZE(SIZE) | BPF_MEM, \ + .dst_reg = DST, \ + .src_reg = 0, \ + .off = OFF, \ + .imm = IMM }) + +/* Conditional jumps against registers, if (dst_reg 'op' src_reg) goto pc + off16 */ + +#define BPF_JMP_REG(OP, DST, SRC, OFF) \ + ((struct bpf_insn) { \ + .code = BPF_JMP | BPF_OP(OP) | BPF_X, \ + .dst_reg = DST, \ + .src_reg = SRC, \ + .off = OFF, \ + .imm = 0 }) + +/* Conditional jumps against immediates, if (dst_reg 'op' imm32) goto pc + off16 */ + +#define BPF_JMP_IMM(OP, DST, IMM, OFF) \ + ((struct bpf_insn) { \ + .code = BPF_JMP | BPF_OP(OP) | BPF_K, \ + .dst_reg = DST, \ + .src_reg = 0, \ + .off = OFF, \ + .imm = IMM }) + +/* Like BPF_JMP_REG, but with 32-bit wide operands for comparison. */ + +#define BPF_JMP32_REG(OP, DST, SRC, OFF) \ + ((struct bpf_insn) { \ + .code = BPF_JMP32 | BPF_OP(OP) | BPF_X, \ + .dst_reg = DST, \ + .src_reg = SRC, \ + .off = OFF, \ + .imm = 0 }) + +/* Like BPF_JMP_IMM, but with 32-bit wide operands for comparison. */ + +#define BPF_JMP32_IMM(OP, DST, IMM, OFF) \ + ((struct bpf_insn) { \ + .code = BPF_JMP32 | BPF_OP(OP) | BPF_K, \ + .dst_reg = DST, \ + .src_reg = 0, \ + .off = OFF, \ + .imm = IMM }) + +/* Unconditional jumps, goto pc + off16 */ + +#define BPF_JMP_A(OFF) \ + ((struct bpf_insn) { \ + .code = BPF_JMP | BPF_JA, \ + .dst_reg = 0, \ + .src_reg = 0, \ + .off = OFF, \ + .imm = 0 }) + +/* Relative call */ + +#define BPF_CALL_REL(TGT) \ + ((struct bpf_insn) { \ + .code = BPF_JMP | BPF_CALL, \ + .dst_reg = 0, \ + .src_reg = BPF_PSEUDO_CALL, \ + .off = 0, \ + .imm = TGT }) + +/* Function call */ + +#define BPF_CAST_CALL(x) \ + ((u64 (*)(u64, u64, u64, u64, u64))(x)) + +#define BPF_EMIT_CALL(FUNC) \ + ((struct bpf_insn) { \ + .code = BPF_JMP | BPF_CALL, \ + .dst_reg = 0, \ + .src_reg = 0, \ + .off = 0, \ + .imm = ((FUNC) - __bpf_call_base) }) + +/* Raw code statement block */ + +#define BPF_RAW_INSN(CODE, DST, SRC, OFF, IMM) \ + ((struct bpf_insn) { \ + .code = CODE, \ + .dst_reg = DST, \ + .src_reg = SRC, \ + .off = OFF, \ + .imm = IMM }) + +/* Program exit */ + +#define BPF_EXIT_INSN() \ + ((struct bpf_insn) { \ + .code = BPF_JMP | BPF_EXIT, \ + .dst_reg = 0, \ + .src_reg = 0, \ + .off = 0, \ + .imm = 0 }) + +#endif /* __BPF_INSN_H__ */ diff --git a/cmd/libsnap-confine-private/bpf/vendor/linux/bpf.h b/cmd/libsnap-confine-private/bpf/vendor/linux/bpf.h new file mode 100644 index 00000000..bf9252c7 --- /dev/null +++ b/cmd/libsnap-confine-private/bpf/vendor/linux/bpf.h @@ -0,0 +1,6152 @@ +/* SPDX-License-Identifier: GPL-2.0 WITH Linux-syscall-note */ +/* Copyright (c) 2011-2014 PLUMgrid, http://plumgrid.com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of version 2 of the GNU General Public + * License as published by the Free Software Foundation. + */ +#ifndef _UAPI__LINUX_BPF_H__ +#define _UAPI__LINUX_BPF_H__ + +#include +#include + +/* Extended instruction set based on top of classic BPF */ + +/* instruction classes */ +#define BPF_JMP32 0x06 /* jmp mode in word width */ +#define BPF_ALU64 0x07 /* alu mode in double word width */ + +/* ld/ldx fields */ +#define BPF_DW 0x18 /* double word (64-bit) */ +#define BPF_ATOMIC 0xc0 /* atomic memory ops - op type in immediate */ +#define BPF_XADD 0xc0 /* exclusive add - legacy name */ + +/* alu/jmp fields */ +#define BPF_MOV 0xb0 /* mov reg to reg */ +#define BPF_ARSH 0xc0 /* sign extending arithmetic shift right */ + +/* change endianness of a register */ +#define BPF_END 0xd0 /* flags for endianness conversion: */ +#define BPF_TO_LE 0x00 /* convert to little-endian */ +#define BPF_TO_BE 0x08 /* convert to big-endian */ +#define BPF_FROM_LE BPF_TO_LE +#define BPF_FROM_BE BPF_TO_BE + +/* jmp encodings */ +#define BPF_JNE 0x50 /* jump != */ +#define BPF_JLT 0xa0 /* LT is unsigned, '<' */ +#define BPF_JLE 0xb0 /* LE is unsigned, '<=' */ +#define BPF_JSGT 0x60 /* SGT is signed '>', GT in x86 */ +#define BPF_JSGE 0x70 /* SGE is signed '>=', GE in x86 */ +#define BPF_JSLT 0xc0 /* SLT is signed, '<' */ +#define BPF_JSLE 0xd0 /* SLE is signed, '<=' */ +#define BPF_CALL 0x80 /* function call */ +#define BPF_EXIT 0x90 /* function return */ + +/* atomic op type fields (stored in immediate) */ +#define BPF_FETCH 0x01 /* not an opcode on its own, used to build others */ +#define BPF_XCHG (0xe0 | BPF_FETCH) /* atomic exchange */ +#define BPF_CMPXCHG (0xf0 | BPF_FETCH) /* atomic compare-and-write */ + +/* Register numbers */ +enum { + BPF_REG_0 = 0, + BPF_REG_1, + BPF_REG_2, + BPF_REG_3, + BPF_REG_4, + BPF_REG_5, + BPF_REG_6, + BPF_REG_7, + BPF_REG_8, + BPF_REG_9, + BPF_REG_10, + __MAX_BPF_REG, +}; + +/* BPF has 10 general purpose 64-bit registers and stack frame. */ +#define MAX_BPF_REG __MAX_BPF_REG + +struct bpf_insn { + __u8 code; /* opcode */ + __u8 dst_reg:4; /* dest register */ + __u8 src_reg:4; /* source register */ + __s16 off; /* signed offset */ + __s32 imm; /* signed immediate constant */ +}; + +/* Key of an a BPF_MAP_TYPE_LPM_TRIE entry */ +struct bpf_lpm_trie_key { + __u32 prefixlen; /* up to 32 for AF_INET, 128 for AF_INET6 */ + __u8 data[0]; /* Arbitrary size */ +}; + +struct bpf_cgroup_storage_key { + __u64 cgroup_inode_id; /* cgroup inode id */ + __u32 attach_type; /* program attach type */ +}; + +union bpf_iter_link_info { + struct { + __u32 map_fd; + } map; +}; + +/* BPF syscall commands, see bpf(2) man-page for more details. */ +/** + * DOC: eBPF Syscall Preamble + * + * The operation to be performed by the **bpf**\ () system call is determined + * by the *cmd* argument. Each operation takes an accompanying argument, + * provided via *attr*, which is a pointer to a union of type *bpf_attr* (see + * below). The size argument is the size of the union pointed to by *attr*. + */ +/** + * DOC: eBPF Syscall Commands + * + * BPF_MAP_CREATE + * Description + * Create a map and return a file descriptor that refers to the + * map. The close-on-exec file descriptor flag (see **fcntl**\ (2)) + * is automatically enabled for the new file descriptor. + * + * Applying **close**\ (2) to the file descriptor returned by + * **BPF_MAP_CREATE** will delete the map (but see NOTES). + * + * Return + * A new file descriptor (a nonnegative integer), or -1 if an + * error occurred (in which case, *errno* is set appropriately). + * + * BPF_MAP_LOOKUP_ELEM + * Description + * Look up an element with a given *key* in the map referred to + * by the file descriptor *map_fd*. + * + * The *flags* argument may be specified as one of the + * following: + * + * **BPF_F_LOCK** + * Look up the value of a spin-locked map without + * returning the lock. This must be specified if the + * elements contain a spinlock. + * + * Return + * Returns zero on success. On error, -1 is returned and *errno* + * is set appropriately. + * + * BPF_MAP_UPDATE_ELEM + * Description + * Create or update an element (key/value pair) in a specified map. + * + * The *flags* argument should be specified as one of the + * following: + * + * **BPF_ANY** + * Create a new element or update an existing element. + * **BPF_NOEXIST** + * Create a new element only if it did not exist. + * **BPF_EXIST** + * Update an existing element. + * **BPF_F_LOCK** + * Update a spin_lock-ed map element. + * + * Return + * Returns zero on success. On error, -1 is returned and *errno* + * is set appropriately. + * + * May set *errno* to **EINVAL**, **EPERM**, **ENOMEM**, + * **E2BIG**, **EEXIST**, or **ENOENT**. + * + * **E2BIG** + * The number of elements in the map reached the + * *max_entries* limit specified at map creation time. + * **EEXIST** + * If *flags* specifies **BPF_NOEXIST** and the element + * with *key* already exists in the map. + * **ENOENT** + * If *flags* specifies **BPF_EXIST** and the element with + * *key* does not exist in the map. + * + * BPF_MAP_DELETE_ELEM + * Description + * Look up and delete an element by key in a specified map. + * + * Return + * Returns zero on success. On error, -1 is returned and *errno* + * is set appropriately. + * + * BPF_MAP_GET_NEXT_KEY + * Description + * Look up an element by key in a specified map and return the key + * of the next element. Can be used to iterate over all elements + * in the map. + * + * Return + * Returns zero on success. On error, -1 is returned and *errno* + * is set appropriately. + * + * The following cases can be used to iterate over all elements of + * the map: + * + * * If *key* is not found, the operation returns zero and sets + * the *next_key* pointer to the key of the first element. + * * If *key* is found, the operation returns zero and sets the + * *next_key* pointer to the key of the next element. + * * If *key* is the last element, returns -1 and *errno* is set + * to **ENOENT**. + * + * May set *errno* to **ENOMEM**, **EFAULT**, **EPERM**, or + * **EINVAL** on error. + * + * BPF_PROG_LOAD + * Description + * Verify and load an eBPF program, returning a new file + * descriptor associated with the program. + * + * Applying **close**\ (2) to the file descriptor returned by + * **BPF_PROG_LOAD** will unload the eBPF program (but see NOTES). + * + * The close-on-exec file descriptor flag (see **fcntl**\ (2)) is + * automatically enabled for the new file descriptor. + * + * Return + * A new file descriptor (a nonnegative integer), or -1 if an + * error occurred (in which case, *errno* is set appropriately). + * + * BPF_OBJ_PIN + * Description + * Pin an eBPF program or map referred by the specified *bpf_fd* + * to the provided *pathname* on the filesystem. + * + * The *pathname* argument must not contain a dot ("."). + * + * On success, *pathname* retains a reference to the eBPF object, + * preventing deallocation of the object when the original + * *bpf_fd* is closed. This allow the eBPF object to live beyond + * **close**\ (\ *bpf_fd*\ ), and hence the lifetime of the parent + * process. + * + * Applying **unlink**\ (2) or similar calls to the *pathname* + * unpins the object from the filesystem, removing the reference. + * If no other file descriptors or filesystem nodes refer to the + * same object, it will be deallocated (see NOTES). + * + * The filesystem type for the parent directory of *pathname* must + * be **BPF_FS_MAGIC**. + * + * Return + * Returns zero on success. On error, -1 is returned and *errno* + * is set appropriately. + * + * BPF_OBJ_GET + * Description + * Open a file descriptor for the eBPF object pinned to the + * specified *pathname*. + * + * Return + * A new file descriptor (a nonnegative integer), or -1 if an + * error occurred (in which case, *errno* is set appropriately). + * + * BPF_PROG_ATTACH + * Description + * Attach an eBPF program to a *target_fd* at the specified + * *attach_type* hook. + * + * The *attach_type* specifies the eBPF attachment point to + * attach the program to, and must be one of *bpf_attach_type* + * (see below). + * + * The *attach_bpf_fd* must be a valid file descriptor for a + * loaded eBPF program of a cgroup, flow dissector, LIRC, sockmap + * or sock_ops type corresponding to the specified *attach_type*. + * + * The *target_fd* must be a valid file descriptor for a kernel + * object which depends on the attach type of *attach_bpf_fd*: + * + * **BPF_PROG_TYPE_CGROUP_DEVICE**, + * **BPF_PROG_TYPE_CGROUP_SKB**, + * **BPF_PROG_TYPE_CGROUP_SOCK**, + * **BPF_PROG_TYPE_CGROUP_SOCK_ADDR**, + * **BPF_PROG_TYPE_CGROUP_SOCKOPT**, + * **BPF_PROG_TYPE_CGROUP_SYSCTL**, + * **BPF_PROG_TYPE_SOCK_OPS** + * + * Control Group v2 hierarchy with the eBPF controller + * enabled. Requires the kernel to be compiled with + * **CONFIG_CGROUP_BPF**. + * + * **BPF_PROG_TYPE_FLOW_DISSECTOR** + * + * Network namespace (eg /proc/self/ns/net). + * + * **BPF_PROG_TYPE_LIRC_MODE2** + * + * LIRC device path (eg /dev/lircN). Requires the kernel + * to be compiled with **CONFIG_BPF_LIRC_MODE2**. + * + * **BPF_PROG_TYPE_SK_SKB**, + * **BPF_PROG_TYPE_SK_MSG** + * + * eBPF map of socket type (eg **BPF_MAP_TYPE_SOCKHASH**). + * + * Return + * Returns zero on success. On error, -1 is returned and *errno* + * is set appropriately. + * + * BPF_PROG_DETACH + * Description + * Detach the eBPF program associated with the *target_fd* at the + * hook specified by *attach_type*. The program must have been + * previously attached using **BPF_PROG_ATTACH**. + * + * Return + * Returns zero on success. On error, -1 is returned and *errno* + * is set appropriately. + * + * BPF_PROG_TEST_RUN + * Description + * Run the eBPF program associated with the *prog_fd* a *repeat* + * number of times against a provided program context *ctx_in* and + * data *data_in*, and return the modified program context + * *ctx_out*, *data_out* (for example, packet data), result of the + * execution *retval*, and *duration* of the test run. + * + * The sizes of the buffers provided as input and output + * parameters *ctx_in*, *ctx_out*, *data_in*, and *data_out* must + * be provided in the corresponding variables *ctx_size_in*, + * *ctx_size_out*, *data_size_in*, and/or *data_size_out*. If any + * of these parameters are not provided (ie set to NULL), the + * corresponding size field must be zero. + * + * Some program types have particular requirements: + * + * **BPF_PROG_TYPE_SK_LOOKUP** + * *data_in* and *data_out* must be NULL. + * + * **BPF_PROG_TYPE_XDP** + * *ctx_in* and *ctx_out* must be NULL. + * + * **BPF_PROG_TYPE_RAW_TRACEPOINT**, + * **BPF_PROG_TYPE_RAW_TRACEPOINT_WRITABLE** + * + * *ctx_out*, *data_in* and *data_out* must be NULL. + * *repeat* must be zero. + * + * Return + * Returns zero on success. On error, -1 is returned and *errno* + * is set appropriately. + * + * **ENOSPC** + * Either *data_size_out* or *ctx_size_out* is too small. + * **ENOTSUPP** + * This command is not supported by the program type of + * the program referred to by *prog_fd*. + * + * BPF_PROG_GET_NEXT_ID + * Description + * Fetch the next eBPF program currently loaded into the kernel. + * + * Looks for the eBPF program with an id greater than *start_id* + * and updates *next_id* on success. If no other eBPF programs + * remain with ids higher than *start_id*, returns -1 and sets + * *errno* to **ENOENT**. + * + * Return + * Returns zero on success. On error, or when no id remains, -1 + * is returned and *errno* is set appropriately. + * + * BPF_MAP_GET_NEXT_ID + * Description + * Fetch the next eBPF map currently loaded into the kernel. + * + * Looks for the eBPF map with an id greater than *start_id* + * and updates *next_id* on success. If no other eBPF maps + * remain with ids higher than *start_id*, returns -1 and sets + * *errno* to **ENOENT**. + * + * Return + * Returns zero on success. On error, or when no id remains, -1 + * is returned and *errno* is set appropriately. + * + * BPF_PROG_GET_FD_BY_ID + * Description + * Open a file descriptor for the eBPF program corresponding to + * *prog_id*. + * + * Return + * A new file descriptor (a nonnegative integer), or -1 if an + * error occurred (in which case, *errno* is set appropriately). + * + * BPF_MAP_GET_FD_BY_ID + * Description + * Open a file descriptor for the eBPF map corresponding to + * *map_id*. + * + * Return + * A new file descriptor (a nonnegative integer), or -1 if an + * error occurred (in which case, *errno* is set appropriately). + * + * BPF_OBJ_GET_INFO_BY_FD + * Description + * Obtain information about the eBPF object corresponding to + * *bpf_fd*. + * + * Populates up to *info_len* bytes of *info*, which will be in + * one of the following formats depending on the eBPF object type + * of *bpf_fd*: + * + * * **struct bpf_prog_info** + * * **struct bpf_map_info** + * * **struct bpf_btf_info** + * * **struct bpf_link_info** + * + * Return + * Returns zero on success. On error, -1 is returned and *errno* + * is set appropriately. + * + * BPF_PROG_QUERY + * Description + * Obtain information about eBPF programs associated with the + * specified *attach_type* hook. + * + * The *target_fd* must be a valid file descriptor for a kernel + * object which depends on the attach type of *attach_bpf_fd*: + * + * **BPF_PROG_TYPE_CGROUP_DEVICE**, + * **BPF_PROG_TYPE_CGROUP_SKB**, + * **BPF_PROG_TYPE_CGROUP_SOCK**, + * **BPF_PROG_TYPE_CGROUP_SOCK_ADDR**, + * **BPF_PROG_TYPE_CGROUP_SOCKOPT**, + * **BPF_PROG_TYPE_CGROUP_SYSCTL**, + * **BPF_PROG_TYPE_SOCK_OPS** + * + * Control Group v2 hierarchy with the eBPF controller + * enabled. Requires the kernel to be compiled with + * **CONFIG_CGROUP_BPF**. + * + * **BPF_PROG_TYPE_FLOW_DISSECTOR** + * + * Network namespace (eg /proc/self/ns/net). + * + * **BPF_PROG_TYPE_LIRC_MODE2** + * + * LIRC device path (eg /dev/lircN). Requires the kernel + * to be compiled with **CONFIG_BPF_LIRC_MODE2**. + * + * **BPF_PROG_QUERY** always fetches the number of programs + * attached and the *attach_flags* which were used to attach those + * programs. Additionally, if *prog_ids* is nonzero and the number + * of attached programs is less than *prog_cnt*, populates + * *prog_ids* with the eBPF program ids of the programs attached + * at *target_fd*. + * + * The following flags may alter the result: + * + * **BPF_F_QUERY_EFFECTIVE** + * Only return information regarding programs which are + * currently effective at the specified *target_fd*. + * + * Return + * Returns zero on success. On error, -1 is returned and *errno* + * is set appropriately. + * + * BPF_RAW_TRACEPOINT_OPEN + * Description + * Attach an eBPF program to a tracepoint *name* to access kernel + * internal arguments of the tracepoint in their raw form. + * + * The *prog_fd* must be a valid file descriptor associated with + * a loaded eBPF program of type **BPF_PROG_TYPE_RAW_TRACEPOINT**. + * + * No ABI guarantees are made about the content of tracepoint + * arguments exposed to the corresponding eBPF program. + * + * Applying **close**\ (2) to the file descriptor returned by + * **BPF_RAW_TRACEPOINT_OPEN** will delete the map (but see NOTES). + * + * Return + * A new file descriptor (a nonnegative integer), or -1 if an + * error occurred (in which case, *errno* is set appropriately). + * + * BPF_BTF_LOAD + * Description + * Verify and load BPF Type Format (BTF) metadata into the kernel, + * returning a new file descriptor associated with the metadata. + * BTF is described in more detail at + * https://www.kernel.org/doc/html/latest/bpf/btf.html. + * + * The *btf* parameter must point to valid memory providing + * *btf_size* bytes of BTF binary metadata. + * + * The returned file descriptor can be passed to other **bpf**\ () + * subcommands such as **BPF_PROG_LOAD** or **BPF_MAP_CREATE** to + * associate the BTF with those objects. + * + * Similar to **BPF_PROG_LOAD**, **BPF_BTF_LOAD** has optional + * parameters to specify a *btf_log_buf*, *btf_log_size* and + * *btf_log_level* which allow the kernel to return freeform log + * output regarding the BTF verification process. + * + * Return + * A new file descriptor (a nonnegative integer), or -1 if an + * error occurred (in which case, *errno* is set appropriately). + * + * BPF_BTF_GET_FD_BY_ID + * Description + * Open a file descriptor for the BPF Type Format (BTF) + * corresponding to *btf_id*. + * + * Return + * A new file descriptor (a nonnegative integer), or -1 if an + * error occurred (in which case, *errno* is set appropriately). + * + * BPF_TASK_FD_QUERY + * Description + * Obtain information about eBPF programs associated with the + * target process identified by *pid* and *fd*. + * + * If the *pid* and *fd* are associated with a tracepoint, kprobe + * or uprobe perf event, then the *prog_id* and *fd_type* will + * be populated with the eBPF program id and file descriptor type + * of type **bpf_task_fd_type**. If associated with a kprobe or + * uprobe, the *probe_offset* and *probe_addr* will also be + * populated. Optionally, if *buf* is provided, then up to + * *buf_len* bytes of *buf* will be populated with the name of + * the tracepoint, kprobe or uprobe. + * + * The resulting *prog_id* may be introspected in deeper detail + * using **BPF_PROG_GET_FD_BY_ID** and **BPF_OBJ_GET_INFO_BY_FD**. + * + * Return + * Returns zero on success. On error, -1 is returned and *errno* + * is set appropriately. + * + * BPF_MAP_LOOKUP_AND_DELETE_ELEM + * Description + * Look up an element with the given *key* in the map referred to + * by the file descriptor *fd*, and if found, delete the element. + * + * For **BPF_MAP_TYPE_QUEUE** and **BPF_MAP_TYPE_STACK** map + * types, the *flags* argument needs to be set to 0, but for other + * map types, it may be specified as: + * + * **BPF_F_LOCK** + * Look up and delete the value of a spin-locked map + * without returning the lock. This must be specified if + * the elements contain a spinlock. + * + * The **BPF_MAP_TYPE_QUEUE** and **BPF_MAP_TYPE_STACK** map types + * implement this command as a "pop" operation, deleting the top + * element rather than one corresponding to *key*. + * The *key* and *key_len* parameters should be zeroed when + * issuing this operation for these map types. + * + * This command is only valid for the following map types: + * * **BPF_MAP_TYPE_QUEUE** + * * **BPF_MAP_TYPE_STACK** + * * **BPF_MAP_TYPE_HASH** + * * **BPF_MAP_TYPE_PERCPU_HASH** + * * **BPF_MAP_TYPE_LRU_HASH** + * * **BPF_MAP_TYPE_LRU_PERCPU_HASH** + * + * Return + * Returns zero on success. On error, -1 is returned and *errno* + * is set appropriately. + * + * BPF_MAP_FREEZE + * Description + * Freeze the permissions of the specified map. + * + * Write permissions may be frozen by passing zero *flags*. + * Upon success, no future syscall invocations may alter the + * map state of *map_fd*. Write operations from eBPF programs + * are still possible for a frozen map. + * + * Not supported for maps of type **BPF_MAP_TYPE_STRUCT_OPS**. + * + * Return + * Returns zero on success. On error, -1 is returned and *errno* + * is set appropriately. + * + * BPF_BTF_GET_NEXT_ID + * Description + * Fetch the next BPF Type Format (BTF) object currently loaded + * into the kernel. + * + * Looks for the BTF object with an id greater than *start_id* + * and updates *next_id* on success. If no other BTF objects + * remain with ids higher than *start_id*, returns -1 and sets + * *errno* to **ENOENT**. + * + * Return + * Returns zero on success. On error, or when no id remains, -1 + * is returned and *errno* is set appropriately. + * + * BPF_MAP_LOOKUP_BATCH + * Description + * Iterate and fetch multiple elements in a map. + * + * Two opaque values are used to manage batch operations, + * *in_batch* and *out_batch*. Initially, *in_batch* must be set + * to NULL to begin the batched operation. After each subsequent + * **BPF_MAP_LOOKUP_BATCH**, the caller should pass the resultant + * *out_batch* as the *in_batch* for the next operation to + * continue iteration from the current point. + * + * The *keys* and *values* are output parameters which must point + * to memory large enough to hold *count* items based on the key + * and value size of the map *map_fd*. The *keys* buffer must be + * of *key_size* * *count*. The *values* buffer must be of + * *value_size* * *count*. + * + * The *elem_flags* argument may be specified as one of the + * following: + * + * **BPF_F_LOCK** + * Look up the value of a spin-locked map without + * returning the lock. This must be specified if the + * elements contain a spinlock. + * + * On success, *count* elements from the map are copied into the + * user buffer, with the keys copied into *keys* and the values + * copied into the corresponding indices in *values*. + * + * If an error is returned and *errno* is not **EFAULT**, *count* + * is set to the number of successfully processed elements. + * + * Return + * Returns zero on success. On error, -1 is returned and *errno* + * is set appropriately. + * + * May set *errno* to **ENOSPC** to indicate that *keys* or + * *values* is too small to dump an entire bucket during + * iteration of a hash-based map type. + * + * BPF_MAP_LOOKUP_AND_DELETE_BATCH + * Description + * Iterate and delete all elements in a map. + * + * This operation has the same behavior as + * **BPF_MAP_LOOKUP_BATCH** with two exceptions: + * + * * Every element that is successfully returned is also deleted + * from the map. This is at least *count* elements. Note that + * *count* is both an input and an output parameter. + * * Upon returning with *errno* set to **EFAULT**, up to + * *count* elements may be deleted without returning the keys + * and values of the deleted elements. + * + * Return + * Returns zero on success. On error, -1 is returned and *errno* + * is set appropriately. + * + * BPF_MAP_UPDATE_BATCH + * Description + * Update multiple elements in a map by *key*. + * + * The *keys* and *values* are input parameters which must point + * to memory large enough to hold *count* items based on the key + * and value size of the map *map_fd*. The *keys* buffer must be + * of *key_size* * *count*. The *values* buffer must be of + * *value_size* * *count*. + * + * Each element specified in *keys* is sequentially updated to the + * value in the corresponding index in *values*. The *in_batch* + * and *out_batch* parameters are ignored and should be zeroed. + * + * The *elem_flags* argument should be specified as one of the + * following: + * + * **BPF_ANY** + * Create new elements or update a existing elements. + * **BPF_NOEXIST** + * Create new elements only if they do not exist. + * **BPF_EXIST** + * Update existing elements. + * **BPF_F_LOCK** + * Update spin_lock-ed map elements. This must be + * specified if the map value contains a spinlock. + * + * On success, *count* elements from the map are updated. + * + * If an error is returned and *errno* is not **EFAULT**, *count* + * is set to the number of successfully processed elements. + * + * Return + * Returns zero on success. On error, -1 is returned and *errno* + * is set appropriately. + * + * May set *errno* to **EINVAL**, **EPERM**, **ENOMEM**, or + * **E2BIG**. **E2BIG** indicates that the number of elements in + * the map reached the *max_entries* limit specified at map + * creation time. + * + * May set *errno* to one of the following error codes under + * specific circumstances: + * + * **EEXIST** + * If *flags* specifies **BPF_NOEXIST** and the element + * with *key* already exists in the map. + * **ENOENT** + * If *flags* specifies **BPF_EXIST** and the element with + * *key* does not exist in the map. + * + * BPF_MAP_DELETE_BATCH + * Description + * Delete multiple elements in a map by *key*. + * + * The *keys* parameter is an input parameter which must point + * to memory large enough to hold *count* items based on the key + * size of the map *map_fd*, that is, *key_size* * *count*. + * + * Each element specified in *keys* is sequentially deleted. The + * *in_batch*, *out_batch*, and *values* parameters are ignored + * and should be zeroed. + * + * The *elem_flags* argument may be specified as one of the + * following: + * + * **BPF_F_LOCK** + * Look up the value of a spin-locked map without + * returning the lock. This must be specified if the + * elements contain a spinlock. + * + * On success, *count* elements from the map are updated. + * + * If an error is returned and *errno* is not **EFAULT**, *count* + * is set to the number of successfully processed elements. If + * *errno* is **EFAULT**, up to *count* elements may be been + * deleted. + * + * Return + * Returns zero on success. On error, -1 is returned and *errno* + * is set appropriately. + * + * BPF_LINK_CREATE + * Description + * Attach an eBPF program to a *target_fd* at the specified + * *attach_type* hook and return a file descriptor handle for + * managing the link. + * + * Return + * A new file descriptor (a nonnegative integer), or -1 if an + * error occurred (in which case, *errno* is set appropriately). + * + * BPF_LINK_UPDATE + * Description + * Update the eBPF program in the specified *link_fd* to + * *new_prog_fd*. + * + * Return + * Returns zero on success. On error, -1 is returned and *errno* + * is set appropriately. + * + * BPF_LINK_GET_FD_BY_ID + * Description + * Open a file descriptor for the eBPF Link corresponding to + * *link_id*. + * + * Return + * A new file descriptor (a nonnegative integer), or -1 if an + * error occurred (in which case, *errno* is set appropriately). + * + * BPF_LINK_GET_NEXT_ID + * Description + * Fetch the next eBPF link currently loaded into the kernel. + * + * Looks for the eBPF link with an id greater than *start_id* + * and updates *next_id* on success. If no other eBPF links + * remain with ids higher than *start_id*, returns -1 and sets + * *errno* to **ENOENT**. + * + * Return + * Returns zero on success. On error, or when no id remains, -1 + * is returned and *errno* is set appropriately. + * + * BPF_ENABLE_STATS + * Description + * Enable eBPF runtime statistics gathering. + * + * Runtime statistics gathering for the eBPF runtime is disabled + * by default to minimize the corresponding performance overhead. + * This command enables statistics globally. + * + * Multiple programs may independently enable statistics. + * After gathering the desired statistics, eBPF runtime statistics + * may be disabled again by calling **close**\ (2) for the file + * descriptor returned by this function. Statistics will only be + * disabled system-wide when all outstanding file descriptors + * returned by prior calls for this subcommand are closed. + * + * Return + * A new file descriptor (a nonnegative integer), or -1 if an + * error occurred (in which case, *errno* is set appropriately). + * + * BPF_ITER_CREATE + * Description + * Create an iterator on top of the specified *link_fd* (as + * previously created using **BPF_LINK_CREATE**) and return a + * file descriptor that can be used to trigger the iteration. + * + * If the resulting file descriptor is pinned to the filesystem + * using **BPF_OBJ_PIN**, then subsequent **read**\ (2) syscalls + * for that path will trigger the iterator to read kernel state + * using the eBPF program attached to *link_fd*. + * + * Return + * A new file descriptor (a nonnegative integer), or -1 if an + * error occurred (in which case, *errno* is set appropriately). + * + * BPF_LINK_DETACH + * Description + * Forcefully detach the specified *link_fd* from its + * corresponding attachment point. + * + * Return + * Returns zero on success. On error, -1 is returned and *errno* + * is set appropriately. + * + * BPF_PROG_BIND_MAP + * Description + * Bind a map to the lifetime of an eBPF program. + * + * The map identified by *map_fd* is bound to the program + * identified by *prog_fd* and only released when *prog_fd* is + * released. This may be used in cases where metadata should be + * associated with a program which otherwise does not contain any + * references to the map (for example, embedded in the eBPF + * program instructions). + * + * Return + * Returns zero on success. On error, -1 is returned and *errno* + * is set appropriately. + * + * NOTES + * eBPF objects (maps and programs) can be shared between processes. + * + * * After **fork**\ (2), the child inherits file descriptors + * referring to the same eBPF objects. + * * File descriptors referring to eBPF objects can be transferred over + * **unix**\ (7) domain sockets. + * * File descriptors referring to eBPF objects can be duplicated in the + * usual way, using **dup**\ (2) and similar calls. + * * File descriptors referring to eBPF objects can be pinned to the + * filesystem using the **BPF_OBJ_PIN** command of **bpf**\ (2). + * + * An eBPF object is deallocated only after all file descriptors referring + * to the object have been closed and no references remain pinned to the + * filesystem or attached (for example, bound to a program or device). + */ +enum bpf_cmd { + BPF_MAP_CREATE, + BPF_MAP_LOOKUP_ELEM, + BPF_MAP_UPDATE_ELEM, + BPF_MAP_DELETE_ELEM, + BPF_MAP_GET_NEXT_KEY, + BPF_PROG_LOAD, + BPF_OBJ_PIN, + BPF_OBJ_GET, + BPF_PROG_ATTACH, + BPF_PROG_DETACH, + BPF_PROG_TEST_RUN, + BPF_PROG_RUN = BPF_PROG_TEST_RUN, + BPF_PROG_GET_NEXT_ID, + BPF_MAP_GET_NEXT_ID, + BPF_PROG_GET_FD_BY_ID, + BPF_MAP_GET_FD_BY_ID, + BPF_OBJ_GET_INFO_BY_FD, + BPF_PROG_QUERY, + BPF_RAW_TRACEPOINT_OPEN, + BPF_BTF_LOAD, + BPF_BTF_GET_FD_BY_ID, + BPF_TASK_FD_QUERY, + BPF_MAP_LOOKUP_AND_DELETE_ELEM, + BPF_MAP_FREEZE, + BPF_BTF_GET_NEXT_ID, + BPF_MAP_LOOKUP_BATCH, + BPF_MAP_LOOKUP_AND_DELETE_BATCH, + BPF_MAP_UPDATE_BATCH, + BPF_MAP_DELETE_BATCH, + BPF_LINK_CREATE, + BPF_LINK_UPDATE, + BPF_LINK_GET_FD_BY_ID, + BPF_LINK_GET_NEXT_ID, + BPF_ENABLE_STATS, + BPF_ITER_CREATE, + BPF_LINK_DETACH, + BPF_PROG_BIND_MAP, +}; + +enum bpf_map_type { + BPF_MAP_TYPE_UNSPEC, + BPF_MAP_TYPE_HASH, + BPF_MAP_TYPE_ARRAY, + BPF_MAP_TYPE_PROG_ARRAY, + BPF_MAP_TYPE_PERF_EVENT_ARRAY, + BPF_MAP_TYPE_PERCPU_HASH, + BPF_MAP_TYPE_PERCPU_ARRAY, + BPF_MAP_TYPE_STACK_TRACE, + BPF_MAP_TYPE_CGROUP_ARRAY, + BPF_MAP_TYPE_LRU_HASH, + BPF_MAP_TYPE_LRU_PERCPU_HASH, + BPF_MAP_TYPE_LPM_TRIE, + BPF_MAP_TYPE_ARRAY_OF_MAPS, + BPF_MAP_TYPE_HASH_OF_MAPS, + BPF_MAP_TYPE_DEVMAP, + BPF_MAP_TYPE_SOCKMAP, + BPF_MAP_TYPE_CPUMAP, + BPF_MAP_TYPE_XSKMAP, + BPF_MAP_TYPE_SOCKHASH, + BPF_MAP_TYPE_CGROUP_STORAGE, + BPF_MAP_TYPE_REUSEPORT_SOCKARRAY, + BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE, + BPF_MAP_TYPE_QUEUE, + BPF_MAP_TYPE_STACK, + BPF_MAP_TYPE_SK_STORAGE, + BPF_MAP_TYPE_DEVMAP_HASH, + BPF_MAP_TYPE_STRUCT_OPS, + BPF_MAP_TYPE_RINGBUF, + BPF_MAP_TYPE_INODE_STORAGE, + BPF_MAP_TYPE_TASK_STORAGE, +}; + +/* Note that tracing related programs such as + * BPF_PROG_TYPE_{KPROBE,TRACEPOINT,PERF_EVENT,RAW_TRACEPOINT} + * are not subject to a stable API since kernel internal data + * structures can change from release to release and may + * therefore break existing tracing BPF programs. Tracing BPF + * programs correspond to /a/ specific kernel which is to be + * analyzed, and not /a/ specific kernel /and/ all future ones. + */ +enum bpf_prog_type { + BPF_PROG_TYPE_UNSPEC, + BPF_PROG_TYPE_SOCKET_FILTER, + BPF_PROG_TYPE_KPROBE, + BPF_PROG_TYPE_SCHED_CLS, + BPF_PROG_TYPE_SCHED_ACT, + BPF_PROG_TYPE_TRACEPOINT, + BPF_PROG_TYPE_XDP, + BPF_PROG_TYPE_PERF_EVENT, + BPF_PROG_TYPE_CGROUP_SKB, + BPF_PROG_TYPE_CGROUP_SOCK, + BPF_PROG_TYPE_LWT_IN, + BPF_PROG_TYPE_LWT_OUT, + BPF_PROG_TYPE_LWT_XMIT, + BPF_PROG_TYPE_SOCK_OPS, + BPF_PROG_TYPE_SK_SKB, + BPF_PROG_TYPE_CGROUP_DEVICE, + BPF_PROG_TYPE_SK_MSG, + BPF_PROG_TYPE_RAW_TRACEPOINT, + BPF_PROG_TYPE_CGROUP_SOCK_ADDR, + BPF_PROG_TYPE_LWT_SEG6LOCAL, + BPF_PROG_TYPE_LIRC_MODE2, + BPF_PROG_TYPE_SK_REUSEPORT, + BPF_PROG_TYPE_FLOW_DISSECTOR, + BPF_PROG_TYPE_CGROUP_SYSCTL, + BPF_PROG_TYPE_RAW_TRACEPOINT_WRITABLE, + BPF_PROG_TYPE_CGROUP_SOCKOPT, + BPF_PROG_TYPE_TRACING, + BPF_PROG_TYPE_STRUCT_OPS, + BPF_PROG_TYPE_EXT, + BPF_PROG_TYPE_LSM, + BPF_PROG_TYPE_SK_LOOKUP, + BPF_PROG_TYPE_SYSCALL, /* a program that can execute syscalls */ +}; + +enum bpf_attach_type { + BPF_CGROUP_INET_INGRESS, + BPF_CGROUP_INET_EGRESS, + BPF_CGROUP_INET_SOCK_CREATE, + BPF_CGROUP_SOCK_OPS, + BPF_SK_SKB_STREAM_PARSER, + BPF_SK_SKB_STREAM_VERDICT, + BPF_CGROUP_DEVICE, + BPF_SK_MSG_VERDICT, + BPF_CGROUP_INET4_BIND, + BPF_CGROUP_INET6_BIND, + BPF_CGROUP_INET4_CONNECT, + BPF_CGROUP_INET6_CONNECT, + BPF_CGROUP_INET4_POST_BIND, + BPF_CGROUP_INET6_POST_BIND, + BPF_CGROUP_UDP4_SENDMSG, + BPF_CGROUP_UDP6_SENDMSG, + BPF_LIRC_MODE2, + BPF_FLOW_DISSECTOR, + BPF_CGROUP_SYSCTL, + BPF_CGROUP_UDP4_RECVMSG, + BPF_CGROUP_UDP6_RECVMSG, + BPF_CGROUP_GETSOCKOPT, + BPF_CGROUP_SETSOCKOPT, + BPF_TRACE_RAW_TP, + BPF_TRACE_FENTRY, + BPF_TRACE_FEXIT, + BPF_MODIFY_RETURN, + BPF_LSM_MAC, + BPF_TRACE_ITER, + BPF_CGROUP_INET4_GETPEERNAME, + BPF_CGROUP_INET6_GETPEERNAME, + BPF_CGROUP_INET4_GETSOCKNAME, + BPF_CGROUP_INET6_GETSOCKNAME, + BPF_XDP_DEVMAP, + BPF_CGROUP_INET_SOCK_RELEASE, + BPF_XDP_CPUMAP, + BPF_SK_LOOKUP, + BPF_XDP, + BPF_SK_SKB_VERDICT, + BPF_SK_REUSEPORT_SELECT, + BPF_SK_REUSEPORT_SELECT_OR_MIGRATE, + __MAX_BPF_ATTACH_TYPE +}; + +#define MAX_BPF_ATTACH_TYPE __MAX_BPF_ATTACH_TYPE + +enum bpf_link_type { + BPF_LINK_TYPE_UNSPEC = 0, + BPF_LINK_TYPE_RAW_TRACEPOINT = 1, + BPF_LINK_TYPE_TRACING = 2, + BPF_LINK_TYPE_CGROUP = 3, + BPF_LINK_TYPE_ITER = 4, + BPF_LINK_TYPE_NETNS = 5, + BPF_LINK_TYPE_XDP = 6, + + MAX_BPF_LINK_TYPE, +}; + +/* cgroup-bpf attach flags used in BPF_PROG_ATTACH command + * + * NONE(default): No further bpf programs allowed in the subtree. + * + * BPF_F_ALLOW_OVERRIDE: If a sub-cgroup installs some bpf program, + * the program in this cgroup yields to sub-cgroup program. + * + * BPF_F_ALLOW_MULTI: If a sub-cgroup installs some bpf program, + * that cgroup program gets run in addition to the program in this cgroup. + * + * Only one program is allowed to be attached to a cgroup with + * NONE or BPF_F_ALLOW_OVERRIDE flag. + * Attaching another program on top of NONE or BPF_F_ALLOW_OVERRIDE will + * release old program and attach the new one. Attach flags has to match. + * + * Multiple programs are allowed to be attached to a cgroup with + * BPF_F_ALLOW_MULTI flag. They are executed in FIFO order + * (those that were attached first, run first) + * The programs of sub-cgroup are executed first, then programs of + * this cgroup and then programs of parent cgroup. + * When children program makes decision (like picking TCP CA or sock bind) + * parent program has a chance to override it. + * + * With BPF_F_ALLOW_MULTI a new program is added to the end of the list of + * programs for a cgroup. Though it's possible to replace an old program at + * any position by also specifying BPF_F_REPLACE flag and position itself in + * replace_bpf_fd attribute. Old program at this position will be released. + * + * A cgroup with MULTI or OVERRIDE flag allows any attach flags in sub-cgroups. + * A cgroup with NONE doesn't allow any programs in sub-cgroups. + * Ex1: + * cgrp1 (MULTI progs A, B) -> + * cgrp2 (OVERRIDE prog C) -> + * cgrp3 (MULTI prog D) -> + * cgrp4 (OVERRIDE prog E) -> + * cgrp5 (NONE prog F) + * the event in cgrp5 triggers execution of F,D,A,B in that order. + * if prog F is detached, the execution is E,D,A,B + * if prog F and D are detached, the execution is E,A,B + * if prog F, E and D are detached, the execution is C,A,B + * + * All eligible programs are executed regardless of return code from + * earlier programs. + */ +#define BPF_F_ALLOW_OVERRIDE (1U << 0) +#define BPF_F_ALLOW_MULTI (1U << 1) +#define BPF_F_REPLACE (1U << 2) + +/* If BPF_F_STRICT_ALIGNMENT is used in BPF_PROG_LOAD command, the + * verifier will perform strict alignment checking as if the kernel + * has been built with CONFIG_EFFICIENT_UNALIGNED_ACCESS not set, + * and NET_IP_ALIGN defined to 2. + */ +#define BPF_F_STRICT_ALIGNMENT (1U << 0) + +/* If BPF_F_ANY_ALIGNMENT is used in BPF_PROF_LOAD command, the + * verifier will allow any alignment whatsoever. On platforms + * with strict alignment requirements for loads ands stores (such + * as sparc and mips) the verifier validates that all loads and + * stores provably follow this requirement. This flag turns that + * checking and enforcement off. + * + * It is mostly used for testing when we want to validate the + * context and memory access aspects of the verifier, but because + * of an unaligned access the alignment check would trigger before + * the one we are interested in. + */ +#define BPF_F_ANY_ALIGNMENT (1U << 1) + +/* BPF_F_TEST_RND_HI32 is used in BPF_PROG_LOAD command for testing purpose. + * Verifier does sub-register def/use analysis and identifies instructions whose + * def only matters for low 32-bit, high 32-bit is never referenced later + * through implicit zero extension. Therefore verifier notifies JIT back-ends + * that it is safe to ignore clearing high 32-bit for these instructions. This + * saves some back-ends a lot of code-gen. However such optimization is not + * necessary on some arches, for example x86_64, arm64 etc, whose JIT back-ends + * hence hasn't used verifier's analysis result. But, we really want to have a + * way to be able to verify the correctness of the described optimization on + * x86_64 on which testsuites are frequently exercised. + * + * So, this flag is introduced. Once it is set, verifier will randomize high + * 32-bit for those instructions who has been identified as safe to ignore them. + * Then, if verifier is not doing correct analysis, such randomization will + * regress tests to expose bugs. + */ +#define BPF_F_TEST_RND_HI32 (1U << 2) + +/* The verifier internal test flag. Behavior is undefined */ +#define BPF_F_TEST_STATE_FREQ (1U << 3) + +/* If BPF_F_SLEEPABLE is used in BPF_PROG_LOAD command, the verifier will + * restrict map and helper usage for such programs. Sleepable BPF programs can + * only be attached to hooks where kernel execution context allows sleeping. + * Such programs are allowed to use helpers that may sleep like + * bpf_copy_from_user(). + */ +#define BPF_F_SLEEPABLE (1U << 4) + +/* When BPF ldimm64's insn[0].src_reg != 0 then this can have + * the following extensions: + * + * insn[0].src_reg: BPF_PSEUDO_MAP_[FD|IDX] + * insn[0].imm: map fd or fd_idx + * insn[1].imm: 0 + * insn[0].off: 0 + * insn[1].off: 0 + * ldimm64 rewrite: address of map + * verifier type: CONST_PTR_TO_MAP + */ +#define BPF_PSEUDO_MAP_FD 1 +#define BPF_PSEUDO_MAP_IDX 5 + +/* insn[0].src_reg: BPF_PSEUDO_MAP_[IDX_]VALUE + * insn[0].imm: map fd or fd_idx + * insn[1].imm: offset into value + * insn[0].off: 0 + * insn[1].off: 0 + * ldimm64 rewrite: address of map[0]+offset + * verifier type: PTR_TO_MAP_VALUE + */ +#define BPF_PSEUDO_MAP_VALUE 2 +#define BPF_PSEUDO_MAP_IDX_VALUE 6 + +/* insn[0].src_reg: BPF_PSEUDO_BTF_ID + * insn[0].imm: kernel btd id of VAR + * insn[1].imm: 0 + * insn[0].off: 0 + * insn[1].off: 0 + * ldimm64 rewrite: address of the kernel variable + * verifier type: PTR_TO_BTF_ID or PTR_TO_MEM, depending on whether the var + * is struct/union. + */ +#define BPF_PSEUDO_BTF_ID 3 +/* insn[0].src_reg: BPF_PSEUDO_FUNC + * insn[0].imm: insn offset to the func + * insn[1].imm: 0 + * insn[0].off: 0 + * insn[1].off: 0 + * ldimm64 rewrite: address of the function + * verifier type: PTR_TO_FUNC. + */ +#define BPF_PSEUDO_FUNC 4 + +/* when bpf_call->src_reg == BPF_PSEUDO_CALL, bpf_call->imm == pc-relative + * offset to another bpf function + */ +#define BPF_PSEUDO_CALL 1 +/* when bpf_call->src_reg == BPF_PSEUDO_KFUNC_CALL, + * bpf_call->imm == btf_id of a BTF_KIND_FUNC in the running kernel + */ +#define BPF_PSEUDO_KFUNC_CALL 2 + +/* flags for BPF_MAP_UPDATE_ELEM command */ +enum { + BPF_ANY = 0, /* create new element or update existing */ + BPF_NOEXIST = 1, /* create new element if it didn't exist */ + BPF_EXIST = 2, /* update existing element */ + BPF_F_LOCK = 4, /* spin_lock-ed map_lookup/map_update */ +}; + +/* flags for BPF_MAP_CREATE command */ +enum { + BPF_F_NO_PREALLOC = (1U << 0), +/* Instead of having one common LRU list in the + * BPF_MAP_TYPE_LRU_[PERCPU_]HASH map, use a percpu LRU list + * which can scale and perform better. + * Note, the LRU nodes (including free nodes) cannot be moved + * across different LRU lists. + */ + BPF_F_NO_COMMON_LRU = (1U << 1), +/* Specify numa node during map creation */ + BPF_F_NUMA_NODE = (1U << 2), + +/* Flags for accessing BPF object from syscall side. */ + BPF_F_RDONLY = (1U << 3), + BPF_F_WRONLY = (1U << 4), + +/* Flag for stack_map, store build_id+offset instead of pointer */ + BPF_F_STACK_BUILD_ID = (1U << 5), + +/* Zero-initialize hash function seed. This should only be used for testing. */ + BPF_F_ZERO_SEED = (1U << 6), + +/* Flags for accessing BPF object from program side. */ + BPF_F_RDONLY_PROG = (1U << 7), + BPF_F_WRONLY_PROG = (1U << 8), + +/* Clone map from listener for newly accepted socket */ + BPF_F_CLONE = (1U << 9), + +/* Enable memory-mapping BPF map */ + BPF_F_MMAPABLE = (1U << 10), + +/* Share perf_event among processes */ + BPF_F_PRESERVE_ELEMS = (1U << 11), + +/* Create a map that is suitable to be an inner map with dynamic max entries */ + BPF_F_INNER_MAP = (1U << 12), +}; + +/* Flags for BPF_PROG_QUERY. */ + +/* Query effective (directly attached + inherited from ancestor cgroups) + * programs that will be executed for events within a cgroup. + * attach_flags with this flag are returned only for directly attached programs. + */ +#define BPF_F_QUERY_EFFECTIVE (1U << 0) + +/* Flags for BPF_PROG_TEST_RUN */ + +/* If set, run the test on the cpu specified by bpf_attr.test.cpu */ +#define BPF_F_TEST_RUN_ON_CPU (1U << 0) + +/* type for BPF_ENABLE_STATS */ +enum bpf_stats_type { + /* enabled run_time_ns and run_cnt */ + BPF_STATS_RUN_TIME = 0, +}; + +enum bpf_stack_build_id_status { + /* user space need an empty entry to identify end of a trace */ + BPF_STACK_BUILD_ID_EMPTY = 0, + /* with valid build_id and offset */ + BPF_STACK_BUILD_ID_VALID = 1, + /* couldn't get build_id, fallback to ip */ + BPF_STACK_BUILD_ID_IP = 2, +}; + +#define BPF_BUILD_ID_SIZE 20 +struct bpf_stack_build_id { + __s32 status; + unsigned char build_id[BPF_BUILD_ID_SIZE]; + union { + __u64 offset; + __u64 ip; + }; +}; + +#define BPF_OBJ_NAME_LEN 16U + +union bpf_attr { + struct { /* anonymous struct used by BPF_MAP_CREATE command */ + __u32 map_type; /* one of enum bpf_map_type */ + __u32 key_size; /* size of key in bytes */ + __u32 value_size; /* size of value in bytes */ + __u32 max_entries; /* max number of entries in a map */ + __u32 map_flags; /* BPF_MAP_CREATE related + * flags defined above. + */ + __u32 inner_map_fd; /* fd pointing to the inner map */ + __u32 numa_node; /* numa node (effective only if + * BPF_F_NUMA_NODE is set). + */ + char map_name[BPF_OBJ_NAME_LEN]; + __u32 map_ifindex; /* ifindex of netdev to create on */ + __u32 btf_fd; /* fd pointing to a BTF type data */ + __u32 btf_key_type_id; /* BTF type_id of the key */ + __u32 btf_value_type_id; /* BTF type_id of the value */ + __u32 btf_vmlinux_value_type_id;/* BTF type_id of a kernel- + * struct stored as the + * map value + */ + }; + + struct { /* anonymous struct used by BPF_MAP_*_ELEM commands */ + __u32 map_fd; + __aligned_u64 key; + union { + __aligned_u64 value; + __aligned_u64 next_key; + }; + __u64 flags; + }; + + struct { /* struct used by BPF_MAP_*_BATCH commands */ + __aligned_u64 in_batch; /* start batch, + * NULL to start from beginning + */ + __aligned_u64 out_batch; /* output: next start batch */ + __aligned_u64 keys; + __aligned_u64 values; + __u32 count; /* input/output: + * input: # of key/value + * elements + * output: # of filled elements + */ + __u32 map_fd; + __u64 elem_flags; + __u64 flags; + } batch; + + struct { /* anonymous struct used by BPF_PROG_LOAD command */ + __u32 prog_type; /* one of enum bpf_prog_type */ + __u32 insn_cnt; + __aligned_u64 insns; + __aligned_u64 license; + __u32 log_level; /* verbosity level of verifier */ + __u32 log_size; /* size of user buffer */ + __aligned_u64 log_buf; /* user supplied buffer */ + __u32 kern_version; /* not used */ + __u32 prog_flags; + char prog_name[BPF_OBJ_NAME_LEN]; + __u32 prog_ifindex; /* ifindex of netdev to prep for */ + /* For some prog types expected attach type must be known at + * load time to verify attach type specific parts of prog + * (context accesses, allowed helpers, etc). + */ + __u32 expected_attach_type; + __u32 prog_btf_fd; /* fd pointing to BTF type data */ + __u32 func_info_rec_size; /* userspace bpf_func_info size */ + __aligned_u64 func_info; /* func info */ + __u32 func_info_cnt; /* number of bpf_func_info records */ + __u32 line_info_rec_size; /* userspace bpf_line_info size */ + __aligned_u64 line_info; /* line info */ + __u32 line_info_cnt; /* number of bpf_line_info records */ + __u32 attach_btf_id; /* in-kernel BTF type id to attach to */ + union { + /* valid prog_fd to attach to bpf prog */ + __u32 attach_prog_fd; + /* or valid module BTF object fd or 0 to attach to vmlinux */ + __u32 attach_btf_obj_fd; + }; + __u32 :32; /* pad */ + __aligned_u64 fd_array; /* array of FDs */ + }; + + struct { /* anonymous struct used by BPF_OBJ_* commands */ + __aligned_u64 pathname; + __u32 bpf_fd; + __u32 file_flags; + }; + + struct { /* anonymous struct used by BPF_PROG_ATTACH/DETACH commands */ + __u32 target_fd; /* container object to attach to */ + __u32 attach_bpf_fd; /* eBPF program to attach */ + __u32 attach_type; + __u32 attach_flags; + __u32 replace_bpf_fd; /* previously attached eBPF + * program to replace if + * BPF_F_REPLACE is used + */ + }; + + struct { /* anonymous struct used by BPF_PROG_TEST_RUN command */ + __u32 prog_fd; + __u32 retval; + __u32 data_size_in; /* input: len of data_in */ + __u32 data_size_out; /* input/output: len of data_out + * returns ENOSPC if data_out + * is too small. + */ + __aligned_u64 data_in; + __aligned_u64 data_out; + __u32 repeat; + __u32 duration; + __u32 ctx_size_in; /* input: len of ctx_in */ + __u32 ctx_size_out; /* input/output: len of ctx_out + * returns ENOSPC if ctx_out + * is too small. + */ + __aligned_u64 ctx_in; + __aligned_u64 ctx_out; + __u32 flags; + __u32 cpu; + } test; + + struct { /* anonymous struct used by BPF_*_GET_*_ID */ + union { + __u32 start_id; + __u32 prog_id; + __u32 map_id; + __u32 btf_id; + __u32 link_id; + }; + __u32 next_id; + __u32 open_flags; + }; + + struct { /* anonymous struct used by BPF_OBJ_GET_INFO_BY_FD */ + __u32 bpf_fd; + __u32 info_len; + __aligned_u64 info; + } info; + + struct { /* anonymous struct used by BPF_PROG_QUERY command */ + __u32 target_fd; /* container object to query */ + __u32 attach_type; + __u32 query_flags; + __u32 attach_flags; + __aligned_u64 prog_ids; + __u32 prog_cnt; + } query; + + struct { /* anonymous struct used by BPF_RAW_TRACEPOINT_OPEN command */ + __u64 name; + __u32 prog_fd; + } raw_tracepoint; + + struct { /* anonymous struct for BPF_BTF_LOAD */ + __aligned_u64 btf; + __aligned_u64 btf_log_buf; + __u32 btf_size; + __u32 btf_log_size; + __u32 btf_log_level; + }; + + struct { + __u32 pid; /* input: pid */ + __u32 fd; /* input: fd */ + __u32 flags; /* input: flags */ + __u32 buf_len; /* input/output: buf len */ + __aligned_u64 buf; /* input/output: + * tp_name for tracepoint + * symbol for kprobe + * filename for uprobe + */ + __u32 prog_id; /* output: prod_id */ + __u32 fd_type; /* output: BPF_FD_TYPE_* */ + __u64 probe_offset; /* output: probe_offset */ + __u64 probe_addr; /* output: probe_addr */ + } task_fd_query; + + struct { /* struct used by BPF_LINK_CREATE command */ + __u32 prog_fd; /* eBPF program to attach */ + union { + __u32 target_fd; /* object to attach to */ + __u32 target_ifindex; /* target ifindex */ + }; + __u32 attach_type; /* attach type */ + __u32 flags; /* extra flags */ + union { + __u32 target_btf_id; /* btf_id of target to attach to */ + struct { + __aligned_u64 iter_info; /* extra bpf_iter_link_info */ + __u32 iter_info_len; /* iter_info length */ + }; + }; + } link_create; + + struct { /* struct used by BPF_LINK_UPDATE command */ + __u32 link_fd; /* link fd */ + /* new program fd to update link with */ + __u32 new_prog_fd; + __u32 flags; /* extra flags */ + /* expected link's program fd; is specified only if + * BPF_F_REPLACE flag is set in flags */ + __u32 old_prog_fd; + } link_update; + + struct { + __u32 link_fd; + } link_detach; + + struct { /* struct used by BPF_ENABLE_STATS command */ + __u32 type; + } enable_stats; + + struct { /* struct used by BPF_ITER_CREATE command */ + __u32 link_fd; + __u32 flags; + } iter_create; + + struct { /* struct used by BPF_PROG_BIND_MAP command */ + __u32 prog_fd; + __u32 map_fd; + __u32 flags; /* extra flags */ + } prog_bind_map; + +} __attribute__((aligned(8))); + +/* The description below is an attempt at providing documentation to eBPF + * developers about the multiple available eBPF helper functions. It can be + * parsed and used to produce a manual page. The workflow is the following, + * and requires the rst2man utility: + * + * $ ./scripts/bpf_doc.py \ + * --filename include/uapi/linux/bpf.h > /tmp/bpf-helpers.rst + * $ rst2man /tmp/bpf-helpers.rst > /tmp/bpf-helpers.7 + * $ man /tmp/bpf-helpers.7 + * + * Note that in order to produce this external documentation, some RST + * formatting is used in the descriptions to get "bold" and "italics" in + * manual pages. Also note that the few trailing white spaces are + * intentional, removing them would break paragraphs for rst2man. + * + * Start of BPF helper function descriptions: + * + * void *bpf_map_lookup_elem(struct bpf_map *map, const void *key) + * Description + * Perform a lookup in *map* for an entry associated to *key*. + * Return + * Map value associated to *key*, or **NULL** if no entry was + * found. + * + * long bpf_map_update_elem(struct bpf_map *map, const void *key, const void *value, u64 flags) + * Description + * Add or update the value of the entry associated to *key* in + * *map* with *value*. *flags* is one of: + * + * **BPF_NOEXIST** + * The entry for *key* must not exist in the map. + * **BPF_EXIST** + * The entry for *key* must already exist in the map. + * **BPF_ANY** + * No condition on the existence of the entry for *key*. + * + * Flag value **BPF_NOEXIST** cannot be used for maps of types + * **BPF_MAP_TYPE_ARRAY** or **BPF_MAP_TYPE_PERCPU_ARRAY** (all + * elements always exist), the helper would return an error. + * Return + * 0 on success, or a negative error in case of failure. + * + * long bpf_map_delete_elem(struct bpf_map *map, const void *key) + * Description + * Delete entry with *key* from *map*. + * Return + * 0 on success, or a negative error in case of failure. + * + * long bpf_probe_read(void *dst, u32 size, const void *unsafe_ptr) + * Description + * For tracing programs, safely attempt to read *size* bytes from + * kernel space address *unsafe_ptr* and store the data in *dst*. + * + * Generally, use **bpf_probe_read_user**\ () or + * **bpf_probe_read_kernel**\ () instead. + * Return + * 0 on success, or a negative error in case of failure. + * + * u64 bpf_ktime_get_ns(void) + * Description + * Return the time elapsed since system boot, in nanoseconds. + * Does not include time the system was suspended. + * See: **clock_gettime**\ (**CLOCK_MONOTONIC**) + * Return + * Current *ktime*. + * + * long bpf_trace_printk(const char *fmt, u32 fmt_size, ...) + * Description + * This helper is a "printk()-like" facility for debugging. It + * prints a message defined by format *fmt* (of size *fmt_size*) + * to file *\/sys/kernel/debug/tracing/trace* from DebugFS, if + * available. It can take up to three additional **u64** + * arguments (as an eBPF helpers, the total number of arguments is + * limited to five). + * + * Each time the helper is called, it appends a line to the trace. + * Lines are discarded while *\/sys/kernel/debug/tracing/trace* is + * open, use *\/sys/kernel/debug/tracing/trace_pipe* to avoid this. + * The format of the trace is customizable, and the exact output + * one will get depends on the options set in + * *\/sys/kernel/debug/tracing/trace_options* (see also the + * *README* file under the same directory). However, it usually + * defaults to something like: + * + * :: + * + * telnet-470 [001] .N.. 419421.045894: 0x00000001: + * + * In the above: + * + * * ``telnet`` is the name of the current task. + * * ``470`` is the PID of the current task. + * * ``001`` is the CPU number on which the task is + * running. + * * In ``.N..``, each character refers to a set of + * options (whether irqs are enabled, scheduling + * options, whether hard/softirqs are running, level of + * preempt_disabled respectively). **N** means that + * **TIF_NEED_RESCHED** and **PREEMPT_NEED_RESCHED** + * are set. + * * ``419421.045894`` is a timestamp. + * * ``0x00000001`` is a fake value used by BPF for the + * instruction pointer register. + * * ```` is the message formatted with + * *fmt*. + * + * The conversion specifiers supported by *fmt* are similar, but + * more limited than for printk(). They are **%d**, **%i**, + * **%u**, **%x**, **%ld**, **%li**, **%lu**, **%lx**, **%lld**, + * **%lli**, **%llu**, **%llx**, **%p**, **%s**. No modifier (size + * of field, padding with zeroes, etc.) is available, and the + * helper will return **-EINVAL** (but print nothing) if it + * encounters an unknown specifier. + * + * Also, note that **bpf_trace_printk**\ () is slow, and should + * only be used for debugging purposes. For this reason, a notice + * block (spanning several lines) is printed to kernel logs and + * states that the helper should not be used "for production use" + * the first time this helper is used (or more precisely, when + * **trace_printk**\ () buffers are allocated). For passing values + * to user space, perf events should be preferred. + * Return + * The number of bytes written to the buffer, or a negative error + * in case of failure. + * + * u32 bpf_get_prandom_u32(void) + * Description + * Get a pseudo-random number. + * + * From a security point of view, this helper uses its own + * pseudo-random internal state, and cannot be used to infer the + * seed of other random functions in the kernel. However, it is + * essential to note that the generator used by the helper is not + * cryptographically secure. + * Return + * A random 32-bit unsigned value. + * + * u32 bpf_get_smp_processor_id(void) + * Description + * Get the SMP (symmetric multiprocessing) processor id. Note that + * all programs run with preemption disabled, which means that the + * SMP processor id is stable during all the execution of the + * program. + * Return + * The SMP id of the processor running the program. + * + * long bpf_skb_store_bytes(struct sk_buff *skb, u32 offset, const void *from, u32 len, u64 flags) + * Description + * Store *len* bytes from address *from* into the packet + * associated to *skb*, at *offset*. *flags* are a combination of + * **BPF_F_RECOMPUTE_CSUM** (automatically recompute the + * checksum for the packet after storing the bytes) and + * **BPF_F_INVALIDATE_HASH** (set *skb*\ **->hash**, *skb*\ + * **->swhash** and *skb*\ **->l4hash** to 0). + * + * A call to this helper is susceptible to change the underlying + * packet buffer. Therefore, at load time, all checks on pointers + * previously done by the verifier are invalidated and must be + * performed again, if the helper is used in combination with + * direct packet access. + * Return + * 0 on success, or a negative error in case of failure. + * + * long bpf_l3_csum_replace(struct sk_buff *skb, u32 offset, u64 from, u64 to, u64 size) + * Description + * Recompute the layer 3 (e.g. IP) checksum for the packet + * associated to *skb*. Computation is incremental, so the helper + * must know the former value of the header field that was + * modified (*from*), the new value of this field (*to*), and the + * number of bytes (2 or 4) for this field, stored in *size*. + * Alternatively, it is possible to store the difference between + * the previous and the new values of the header field in *to*, by + * setting *from* and *size* to 0. For both methods, *offset* + * indicates the location of the IP checksum within the packet. + * + * This helper works in combination with **bpf_csum_diff**\ (), + * which does not update the checksum in-place, but offers more + * flexibility and can handle sizes larger than 2 or 4 for the + * checksum to update. + * + * A call to this helper is susceptible to change the underlying + * packet buffer. Therefore, at load time, all checks on pointers + * previously done by the verifier are invalidated and must be + * performed again, if the helper is used in combination with + * direct packet access. + * Return + * 0 on success, or a negative error in case of failure. + * + * long bpf_l4_csum_replace(struct sk_buff *skb, u32 offset, u64 from, u64 to, u64 flags) + * Description + * Recompute the layer 4 (e.g. TCP, UDP or ICMP) checksum for the + * packet associated to *skb*. Computation is incremental, so the + * helper must know the former value of the header field that was + * modified (*from*), the new value of this field (*to*), and the + * number of bytes (2 or 4) for this field, stored on the lowest + * four bits of *flags*. Alternatively, it is possible to store + * the difference between the previous and the new values of the + * header field in *to*, by setting *from* and the four lowest + * bits of *flags* to 0. For both methods, *offset* indicates the + * location of the IP checksum within the packet. In addition to + * the size of the field, *flags* can be added (bitwise OR) actual + * flags. With **BPF_F_MARK_MANGLED_0**, a null checksum is left + * untouched (unless **BPF_F_MARK_ENFORCE** is added as well), and + * for updates resulting in a null checksum the value is set to + * **CSUM_MANGLED_0** instead. Flag **BPF_F_PSEUDO_HDR** indicates + * the checksum is to be computed against a pseudo-header. + * + * This helper works in combination with **bpf_csum_diff**\ (), + * which does not update the checksum in-place, but offers more + * flexibility and can handle sizes larger than 2 or 4 for the + * checksum to update. + * + * A call to this helper is susceptible to change the underlying + * packet buffer. Therefore, at load time, all checks on pointers + * previously done by the verifier are invalidated and must be + * performed again, if the helper is used in combination with + * direct packet access. + * Return + * 0 on success, or a negative error in case of failure. + * + * long bpf_tail_call(void *ctx, struct bpf_map *prog_array_map, u32 index) + * Description + * This special helper is used to trigger a "tail call", or in + * other words, to jump into another eBPF program. The same stack + * frame is used (but values on stack and in registers for the + * caller are not accessible to the callee). This mechanism allows + * for program chaining, either for raising the maximum number of + * available eBPF instructions, or to execute given programs in + * conditional blocks. For security reasons, there is an upper + * limit to the number of successive tail calls that can be + * performed. + * + * Upon call of this helper, the program attempts to jump into a + * program referenced at index *index* in *prog_array_map*, a + * special map of type **BPF_MAP_TYPE_PROG_ARRAY**, and passes + * *ctx*, a pointer to the context. + * + * If the call succeeds, the kernel immediately runs the first + * instruction of the new program. This is not a function call, + * and it never returns to the previous program. If the call + * fails, then the helper has no effect, and the caller continues + * to run its subsequent instructions. A call can fail if the + * destination program for the jump does not exist (i.e. *index* + * is superior to the number of entries in *prog_array_map*), or + * if the maximum number of tail calls has been reached for this + * chain of programs. This limit is defined in the kernel by the + * macro **MAX_TAIL_CALL_CNT** (not accessible to user space), + * which is currently set to 32. + * Return + * 0 on success, or a negative error in case of failure. + * + * long bpf_clone_redirect(struct sk_buff *skb, u32 ifindex, u64 flags) + * Description + * Clone and redirect the packet associated to *skb* to another + * net device of index *ifindex*. Both ingress and egress + * interfaces can be used for redirection. The **BPF_F_INGRESS** + * value in *flags* is used to make the distinction (ingress path + * is selected if the flag is present, egress path otherwise). + * This is the only flag supported for now. + * + * In comparison with **bpf_redirect**\ () helper, + * **bpf_clone_redirect**\ () has the associated cost of + * duplicating the packet buffer, but this can be executed out of + * the eBPF program. Conversely, **bpf_redirect**\ () is more + * efficient, but it is handled through an action code where the + * redirection happens only after the eBPF program has returned. + * + * A call to this helper is susceptible to change the underlying + * packet buffer. Therefore, at load time, all checks on pointers + * previously done by the verifier are invalidated and must be + * performed again, if the helper is used in combination with + * direct packet access. + * Return + * 0 on success, or a negative error in case of failure. + * + * u64 bpf_get_current_pid_tgid(void) + * Return + * A 64-bit integer containing the current tgid and pid, and + * created as such: + * *current_task*\ **->tgid << 32 \|** + * *current_task*\ **->pid**. + * + * u64 bpf_get_current_uid_gid(void) + * Return + * A 64-bit integer containing the current GID and UID, and + * created as such: *current_gid* **<< 32 \|** *current_uid*. + * + * long bpf_get_current_comm(void *buf, u32 size_of_buf) + * Description + * Copy the **comm** attribute of the current task into *buf* of + * *size_of_buf*. The **comm** attribute contains the name of + * the executable (excluding the path) for the current task. The + * *size_of_buf* must be strictly positive. On success, the + * helper makes sure that the *buf* is NUL-terminated. On failure, + * it is filled with zeroes. + * Return + * 0 on success, or a negative error in case of failure. + * + * u32 bpf_get_cgroup_classid(struct sk_buff *skb) + * Description + * Retrieve the classid for the current task, i.e. for the net_cls + * cgroup to which *skb* belongs. + * + * This helper can be used on TC egress path, but not on ingress. + * + * The net_cls cgroup provides an interface to tag network packets + * based on a user-provided identifier for all traffic coming from + * the tasks belonging to the related cgroup. See also the related + * kernel documentation, available from the Linux sources in file + * *Documentation/admin-guide/cgroup-v1/net_cls.rst*. + * + * The Linux kernel has two versions for cgroups: there are + * cgroups v1 and cgroups v2. Both are available to users, who can + * use a mixture of them, but note that the net_cls cgroup is for + * cgroup v1 only. This makes it incompatible with BPF programs + * run on cgroups, which is a cgroup-v2-only feature (a socket can + * only hold data for one version of cgroups at a time). + * + * This helper is only available is the kernel was compiled with + * the **CONFIG_CGROUP_NET_CLASSID** configuration option set to + * "**y**" or to "**m**". + * Return + * The classid, or 0 for the default unconfigured classid. + * + * long bpf_skb_vlan_push(struct sk_buff *skb, __be16 vlan_proto, u16 vlan_tci) + * Description + * Push a *vlan_tci* (VLAN tag control information) of protocol + * *vlan_proto* to the packet associated to *skb*, then update + * the checksum. Note that if *vlan_proto* is different from + * **ETH_P_8021Q** and **ETH_P_8021AD**, it is considered to + * be **ETH_P_8021Q**. + * + * A call to this helper is susceptible to change the underlying + * packet buffer. Therefore, at load time, all checks on pointers + * previously done by the verifier are invalidated and must be + * performed again, if the helper is used in combination with + * direct packet access. + * Return + * 0 on success, or a negative error in case of failure. + * + * long bpf_skb_vlan_pop(struct sk_buff *skb) + * Description + * Pop a VLAN header from the packet associated to *skb*. + * + * A call to this helper is susceptible to change the underlying + * packet buffer. Therefore, at load time, all checks on pointers + * previously done by the verifier are invalidated and must be + * performed again, if the helper is used in combination with + * direct packet access. + * Return + * 0 on success, or a negative error in case of failure. + * + * long bpf_skb_get_tunnel_key(struct sk_buff *skb, struct bpf_tunnel_key *key, u32 size, u64 flags) + * Description + * Get tunnel metadata. This helper takes a pointer *key* to an + * empty **struct bpf_tunnel_key** of **size**, that will be + * filled with tunnel metadata for the packet associated to *skb*. + * The *flags* can be set to **BPF_F_TUNINFO_IPV6**, which + * indicates that the tunnel is based on IPv6 protocol instead of + * IPv4. + * + * The **struct bpf_tunnel_key** is an object that generalizes the + * principal parameters used by various tunneling protocols into a + * single struct. This way, it can be used to easily make a + * decision based on the contents of the encapsulation header, + * "summarized" in this struct. In particular, it holds the IP + * address of the remote end (IPv4 or IPv6, depending on the case) + * in *key*\ **->remote_ipv4** or *key*\ **->remote_ipv6**. Also, + * this struct exposes the *key*\ **->tunnel_id**, which is + * generally mapped to a VNI (Virtual Network Identifier), making + * it programmable together with the **bpf_skb_set_tunnel_key**\ + * () helper. + * + * Let's imagine that the following code is part of a program + * attached to the TC ingress interface, on one end of a GRE + * tunnel, and is supposed to filter out all messages coming from + * remote ends with IPv4 address other than 10.0.0.1: + * + * :: + * + * int ret; + * struct bpf_tunnel_key key = {}; + * + * ret = bpf_skb_get_tunnel_key(skb, &key, sizeof(key), 0); + * if (ret < 0) + * return TC_ACT_SHOT; // drop packet + * + * if (key.remote_ipv4 != 0x0a000001) + * return TC_ACT_SHOT; // drop packet + * + * return TC_ACT_OK; // accept packet + * + * This interface can also be used with all encapsulation devices + * that can operate in "collect metadata" mode: instead of having + * one network device per specific configuration, the "collect + * metadata" mode only requires a single device where the + * configuration can be extracted from this helper. + * + * This can be used together with various tunnels such as VXLan, + * Geneve, GRE or IP in IP (IPIP). + * Return + * 0 on success, or a negative error in case of failure. + * + * long bpf_skb_set_tunnel_key(struct sk_buff *skb, struct bpf_tunnel_key *key, u32 size, u64 flags) + * Description + * Populate tunnel metadata for packet associated to *skb.* The + * tunnel metadata is set to the contents of *key*, of *size*. The + * *flags* can be set to a combination of the following values: + * + * **BPF_F_TUNINFO_IPV6** + * Indicate that the tunnel is based on IPv6 protocol + * instead of IPv4. + * **BPF_F_ZERO_CSUM_TX** + * For IPv4 packets, add a flag to tunnel metadata + * indicating that checksum computation should be skipped + * and checksum set to zeroes. + * **BPF_F_DONT_FRAGMENT** + * Add a flag to tunnel metadata indicating that the + * packet should not be fragmented. + * **BPF_F_SEQ_NUMBER** + * Add a flag to tunnel metadata indicating that a + * sequence number should be added to tunnel header before + * sending the packet. This flag was added for GRE + * encapsulation, but might be used with other protocols + * as well in the future. + * + * Here is a typical usage on the transmit path: + * + * :: + * + * struct bpf_tunnel_key key; + * populate key ... + * bpf_skb_set_tunnel_key(skb, &key, sizeof(key), 0); + * bpf_clone_redirect(skb, vxlan_dev_ifindex, 0); + * + * See also the description of the **bpf_skb_get_tunnel_key**\ () + * helper for additional information. + * Return + * 0 on success, or a negative error in case of failure. + * + * u64 bpf_perf_event_read(struct bpf_map *map, u64 flags) + * Description + * Read the value of a perf event counter. This helper relies on a + * *map* of type **BPF_MAP_TYPE_PERF_EVENT_ARRAY**. The nature of + * the perf event counter is selected when *map* is updated with + * perf event file descriptors. The *map* is an array whose size + * is the number of available CPUs, and each cell contains a value + * relative to one CPU. The value to retrieve is indicated by + * *flags*, that contains the index of the CPU to look up, masked + * with **BPF_F_INDEX_MASK**. Alternatively, *flags* can be set to + * **BPF_F_CURRENT_CPU** to indicate that the value for the + * current CPU should be retrieved. + * + * Note that before Linux 4.13, only hardware perf event can be + * retrieved. + * + * Also, be aware that the newer helper + * **bpf_perf_event_read_value**\ () is recommended over + * **bpf_perf_event_read**\ () in general. The latter has some ABI + * quirks where error and counter value are used as a return code + * (which is wrong to do since ranges may overlap). This issue is + * fixed with **bpf_perf_event_read_value**\ (), which at the same + * time provides more features over the **bpf_perf_event_read**\ + * () interface. Please refer to the description of + * **bpf_perf_event_read_value**\ () for details. + * Return + * The value of the perf event counter read from the map, or a + * negative error code in case of failure. + * + * long bpf_redirect(u32 ifindex, u64 flags) + * Description + * Redirect the packet to another net device of index *ifindex*. + * This helper is somewhat similar to **bpf_clone_redirect**\ + * (), except that the packet is not cloned, which provides + * increased performance. + * + * Except for XDP, both ingress and egress interfaces can be used + * for redirection. The **BPF_F_INGRESS** value in *flags* is used + * to make the distinction (ingress path is selected if the flag + * is present, egress path otherwise). Currently, XDP only + * supports redirection to the egress interface, and accepts no + * flag at all. + * + * The same effect can also be attained with the more generic + * **bpf_redirect_map**\ (), which uses a BPF map to store the + * redirect target instead of providing it directly to the helper. + * Return + * For XDP, the helper returns **XDP_REDIRECT** on success or + * **XDP_ABORTED** on error. For other program types, the values + * are **TC_ACT_REDIRECT** on success or **TC_ACT_SHOT** on + * error. + * + * u32 bpf_get_route_realm(struct sk_buff *skb) + * Description + * Retrieve the realm or the route, that is to say the + * **tclassid** field of the destination for the *skb*. The + * identifier retrieved is a user-provided tag, similar to the + * one used with the net_cls cgroup (see description for + * **bpf_get_cgroup_classid**\ () helper), but here this tag is + * held by a route (a destination entry), not by a task. + * + * Retrieving this identifier works with the clsact TC egress hook + * (see also **tc-bpf(8)**), or alternatively on conventional + * classful egress qdiscs, but not on TC ingress path. In case of + * clsact TC egress hook, this has the advantage that, internally, + * the destination entry has not been dropped yet in the transmit + * path. Therefore, the destination entry does not need to be + * artificially held via **netif_keep_dst**\ () for a classful + * qdisc until the *skb* is freed. + * + * This helper is available only if the kernel was compiled with + * **CONFIG_IP_ROUTE_CLASSID** configuration option. + * Return + * The realm of the route for the packet associated to *skb*, or 0 + * if none was found. + * + * long bpf_perf_event_output(void *ctx, struct bpf_map *map, u64 flags, void *data, u64 size) + * Description + * Write raw *data* blob into a special BPF perf event held by + * *map* of type **BPF_MAP_TYPE_PERF_EVENT_ARRAY**. This perf + * event must have the following attributes: **PERF_SAMPLE_RAW** + * as **sample_type**, **PERF_TYPE_SOFTWARE** as **type**, and + * **PERF_COUNT_SW_BPF_OUTPUT** as **config**. + * + * The *flags* are used to indicate the index in *map* for which + * the value must be put, masked with **BPF_F_INDEX_MASK**. + * Alternatively, *flags* can be set to **BPF_F_CURRENT_CPU** + * to indicate that the index of the current CPU core should be + * used. + * + * The value to write, of *size*, is passed through eBPF stack and + * pointed by *data*. + * + * The context of the program *ctx* needs also be passed to the + * helper. + * + * On user space, a program willing to read the values needs to + * call **perf_event_open**\ () on the perf event (either for + * one or for all CPUs) and to store the file descriptor into the + * *map*. This must be done before the eBPF program can send data + * into it. An example is available in file + * *samples/bpf/trace_output_user.c* in the Linux kernel source + * tree (the eBPF program counterpart is in + * *samples/bpf/trace_output_kern.c*). + * + * **bpf_perf_event_output**\ () achieves better performance + * than **bpf_trace_printk**\ () for sharing data with user + * space, and is much better suitable for streaming data from eBPF + * programs. + * + * Note that this helper is not restricted to tracing use cases + * and can be used with programs attached to TC or XDP as well, + * where it allows for passing data to user space listeners. Data + * can be: + * + * * Only custom structs, + * * Only the packet payload, or + * * A combination of both. + * Return + * 0 on success, or a negative error in case of failure. + * + * long bpf_skb_load_bytes(const void *skb, u32 offset, void *to, u32 len) + * Description + * This helper was provided as an easy way to load data from a + * packet. It can be used to load *len* bytes from *offset* from + * the packet associated to *skb*, into the buffer pointed by + * *to*. + * + * Since Linux 4.7, usage of this helper has mostly been replaced + * by "direct packet access", enabling packet data to be + * manipulated with *skb*\ **->data** and *skb*\ **->data_end** + * pointing respectively to the first byte of packet data and to + * the byte after the last byte of packet data. However, it + * remains useful if one wishes to read large quantities of data + * at once from a packet into the eBPF stack. + * Return + * 0 on success, or a negative error in case of failure. + * + * long bpf_get_stackid(void *ctx, struct bpf_map *map, u64 flags) + * Description + * Walk a user or a kernel stack and return its id. To achieve + * this, the helper needs *ctx*, which is a pointer to the context + * on which the tracing program is executed, and a pointer to a + * *map* of type **BPF_MAP_TYPE_STACK_TRACE**. + * + * The last argument, *flags*, holds the number of stack frames to + * skip (from 0 to 255), masked with + * **BPF_F_SKIP_FIELD_MASK**. The next bits can be used to set + * a combination of the following flags: + * + * **BPF_F_USER_STACK** + * Collect a user space stack instead of a kernel stack. + * **BPF_F_FAST_STACK_CMP** + * Compare stacks by hash only. + * **BPF_F_REUSE_STACKID** + * If two different stacks hash into the same *stackid*, + * discard the old one. + * + * The stack id retrieved is a 32 bit long integer handle which + * can be further combined with other data (including other stack + * ids) and used as a key into maps. This can be useful for + * generating a variety of graphs (such as flame graphs or off-cpu + * graphs). + * + * For walking a stack, this helper is an improvement over + * **bpf_probe_read**\ (), which can be used with unrolled loops + * but is not efficient and consumes a lot of eBPF instructions. + * Instead, **bpf_get_stackid**\ () can collect up to + * **PERF_MAX_STACK_DEPTH** both kernel and user frames. Note that + * this limit can be controlled with the **sysctl** program, and + * that it should be manually increased in order to profile long + * user stacks (such as stacks for Java programs). To do so, use: + * + * :: + * + * # sysctl kernel.perf_event_max_stack= + * Return + * The positive or null stack id on success, or a negative error + * in case of failure. + * + * s64 bpf_csum_diff(__be32 *from, u32 from_size, __be32 *to, u32 to_size, __wsum seed) + * Description + * Compute a checksum difference, from the raw buffer pointed by + * *from*, of length *from_size* (that must be a multiple of 4), + * towards the raw buffer pointed by *to*, of size *to_size* + * (same remark). An optional *seed* can be added to the value + * (this can be cascaded, the seed may come from a previous call + * to the helper). + * + * This is flexible enough to be used in several ways: + * + * * With *from_size* == 0, *to_size* > 0 and *seed* set to + * checksum, it can be used when pushing new data. + * * With *from_size* > 0, *to_size* == 0 and *seed* set to + * checksum, it can be used when removing data from a packet. + * * With *from_size* > 0, *to_size* > 0 and *seed* set to 0, it + * can be used to compute a diff. Note that *from_size* and + * *to_size* do not need to be equal. + * + * This helper can be used in combination with + * **bpf_l3_csum_replace**\ () and **bpf_l4_csum_replace**\ (), to + * which one can feed in the difference computed with + * **bpf_csum_diff**\ (). + * Return + * The checksum result, or a negative error code in case of + * failure. + * + * long bpf_skb_get_tunnel_opt(struct sk_buff *skb, void *opt, u32 size) + * Description + * Retrieve tunnel options metadata for the packet associated to + * *skb*, and store the raw tunnel option data to the buffer *opt* + * of *size*. + * + * This helper can be used with encapsulation devices that can + * operate in "collect metadata" mode (please refer to the related + * note in the description of **bpf_skb_get_tunnel_key**\ () for + * more details). A particular example where this can be used is + * in combination with the Geneve encapsulation protocol, where it + * allows for pushing (with **bpf_skb_get_tunnel_opt**\ () helper) + * and retrieving arbitrary TLVs (Type-Length-Value headers) from + * the eBPF program. This allows for full customization of these + * headers. + * Return + * The size of the option data retrieved. + * + * long bpf_skb_set_tunnel_opt(struct sk_buff *skb, void *opt, u32 size) + * Description + * Set tunnel options metadata for the packet associated to *skb* + * to the option data contained in the raw buffer *opt* of *size*. + * + * See also the description of the **bpf_skb_get_tunnel_opt**\ () + * helper for additional information. + * Return + * 0 on success, or a negative error in case of failure. + * + * long bpf_skb_change_proto(struct sk_buff *skb, __be16 proto, u64 flags) + * Description + * Change the protocol of the *skb* to *proto*. Currently + * supported are transition from IPv4 to IPv6, and from IPv6 to + * IPv4. The helper takes care of the groundwork for the + * transition, including resizing the socket buffer. The eBPF + * program is expected to fill the new headers, if any, via + * **skb_store_bytes**\ () and to recompute the checksums with + * **bpf_l3_csum_replace**\ () and **bpf_l4_csum_replace**\ + * (). The main case for this helper is to perform NAT64 + * operations out of an eBPF program. + * + * Internally, the GSO type is marked as dodgy so that headers are + * checked and segments are recalculated by the GSO/GRO engine. + * The size for GSO target is adapted as well. + * + * All values for *flags* are reserved for future usage, and must + * be left at zero. + * + * A call to this helper is susceptible to change the underlying + * packet buffer. Therefore, at load time, all checks on pointers + * previously done by the verifier are invalidated and must be + * performed again, if the helper is used in combination with + * direct packet access. + * Return + * 0 on success, or a negative error in case of failure. + * + * long bpf_skb_change_type(struct sk_buff *skb, u32 type) + * Description + * Change the packet type for the packet associated to *skb*. This + * comes down to setting *skb*\ **->pkt_type** to *type*, except + * the eBPF program does not have a write access to *skb*\ + * **->pkt_type** beside this helper. Using a helper here allows + * for graceful handling of errors. + * + * The major use case is to change incoming *skb*s to + * **PACKET_HOST** in a programmatic way instead of having to + * recirculate via **redirect**\ (..., **BPF_F_INGRESS**), for + * example. + * + * Note that *type* only allows certain values. At this time, they + * are: + * + * **PACKET_HOST** + * Packet is for us. + * **PACKET_BROADCAST** + * Send packet to all. + * **PACKET_MULTICAST** + * Send packet to group. + * **PACKET_OTHERHOST** + * Send packet to someone else. + * Return + * 0 on success, or a negative error in case of failure. + * + * long bpf_skb_under_cgroup(struct sk_buff *skb, struct bpf_map *map, u32 index) + * Description + * Check whether *skb* is a descendant of the cgroup2 held by + * *map* of type **BPF_MAP_TYPE_CGROUP_ARRAY**, at *index*. + * Return + * The return value depends on the result of the test, and can be: + * + * * 0, if the *skb* failed the cgroup2 descendant test. + * * 1, if the *skb* succeeded the cgroup2 descendant test. + * * A negative error code, if an error occurred. + * + * u32 bpf_get_hash_recalc(struct sk_buff *skb) + * Description + * Retrieve the hash of the packet, *skb*\ **->hash**. If it is + * not set, in particular if the hash was cleared due to mangling, + * recompute this hash. Later accesses to the hash can be done + * directly with *skb*\ **->hash**. + * + * Calling **bpf_set_hash_invalid**\ (), changing a packet + * prototype with **bpf_skb_change_proto**\ (), or calling + * **bpf_skb_store_bytes**\ () with the + * **BPF_F_INVALIDATE_HASH** are actions susceptible to clear + * the hash and to trigger a new computation for the next call to + * **bpf_get_hash_recalc**\ (). + * Return + * The 32-bit hash. + * + * u64 bpf_get_current_task(void) + * Return + * A pointer to the current task struct. + * + * long bpf_probe_write_user(void *dst, const void *src, u32 len) + * Description + * Attempt in a safe way to write *len* bytes from the buffer + * *src* to *dst* in memory. It only works for threads that are in + * user context, and *dst* must be a valid user space address. + * + * This helper should not be used to implement any kind of + * security mechanism because of TOC-TOU attacks, but rather to + * debug, divert, and manipulate execution of semi-cooperative + * processes. + * + * Keep in mind that this feature is meant for experiments, and it + * has a risk of crashing the system and running programs. + * Therefore, when an eBPF program using this helper is attached, + * a warning including PID and process name is printed to kernel + * logs. + * Return + * 0 on success, or a negative error in case of failure. + * + * long bpf_current_task_under_cgroup(struct bpf_map *map, u32 index) + * Description + * Check whether the probe is being run is the context of a given + * subset of the cgroup2 hierarchy. The cgroup2 to test is held by + * *map* of type **BPF_MAP_TYPE_CGROUP_ARRAY**, at *index*. + * Return + * The return value depends on the result of the test, and can be: + * + * * 0, if current task belongs to the cgroup2. + * * 1, if current task does not belong to the cgroup2. + * * A negative error code, if an error occurred. + * + * long bpf_skb_change_tail(struct sk_buff *skb, u32 len, u64 flags) + * Description + * Resize (trim or grow) the packet associated to *skb* to the + * new *len*. The *flags* are reserved for future usage, and must + * be left at zero. + * + * The basic idea is that the helper performs the needed work to + * change the size of the packet, then the eBPF program rewrites + * the rest via helpers like **bpf_skb_store_bytes**\ (), + * **bpf_l3_csum_replace**\ (), **bpf_l3_csum_replace**\ () + * and others. This helper is a slow path utility intended for + * replies with control messages. And because it is targeted for + * slow path, the helper itself can afford to be slow: it + * implicitly linearizes, unclones and drops offloads from the + * *skb*. + * + * A call to this helper is susceptible to change the underlying + * packet buffer. Therefore, at load time, all checks on pointers + * previously done by the verifier are invalidated and must be + * performed again, if the helper is used in combination with + * direct packet access. + * Return + * 0 on success, or a negative error in case of failure. + * + * long bpf_skb_pull_data(struct sk_buff *skb, u32 len) + * Description + * Pull in non-linear data in case the *skb* is non-linear and not + * all of *len* are part of the linear section. Make *len* bytes + * from *skb* readable and writable. If a zero value is passed for + * *len*, then the whole length of the *skb* is pulled. + * + * This helper is only needed for reading and writing with direct + * packet access. + * + * For direct packet access, testing that offsets to access + * are within packet boundaries (test on *skb*\ **->data_end**) is + * susceptible to fail if offsets are invalid, or if the requested + * data is in non-linear parts of the *skb*. On failure the + * program can just bail out, or in the case of a non-linear + * buffer, use a helper to make the data available. The + * **bpf_skb_load_bytes**\ () helper is a first solution to access + * the data. Another one consists in using **bpf_skb_pull_data** + * to pull in once the non-linear parts, then retesting and + * eventually access the data. + * + * At the same time, this also makes sure the *skb* is uncloned, + * which is a necessary condition for direct write. As this needs + * to be an invariant for the write part only, the verifier + * detects writes and adds a prologue that is calling + * **bpf_skb_pull_data()** to effectively unclone the *skb* from + * the very beginning in case it is indeed cloned. + * + * A call to this helper is susceptible to change the underlying + * packet buffer. Therefore, at load time, all checks on pointers + * previously done by the verifier are invalidated and must be + * performed again, if the helper is used in combination with + * direct packet access. + * Return + * 0 on success, or a negative error in case of failure. + * + * s64 bpf_csum_update(struct sk_buff *skb, __wsum csum) + * Description + * Add the checksum *csum* into *skb*\ **->csum** in case the + * driver has supplied a checksum for the entire packet into that + * field. Return an error otherwise. This helper is intended to be + * used in combination with **bpf_csum_diff**\ (), in particular + * when the checksum needs to be updated after data has been + * written into the packet through direct packet access. + * Return + * The checksum on success, or a negative error code in case of + * failure. + * + * void bpf_set_hash_invalid(struct sk_buff *skb) + * Description + * Invalidate the current *skb*\ **->hash**. It can be used after + * mangling on headers through direct packet access, in order to + * indicate that the hash is outdated and to trigger a + * recalculation the next time the kernel tries to access this + * hash or when the **bpf_get_hash_recalc**\ () helper is called. + * + * long bpf_get_numa_node_id(void) + * Description + * Return the id of the current NUMA node. The primary use case + * for this helper is the selection of sockets for the local NUMA + * node, when the program is attached to sockets using the + * **SO_ATTACH_REUSEPORT_EBPF** option (see also **socket(7)**), + * but the helper is also available to other eBPF program types, + * similarly to **bpf_get_smp_processor_id**\ (). + * Return + * The id of current NUMA node. + * + * long bpf_skb_change_head(struct sk_buff *skb, u32 len, u64 flags) + * Description + * Grows headroom of packet associated to *skb* and adjusts the + * offset of the MAC header accordingly, adding *len* bytes of + * space. It automatically extends and reallocates memory as + * required. + * + * This helper can be used on a layer 3 *skb* to push a MAC header + * for redirection into a layer 2 device. + * + * All values for *flags* are reserved for future usage, and must + * be left at zero. + * + * A call to this helper is susceptible to change the underlying + * packet buffer. Therefore, at load time, all checks on pointers + * previously done by the verifier are invalidated and must be + * performed again, if the helper is used in combination with + * direct packet access. + * Return + * 0 on success, or a negative error in case of failure. + * + * long bpf_xdp_adjust_head(struct xdp_buff *xdp_md, int delta) + * Description + * Adjust (move) *xdp_md*\ **->data** by *delta* bytes. Note that + * it is possible to use a negative value for *delta*. This helper + * can be used to prepare the packet for pushing or popping + * headers. + * + * A call to this helper is susceptible to change the underlying + * packet buffer. Therefore, at load time, all checks on pointers + * previously done by the verifier are invalidated and must be + * performed again, if the helper is used in combination with + * direct packet access. + * Return + * 0 on success, or a negative error in case of failure. + * + * long bpf_probe_read_str(void *dst, u32 size, const void *unsafe_ptr) + * Description + * Copy a NUL terminated string from an unsafe kernel address + * *unsafe_ptr* to *dst*. See **bpf_probe_read_kernel_str**\ () for + * more details. + * + * Generally, use **bpf_probe_read_user_str**\ () or + * **bpf_probe_read_kernel_str**\ () instead. + * Return + * On success, the strictly positive length of the string, + * including the trailing NUL character. On error, a negative + * value. + * + * u64 bpf_get_socket_cookie(struct sk_buff *skb) + * Description + * If the **struct sk_buff** pointed by *skb* has a known socket, + * retrieve the cookie (generated by the kernel) of this socket. + * If no cookie has been set yet, generate a new cookie. Once + * generated, the socket cookie remains stable for the life of the + * socket. This helper can be useful for monitoring per socket + * networking traffic statistics as it provides a global socket + * identifier that can be assumed unique. + * Return + * A 8-byte long unique number on success, or 0 if the socket + * field is missing inside *skb*. + * + * u64 bpf_get_socket_cookie(struct bpf_sock_addr *ctx) + * Description + * Equivalent to bpf_get_socket_cookie() helper that accepts + * *skb*, but gets socket from **struct bpf_sock_addr** context. + * Return + * A 8-byte long unique number. + * + * u64 bpf_get_socket_cookie(struct bpf_sock_ops *ctx) + * Description + * Equivalent to **bpf_get_socket_cookie**\ () helper that accepts + * *skb*, but gets socket from **struct bpf_sock_ops** context. + * Return + * A 8-byte long unique number. + * + * u64 bpf_get_socket_cookie(struct sock *sk) + * Description + * Equivalent to **bpf_get_socket_cookie**\ () helper that accepts + * *sk*, but gets socket from a BTF **struct sock**. This helper + * also works for sleepable programs. + * Return + * A 8-byte long unique number or 0 if *sk* is NULL. + * + * u32 bpf_get_socket_uid(struct sk_buff *skb) + * Return + * The owner UID of the socket associated to *skb*. If the socket + * is **NULL**, or if it is not a full socket (i.e. if it is a + * time-wait or a request socket instead), **overflowuid** value + * is returned (note that **overflowuid** might also be the actual + * UID value for the socket). + * + * long bpf_set_hash(struct sk_buff *skb, u32 hash) + * Description + * Set the full hash for *skb* (set the field *skb*\ **->hash**) + * to value *hash*. + * Return + * 0 + * + * long bpf_setsockopt(void *bpf_socket, int level, int optname, void *optval, int optlen) + * Description + * Emulate a call to **setsockopt()** on the socket associated to + * *bpf_socket*, which must be a full socket. The *level* at + * which the option resides and the name *optname* of the option + * must be specified, see **setsockopt(2)** for more information. + * The option value of length *optlen* is pointed by *optval*. + * + * *bpf_socket* should be one of the following: + * + * * **struct bpf_sock_ops** for **BPF_PROG_TYPE_SOCK_OPS**. + * * **struct bpf_sock_addr** for **BPF_CGROUP_INET4_CONNECT** + * and **BPF_CGROUP_INET6_CONNECT**. + * + * This helper actually implements a subset of **setsockopt()**. + * It supports the following *level*\ s: + * + * * **SOL_SOCKET**, which supports the following *optname*\ s: + * **SO_RCVBUF**, **SO_SNDBUF**, **SO_MAX_PACING_RATE**, + * **SO_PRIORITY**, **SO_RCVLOWAT**, **SO_MARK**, + * **SO_BINDTODEVICE**, **SO_KEEPALIVE**. + * * **IPPROTO_TCP**, which supports the following *optname*\ s: + * **TCP_CONGESTION**, **TCP_BPF_IW**, + * **TCP_BPF_SNDCWND_CLAMP**, **TCP_SAVE_SYN**, + * **TCP_KEEPIDLE**, **TCP_KEEPINTVL**, **TCP_KEEPCNT**, + * **TCP_SYNCNT**, **TCP_USER_TIMEOUT**, **TCP_NOTSENT_LOWAT**. + * * **IPPROTO_IP**, which supports *optname* **IP_TOS**. + * * **IPPROTO_IPV6**, which supports *optname* **IPV6_TCLASS**. + * Return + * 0 on success, or a negative error in case of failure. + * + * long bpf_skb_adjust_room(struct sk_buff *skb, s32 len_diff, u32 mode, u64 flags) + * Description + * Grow or shrink the room for data in the packet associated to + * *skb* by *len_diff*, and according to the selected *mode*. + * + * By default, the helper will reset any offloaded checksum + * indicator of the skb to CHECKSUM_NONE. This can be avoided + * by the following flag: + * + * * **BPF_F_ADJ_ROOM_NO_CSUM_RESET**: Do not reset offloaded + * checksum data of the skb to CHECKSUM_NONE. + * + * There are two supported modes at this time: + * + * * **BPF_ADJ_ROOM_MAC**: Adjust room at the mac layer + * (room space is added or removed below the layer 2 header). + * + * * **BPF_ADJ_ROOM_NET**: Adjust room at the network layer + * (room space is added or removed below the layer 3 header). + * + * The following flags are supported at this time: + * + * * **BPF_F_ADJ_ROOM_FIXED_GSO**: Do not adjust gso_size. + * Adjusting mss in this way is not allowed for datagrams. + * + * * **BPF_F_ADJ_ROOM_ENCAP_L3_IPV4**, + * **BPF_F_ADJ_ROOM_ENCAP_L3_IPV6**: + * Any new space is reserved to hold a tunnel header. + * Configure skb offsets and other fields accordingly. + * + * * **BPF_F_ADJ_ROOM_ENCAP_L4_GRE**, + * **BPF_F_ADJ_ROOM_ENCAP_L4_UDP**: + * Use with ENCAP_L3 flags to further specify the tunnel type. + * + * * **BPF_F_ADJ_ROOM_ENCAP_L2**\ (*len*): + * Use with ENCAP_L3/L4 flags to further specify the tunnel + * type; *len* is the length of the inner MAC header. + * + * * **BPF_F_ADJ_ROOM_ENCAP_L2_ETH**: + * Use with BPF_F_ADJ_ROOM_ENCAP_L2 flag to further specify the + * L2 type as Ethernet. + * + * A call to this helper is susceptible to change the underlying + * packet buffer. Therefore, at load time, all checks on pointers + * previously done by the verifier are invalidated and must be + * performed again, if the helper is used in combination with + * direct packet access. + * Return + * 0 on success, or a negative error in case of failure. + * + * long bpf_redirect_map(struct bpf_map *map, u32 key, u64 flags) + * Description + * Redirect the packet to the endpoint referenced by *map* at + * index *key*. Depending on its type, this *map* can contain + * references to net devices (for forwarding packets through other + * ports), or to CPUs (for redirecting XDP frames to another CPU; + * but this is only implemented for native XDP (with driver + * support) as of this writing). + * + * The lower two bits of *flags* are used as the return code if + * the map lookup fails. This is so that the return value can be + * one of the XDP program return codes up to **XDP_TX**, as chosen + * by the caller. The higher bits of *flags* can be set to + * BPF_F_BROADCAST or BPF_F_EXCLUDE_INGRESS as defined below. + * + * With BPF_F_BROADCAST the packet will be broadcasted to all the + * interfaces in the map, with BPF_F_EXCLUDE_INGRESS the ingress + * interface will be excluded when do broadcasting. + * + * See also **bpf_redirect**\ (), which only supports redirecting + * to an ifindex, but doesn't require a map to do so. + * Return + * **XDP_REDIRECT** on success, or the value of the two lower bits + * of the *flags* argument on error. + * + * long bpf_sk_redirect_map(struct sk_buff *skb, struct bpf_map *map, u32 key, u64 flags) + * Description + * Redirect the packet to the socket referenced by *map* (of type + * **BPF_MAP_TYPE_SOCKMAP**) at index *key*. Both ingress and + * egress interfaces can be used for redirection. The + * **BPF_F_INGRESS** value in *flags* is used to make the + * distinction (ingress path is selected if the flag is present, + * egress path otherwise). This is the only flag supported for now. + * Return + * **SK_PASS** on success, or **SK_DROP** on error. + * + * long bpf_sock_map_update(struct bpf_sock_ops *skops, struct bpf_map *map, void *key, u64 flags) + * Description + * Add an entry to, or update a *map* referencing sockets. The + * *skops* is used as a new value for the entry associated to + * *key*. *flags* is one of: + * + * **BPF_NOEXIST** + * The entry for *key* must not exist in the map. + * **BPF_EXIST** + * The entry for *key* must already exist in the map. + * **BPF_ANY** + * No condition on the existence of the entry for *key*. + * + * If the *map* has eBPF programs (parser and verdict), those will + * be inherited by the socket being added. If the socket is + * already attached to eBPF programs, this results in an error. + * Return + * 0 on success, or a negative error in case of failure. + * + * long bpf_xdp_adjust_meta(struct xdp_buff *xdp_md, int delta) + * Description + * Adjust the address pointed by *xdp_md*\ **->data_meta** by + * *delta* (which can be positive or negative). Note that this + * operation modifies the address stored in *xdp_md*\ **->data**, + * so the latter must be loaded only after the helper has been + * called. + * + * The use of *xdp_md*\ **->data_meta** is optional and programs + * are not required to use it. The rationale is that when the + * packet is processed with XDP (e.g. as DoS filter), it is + * possible to push further meta data along with it before passing + * to the stack, and to give the guarantee that an ingress eBPF + * program attached as a TC classifier on the same device can pick + * this up for further post-processing. Since TC works with socket + * buffers, it remains possible to set from XDP the **mark** or + * **priority** pointers, or other pointers for the socket buffer. + * Having this scratch space generic and programmable allows for + * more flexibility as the user is free to store whatever meta + * data they need. + * + * A call to this helper is susceptible to change the underlying + * packet buffer. Therefore, at load time, all checks on pointers + * previously done by the verifier are invalidated and must be + * performed again, if the helper is used in combination with + * direct packet access. + * Return + * 0 on success, or a negative error in case of failure. + * + * long bpf_perf_event_read_value(struct bpf_map *map, u64 flags, struct bpf_perf_event_value *buf, u32 buf_size) + * Description + * Read the value of a perf event counter, and store it into *buf* + * of size *buf_size*. This helper relies on a *map* of type + * **BPF_MAP_TYPE_PERF_EVENT_ARRAY**. The nature of the perf event + * counter is selected when *map* is updated with perf event file + * descriptors. The *map* is an array whose size is the number of + * available CPUs, and each cell contains a value relative to one + * CPU. The value to retrieve is indicated by *flags*, that + * contains the index of the CPU to look up, masked with + * **BPF_F_INDEX_MASK**. Alternatively, *flags* can be set to + * **BPF_F_CURRENT_CPU** to indicate that the value for the + * current CPU should be retrieved. + * + * This helper behaves in a way close to + * **bpf_perf_event_read**\ () helper, save that instead of + * just returning the value observed, it fills the *buf* + * structure. This allows for additional data to be retrieved: in + * particular, the enabled and running times (in *buf*\ + * **->enabled** and *buf*\ **->running**, respectively) are + * copied. In general, **bpf_perf_event_read_value**\ () is + * recommended over **bpf_perf_event_read**\ (), which has some + * ABI issues and provides fewer functionalities. + * + * These values are interesting, because hardware PMU (Performance + * Monitoring Unit) counters are limited resources. When there are + * more PMU based perf events opened than available counters, + * kernel will multiplex these events so each event gets certain + * percentage (but not all) of the PMU time. In case that + * multiplexing happens, the number of samples or counter value + * will not reflect the case compared to when no multiplexing + * occurs. This makes comparison between different runs difficult. + * Typically, the counter value should be normalized before + * comparing to other experiments. The usual normalization is done + * as follows. + * + * :: + * + * normalized_counter = counter * t_enabled / t_running + * + * Where t_enabled is the time enabled for event and t_running is + * the time running for event since last normalization. The + * enabled and running times are accumulated since the perf event + * open. To achieve scaling factor between two invocations of an + * eBPF program, users can use CPU id as the key (which is + * typical for perf array usage model) to remember the previous + * value and do the calculation inside the eBPF program. + * Return + * 0 on success, or a negative error in case of failure. + * + * long bpf_perf_prog_read_value(struct bpf_perf_event_data *ctx, struct bpf_perf_event_value *buf, u32 buf_size) + * Description + * For en eBPF program attached to a perf event, retrieve the + * value of the event counter associated to *ctx* and store it in + * the structure pointed by *buf* and of size *buf_size*. Enabled + * and running times are also stored in the structure (see + * description of helper **bpf_perf_event_read_value**\ () for + * more details). + * Return + * 0 on success, or a negative error in case of failure. + * + * long bpf_getsockopt(void *bpf_socket, int level, int optname, void *optval, int optlen) + * Description + * Emulate a call to **getsockopt()** on the socket associated to + * *bpf_socket*, which must be a full socket. The *level* at + * which the option resides and the name *optname* of the option + * must be specified, see **getsockopt(2)** for more information. + * The retrieved value is stored in the structure pointed by + * *opval* and of length *optlen*. + * + * *bpf_socket* should be one of the following: + * + * * **struct bpf_sock_ops** for **BPF_PROG_TYPE_SOCK_OPS**. + * * **struct bpf_sock_addr** for **BPF_CGROUP_INET4_CONNECT** + * and **BPF_CGROUP_INET6_CONNECT**. + * + * This helper actually implements a subset of **getsockopt()**. + * It supports the following *level*\ s: + * + * * **IPPROTO_TCP**, which supports *optname* + * **TCP_CONGESTION**. + * * **IPPROTO_IP**, which supports *optname* **IP_TOS**. + * * **IPPROTO_IPV6**, which supports *optname* **IPV6_TCLASS**. + * Return + * 0 on success, or a negative error in case of failure. + * + * long bpf_override_return(struct pt_regs *regs, u64 rc) + * Description + * Used for error injection, this helper uses kprobes to override + * the return value of the probed function, and to set it to *rc*. + * The first argument is the context *regs* on which the kprobe + * works. + * + * This helper works by setting the PC (program counter) + * to an override function which is run in place of the original + * probed function. This means the probed function is not run at + * all. The replacement function just returns with the required + * value. + * + * This helper has security implications, and thus is subject to + * restrictions. It is only available if the kernel was compiled + * with the **CONFIG_BPF_KPROBE_OVERRIDE** configuration + * option, and in this case it only works on functions tagged with + * **ALLOW_ERROR_INJECTION** in the kernel code. + * + * Also, the helper is only available for the architectures having + * the CONFIG_FUNCTION_ERROR_INJECTION option. As of this writing, + * x86 architecture is the only one to support this feature. + * Return + * 0 + * + * long bpf_sock_ops_cb_flags_set(struct bpf_sock_ops *bpf_sock, int argval) + * Description + * Attempt to set the value of the **bpf_sock_ops_cb_flags** field + * for the full TCP socket associated to *bpf_sock_ops* to + * *argval*. + * + * The primary use of this field is to determine if there should + * be calls to eBPF programs of type + * **BPF_PROG_TYPE_SOCK_OPS** at various points in the TCP + * code. A program of the same type can change its value, per + * connection and as necessary, when the connection is + * established. This field is directly accessible for reading, but + * this helper must be used for updates in order to return an + * error if an eBPF program tries to set a callback that is not + * supported in the current kernel. + * + * *argval* is a flag array which can combine these flags: + * + * * **BPF_SOCK_OPS_RTO_CB_FLAG** (retransmission time out) + * * **BPF_SOCK_OPS_RETRANS_CB_FLAG** (retransmission) + * * **BPF_SOCK_OPS_STATE_CB_FLAG** (TCP state change) + * * **BPF_SOCK_OPS_RTT_CB_FLAG** (every RTT) + * + * Therefore, this function can be used to clear a callback flag by + * setting the appropriate bit to zero. e.g. to disable the RTO + * callback: + * + * **bpf_sock_ops_cb_flags_set(bpf_sock,** + * **bpf_sock->bpf_sock_ops_cb_flags & ~BPF_SOCK_OPS_RTO_CB_FLAG)** + * + * Here are some examples of where one could call such eBPF + * program: + * + * * When RTO fires. + * * When a packet is retransmitted. + * * When the connection terminates. + * * When a packet is sent. + * * When a packet is received. + * Return + * Code **-EINVAL** if the socket is not a full TCP socket; + * otherwise, a positive number containing the bits that could not + * be set is returned (which comes down to 0 if all bits were set + * as required). + * + * long bpf_msg_redirect_map(struct sk_msg_buff *msg, struct bpf_map *map, u32 key, u64 flags) + * Description + * This helper is used in programs implementing policies at the + * socket level. If the message *msg* is allowed to pass (i.e. if + * the verdict eBPF program returns **SK_PASS**), redirect it to + * the socket referenced by *map* (of type + * **BPF_MAP_TYPE_SOCKMAP**) at index *key*. Both ingress and + * egress interfaces can be used for redirection. The + * **BPF_F_INGRESS** value in *flags* is used to make the + * distinction (ingress path is selected if the flag is present, + * egress path otherwise). This is the only flag supported for now. + * Return + * **SK_PASS** on success, or **SK_DROP** on error. + * + * long bpf_msg_apply_bytes(struct sk_msg_buff *msg, u32 bytes) + * Description + * For socket policies, apply the verdict of the eBPF program to + * the next *bytes* (number of bytes) of message *msg*. + * + * For example, this helper can be used in the following cases: + * + * * A single **sendmsg**\ () or **sendfile**\ () system call + * contains multiple logical messages that the eBPF program is + * supposed to read and for which it should apply a verdict. + * * An eBPF program only cares to read the first *bytes* of a + * *msg*. If the message has a large payload, then setting up + * and calling the eBPF program repeatedly for all bytes, even + * though the verdict is already known, would create unnecessary + * overhead. + * + * When called from within an eBPF program, the helper sets a + * counter internal to the BPF infrastructure, that is used to + * apply the last verdict to the next *bytes*. If *bytes* is + * smaller than the current data being processed from a + * **sendmsg**\ () or **sendfile**\ () system call, the first + * *bytes* will be sent and the eBPF program will be re-run with + * the pointer for start of data pointing to byte number *bytes* + * **+ 1**. If *bytes* is larger than the current data being + * processed, then the eBPF verdict will be applied to multiple + * **sendmsg**\ () or **sendfile**\ () calls until *bytes* are + * consumed. + * + * Note that if a socket closes with the internal counter holding + * a non-zero value, this is not a problem because data is not + * being buffered for *bytes* and is sent as it is received. + * Return + * 0 + * + * long bpf_msg_cork_bytes(struct sk_msg_buff *msg, u32 bytes) + * Description + * For socket policies, prevent the execution of the verdict eBPF + * program for message *msg* until *bytes* (byte number) have been + * accumulated. + * + * This can be used when one needs a specific number of bytes + * before a verdict can be assigned, even if the data spans + * multiple **sendmsg**\ () or **sendfile**\ () calls. The extreme + * case would be a user calling **sendmsg**\ () repeatedly with + * 1-byte long message segments. Obviously, this is bad for + * performance, but it is still valid. If the eBPF program needs + * *bytes* bytes to validate a header, this helper can be used to + * prevent the eBPF program to be called again until *bytes* have + * been accumulated. + * Return + * 0 + * + * long bpf_msg_pull_data(struct sk_msg_buff *msg, u32 start, u32 end, u64 flags) + * Description + * For socket policies, pull in non-linear data from user space + * for *msg* and set pointers *msg*\ **->data** and *msg*\ + * **->data_end** to *start* and *end* bytes offsets into *msg*, + * respectively. + * + * If a program of type **BPF_PROG_TYPE_SK_MSG** is run on a + * *msg* it can only parse data that the (**data**, **data_end**) + * pointers have already consumed. For **sendmsg**\ () hooks this + * is likely the first scatterlist element. But for calls relying + * on the **sendpage** handler (e.g. **sendfile**\ ()) this will + * be the range (**0**, **0**) because the data is shared with + * user space and by default the objective is to avoid allowing + * user space to modify data while (or after) eBPF verdict is + * being decided. This helper can be used to pull in data and to + * set the start and end pointer to given values. Data will be + * copied if necessary (i.e. if data was not linear and if start + * and end pointers do not point to the same chunk). + * + * A call to this helper is susceptible to change the underlying + * packet buffer. Therefore, at load time, all checks on pointers + * previously done by the verifier are invalidated and must be + * performed again, if the helper is used in combination with + * direct packet access. + * + * All values for *flags* are reserved for future usage, and must + * be left at zero. + * Return + * 0 on success, or a negative error in case of failure. + * + * long bpf_bind(struct bpf_sock_addr *ctx, struct sockaddr *addr, int addr_len) + * Description + * Bind the socket associated to *ctx* to the address pointed by + * *addr*, of length *addr_len*. This allows for making outgoing + * connection from the desired IP address, which can be useful for + * example when all processes inside a cgroup should use one + * single IP address on a host that has multiple IP configured. + * + * This helper works for IPv4 and IPv6, TCP and UDP sockets. The + * domain (*addr*\ **->sa_family**) must be **AF_INET** (or + * **AF_INET6**). It's advised to pass zero port (**sin_port** + * or **sin6_port**) which triggers IP_BIND_ADDRESS_NO_PORT-like + * behavior and lets the kernel efficiently pick up an unused + * port as long as 4-tuple is unique. Passing non-zero port might + * lead to degraded performance. + * Return + * 0 on success, or a negative error in case of failure. + * + * long bpf_xdp_adjust_tail(struct xdp_buff *xdp_md, int delta) + * Description + * Adjust (move) *xdp_md*\ **->data_end** by *delta* bytes. It is + * possible to both shrink and grow the packet tail. + * Shrink done via *delta* being a negative integer. + * + * A call to this helper is susceptible to change the underlying + * packet buffer. Therefore, at load time, all checks on pointers + * previously done by the verifier are invalidated and must be + * performed again, if the helper is used in combination with + * direct packet access. + * Return + * 0 on success, or a negative error in case of failure. + * + * long bpf_skb_get_xfrm_state(struct sk_buff *skb, u32 index, struct bpf_xfrm_state *xfrm_state, u32 size, u64 flags) + * Description + * Retrieve the XFRM state (IP transform framework, see also + * **ip-xfrm(8)**) at *index* in XFRM "security path" for *skb*. + * + * The retrieved value is stored in the **struct bpf_xfrm_state** + * pointed by *xfrm_state* and of length *size*. + * + * All values for *flags* are reserved for future usage, and must + * be left at zero. + * + * This helper is available only if the kernel was compiled with + * **CONFIG_XFRM** configuration option. + * Return + * 0 on success, or a negative error in case of failure. + * + * long bpf_get_stack(void *ctx, void *buf, u32 size, u64 flags) + * Description + * Return a user or a kernel stack in bpf program provided buffer. + * To achieve this, the helper needs *ctx*, which is a pointer + * to the context on which the tracing program is executed. + * To store the stacktrace, the bpf program provides *buf* with + * a nonnegative *size*. + * + * The last argument, *flags*, holds the number of stack frames to + * skip (from 0 to 255), masked with + * **BPF_F_SKIP_FIELD_MASK**. The next bits can be used to set + * the following flags: + * + * **BPF_F_USER_STACK** + * Collect a user space stack instead of a kernel stack. + * **BPF_F_USER_BUILD_ID** + * Collect buildid+offset instead of ips for user stack, + * only valid if **BPF_F_USER_STACK** is also specified. + * + * **bpf_get_stack**\ () can collect up to + * **PERF_MAX_STACK_DEPTH** both kernel and user frames, subject + * to sufficient large buffer size. Note that + * this limit can be controlled with the **sysctl** program, and + * that it should be manually increased in order to profile long + * user stacks (such as stacks for Java programs). To do so, use: + * + * :: + * + * # sysctl kernel.perf_event_max_stack= + * Return + * A non-negative value equal to or less than *size* on success, + * or a negative error in case of failure. + * + * long bpf_skb_load_bytes_relative(const void *skb, u32 offset, void *to, u32 len, u32 start_header) + * Description + * This helper is similar to **bpf_skb_load_bytes**\ () in that + * it provides an easy way to load *len* bytes from *offset* + * from the packet associated to *skb*, into the buffer pointed + * by *to*. The difference to **bpf_skb_load_bytes**\ () is that + * a fifth argument *start_header* exists in order to select a + * base offset to start from. *start_header* can be one of: + * + * **BPF_HDR_START_MAC** + * Base offset to load data from is *skb*'s mac header. + * **BPF_HDR_START_NET** + * Base offset to load data from is *skb*'s network header. + * + * In general, "direct packet access" is the preferred method to + * access packet data, however, this helper is in particular useful + * in socket filters where *skb*\ **->data** does not always point + * to the start of the mac header and where "direct packet access" + * is not available. + * Return + * 0 on success, or a negative error in case of failure. + * + * long bpf_fib_lookup(void *ctx, struct bpf_fib_lookup *params, int plen, u32 flags) + * Description + * Do FIB lookup in kernel tables using parameters in *params*. + * If lookup is successful and result shows packet is to be + * forwarded, the neighbor tables are searched for the nexthop. + * If successful (ie., FIB lookup shows forwarding and nexthop + * is resolved), the nexthop address is returned in ipv4_dst + * or ipv6_dst based on family, smac is set to mac address of + * egress device, dmac is set to nexthop mac address, rt_metric + * is set to metric from route (IPv4/IPv6 only), and ifindex + * is set to the device index of the nexthop from the FIB lookup. + * + * *plen* argument is the size of the passed in struct. + * *flags* argument can be a combination of one or more of the + * following values: + * + * **BPF_FIB_LOOKUP_DIRECT** + * Do a direct table lookup vs full lookup using FIB + * rules. + * **BPF_FIB_LOOKUP_OUTPUT** + * Perform lookup from an egress perspective (default is + * ingress). + * + * *ctx* is either **struct xdp_md** for XDP programs or + * **struct sk_buff** tc cls_act programs. + * Return + * * < 0 if any input argument is invalid + * * 0 on success (packet is forwarded, nexthop neighbor exists) + * * > 0 one of **BPF_FIB_LKUP_RET_** codes explaining why the + * packet is not forwarded or needs assist from full stack + * + * If lookup fails with BPF_FIB_LKUP_RET_FRAG_NEEDED, then the MTU + * was exceeded and output params->mtu_result contains the MTU. + * + * long bpf_sock_hash_update(struct bpf_sock_ops *skops, struct bpf_map *map, void *key, u64 flags) + * Description + * Add an entry to, or update a sockhash *map* referencing sockets. + * The *skops* is used as a new value for the entry associated to + * *key*. *flags* is one of: + * + * **BPF_NOEXIST** + * The entry for *key* must not exist in the map. + * **BPF_EXIST** + * The entry for *key* must already exist in the map. + * **BPF_ANY** + * No condition on the existence of the entry for *key*. + * + * If the *map* has eBPF programs (parser and verdict), those will + * be inherited by the socket being added. If the socket is + * already attached to eBPF programs, this results in an error. + * Return + * 0 on success, or a negative error in case of failure. + * + * long bpf_msg_redirect_hash(struct sk_msg_buff *msg, struct bpf_map *map, void *key, u64 flags) + * Description + * This helper is used in programs implementing policies at the + * socket level. If the message *msg* is allowed to pass (i.e. if + * the verdict eBPF program returns **SK_PASS**), redirect it to + * the socket referenced by *map* (of type + * **BPF_MAP_TYPE_SOCKHASH**) using hash *key*. Both ingress and + * egress interfaces can be used for redirection. The + * **BPF_F_INGRESS** value in *flags* is used to make the + * distinction (ingress path is selected if the flag is present, + * egress path otherwise). This is the only flag supported for now. + * Return + * **SK_PASS** on success, or **SK_DROP** on error. + * + * long bpf_sk_redirect_hash(struct sk_buff *skb, struct bpf_map *map, void *key, u64 flags) + * Description + * This helper is used in programs implementing policies at the + * skb socket level. If the sk_buff *skb* is allowed to pass (i.e. + * if the verdict eBPF program returns **SK_PASS**), redirect it + * to the socket referenced by *map* (of type + * **BPF_MAP_TYPE_SOCKHASH**) using hash *key*. Both ingress and + * egress interfaces can be used for redirection. The + * **BPF_F_INGRESS** value in *flags* is used to make the + * distinction (ingress path is selected if the flag is present, + * egress otherwise). This is the only flag supported for now. + * Return + * **SK_PASS** on success, or **SK_DROP** on error. + * + * long bpf_lwt_push_encap(struct sk_buff *skb, u32 type, void *hdr, u32 len) + * Description + * Encapsulate the packet associated to *skb* within a Layer 3 + * protocol header. This header is provided in the buffer at + * address *hdr*, with *len* its size in bytes. *type* indicates + * the protocol of the header and can be one of: + * + * **BPF_LWT_ENCAP_SEG6** + * IPv6 encapsulation with Segment Routing Header + * (**struct ipv6_sr_hdr**). *hdr* only contains the SRH, + * the IPv6 header is computed by the kernel. + * **BPF_LWT_ENCAP_SEG6_INLINE** + * Only works if *skb* contains an IPv6 packet. Insert a + * Segment Routing Header (**struct ipv6_sr_hdr**) inside + * the IPv6 header. + * **BPF_LWT_ENCAP_IP** + * IP encapsulation (GRE/GUE/IPIP/etc). The outer header + * must be IPv4 or IPv6, followed by zero or more + * additional headers, up to **LWT_BPF_MAX_HEADROOM** + * total bytes in all prepended headers. Please note that + * if **skb_is_gso**\ (*skb*) is true, no more than two + * headers can be prepended, and the inner header, if + * present, should be either GRE or UDP/GUE. + * + * **BPF_LWT_ENCAP_SEG6**\ \* types can be called by BPF programs + * of type **BPF_PROG_TYPE_LWT_IN**; **BPF_LWT_ENCAP_IP** type can + * be called by bpf programs of types **BPF_PROG_TYPE_LWT_IN** and + * **BPF_PROG_TYPE_LWT_XMIT**. + * + * A call to this helper is susceptible to change the underlying + * packet buffer. Therefore, at load time, all checks on pointers + * previously done by the verifier are invalidated and must be + * performed again, if the helper is used in combination with + * direct packet access. + * Return + * 0 on success, or a negative error in case of failure. + * + * long bpf_lwt_seg6_store_bytes(struct sk_buff *skb, u32 offset, const void *from, u32 len) + * Description + * Store *len* bytes from address *from* into the packet + * associated to *skb*, at *offset*. Only the flags, tag and TLVs + * inside the outermost IPv6 Segment Routing Header can be + * modified through this helper. + * + * A call to this helper is susceptible to change the underlying + * packet buffer. Therefore, at load time, all checks on pointers + * previously done by the verifier are invalidated and must be + * performed again, if the helper is used in combination with + * direct packet access. + * Return + * 0 on success, or a negative error in case of failure. + * + * long bpf_lwt_seg6_adjust_srh(struct sk_buff *skb, u32 offset, s32 delta) + * Description + * Adjust the size allocated to TLVs in the outermost IPv6 + * Segment Routing Header contained in the packet associated to + * *skb*, at position *offset* by *delta* bytes. Only offsets + * after the segments are accepted. *delta* can be as well + * positive (growing) as negative (shrinking). + * + * A call to this helper is susceptible to change the underlying + * packet buffer. Therefore, at load time, all checks on pointers + * previously done by the verifier are invalidated and must be + * performed again, if the helper is used in combination with + * direct packet access. + * Return + * 0 on success, or a negative error in case of failure. + * + * long bpf_lwt_seg6_action(struct sk_buff *skb, u32 action, void *param, u32 param_len) + * Description + * Apply an IPv6 Segment Routing action of type *action* to the + * packet associated to *skb*. Each action takes a parameter + * contained at address *param*, and of length *param_len* bytes. + * *action* can be one of: + * + * **SEG6_LOCAL_ACTION_END_X** + * End.X action: Endpoint with Layer-3 cross-connect. + * Type of *param*: **struct in6_addr**. + * **SEG6_LOCAL_ACTION_END_T** + * End.T action: Endpoint with specific IPv6 table lookup. + * Type of *param*: **int**. + * **SEG6_LOCAL_ACTION_END_B6** + * End.B6 action: Endpoint bound to an SRv6 policy. + * Type of *param*: **struct ipv6_sr_hdr**. + * **SEG6_LOCAL_ACTION_END_B6_ENCAP** + * End.B6.Encap action: Endpoint bound to an SRv6 + * encapsulation policy. + * Type of *param*: **struct ipv6_sr_hdr**. + * + * A call to this helper is susceptible to change the underlying + * packet buffer. Therefore, at load time, all checks on pointers + * previously done by the verifier are invalidated and must be + * performed again, if the helper is used in combination with + * direct packet access. + * Return + * 0 on success, or a negative error in case of failure. + * + * long bpf_rc_repeat(void *ctx) + * Description + * This helper is used in programs implementing IR decoding, to + * report a successfully decoded repeat key message. This delays + * the generation of a key up event for previously generated + * key down event. + * + * Some IR protocols like NEC have a special IR message for + * repeating last button, for when a button is held down. + * + * The *ctx* should point to the lirc sample as passed into + * the program. + * + * This helper is only available is the kernel was compiled with + * the **CONFIG_BPF_LIRC_MODE2** configuration option set to + * "**y**". + * Return + * 0 + * + * long bpf_rc_keydown(void *ctx, u32 protocol, u64 scancode, u32 toggle) + * Description + * This helper is used in programs implementing IR decoding, to + * report a successfully decoded key press with *scancode*, + * *toggle* value in the given *protocol*. The scancode will be + * translated to a keycode using the rc keymap, and reported as + * an input key down event. After a period a key up event is + * generated. This period can be extended by calling either + * **bpf_rc_keydown**\ () again with the same values, or calling + * **bpf_rc_repeat**\ (). + * + * Some protocols include a toggle bit, in case the button was + * released and pressed again between consecutive scancodes. + * + * The *ctx* should point to the lirc sample as passed into + * the program. + * + * The *protocol* is the decoded protocol number (see + * **enum rc_proto** for some predefined values). + * + * This helper is only available is the kernel was compiled with + * the **CONFIG_BPF_LIRC_MODE2** configuration option set to + * "**y**". + * Return + * 0 + * + * u64 bpf_skb_cgroup_id(struct sk_buff *skb) + * Description + * Return the cgroup v2 id of the socket associated with the *skb*. + * This is roughly similar to the **bpf_get_cgroup_classid**\ () + * helper for cgroup v1 by providing a tag resp. identifier that + * can be matched on or used for map lookups e.g. to implement + * policy. The cgroup v2 id of a given path in the hierarchy is + * exposed in user space through the f_handle API in order to get + * to the same 64-bit id. + * + * This helper can be used on TC egress path, but not on ingress, + * and is available only if the kernel was compiled with the + * **CONFIG_SOCK_CGROUP_DATA** configuration option. + * Return + * The id is returned or 0 in case the id could not be retrieved. + * + * u64 bpf_get_current_cgroup_id(void) + * Return + * A 64-bit integer containing the current cgroup id based + * on the cgroup within which the current task is running. + * + * void *bpf_get_local_storage(void *map, u64 flags) + * Description + * Get the pointer to the local storage area. + * The type and the size of the local storage is defined + * by the *map* argument. + * The *flags* meaning is specific for each map type, + * and has to be 0 for cgroup local storage. + * + * Depending on the BPF program type, a local storage area + * can be shared between multiple instances of the BPF program, + * running simultaneously. + * + * A user should care about the synchronization by himself. + * For example, by using the **BPF_ATOMIC** instructions to alter + * the shared data. + * Return + * A pointer to the local storage area. + * + * long bpf_sk_select_reuseport(struct sk_reuseport_md *reuse, struct bpf_map *map, void *key, u64 flags) + * Description + * Select a **SO_REUSEPORT** socket from a + * **BPF_MAP_TYPE_REUSEPORT_ARRAY** *map*. + * It checks the selected socket is matching the incoming + * request in the socket buffer. + * Return + * 0 on success, or a negative error in case of failure. + * + * u64 bpf_skb_ancestor_cgroup_id(struct sk_buff *skb, int ancestor_level) + * Description + * Return id of cgroup v2 that is ancestor of cgroup associated + * with the *skb* at the *ancestor_level*. The root cgroup is at + * *ancestor_level* zero and each step down the hierarchy + * increments the level. If *ancestor_level* == level of cgroup + * associated with *skb*, then return value will be same as that + * of **bpf_skb_cgroup_id**\ (). + * + * The helper is useful to implement policies based on cgroups + * that are upper in hierarchy than immediate cgroup associated + * with *skb*. + * + * The format of returned id and helper limitations are same as in + * **bpf_skb_cgroup_id**\ (). + * Return + * The id is returned or 0 in case the id could not be retrieved. + * + * struct bpf_sock *bpf_sk_lookup_tcp(void *ctx, struct bpf_sock_tuple *tuple, u32 tuple_size, u64 netns, u64 flags) + * Description + * Look for TCP socket matching *tuple*, optionally in a child + * network namespace *netns*. The return value must be checked, + * and if non-**NULL**, released via **bpf_sk_release**\ (). + * + * The *ctx* should point to the context of the program, such as + * the skb or socket (depending on the hook in use). This is used + * to determine the base network namespace for the lookup. + * + * *tuple_size* must be one of: + * + * **sizeof**\ (*tuple*\ **->ipv4**) + * Look for an IPv4 socket. + * **sizeof**\ (*tuple*\ **->ipv6**) + * Look for an IPv6 socket. + * + * If the *netns* is a negative signed 32-bit integer, then the + * socket lookup table in the netns associated with the *ctx* + * will be used. For the TC hooks, this is the netns of the device + * in the skb. For socket hooks, this is the netns of the socket. + * If *netns* is any other signed 32-bit value greater than or + * equal to zero then it specifies the ID of the netns relative to + * the netns associated with the *ctx*. *netns* values beyond the + * range of 32-bit integers are reserved for future use. + * + * All values for *flags* are reserved for future usage, and must + * be left at zero. + * + * This helper is available only if the kernel was compiled with + * **CONFIG_NET** configuration option. + * Return + * Pointer to **struct bpf_sock**, or **NULL** in case of failure. + * For sockets with reuseport option, the **struct bpf_sock** + * result is from *reuse*\ **->socks**\ [] using the hash of the + * tuple. + * + * struct bpf_sock *bpf_sk_lookup_udp(void *ctx, struct bpf_sock_tuple *tuple, u32 tuple_size, u64 netns, u64 flags) + * Description + * Look for UDP socket matching *tuple*, optionally in a child + * network namespace *netns*. The return value must be checked, + * and if non-**NULL**, released via **bpf_sk_release**\ (). + * + * The *ctx* should point to the context of the program, such as + * the skb or socket (depending on the hook in use). This is used + * to determine the base network namespace for the lookup. + * + * *tuple_size* must be one of: + * + * **sizeof**\ (*tuple*\ **->ipv4**) + * Look for an IPv4 socket. + * **sizeof**\ (*tuple*\ **->ipv6**) + * Look for an IPv6 socket. + * + * If the *netns* is a negative signed 32-bit integer, then the + * socket lookup table in the netns associated with the *ctx* + * will be used. For the TC hooks, this is the netns of the device + * in the skb. For socket hooks, this is the netns of the socket. + * If *netns* is any other signed 32-bit value greater than or + * equal to zero then it specifies the ID of the netns relative to + * the netns associated with the *ctx*. *netns* values beyond the + * range of 32-bit integers are reserved for future use. + * + * All values for *flags* are reserved for future usage, and must + * be left at zero. + * + * This helper is available only if the kernel was compiled with + * **CONFIG_NET** configuration option. + * Return + * Pointer to **struct bpf_sock**, or **NULL** in case of failure. + * For sockets with reuseport option, the **struct bpf_sock** + * result is from *reuse*\ **->socks**\ [] using the hash of the + * tuple. + * + * long bpf_sk_release(void *sock) + * Description + * Release the reference held by *sock*. *sock* must be a + * non-**NULL** pointer that was returned from + * **bpf_sk_lookup_xxx**\ (). + * Return + * 0 on success, or a negative error in case of failure. + * + * long bpf_map_push_elem(struct bpf_map *map, const void *value, u64 flags) + * Description + * Push an element *value* in *map*. *flags* is one of: + * + * **BPF_EXIST** + * If the queue/stack is full, the oldest element is + * removed to make room for this. + * Return + * 0 on success, or a negative error in case of failure. + * + * long bpf_map_pop_elem(struct bpf_map *map, void *value) + * Description + * Pop an element from *map*. + * Return + * 0 on success, or a negative error in case of failure. + * + * long bpf_map_peek_elem(struct bpf_map *map, void *value) + * Description + * Get an element from *map* without removing it. + * Return + * 0 on success, or a negative error in case of failure. + * + * long bpf_msg_push_data(struct sk_msg_buff *msg, u32 start, u32 len, u64 flags) + * Description + * For socket policies, insert *len* bytes into *msg* at offset + * *start*. + * + * If a program of type **BPF_PROG_TYPE_SK_MSG** is run on a + * *msg* it may want to insert metadata or options into the *msg*. + * This can later be read and used by any of the lower layer BPF + * hooks. + * + * This helper may fail if under memory pressure (a malloc + * fails) in these cases BPF programs will get an appropriate + * error and BPF programs will need to handle them. + * Return + * 0 on success, or a negative error in case of failure. + * + * long bpf_msg_pop_data(struct sk_msg_buff *msg, u32 start, u32 len, u64 flags) + * Description + * Will remove *len* bytes from a *msg* starting at byte *start*. + * This may result in **ENOMEM** errors under certain situations if + * an allocation and copy are required due to a full ring buffer. + * However, the helper will try to avoid doing the allocation + * if possible. Other errors can occur if input parameters are + * invalid either due to *start* byte not being valid part of *msg* + * payload and/or *pop* value being to large. + * Return + * 0 on success, or a negative error in case of failure. + * + * long bpf_rc_pointer_rel(void *ctx, s32 rel_x, s32 rel_y) + * Description + * This helper is used in programs implementing IR decoding, to + * report a successfully decoded pointer movement. + * + * The *ctx* should point to the lirc sample as passed into + * the program. + * + * This helper is only available is the kernel was compiled with + * the **CONFIG_BPF_LIRC_MODE2** configuration option set to + * "**y**". + * Return + * 0 + * + * long bpf_spin_lock(struct bpf_spin_lock *lock) + * Description + * Acquire a spinlock represented by the pointer *lock*, which is + * stored as part of a value of a map. Taking the lock allows to + * safely update the rest of the fields in that value. The + * spinlock can (and must) later be released with a call to + * **bpf_spin_unlock**\ (\ *lock*\ ). + * + * Spinlocks in BPF programs come with a number of restrictions + * and constraints: + * + * * **bpf_spin_lock** objects are only allowed inside maps of + * types **BPF_MAP_TYPE_HASH** and **BPF_MAP_TYPE_ARRAY** (this + * list could be extended in the future). + * * BTF description of the map is mandatory. + * * The BPF program can take ONE lock at a time, since taking two + * or more could cause dead locks. + * * Only one **struct bpf_spin_lock** is allowed per map element. + * * When the lock is taken, calls (either BPF to BPF or helpers) + * are not allowed. + * * The **BPF_LD_ABS** and **BPF_LD_IND** instructions are not + * allowed inside a spinlock-ed region. + * * The BPF program MUST call **bpf_spin_unlock**\ () to release + * the lock, on all execution paths, before it returns. + * * The BPF program can access **struct bpf_spin_lock** only via + * the **bpf_spin_lock**\ () and **bpf_spin_unlock**\ () + * helpers. Loading or storing data into the **struct + * bpf_spin_lock** *lock*\ **;** field of a map is not allowed. + * * To use the **bpf_spin_lock**\ () helper, the BTF description + * of the map value must be a struct and have **struct + * bpf_spin_lock** *anyname*\ **;** field at the top level. + * Nested lock inside another struct is not allowed. + * * The **struct bpf_spin_lock** *lock* field in a map value must + * be aligned on a multiple of 4 bytes in that value. + * * Syscall with command **BPF_MAP_LOOKUP_ELEM** does not copy + * the **bpf_spin_lock** field to user space. + * * Syscall with command **BPF_MAP_UPDATE_ELEM**, or update from + * a BPF program, do not update the **bpf_spin_lock** field. + * * **bpf_spin_lock** cannot be on the stack or inside a + * networking packet (it can only be inside of a map values). + * * **bpf_spin_lock** is available to root only. + * * Tracing programs and socket filter programs cannot use + * **bpf_spin_lock**\ () due to insufficient preemption checks + * (but this may change in the future). + * * **bpf_spin_lock** is not allowed in inner maps of map-in-map. + * Return + * 0 + * + * long bpf_spin_unlock(struct bpf_spin_lock *lock) + * Description + * Release the *lock* previously locked by a call to + * **bpf_spin_lock**\ (\ *lock*\ ). + * Return + * 0 + * + * struct bpf_sock *bpf_sk_fullsock(struct bpf_sock *sk) + * Description + * This helper gets a **struct bpf_sock** pointer such + * that all the fields in this **bpf_sock** can be accessed. + * Return + * A **struct bpf_sock** pointer on success, or **NULL** in + * case of failure. + * + * struct bpf_tcp_sock *bpf_tcp_sock(struct bpf_sock *sk) + * Description + * This helper gets a **struct bpf_tcp_sock** pointer from a + * **struct bpf_sock** pointer. + * Return + * A **struct bpf_tcp_sock** pointer on success, or **NULL** in + * case of failure. + * + * long bpf_skb_ecn_set_ce(struct sk_buff *skb) + * Description + * Set ECN (Explicit Congestion Notification) field of IP header + * to **CE** (Congestion Encountered) if current value is **ECT** + * (ECN Capable Transport). Otherwise, do nothing. Works with IPv6 + * and IPv4. + * Return + * 1 if the **CE** flag is set (either by the current helper call + * or because it was already present), 0 if it is not set. + * + * struct bpf_sock *bpf_get_listener_sock(struct bpf_sock *sk) + * Description + * Return a **struct bpf_sock** pointer in **TCP_LISTEN** state. + * **bpf_sk_release**\ () is unnecessary and not allowed. + * Return + * A **struct bpf_sock** pointer on success, or **NULL** in + * case of failure. + * + * struct bpf_sock *bpf_skc_lookup_tcp(void *ctx, struct bpf_sock_tuple *tuple, u32 tuple_size, u64 netns, u64 flags) + * Description + * Look for TCP socket matching *tuple*, optionally in a child + * network namespace *netns*. The return value must be checked, + * and if non-**NULL**, released via **bpf_sk_release**\ (). + * + * This function is identical to **bpf_sk_lookup_tcp**\ (), except + * that it also returns timewait or request sockets. Use + * **bpf_sk_fullsock**\ () or **bpf_tcp_sock**\ () to access the + * full structure. + * + * This helper is available only if the kernel was compiled with + * **CONFIG_NET** configuration option. + * Return + * Pointer to **struct bpf_sock**, or **NULL** in case of failure. + * For sockets with reuseport option, the **struct bpf_sock** + * result is from *reuse*\ **->socks**\ [] using the hash of the + * tuple. + * + * long bpf_tcp_check_syncookie(void *sk, void *iph, u32 iph_len, struct tcphdr *th, u32 th_len) + * Description + * Check whether *iph* and *th* contain a valid SYN cookie ACK for + * the listening socket in *sk*. + * + * *iph* points to the start of the IPv4 or IPv6 header, while + * *iph_len* contains **sizeof**\ (**struct iphdr**) or + * **sizeof**\ (**struct ip6hdr**). + * + * *th* points to the start of the TCP header, while *th_len* + * contains **sizeof**\ (**struct tcphdr**). + * Return + * 0 if *iph* and *th* are a valid SYN cookie ACK, or a negative + * error otherwise. + * + * long bpf_sysctl_get_name(struct bpf_sysctl *ctx, char *buf, size_t buf_len, u64 flags) + * Description + * Get name of sysctl in /proc/sys/ and copy it into provided by + * program buffer *buf* of size *buf_len*. + * + * The buffer is always NUL terminated, unless it's zero-sized. + * + * If *flags* is zero, full name (e.g. "net/ipv4/tcp_mem") is + * copied. Use **BPF_F_SYSCTL_BASE_NAME** flag to copy base name + * only (e.g. "tcp_mem"). + * Return + * Number of character copied (not including the trailing NUL). + * + * **-E2BIG** if the buffer wasn't big enough (*buf* will contain + * truncated name in this case). + * + * long bpf_sysctl_get_current_value(struct bpf_sysctl *ctx, char *buf, size_t buf_len) + * Description + * Get current value of sysctl as it is presented in /proc/sys + * (incl. newline, etc), and copy it as a string into provided + * by program buffer *buf* of size *buf_len*. + * + * The whole value is copied, no matter what file position user + * space issued e.g. sys_read at. + * + * The buffer is always NUL terminated, unless it's zero-sized. + * Return + * Number of character copied (not including the trailing NUL). + * + * **-E2BIG** if the buffer wasn't big enough (*buf* will contain + * truncated name in this case). + * + * **-EINVAL** if current value was unavailable, e.g. because + * sysctl is uninitialized and read returns -EIO for it. + * + * long bpf_sysctl_get_new_value(struct bpf_sysctl *ctx, char *buf, size_t buf_len) + * Description + * Get new value being written by user space to sysctl (before + * the actual write happens) and copy it as a string into + * provided by program buffer *buf* of size *buf_len*. + * + * User space may write new value at file position > 0. + * + * The buffer is always NUL terminated, unless it's zero-sized. + * Return + * Number of character copied (not including the trailing NUL). + * + * **-E2BIG** if the buffer wasn't big enough (*buf* will contain + * truncated name in this case). + * + * **-EINVAL** if sysctl is being read. + * + * long bpf_sysctl_set_new_value(struct bpf_sysctl *ctx, const char *buf, size_t buf_len) + * Description + * Override new value being written by user space to sysctl with + * value provided by program in buffer *buf* of size *buf_len*. + * + * *buf* should contain a string in same form as provided by user + * space on sysctl write. + * + * User space may write new value at file position > 0. To override + * the whole sysctl value file position should be set to zero. + * Return + * 0 on success. + * + * **-E2BIG** if the *buf_len* is too big. + * + * **-EINVAL** if sysctl is being read. + * + * long bpf_strtol(const char *buf, size_t buf_len, u64 flags, long *res) + * Description + * Convert the initial part of the string from buffer *buf* of + * size *buf_len* to a long integer according to the given base + * and save the result in *res*. + * + * The string may begin with an arbitrary amount of white space + * (as determined by **isspace**\ (3)) followed by a single + * optional '**-**' sign. + * + * Five least significant bits of *flags* encode base, other bits + * are currently unused. + * + * Base must be either 8, 10, 16 or 0 to detect it automatically + * similar to user space **strtol**\ (3). + * Return + * Number of characters consumed on success. Must be positive but + * no more than *buf_len*. + * + * **-EINVAL** if no valid digits were found or unsupported base + * was provided. + * + * **-ERANGE** if resulting value was out of range. + * + * long bpf_strtoul(const char *buf, size_t buf_len, u64 flags, unsigned long *res) + * Description + * Convert the initial part of the string from buffer *buf* of + * size *buf_len* to an unsigned long integer according to the + * given base and save the result in *res*. + * + * The string may begin with an arbitrary amount of white space + * (as determined by **isspace**\ (3)). + * + * Five least significant bits of *flags* encode base, other bits + * are currently unused. + * + * Base must be either 8, 10, 16 or 0 to detect it automatically + * similar to user space **strtoul**\ (3). + * Return + * Number of characters consumed on success. Must be positive but + * no more than *buf_len*. + * + * **-EINVAL** if no valid digits were found or unsupported base + * was provided. + * + * **-ERANGE** if resulting value was out of range. + * + * void *bpf_sk_storage_get(struct bpf_map *map, void *sk, void *value, u64 flags) + * Description + * Get a bpf-local-storage from a *sk*. + * + * Logically, it could be thought of getting the value from + * a *map* with *sk* as the **key**. From this + * perspective, the usage is not much different from + * **bpf_map_lookup_elem**\ (*map*, **&**\ *sk*) except this + * helper enforces the key must be a full socket and the map must + * be a **BPF_MAP_TYPE_SK_STORAGE** also. + * + * Underneath, the value is stored locally at *sk* instead of + * the *map*. The *map* is used as the bpf-local-storage + * "type". The bpf-local-storage "type" (i.e. the *map*) is + * searched against all bpf-local-storages residing at *sk*. + * + * *sk* is a kernel **struct sock** pointer for LSM program. + * *sk* is a **struct bpf_sock** pointer for other program types. + * + * An optional *flags* (**BPF_SK_STORAGE_GET_F_CREATE**) can be + * used such that a new bpf-local-storage will be + * created if one does not exist. *value* can be used + * together with **BPF_SK_STORAGE_GET_F_CREATE** to specify + * the initial value of a bpf-local-storage. If *value* is + * **NULL**, the new bpf-local-storage will be zero initialized. + * Return + * A bpf-local-storage pointer is returned on success. + * + * **NULL** if not found or there was an error in adding + * a new bpf-local-storage. + * + * long bpf_sk_storage_delete(struct bpf_map *map, void *sk) + * Description + * Delete a bpf-local-storage from a *sk*. + * Return + * 0 on success. + * + * **-ENOENT** if the bpf-local-storage cannot be found. + * **-EINVAL** if sk is not a fullsock (e.g. a request_sock). + * + * long bpf_send_signal(u32 sig) + * Description + * Send signal *sig* to the process of the current task. + * The signal may be delivered to any of this process's threads. + * Return + * 0 on success or successfully queued. + * + * **-EBUSY** if work queue under nmi is full. + * + * **-EINVAL** if *sig* is invalid. + * + * **-EPERM** if no permission to send the *sig*. + * + * **-EAGAIN** if bpf program can try again. + * + * s64 bpf_tcp_gen_syncookie(void *sk, void *iph, u32 iph_len, struct tcphdr *th, u32 th_len) + * Description + * Try to issue a SYN cookie for the packet with corresponding + * IP/TCP headers, *iph* and *th*, on the listening socket in *sk*. + * + * *iph* points to the start of the IPv4 or IPv6 header, while + * *iph_len* contains **sizeof**\ (**struct iphdr**) or + * **sizeof**\ (**struct ip6hdr**). + * + * *th* points to the start of the TCP header, while *th_len* + * contains the length of the TCP header. + * Return + * On success, lower 32 bits hold the generated SYN cookie in + * followed by 16 bits which hold the MSS value for that cookie, + * and the top 16 bits are unused. + * + * On failure, the returned value is one of the following: + * + * **-EINVAL** SYN cookie cannot be issued due to error + * + * **-ENOENT** SYN cookie should not be issued (no SYN flood) + * + * **-EOPNOTSUPP** kernel configuration does not enable SYN cookies + * + * **-EPROTONOSUPPORT** IP packet version is not 4 or 6 + * + * long bpf_skb_output(void *ctx, struct bpf_map *map, u64 flags, void *data, u64 size) + * Description + * Write raw *data* blob into a special BPF perf event held by + * *map* of type **BPF_MAP_TYPE_PERF_EVENT_ARRAY**. This perf + * event must have the following attributes: **PERF_SAMPLE_RAW** + * as **sample_type**, **PERF_TYPE_SOFTWARE** as **type**, and + * **PERF_COUNT_SW_BPF_OUTPUT** as **config**. + * + * The *flags* are used to indicate the index in *map* for which + * the value must be put, masked with **BPF_F_INDEX_MASK**. + * Alternatively, *flags* can be set to **BPF_F_CURRENT_CPU** + * to indicate that the index of the current CPU core should be + * used. + * + * The value to write, of *size*, is passed through eBPF stack and + * pointed by *data*. + * + * *ctx* is a pointer to in-kernel struct sk_buff. + * + * This helper is similar to **bpf_perf_event_output**\ () but + * restricted to raw_tracepoint bpf programs. + * Return + * 0 on success, or a negative error in case of failure. + * + * long bpf_probe_read_user(void *dst, u32 size, const void *unsafe_ptr) + * Description + * Safely attempt to read *size* bytes from user space address + * *unsafe_ptr* and store the data in *dst*. + * Return + * 0 on success, or a negative error in case of failure. + * + * long bpf_probe_read_kernel(void *dst, u32 size, const void *unsafe_ptr) + * Description + * Safely attempt to read *size* bytes from kernel space address + * *unsafe_ptr* and store the data in *dst*. + * Return + * 0 on success, or a negative error in case of failure. + * + * long bpf_probe_read_user_str(void *dst, u32 size, const void *unsafe_ptr) + * Description + * Copy a NUL terminated string from an unsafe user address + * *unsafe_ptr* to *dst*. The *size* should include the + * terminating NUL byte. In case the string length is smaller than + * *size*, the target is not padded with further NUL bytes. If the + * string length is larger than *size*, just *size*-1 bytes are + * copied and the last byte is set to NUL. + * + * On success, returns the number of bytes that were written, + * including the terminal NUL. This makes this helper useful in + * tracing programs for reading strings, and more importantly to + * get its length at runtime. See the following snippet: + * + * :: + * + * SEC("kprobe/sys_open") + * void bpf_sys_open(struct pt_regs *ctx) + * { + * char buf[PATHLEN]; // PATHLEN is defined to 256 + * int res = bpf_probe_read_user_str(buf, sizeof(buf), + * ctx->di); + * + * // Consume buf, for example push it to + * // userspace via bpf_perf_event_output(); we + * // can use res (the string length) as event + * // size, after checking its boundaries. + * } + * + * In comparison, using **bpf_probe_read_user**\ () helper here + * instead to read the string would require to estimate the length + * at compile time, and would often result in copying more memory + * than necessary. + * + * Another useful use case is when parsing individual process + * arguments or individual environment variables navigating + * *current*\ **->mm->arg_start** and *current*\ + * **->mm->env_start**: using this helper and the return value, + * one can quickly iterate at the right offset of the memory area. + * Return + * On success, the strictly positive length of the output string, + * including the trailing NUL character. On error, a negative + * value. + * + * long bpf_probe_read_kernel_str(void *dst, u32 size, const void *unsafe_ptr) + * Description + * Copy a NUL terminated string from an unsafe kernel address *unsafe_ptr* + * to *dst*. Same semantics as with **bpf_probe_read_user_str**\ () apply. + * Return + * On success, the strictly positive length of the string, including + * the trailing NUL character. On error, a negative value. + * + * long bpf_tcp_send_ack(void *tp, u32 rcv_nxt) + * Description + * Send out a tcp-ack. *tp* is the in-kernel struct **tcp_sock**. + * *rcv_nxt* is the ack_seq to be sent out. + * Return + * 0 on success, or a negative error in case of failure. + * + * long bpf_send_signal_thread(u32 sig) + * Description + * Send signal *sig* to the thread corresponding to the current task. + * Return + * 0 on success or successfully queued. + * + * **-EBUSY** if work queue under nmi is full. + * + * **-EINVAL** if *sig* is invalid. + * + * **-EPERM** if no permission to send the *sig*. + * + * **-EAGAIN** if bpf program can try again. + * + * u64 bpf_jiffies64(void) + * Description + * Obtain the 64bit jiffies + * Return + * The 64 bit jiffies + * + * long bpf_read_branch_records(struct bpf_perf_event_data *ctx, void *buf, u32 size, u64 flags) + * Description + * For an eBPF program attached to a perf event, retrieve the + * branch records (**struct perf_branch_entry**) associated to *ctx* + * and store it in the buffer pointed by *buf* up to size + * *size* bytes. + * Return + * On success, number of bytes written to *buf*. On error, a + * negative value. + * + * The *flags* can be set to **BPF_F_GET_BRANCH_RECORDS_SIZE** to + * instead return the number of bytes required to store all the + * branch entries. If this flag is set, *buf* may be NULL. + * + * **-EINVAL** if arguments invalid or **size** not a multiple + * of **sizeof**\ (**struct perf_branch_entry**\ ). + * + * **-ENOENT** if architecture does not support branch records. + * + * long bpf_get_ns_current_pid_tgid(u64 dev, u64 ino, struct bpf_pidns_info *nsdata, u32 size) + * Description + * Returns 0 on success, values for *pid* and *tgid* as seen from the current + * *namespace* will be returned in *nsdata*. + * Return + * 0 on success, or one of the following in case of failure: + * + * **-EINVAL** if dev and inum supplied don't match dev_t and inode number + * with nsfs of current task, or if dev conversion to dev_t lost high bits. + * + * **-ENOENT** if pidns does not exists for the current task. + * + * long bpf_xdp_output(void *ctx, struct bpf_map *map, u64 flags, void *data, u64 size) + * Description + * Write raw *data* blob into a special BPF perf event held by + * *map* of type **BPF_MAP_TYPE_PERF_EVENT_ARRAY**. This perf + * event must have the following attributes: **PERF_SAMPLE_RAW** + * as **sample_type**, **PERF_TYPE_SOFTWARE** as **type**, and + * **PERF_COUNT_SW_BPF_OUTPUT** as **config**. + * + * The *flags* are used to indicate the index in *map* for which + * the value must be put, masked with **BPF_F_INDEX_MASK**. + * Alternatively, *flags* can be set to **BPF_F_CURRENT_CPU** + * to indicate that the index of the current CPU core should be + * used. + * + * The value to write, of *size*, is passed through eBPF stack and + * pointed by *data*. + * + * *ctx* is a pointer to in-kernel struct xdp_buff. + * + * This helper is similar to **bpf_perf_eventoutput**\ () but + * restricted to raw_tracepoint bpf programs. + * Return + * 0 on success, or a negative error in case of failure. + * + * u64 bpf_get_netns_cookie(void *ctx) + * Description + * Retrieve the cookie (generated by the kernel) of the network + * namespace the input *ctx* is associated with. The network + * namespace cookie remains stable for its lifetime and provides + * a global identifier that can be assumed unique. If *ctx* is + * NULL, then the helper returns the cookie for the initial + * network namespace. The cookie itself is very similar to that + * of **bpf_get_socket_cookie**\ () helper, but for network + * namespaces instead of sockets. + * Return + * A 8-byte long opaque number. + * + * u64 bpf_get_current_ancestor_cgroup_id(int ancestor_level) + * Description + * Return id of cgroup v2 that is ancestor of the cgroup associated + * with the current task at the *ancestor_level*. The root cgroup + * is at *ancestor_level* zero and each step down the hierarchy + * increments the level. If *ancestor_level* == level of cgroup + * associated with the current task, then return value will be the + * same as that of **bpf_get_current_cgroup_id**\ (). + * + * The helper is useful to implement policies based on cgroups + * that are upper in hierarchy than immediate cgroup associated + * with the current task. + * + * The format of returned id and helper limitations are same as in + * **bpf_get_current_cgroup_id**\ (). + * Return + * The id is returned or 0 in case the id could not be retrieved. + * + * long bpf_sk_assign(struct sk_buff *skb, void *sk, u64 flags) + * Description + * Helper is overloaded depending on BPF program type. This + * description applies to **BPF_PROG_TYPE_SCHED_CLS** and + * **BPF_PROG_TYPE_SCHED_ACT** programs. + * + * Assign the *sk* to the *skb*. When combined with appropriate + * routing configuration to receive the packet towards the socket, + * will cause *skb* to be delivered to the specified socket. + * Subsequent redirection of *skb* via **bpf_redirect**\ (), + * **bpf_clone_redirect**\ () or other methods outside of BPF may + * interfere with successful delivery to the socket. + * + * This operation is only valid from TC ingress path. + * + * The *flags* argument must be zero. + * Return + * 0 on success, or a negative error in case of failure: + * + * **-EINVAL** if specified *flags* are not supported. + * + * **-ENOENT** if the socket is unavailable for assignment. + * + * **-ENETUNREACH** if the socket is unreachable (wrong netns). + * + * **-EOPNOTSUPP** if the operation is not supported, for example + * a call from outside of TC ingress. + * + * **-ESOCKTNOSUPPORT** if the socket type is not supported + * (reuseport). + * + * long bpf_sk_assign(struct bpf_sk_lookup *ctx, struct bpf_sock *sk, u64 flags) + * Description + * Helper is overloaded depending on BPF program type. This + * description applies to **BPF_PROG_TYPE_SK_LOOKUP** programs. + * + * Select the *sk* as a result of a socket lookup. + * + * For the operation to succeed passed socket must be compatible + * with the packet description provided by the *ctx* object. + * + * L4 protocol (**IPPROTO_TCP** or **IPPROTO_UDP**) must + * be an exact match. While IP family (**AF_INET** or + * **AF_INET6**) must be compatible, that is IPv6 sockets + * that are not v6-only can be selected for IPv4 packets. + * + * Only TCP listeners and UDP unconnected sockets can be + * selected. *sk* can also be NULL to reset any previous + * selection. + * + * *flags* argument can combination of following values: + * + * * **BPF_SK_LOOKUP_F_REPLACE** to override the previous + * socket selection, potentially done by a BPF program + * that ran before us. + * + * * **BPF_SK_LOOKUP_F_NO_REUSEPORT** to skip + * load-balancing within reuseport group for the socket + * being selected. + * + * On success *ctx->sk* will point to the selected socket. + * + * Return + * 0 on success, or a negative errno in case of failure. + * + * * **-EAFNOSUPPORT** if socket family (*sk->family*) is + * not compatible with packet family (*ctx->family*). + * + * * **-EEXIST** if socket has been already selected, + * potentially by another program, and + * **BPF_SK_LOOKUP_F_REPLACE** flag was not specified. + * + * * **-EINVAL** if unsupported flags were specified. + * + * * **-EPROTOTYPE** if socket L4 protocol + * (*sk->protocol*) doesn't match packet protocol + * (*ctx->protocol*). + * + * * **-ESOCKTNOSUPPORT** if socket is not in allowed + * state (TCP listening or UDP unconnected). + * + * u64 bpf_ktime_get_boot_ns(void) + * Description + * Return the time elapsed since system boot, in nanoseconds. + * Does include the time the system was suspended. + * See: **clock_gettime**\ (**CLOCK_BOOTTIME**) + * Return + * Current *ktime*. + * + * long bpf_seq_printf(struct seq_file *m, const char *fmt, u32 fmt_size, const void *data, u32 data_len) + * Description + * **bpf_seq_printf**\ () uses seq_file **seq_printf**\ () to print + * out the format string. + * The *m* represents the seq_file. The *fmt* and *fmt_size* are for + * the format string itself. The *data* and *data_len* are format string + * arguments. The *data* are a **u64** array and corresponding format string + * values are stored in the array. For strings and pointers where pointees + * are accessed, only the pointer values are stored in the *data* array. + * The *data_len* is the size of *data* in bytes. + * + * Formats **%s**, **%p{i,I}{4,6}** requires to read kernel memory. + * Reading kernel memory may fail due to either invalid address or + * valid address but requiring a major memory fault. If reading kernel memory + * fails, the string for **%s** will be an empty string, and the ip + * address for **%p{i,I}{4,6}** will be 0. Not returning error to + * bpf program is consistent with what **bpf_trace_printk**\ () does for now. + * Return + * 0 on success, or a negative error in case of failure: + * + * **-EBUSY** if per-CPU memory copy buffer is busy, can try again + * by returning 1 from bpf program. + * + * **-EINVAL** if arguments are invalid, or if *fmt* is invalid/unsupported. + * + * **-E2BIG** if *fmt* contains too many format specifiers. + * + * **-EOVERFLOW** if an overflow happened: The same object will be tried again. + * + * long bpf_seq_write(struct seq_file *m, const void *data, u32 len) + * Description + * **bpf_seq_write**\ () uses seq_file **seq_write**\ () to write the data. + * The *m* represents the seq_file. The *data* and *len* represent the + * data to write in bytes. + * Return + * 0 on success, or a negative error in case of failure: + * + * **-EOVERFLOW** if an overflow happened: The same object will be tried again. + * + * u64 bpf_sk_cgroup_id(void *sk) + * Description + * Return the cgroup v2 id of the socket *sk*. + * + * *sk* must be a non-**NULL** pointer to a socket, e.g. one + * returned from **bpf_sk_lookup_xxx**\ (), + * **bpf_sk_fullsock**\ (), etc. The format of returned id is + * same as in **bpf_skb_cgroup_id**\ (). + * + * This helper is available only if the kernel was compiled with + * the **CONFIG_SOCK_CGROUP_DATA** configuration option. + * Return + * The id is returned or 0 in case the id could not be retrieved. + * + * u64 bpf_sk_ancestor_cgroup_id(void *sk, int ancestor_level) + * Description + * Return id of cgroup v2 that is ancestor of cgroup associated + * with the *sk* at the *ancestor_level*. The root cgroup is at + * *ancestor_level* zero and each step down the hierarchy + * increments the level. If *ancestor_level* == level of cgroup + * associated with *sk*, then return value will be same as that + * of **bpf_sk_cgroup_id**\ (). + * + * The helper is useful to implement policies based on cgroups + * that are upper in hierarchy than immediate cgroup associated + * with *sk*. + * + * The format of returned id and helper limitations are same as in + * **bpf_sk_cgroup_id**\ (). + * Return + * The id is returned or 0 in case the id could not be retrieved. + * + * long bpf_ringbuf_output(void *ringbuf, void *data, u64 size, u64 flags) + * Description + * Copy *size* bytes from *data* into a ring buffer *ringbuf*. + * If **BPF_RB_NO_WAKEUP** is specified in *flags*, no notification + * of new data availability is sent. + * If **BPF_RB_FORCE_WAKEUP** is specified in *flags*, notification + * of new data availability is sent unconditionally. + * If **0** is specified in *flags*, an adaptive notification + * of new data availability is sent. + * + * An adaptive notification is a notification sent whenever the user-space + * process has caught up and consumed all available payloads. In case the user-space + * process is still processing a previous payload, then no notification is needed + * as it will process the newly added payload automatically. + * Return + * 0 on success, or a negative error in case of failure. + * + * void *bpf_ringbuf_reserve(void *ringbuf, u64 size, u64 flags) + * Description + * Reserve *size* bytes of payload in a ring buffer *ringbuf*. + * *flags* must be 0. + * Return + * Valid pointer with *size* bytes of memory available; NULL, + * otherwise. + * + * void bpf_ringbuf_submit(void *data, u64 flags) + * Description + * Submit reserved ring buffer sample, pointed to by *data*. + * If **BPF_RB_NO_WAKEUP** is specified in *flags*, no notification + * of new data availability is sent. + * If **BPF_RB_FORCE_WAKEUP** is specified in *flags*, notification + * of new data availability is sent unconditionally. + * If **0** is specified in *flags*, an adaptive notification + * of new data availability is sent. + * + * See 'bpf_ringbuf_output()' for the definition of adaptive notification. + * Return + * Nothing. Always succeeds. + * + * void bpf_ringbuf_discard(void *data, u64 flags) + * Description + * Discard reserved ring buffer sample, pointed to by *data*. + * If **BPF_RB_NO_WAKEUP** is specified in *flags*, no notification + * of new data availability is sent. + * If **BPF_RB_FORCE_WAKEUP** is specified in *flags*, notification + * of new data availability is sent unconditionally. + * If **0** is specified in *flags*, an adaptive notification + * of new data availability is sent. + * + * See 'bpf_ringbuf_output()' for the definition of adaptive notification. + * Return + * Nothing. Always succeeds. + * + * u64 bpf_ringbuf_query(void *ringbuf, u64 flags) + * Description + * Query various characteristics of provided ring buffer. What + * exactly is queries is determined by *flags*: + * + * * **BPF_RB_AVAIL_DATA**: Amount of data not yet consumed. + * * **BPF_RB_RING_SIZE**: The size of ring buffer. + * * **BPF_RB_CONS_POS**: Consumer position (can wrap around). + * * **BPF_RB_PROD_POS**: Producer(s) position (can wrap around). + * + * Data returned is just a momentary snapshot of actual values + * and could be inaccurate, so this facility should be used to + * power heuristics and for reporting, not to make 100% correct + * calculation. + * Return + * Requested value, or 0, if *flags* are not recognized. + * + * long bpf_csum_level(struct sk_buff *skb, u64 level) + * Description + * Change the skbs checksum level by one layer up or down, or + * reset it entirely to none in order to have the stack perform + * checksum validation. The level is applicable to the following + * protocols: TCP, UDP, GRE, SCTP, FCOE. For example, a decap of + * | ETH | IP | UDP | GUE | IP | TCP | into | ETH | IP | TCP | + * through **bpf_skb_adjust_room**\ () helper with passing in + * **BPF_F_ADJ_ROOM_NO_CSUM_RESET** flag would require one call + * to **bpf_csum_level**\ () with **BPF_CSUM_LEVEL_DEC** since + * the UDP header is removed. Similarly, an encap of the latter + * into the former could be accompanied by a helper call to + * **bpf_csum_level**\ () with **BPF_CSUM_LEVEL_INC** if the + * skb is still intended to be processed in higher layers of the + * stack instead of just egressing at tc. + * + * There are three supported level settings at this time: + * + * * **BPF_CSUM_LEVEL_INC**: Increases skb->csum_level for skbs + * with CHECKSUM_UNNECESSARY. + * * **BPF_CSUM_LEVEL_DEC**: Decreases skb->csum_level for skbs + * with CHECKSUM_UNNECESSARY. + * * **BPF_CSUM_LEVEL_RESET**: Resets skb->csum_level to 0 and + * sets CHECKSUM_NONE to force checksum validation by the stack. + * * **BPF_CSUM_LEVEL_QUERY**: No-op, returns the current + * skb->csum_level. + * Return + * 0 on success, or a negative error in case of failure. In the + * case of **BPF_CSUM_LEVEL_QUERY**, the current skb->csum_level + * is returned or the error code -EACCES in case the skb is not + * subject to CHECKSUM_UNNECESSARY. + * + * struct tcp6_sock *bpf_skc_to_tcp6_sock(void *sk) + * Description + * Dynamically cast a *sk* pointer to a *tcp6_sock* pointer. + * Return + * *sk* if casting is valid, or **NULL** otherwise. + * + * struct tcp_sock *bpf_skc_to_tcp_sock(void *sk) + * Description + * Dynamically cast a *sk* pointer to a *tcp_sock* pointer. + * Return + * *sk* if casting is valid, or **NULL** otherwise. + * + * struct tcp_timewait_sock *bpf_skc_to_tcp_timewait_sock(void *sk) + * Description + * Dynamically cast a *sk* pointer to a *tcp_timewait_sock* pointer. + * Return + * *sk* if casting is valid, or **NULL** otherwise. + * + * struct tcp_request_sock *bpf_skc_to_tcp_request_sock(void *sk) + * Description + * Dynamically cast a *sk* pointer to a *tcp_request_sock* pointer. + * Return + * *sk* if casting is valid, or **NULL** otherwise. + * + * struct udp6_sock *bpf_skc_to_udp6_sock(void *sk) + * Description + * Dynamically cast a *sk* pointer to a *udp6_sock* pointer. + * Return + * *sk* if casting is valid, or **NULL** otherwise. + * + * long bpf_get_task_stack(struct task_struct *task, void *buf, u32 size, u64 flags) + * Description + * Return a user or a kernel stack in bpf program provided buffer. + * To achieve this, the helper needs *task*, which is a valid + * pointer to **struct task_struct**. To store the stacktrace, the + * bpf program provides *buf* with a nonnegative *size*. + * + * The last argument, *flags*, holds the number of stack frames to + * skip (from 0 to 255), masked with + * **BPF_F_SKIP_FIELD_MASK**. The next bits can be used to set + * the following flags: + * + * **BPF_F_USER_STACK** + * Collect a user space stack instead of a kernel stack. + * **BPF_F_USER_BUILD_ID** + * Collect buildid+offset instead of ips for user stack, + * only valid if **BPF_F_USER_STACK** is also specified. + * + * **bpf_get_task_stack**\ () can collect up to + * **PERF_MAX_STACK_DEPTH** both kernel and user frames, subject + * to sufficient large buffer size. Note that + * this limit can be controlled with the **sysctl** program, and + * that it should be manually increased in order to profile long + * user stacks (such as stacks for Java programs). To do so, use: + * + * :: + * + * # sysctl kernel.perf_event_max_stack= + * Return + * A non-negative value equal to or less than *size* on success, + * or a negative error in case of failure. + * + * long bpf_load_hdr_opt(struct bpf_sock_ops *skops, void *searchby_res, u32 len, u64 flags) + * Description + * Load header option. Support reading a particular TCP header + * option for bpf program (**BPF_PROG_TYPE_SOCK_OPS**). + * + * If *flags* is 0, it will search the option from the + * *skops*\ **->skb_data**. The comment in **struct bpf_sock_ops** + * has details on what skb_data contains under different + * *skops*\ **->op**. + * + * The first byte of the *searchby_res* specifies the + * kind that it wants to search. + * + * If the searching kind is an experimental kind + * (i.e. 253 or 254 according to RFC6994). It also + * needs to specify the "magic" which is either + * 2 bytes or 4 bytes. It then also needs to + * specify the size of the magic by using + * the 2nd byte which is "kind-length" of a TCP + * header option and the "kind-length" also + * includes the first 2 bytes "kind" and "kind-length" + * itself as a normal TCP header option also does. + * + * For example, to search experimental kind 254 with + * 2 byte magic 0xeB9F, the searchby_res should be + * [ 254, 4, 0xeB, 0x9F, 0, 0, .... 0 ]. + * + * To search for the standard window scale option (3), + * the *searchby_res* should be [ 3, 0, 0, .... 0 ]. + * Note, kind-length must be 0 for regular option. + * + * Searching for No-Op (0) and End-of-Option-List (1) are + * not supported. + * + * *len* must be at least 2 bytes which is the minimal size + * of a header option. + * + * Supported flags: + * + * * **BPF_LOAD_HDR_OPT_TCP_SYN** to search from the + * saved_syn packet or the just-received syn packet. + * + * Return + * > 0 when found, the header option is copied to *searchby_res*. + * The return value is the total length copied. On failure, a + * negative error code is returned: + * + * **-EINVAL** if a parameter is invalid. + * + * **-ENOMSG** if the option is not found. + * + * **-ENOENT** if no syn packet is available when + * **BPF_LOAD_HDR_OPT_TCP_SYN** is used. + * + * **-ENOSPC** if there is not enough space. Only *len* number of + * bytes are copied. + * + * **-EFAULT** on failure to parse the header options in the + * packet. + * + * **-EPERM** if the helper cannot be used under the current + * *skops*\ **->op**. + * + * long bpf_store_hdr_opt(struct bpf_sock_ops *skops, const void *from, u32 len, u64 flags) + * Description + * Store header option. The data will be copied + * from buffer *from* with length *len* to the TCP header. + * + * The buffer *from* should have the whole option that + * includes the kind, kind-length, and the actual + * option data. The *len* must be at least kind-length + * long. The kind-length does not have to be 4 byte + * aligned. The kernel will take care of the padding + * and setting the 4 bytes aligned value to th->doff. + * + * This helper will check for duplicated option + * by searching the same option in the outgoing skb. + * + * This helper can only be called during + * **BPF_SOCK_OPS_WRITE_HDR_OPT_CB**. + * + * Return + * 0 on success, or negative error in case of failure: + * + * **-EINVAL** If param is invalid. + * + * **-ENOSPC** if there is not enough space in the header. + * Nothing has been written + * + * **-EEXIST** if the option already exists. + * + * **-EFAULT** on failrue to parse the existing header options. + * + * **-EPERM** if the helper cannot be used under the current + * *skops*\ **->op**. + * + * long bpf_reserve_hdr_opt(struct bpf_sock_ops *skops, u32 len, u64 flags) + * Description + * Reserve *len* bytes for the bpf header option. The + * space will be used by **bpf_store_hdr_opt**\ () later in + * **BPF_SOCK_OPS_WRITE_HDR_OPT_CB**. + * + * If **bpf_reserve_hdr_opt**\ () is called multiple times, + * the total number of bytes will be reserved. + * + * This helper can only be called during + * **BPF_SOCK_OPS_HDR_OPT_LEN_CB**. + * + * Return + * 0 on success, or negative error in case of failure: + * + * **-EINVAL** if a parameter is invalid. + * + * **-ENOSPC** if there is not enough space in the header. + * + * **-EPERM** if the helper cannot be used under the current + * *skops*\ **->op**. + * + * void *bpf_inode_storage_get(struct bpf_map *map, void *inode, void *value, u64 flags) + * Description + * Get a bpf_local_storage from an *inode*. + * + * Logically, it could be thought of as getting the value from + * a *map* with *inode* as the **key**. From this + * perspective, the usage is not much different from + * **bpf_map_lookup_elem**\ (*map*, **&**\ *inode*) except this + * helper enforces the key must be an inode and the map must also + * be a **BPF_MAP_TYPE_INODE_STORAGE**. + * + * Underneath, the value is stored locally at *inode* instead of + * the *map*. The *map* is used as the bpf-local-storage + * "type". The bpf-local-storage "type" (i.e. the *map*) is + * searched against all bpf_local_storage residing at *inode*. + * + * An optional *flags* (**BPF_LOCAL_STORAGE_GET_F_CREATE**) can be + * used such that a new bpf_local_storage will be + * created if one does not exist. *value* can be used + * together with **BPF_LOCAL_STORAGE_GET_F_CREATE** to specify + * the initial value of a bpf_local_storage. If *value* is + * **NULL**, the new bpf_local_storage will be zero initialized. + * Return + * A bpf_local_storage pointer is returned on success. + * + * **NULL** if not found or there was an error in adding + * a new bpf_local_storage. + * + * int bpf_inode_storage_delete(struct bpf_map *map, void *inode) + * Description + * Delete a bpf_local_storage from an *inode*. + * Return + * 0 on success. + * + * **-ENOENT** if the bpf_local_storage cannot be found. + * + * long bpf_d_path(struct path *path, char *buf, u32 sz) + * Description + * Return full path for given **struct path** object, which + * needs to be the kernel BTF *path* object. The path is + * returned in the provided buffer *buf* of size *sz* and + * is zero terminated. + * + * Return + * On success, the strictly positive length of the string, + * including the trailing NUL character. On error, a negative + * value. + * + * long bpf_copy_from_user(void *dst, u32 size, const void *user_ptr) + * Description + * Read *size* bytes from user space address *user_ptr* and store + * the data in *dst*. This is a wrapper of **copy_from_user**\ (). + * Return + * 0 on success, or a negative error in case of failure. + * + * long bpf_snprintf_btf(char *str, u32 str_size, struct btf_ptr *ptr, u32 btf_ptr_size, u64 flags) + * Description + * Use BTF to store a string representation of *ptr*->ptr in *str*, + * using *ptr*->type_id. This value should specify the type + * that *ptr*->ptr points to. LLVM __builtin_btf_type_id(type, 1) + * can be used to look up vmlinux BTF type ids. Traversing the + * data structure using BTF, the type information and values are + * stored in the first *str_size* - 1 bytes of *str*. Safe copy of + * the pointer data is carried out to avoid kernel crashes during + * operation. Smaller types can use string space on the stack; + * larger programs can use map data to store the string + * representation. + * + * The string can be subsequently shared with userspace via + * bpf_perf_event_output() or ring buffer interfaces. + * bpf_trace_printk() is to be avoided as it places too small + * a limit on string size to be useful. + * + * *flags* is a combination of + * + * **BTF_F_COMPACT** + * no formatting around type information + * **BTF_F_NONAME** + * no struct/union member names/types + * **BTF_F_PTR_RAW** + * show raw (unobfuscated) pointer values; + * equivalent to printk specifier %px. + * **BTF_F_ZERO** + * show zero-valued struct/union members; they + * are not displayed by default + * + * Return + * The number of bytes that were written (or would have been + * written if output had to be truncated due to string size), + * or a negative error in cases of failure. + * + * long bpf_seq_printf_btf(struct seq_file *m, struct btf_ptr *ptr, u32 ptr_size, u64 flags) + * Description + * Use BTF to write to seq_write a string representation of + * *ptr*->ptr, using *ptr*->type_id as per bpf_snprintf_btf(). + * *flags* are identical to those used for bpf_snprintf_btf. + * Return + * 0 on success or a negative error in case of failure. + * + * u64 bpf_skb_cgroup_classid(struct sk_buff *skb) + * Description + * See **bpf_get_cgroup_classid**\ () for the main description. + * This helper differs from **bpf_get_cgroup_classid**\ () in that + * the cgroup v1 net_cls class is retrieved only from the *skb*'s + * associated socket instead of the current process. + * Return + * The id is returned or 0 in case the id could not be retrieved. + * + * long bpf_redirect_neigh(u32 ifindex, struct bpf_redir_neigh *params, int plen, u64 flags) + * Description + * Redirect the packet to another net device of index *ifindex* + * and fill in L2 addresses from neighboring subsystem. This helper + * is somewhat similar to **bpf_redirect**\ (), except that it + * populates L2 addresses as well, meaning, internally, the helper + * relies on the neighbor lookup for the L2 address of the nexthop. + * + * The helper will perform a FIB lookup based on the skb's + * networking header to get the address of the next hop, unless + * this is supplied by the caller in the *params* argument. The + * *plen* argument indicates the len of *params* and should be set + * to 0 if *params* is NULL. + * + * The *flags* argument is reserved and must be 0. The helper is + * currently only supported for tc BPF program types, and enabled + * for IPv4 and IPv6 protocols. + * Return + * The helper returns **TC_ACT_REDIRECT** on success or + * **TC_ACT_SHOT** on error. + * + * void *bpf_per_cpu_ptr(const void *percpu_ptr, u32 cpu) + * Description + * Take a pointer to a percpu ksym, *percpu_ptr*, and return a + * pointer to the percpu kernel variable on *cpu*. A ksym is an + * extern variable decorated with '__ksym'. For ksym, there is a + * global var (either static or global) defined of the same name + * in the kernel. The ksym is percpu if the global var is percpu. + * The returned pointer points to the global percpu var on *cpu*. + * + * bpf_per_cpu_ptr() has the same semantic as per_cpu_ptr() in the + * kernel, except that bpf_per_cpu_ptr() may return NULL. This + * happens if *cpu* is larger than nr_cpu_ids. The caller of + * bpf_per_cpu_ptr() must check the returned value. + * Return + * A pointer pointing to the kernel percpu variable on *cpu*, or + * NULL, if *cpu* is invalid. + * + * void *bpf_this_cpu_ptr(const void *percpu_ptr) + * Description + * Take a pointer to a percpu ksym, *percpu_ptr*, and return a + * pointer to the percpu kernel variable on this cpu. See the + * description of 'ksym' in **bpf_per_cpu_ptr**\ (). + * + * bpf_this_cpu_ptr() has the same semantic as this_cpu_ptr() in + * the kernel. Different from **bpf_per_cpu_ptr**\ (), it would + * never return NULL. + * Return + * A pointer pointing to the kernel percpu variable on this cpu. + * + * long bpf_redirect_peer(u32 ifindex, u64 flags) + * Description + * Redirect the packet to another net device of index *ifindex*. + * This helper is somewhat similar to **bpf_redirect**\ (), except + * that the redirection happens to the *ifindex*' peer device and + * the netns switch takes place from ingress to ingress without + * going through the CPU's backlog queue. + * + * The *flags* argument is reserved and must be 0. The helper is + * currently only supported for tc BPF program types at the ingress + * hook and for veth device types. The peer device must reside in a + * different network namespace. + * Return + * The helper returns **TC_ACT_REDIRECT** on success or + * **TC_ACT_SHOT** on error. + * + * void *bpf_task_storage_get(struct bpf_map *map, struct task_struct *task, void *value, u64 flags) + * Description + * Get a bpf_local_storage from the *task*. + * + * Logically, it could be thought of as getting the value from + * a *map* with *task* as the **key**. From this + * perspective, the usage is not much different from + * **bpf_map_lookup_elem**\ (*map*, **&**\ *task*) except this + * helper enforces the key must be an task_struct and the map must also + * be a **BPF_MAP_TYPE_TASK_STORAGE**. + * + * Underneath, the value is stored locally at *task* instead of + * the *map*. The *map* is used as the bpf-local-storage + * "type". The bpf-local-storage "type" (i.e. the *map*) is + * searched against all bpf_local_storage residing at *task*. + * + * An optional *flags* (**BPF_LOCAL_STORAGE_GET_F_CREATE**) can be + * used such that a new bpf_local_storage will be + * created if one does not exist. *value* can be used + * together with **BPF_LOCAL_STORAGE_GET_F_CREATE** to specify + * the initial value of a bpf_local_storage. If *value* is + * **NULL**, the new bpf_local_storage will be zero initialized. + * Return + * A bpf_local_storage pointer is returned on success. + * + * **NULL** if not found or there was an error in adding + * a new bpf_local_storage. + * + * long bpf_task_storage_delete(struct bpf_map *map, struct task_struct *task) + * Description + * Delete a bpf_local_storage from a *task*. + * Return + * 0 on success. + * + * **-ENOENT** if the bpf_local_storage cannot be found. + * + * struct task_struct *bpf_get_current_task_btf(void) + * Description + * Return a BTF pointer to the "current" task. + * This pointer can also be used in helpers that accept an + * *ARG_PTR_TO_BTF_ID* of type *task_struct*. + * Return + * Pointer to the current task. + * + * long bpf_bprm_opts_set(struct linux_binprm *bprm, u64 flags) + * Description + * Set or clear certain options on *bprm*: + * + * **BPF_F_BPRM_SECUREEXEC** Set the secureexec bit + * which sets the **AT_SECURE** auxv for glibc. The bit + * is cleared if the flag is not specified. + * Return + * **-EINVAL** if invalid *flags* are passed, zero otherwise. + * + * u64 bpf_ktime_get_coarse_ns(void) + * Description + * Return a coarse-grained version of the time elapsed since + * system boot, in nanoseconds. Does not include time the system + * was suspended. + * + * See: **clock_gettime**\ (**CLOCK_MONOTONIC_COARSE**) + * Return + * Current *ktime*. + * + * long bpf_ima_inode_hash(struct inode *inode, void *dst, u32 size) + * Description + * Returns the stored IMA hash of the *inode* (if it's avaialable). + * If the hash is larger than *size*, then only *size* + * bytes will be copied to *dst* + * Return + * The **hash_algo** is returned on success, + * **-EOPNOTSUP** if IMA is disabled or **-EINVAL** if + * invalid arguments are passed. + * + * struct socket *bpf_sock_from_file(struct file *file) + * Description + * If the given file represents a socket, returns the associated + * socket. + * Return + * A pointer to a struct socket on success or NULL if the file is + * not a socket. + * + * long bpf_check_mtu(void *ctx, u32 ifindex, u32 *mtu_len, s32 len_diff, u64 flags) + * Description + * Check packet size against exceeding MTU of net device (based + * on *ifindex*). This helper will likely be used in combination + * with helpers that adjust/change the packet size. + * + * The argument *len_diff* can be used for querying with a planned + * size change. This allows to check MTU prior to changing packet + * ctx. Providing an *len_diff* adjustment that is larger than the + * actual packet size (resulting in negative packet size) will in + * principle not exceed the MTU, why it is not considered a + * failure. Other BPF-helpers are needed for performing the + * planned size change, why the responsability for catch a negative + * packet size belong in those helpers. + * + * Specifying *ifindex* zero means the MTU check is performed + * against the current net device. This is practical if this isn't + * used prior to redirect. + * + * On input *mtu_len* must be a valid pointer, else verifier will + * reject BPF program. If the value *mtu_len* is initialized to + * zero then the ctx packet size is use. When value *mtu_len* is + * provided as input this specify the L3 length that the MTU check + * is done against. Remember XDP and TC length operate at L2, but + * this value is L3 as this correlate to MTU and IP-header tot_len + * values which are L3 (similar behavior as bpf_fib_lookup). + * + * The Linux kernel route table can configure MTUs on a more + * specific per route level, which is not provided by this helper. + * For route level MTU checks use the **bpf_fib_lookup**\ () + * helper. + * + * *ctx* is either **struct xdp_md** for XDP programs or + * **struct sk_buff** for tc cls_act programs. + * + * The *flags* argument can be a combination of one or more of the + * following values: + * + * **BPF_MTU_CHK_SEGS** + * This flag will only works for *ctx* **struct sk_buff**. + * If packet context contains extra packet segment buffers + * (often knows as GSO skb), then MTU check is harder to + * check at this point, because in transmit path it is + * possible for the skb packet to get re-segmented + * (depending on net device features). This could still be + * a MTU violation, so this flag enables performing MTU + * check against segments, with a different violation + * return code to tell it apart. Check cannot use len_diff. + * + * On return *mtu_len* pointer contains the MTU value of the net + * device. Remember the net device configured MTU is the L3 size, + * which is returned here and XDP and TC length operate at L2. + * Helper take this into account for you, but remember when using + * MTU value in your BPF-code. + * + * Return + * * 0 on success, and populate MTU value in *mtu_len* pointer. + * + * * < 0 if any input argument is invalid (*mtu_len* not updated) + * + * MTU violations return positive values, but also populate MTU + * value in *mtu_len* pointer, as this can be needed for + * implementing PMTU handing: + * + * * **BPF_MTU_CHK_RET_FRAG_NEEDED** + * * **BPF_MTU_CHK_RET_SEGS_TOOBIG** + * + * long bpf_for_each_map_elem(struct bpf_map *map, void *callback_fn, void *callback_ctx, u64 flags) + * Description + * For each element in **map**, call **callback_fn** function with + * **map**, **callback_ctx** and other map-specific parameters. + * The **callback_fn** should be a static function and + * the **callback_ctx** should be a pointer to the stack. + * The **flags** is used to control certain aspects of the helper. + * Currently, the **flags** must be 0. + * + * The following are a list of supported map types and their + * respective expected callback signatures: + * + * BPF_MAP_TYPE_HASH, BPF_MAP_TYPE_PERCPU_HASH, + * BPF_MAP_TYPE_LRU_HASH, BPF_MAP_TYPE_LRU_PERCPU_HASH, + * BPF_MAP_TYPE_ARRAY, BPF_MAP_TYPE_PERCPU_ARRAY + * + * long (\*callback_fn)(struct bpf_map \*map, const void \*key, void \*value, void \*ctx); + * + * For per_cpu maps, the map_value is the value on the cpu where the + * bpf_prog is running. + * + * If **callback_fn** return 0, the helper will continue to the next + * element. If return value is 1, the helper will skip the rest of + * elements and return. Other return values are not used now. + * + * Return + * The number of traversed map elements for success, **-EINVAL** for + * invalid **flags**. + * + * long bpf_snprintf(char *str, u32 str_size, const char *fmt, u64 *data, u32 data_len) + * Description + * Outputs a string into the **str** buffer of size **str_size** + * based on a format string stored in a read-only map pointed by + * **fmt**. + * + * Each format specifier in **fmt** corresponds to one u64 element + * in the **data** array. For strings and pointers where pointees + * are accessed, only the pointer values are stored in the *data* + * array. The *data_len* is the size of *data* in bytes. + * + * Formats **%s** and **%p{i,I}{4,6}** require to read kernel + * memory. Reading kernel memory may fail due to either invalid + * address or valid address but requiring a major memory fault. If + * reading kernel memory fails, the string for **%s** will be an + * empty string, and the ip address for **%p{i,I}{4,6}** will be 0. + * Not returning error to bpf program is consistent with what + * **bpf_trace_printk**\ () does for now. + * + * Return + * The strictly positive length of the formatted string, including + * the trailing zero character. If the return value is greater than + * **str_size**, **str** contains a truncated string, guaranteed to + * be zero-terminated except when **str_size** is 0. + * + * Or **-EBUSY** if the per-CPU memory copy buffer is busy. + * + * long bpf_sys_bpf(u32 cmd, void *attr, u32 attr_size) + * Description + * Execute bpf syscall with given arguments. + * Return + * A syscall result. + * + * long bpf_btf_find_by_name_kind(char *name, int name_sz, u32 kind, int flags) + * Description + * Find BTF type with given name and kind in vmlinux BTF or in module's BTFs. + * Return + * Returns btf_id and btf_obj_fd in lower and upper 32 bits. + * + * long bpf_sys_close(u32 fd) + * Description + * Execute close syscall for given FD. + * Return + * A syscall result. + */ +#define __BPF_FUNC_MAPPER(FN) \ + FN(unspec), \ + FN(map_lookup_elem), \ + FN(map_update_elem), \ + FN(map_delete_elem), \ + FN(probe_read), \ + FN(ktime_get_ns), \ + FN(trace_printk), \ + FN(get_prandom_u32), \ + FN(get_smp_processor_id), \ + FN(skb_store_bytes), \ + FN(l3_csum_replace), \ + FN(l4_csum_replace), \ + FN(tail_call), \ + FN(clone_redirect), \ + FN(get_current_pid_tgid), \ + FN(get_current_uid_gid), \ + FN(get_current_comm), \ + FN(get_cgroup_classid), \ + FN(skb_vlan_push), \ + FN(skb_vlan_pop), \ + FN(skb_get_tunnel_key), \ + FN(skb_set_tunnel_key), \ + FN(perf_event_read), \ + FN(redirect), \ + FN(get_route_realm), \ + FN(perf_event_output), \ + FN(skb_load_bytes), \ + FN(get_stackid), \ + FN(csum_diff), \ + FN(skb_get_tunnel_opt), \ + FN(skb_set_tunnel_opt), \ + FN(skb_change_proto), \ + FN(skb_change_type), \ + FN(skb_under_cgroup), \ + FN(get_hash_recalc), \ + FN(get_current_task), \ + FN(probe_write_user), \ + FN(current_task_under_cgroup), \ + FN(skb_change_tail), \ + FN(skb_pull_data), \ + FN(csum_update), \ + FN(set_hash_invalid), \ + FN(get_numa_node_id), \ + FN(skb_change_head), \ + FN(xdp_adjust_head), \ + FN(probe_read_str), \ + FN(get_socket_cookie), \ + FN(get_socket_uid), \ + FN(set_hash), \ + FN(setsockopt), \ + FN(skb_adjust_room), \ + FN(redirect_map), \ + FN(sk_redirect_map), \ + FN(sock_map_update), \ + FN(xdp_adjust_meta), \ + FN(perf_event_read_value), \ + FN(perf_prog_read_value), \ + FN(getsockopt), \ + FN(override_return), \ + FN(sock_ops_cb_flags_set), \ + FN(msg_redirect_map), \ + FN(msg_apply_bytes), \ + FN(msg_cork_bytes), \ + FN(msg_pull_data), \ + FN(bind), \ + FN(xdp_adjust_tail), \ + FN(skb_get_xfrm_state), \ + FN(get_stack), \ + FN(skb_load_bytes_relative), \ + FN(fib_lookup), \ + FN(sock_hash_update), \ + FN(msg_redirect_hash), \ + FN(sk_redirect_hash), \ + FN(lwt_push_encap), \ + FN(lwt_seg6_store_bytes), \ + FN(lwt_seg6_adjust_srh), \ + FN(lwt_seg6_action), \ + FN(rc_repeat), \ + FN(rc_keydown), \ + FN(skb_cgroup_id), \ + FN(get_current_cgroup_id), \ + FN(get_local_storage), \ + FN(sk_select_reuseport), \ + FN(skb_ancestor_cgroup_id), \ + FN(sk_lookup_tcp), \ + FN(sk_lookup_udp), \ + FN(sk_release), \ + FN(map_push_elem), \ + FN(map_pop_elem), \ + FN(map_peek_elem), \ + FN(msg_push_data), \ + FN(msg_pop_data), \ + FN(rc_pointer_rel), \ + FN(spin_lock), \ + FN(spin_unlock), \ + FN(sk_fullsock), \ + FN(tcp_sock), \ + FN(skb_ecn_set_ce), \ + FN(get_listener_sock), \ + FN(skc_lookup_tcp), \ + FN(tcp_check_syncookie), \ + FN(sysctl_get_name), \ + FN(sysctl_get_current_value), \ + FN(sysctl_get_new_value), \ + FN(sysctl_set_new_value), \ + FN(strtol), \ + FN(strtoul), \ + FN(sk_storage_get), \ + FN(sk_storage_delete), \ + FN(send_signal), \ + FN(tcp_gen_syncookie), \ + FN(skb_output), \ + FN(probe_read_user), \ + FN(probe_read_kernel), \ + FN(probe_read_user_str), \ + FN(probe_read_kernel_str), \ + FN(tcp_send_ack), \ + FN(send_signal_thread), \ + FN(jiffies64), \ + FN(read_branch_records), \ + FN(get_ns_current_pid_tgid), \ + FN(xdp_output), \ + FN(get_netns_cookie), \ + FN(get_current_ancestor_cgroup_id), \ + FN(sk_assign), \ + FN(ktime_get_boot_ns), \ + FN(seq_printf), \ + FN(seq_write), \ + FN(sk_cgroup_id), \ + FN(sk_ancestor_cgroup_id), \ + FN(ringbuf_output), \ + FN(ringbuf_reserve), \ + FN(ringbuf_submit), \ + FN(ringbuf_discard), \ + FN(ringbuf_query), \ + FN(csum_level), \ + FN(skc_to_tcp6_sock), \ + FN(skc_to_tcp_sock), \ + FN(skc_to_tcp_timewait_sock), \ + FN(skc_to_tcp_request_sock), \ + FN(skc_to_udp6_sock), \ + FN(get_task_stack), \ + FN(load_hdr_opt), \ + FN(store_hdr_opt), \ + FN(reserve_hdr_opt), \ + FN(inode_storage_get), \ + FN(inode_storage_delete), \ + FN(d_path), \ + FN(copy_from_user), \ + FN(snprintf_btf), \ + FN(seq_printf_btf), \ + FN(skb_cgroup_classid), \ + FN(redirect_neigh), \ + FN(per_cpu_ptr), \ + FN(this_cpu_ptr), \ + FN(redirect_peer), \ + FN(task_storage_get), \ + FN(task_storage_delete), \ + FN(get_current_task_btf), \ + FN(bprm_opts_set), \ + FN(ktime_get_coarse_ns), \ + FN(ima_inode_hash), \ + FN(sock_from_file), \ + FN(check_mtu), \ + FN(for_each_map_elem), \ + FN(snprintf), \ + FN(sys_bpf), \ + FN(btf_find_by_name_kind), \ + FN(sys_close), \ + /* */ + +/* integer value in 'imm' field of BPF_CALL instruction selects which helper + * function eBPF program intends to call + */ +#define __BPF_ENUM_FN(x) BPF_FUNC_ ## x +enum bpf_func_id { + __BPF_FUNC_MAPPER(__BPF_ENUM_FN) + __BPF_FUNC_MAX_ID, +}; +#undef __BPF_ENUM_FN + +/* All flags used by eBPF helper functions, placed here. */ + +/* BPF_FUNC_skb_store_bytes flags. */ +enum { + BPF_F_RECOMPUTE_CSUM = (1ULL << 0), + BPF_F_INVALIDATE_HASH = (1ULL << 1), +}; + +/* BPF_FUNC_l3_csum_replace and BPF_FUNC_l4_csum_replace flags. + * First 4 bits are for passing the header field size. + */ +enum { + BPF_F_HDR_FIELD_MASK = 0xfULL, +}; + +/* BPF_FUNC_l4_csum_replace flags. */ +enum { + BPF_F_PSEUDO_HDR = (1ULL << 4), + BPF_F_MARK_MANGLED_0 = (1ULL << 5), + BPF_F_MARK_ENFORCE = (1ULL << 6), +}; + +/* BPF_FUNC_clone_redirect and BPF_FUNC_redirect flags. */ +enum { + BPF_F_INGRESS = (1ULL << 0), +}; + +/* BPF_FUNC_skb_set_tunnel_key and BPF_FUNC_skb_get_tunnel_key flags. */ +enum { + BPF_F_TUNINFO_IPV6 = (1ULL << 0), +}; + +/* flags for both BPF_FUNC_get_stackid and BPF_FUNC_get_stack. */ +enum { + BPF_F_SKIP_FIELD_MASK = 0xffULL, + BPF_F_USER_STACK = (1ULL << 8), +/* flags used by BPF_FUNC_get_stackid only. */ + BPF_F_FAST_STACK_CMP = (1ULL << 9), + BPF_F_REUSE_STACKID = (1ULL << 10), +/* flags used by BPF_FUNC_get_stack only. */ + BPF_F_USER_BUILD_ID = (1ULL << 11), +}; + +/* BPF_FUNC_skb_set_tunnel_key flags. */ +enum { + BPF_F_ZERO_CSUM_TX = (1ULL << 1), + BPF_F_DONT_FRAGMENT = (1ULL << 2), + BPF_F_SEQ_NUMBER = (1ULL << 3), +}; + +/* BPF_FUNC_perf_event_output, BPF_FUNC_perf_event_read and + * BPF_FUNC_perf_event_read_value flags. + */ +enum { + BPF_F_INDEX_MASK = 0xffffffffULL, + BPF_F_CURRENT_CPU = BPF_F_INDEX_MASK, +/* BPF_FUNC_perf_event_output for sk_buff input context. */ + BPF_F_CTXLEN_MASK = (0xfffffULL << 32), +}; + +/* Current network namespace */ +enum { + BPF_F_CURRENT_NETNS = (-1L), +}; + +/* BPF_FUNC_csum_level level values. */ +enum { + BPF_CSUM_LEVEL_QUERY, + BPF_CSUM_LEVEL_INC, + BPF_CSUM_LEVEL_DEC, + BPF_CSUM_LEVEL_RESET, +}; + +/* BPF_FUNC_skb_adjust_room flags. */ +enum { + BPF_F_ADJ_ROOM_FIXED_GSO = (1ULL << 0), + BPF_F_ADJ_ROOM_ENCAP_L3_IPV4 = (1ULL << 1), + BPF_F_ADJ_ROOM_ENCAP_L3_IPV6 = (1ULL << 2), + BPF_F_ADJ_ROOM_ENCAP_L4_GRE = (1ULL << 3), + BPF_F_ADJ_ROOM_ENCAP_L4_UDP = (1ULL << 4), + BPF_F_ADJ_ROOM_NO_CSUM_RESET = (1ULL << 5), + BPF_F_ADJ_ROOM_ENCAP_L2_ETH = (1ULL << 6), +}; + +enum { + BPF_ADJ_ROOM_ENCAP_L2_MASK = 0xff, + BPF_ADJ_ROOM_ENCAP_L2_SHIFT = 56, +}; + +#define BPF_F_ADJ_ROOM_ENCAP_L2(len) (((__u64)len & \ + BPF_ADJ_ROOM_ENCAP_L2_MASK) \ + << BPF_ADJ_ROOM_ENCAP_L2_SHIFT) + +/* BPF_FUNC_sysctl_get_name flags. */ +enum { + BPF_F_SYSCTL_BASE_NAME = (1ULL << 0), +}; + +/* BPF_FUNC__storage_get flags */ +enum { + BPF_LOCAL_STORAGE_GET_F_CREATE = (1ULL << 0), + /* BPF_SK_STORAGE_GET_F_CREATE is only kept for backward compatibility + * and BPF_LOCAL_STORAGE_GET_F_CREATE must be used instead. + */ + BPF_SK_STORAGE_GET_F_CREATE = BPF_LOCAL_STORAGE_GET_F_CREATE, +}; + +/* BPF_FUNC_read_branch_records flags. */ +enum { + BPF_F_GET_BRANCH_RECORDS_SIZE = (1ULL << 0), +}; + +/* BPF_FUNC_bpf_ringbuf_commit, BPF_FUNC_bpf_ringbuf_discard, and + * BPF_FUNC_bpf_ringbuf_output flags. + */ +enum { + BPF_RB_NO_WAKEUP = (1ULL << 0), + BPF_RB_FORCE_WAKEUP = (1ULL << 1), +}; + +/* BPF_FUNC_bpf_ringbuf_query flags */ +enum { + BPF_RB_AVAIL_DATA = 0, + BPF_RB_RING_SIZE = 1, + BPF_RB_CONS_POS = 2, + BPF_RB_PROD_POS = 3, +}; + +/* BPF ring buffer constants */ +enum { + BPF_RINGBUF_BUSY_BIT = (1U << 31), + BPF_RINGBUF_DISCARD_BIT = (1U << 30), + BPF_RINGBUF_HDR_SZ = 8, +}; + +/* BPF_FUNC_sk_assign flags in bpf_sk_lookup context. */ +enum { + BPF_SK_LOOKUP_F_REPLACE = (1ULL << 0), + BPF_SK_LOOKUP_F_NO_REUSEPORT = (1ULL << 1), +}; + +/* Mode for BPF_FUNC_skb_adjust_room helper. */ +enum bpf_adj_room_mode { + BPF_ADJ_ROOM_NET, + BPF_ADJ_ROOM_MAC, +}; + +/* Mode for BPF_FUNC_skb_load_bytes_relative helper. */ +enum bpf_hdr_start_off { + BPF_HDR_START_MAC, + BPF_HDR_START_NET, +}; + +/* Encapsulation type for BPF_FUNC_lwt_push_encap helper. */ +enum bpf_lwt_encap_mode { + BPF_LWT_ENCAP_SEG6, + BPF_LWT_ENCAP_SEG6_INLINE, + BPF_LWT_ENCAP_IP, +}; + +/* Flags for bpf_bprm_opts_set helper */ +enum { + BPF_F_BPRM_SECUREEXEC = (1ULL << 0), +}; + +/* Flags for bpf_redirect_map helper */ +enum { + BPF_F_BROADCAST = (1ULL << 3), + BPF_F_EXCLUDE_INGRESS = (1ULL << 4), +}; + +#define __bpf_md_ptr(type, name) \ +union { \ + type name; \ + __u64 :64; \ +} __attribute__((aligned(8))) + +/* user accessible mirror of in-kernel sk_buff. + * new fields can only be added to the end of this structure + */ +struct __sk_buff { + __u32 len; + __u32 pkt_type; + __u32 mark; + __u32 queue_mapping; + __u32 protocol; + __u32 vlan_present; + __u32 vlan_tci; + __u32 vlan_proto; + __u32 priority; + __u32 ingress_ifindex; + __u32 ifindex; + __u32 tc_index; + __u32 cb[5]; + __u32 hash; + __u32 tc_classid; + __u32 data; + __u32 data_end; + __u32 napi_id; + + /* Accessed by BPF_PROG_TYPE_sk_skb types from here to ... */ + __u32 family; + __u32 remote_ip4; /* Stored in network byte order */ + __u32 local_ip4; /* Stored in network byte order */ + __u32 remote_ip6[4]; /* Stored in network byte order */ + __u32 local_ip6[4]; /* Stored in network byte order */ + __u32 remote_port; /* Stored in network byte order */ + __u32 local_port; /* stored in host byte order */ + /* ... here. */ + + __u32 data_meta; + __bpf_md_ptr(struct bpf_flow_keys *, flow_keys); + __u64 tstamp; + __u32 wire_len; + __u32 gso_segs; + __bpf_md_ptr(struct bpf_sock *, sk); + __u32 gso_size; +}; + +struct bpf_tunnel_key { + __u32 tunnel_id; + union { + __u32 remote_ipv4; + __u32 remote_ipv6[4]; + }; + __u8 tunnel_tos; + __u8 tunnel_ttl; + __u16 tunnel_ext; /* Padding, future use. */ + __u32 tunnel_label; +}; + +/* user accessible mirror of in-kernel xfrm_state. + * new fields can only be added to the end of this structure + */ +struct bpf_xfrm_state { + __u32 reqid; + __u32 spi; /* Stored in network byte order */ + __u16 family; + __u16 ext; /* Padding, future use. */ + union { + __u32 remote_ipv4; /* Stored in network byte order */ + __u32 remote_ipv6[4]; /* Stored in network byte order */ + }; +}; + +/* Generic BPF return codes which all BPF program types may support. + * The values are binary compatible with their TC_ACT_* counter-part to + * provide backwards compatibility with existing SCHED_CLS and SCHED_ACT + * programs. + * + * XDP is handled seprately, see XDP_*. + */ +enum bpf_ret_code { + BPF_OK = 0, + /* 1 reserved */ + BPF_DROP = 2, + /* 3-6 reserved */ + BPF_REDIRECT = 7, + /* >127 are reserved for prog type specific return codes. + * + * BPF_LWT_REROUTE: used by BPF_PROG_TYPE_LWT_IN and + * BPF_PROG_TYPE_LWT_XMIT to indicate that skb had been + * changed and should be routed based on its new L3 header. + * (This is an L3 redirect, as opposed to L2 redirect + * represented by BPF_REDIRECT above). + */ + BPF_LWT_REROUTE = 128, +}; + +struct bpf_sock { + __u32 bound_dev_if; + __u32 family; + __u32 type; + __u32 protocol; + __u32 mark; + __u32 priority; + /* IP address also allows 1 and 2 bytes access */ + __u32 src_ip4; + __u32 src_ip6[4]; + __u32 src_port; /* host byte order */ + __u32 dst_port; /* network byte order */ + __u32 dst_ip4; + __u32 dst_ip6[4]; + __u32 state; + __s32 rx_queue_mapping; +}; + +struct bpf_tcp_sock { + __u32 snd_cwnd; /* Sending congestion window */ + __u32 srtt_us; /* smoothed round trip time << 3 in usecs */ + __u32 rtt_min; + __u32 snd_ssthresh; /* Slow start size threshold */ + __u32 rcv_nxt; /* What we want to receive next */ + __u32 snd_nxt; /* Next sequence we send */ + __u32 snd_una; /* First byte we want an ack for */ + __u32 mss_cache; /* Cached effective mss, not including SACKS */ + __u32 ecn_flags; /* ECN status bits. */ + __u32 rate_delivered; /* saved rate sample: packets delivered */ + __u32 rate_interval_us; /* saved rate sample: time elapsed */ + __u32 packets_out; /* Packets which are "in flight" */ + __u32 retrans_out; /* Retransmitted packets out */ + __u32 total_retrans; /* Total retransmits for entire connection */ + __u32 segs_in; /* RFC4898 tcpEStatsPerfSegsIn + * total number of segments in. + */ + __u32 data_segs_in; /* RFC4898 tcpEStatsPerfDataSegsIn + * total number of data segments in. + */ + __u32 segs_out; /* RFC4898 tcpEStatsPerfSegsOut + * The total number of segments sent. + */ + __u32 data_segs_out; /* RFC4898 tcpEStatsPerfDataSegsOut + * total number of data segments sent. + */ + __u32 lost_out; /* Lost packets */ + __u32 sacked_out; /* SACK'd packets */ + __u64 bytes_received; /* RFC4898 tcpEStatsAppHCThruOctetsReceived + * sum(delta(rcv_nxt)), or how many bytes + * were acked. + */ + __u64 bytes_acked; /* RFC4898 tcpEStatsAppHCThruOctetsAcked + * sum(delta(snd_una)), or how many bytes + * were acked. + */ + __u32 dsack_dups; /* RFC4898 tcpEStatsStackDSACKDups + * total number of DSACK blocks received + */ + __u32 delivered; /* Total data packets delivered incl. rexmits */ + __u32 delivered_ce; /* Like the above but only ECE marked packets */ + __u32 icsk_retransmits; /* Number of unrecovered [RTO] timeouts */ +}; + +struct bpf_sock_tuple { + union { + struct { + __be32 saddr; + __be32 daddr; + __be16 sport; + __be16 dport; + } ipv4; + struct { + __be32 saddr[4]; + __be32 daddr[4]; + __be16 sport; + __be16 dport; + } ipv6; + }; +}; + +struct bpf_xdp_sock { + __u32 queue_id; +}; + +#define XDP_PACKET_HEADROOM 256 + +/* User return codes for XDP prog type. + * A valid XDP program must return one of these defined values. All other + * return codes are reserved for future use. Unknown return codes will + * result in packet drops and a warning via bpf_warn_invalid_xdp_action(). + */ +enum xdp_action { + XDP_ABORTED = 0, + XDP_DROP, + XDP_PASS, + XDP_TX, + XDP_REDIRECT, +}; + +/* user accessible metadata for XDP packet hook + * new fields must be added to the end of this structure + */ +struct xdp_md { + __u32 data; + __u32 data_end; + __u32 data_meta; + /* Below access go through struct xdp_rxq_info */ + __u32 ingress_ifindex; /* rxq->dev->ifindex */ + __u32 rx_queue_index; /* rxq->queue_index */ + + __u32 egress_ifindex; /* txq->dev->ifindex */ +}; + +/* DEVMAP map-value layout + * + * The struct data-layout of map-value is a configuration interface. + * New members can only be added to the end of this structure. + */ +struct bpf_devmap_val { + __u32 ifindex; /* device index */ + union { + int fd; /* prog fd on map write */ + __u32 id; /* prog id on map read */ + } bpf_prog; +}; + +/* CPUMAP map-value layout + * + * The struct data-layout of map-value is a configuration interface. + * New members can only be added to the end of this structure. + */ +struct bpf_cpumap_val { + __u32 qsize; /* queue size to remote target CPU */ + union { + int fd; /* prog fd on map write */ + __u32 id; /* prog id on map read */ + } bpf_prog; +}; + +enum sk_action { + SK_DROP = 0, + SK_PASS, +}; + +/* user accessible metadata for SK_MSG packet hook, new fields must + * be added to the end of this structure + */ +struct sk_msg_md { + __bpf_md_ptr(void *, data); + __bpf_md_ptr(void *, data_end); + + __u32 family; + __u32 remote_ip4; /* Stored in network byte order */ + __u32 local_ip4; /* Stored in network byte order */ + __u32 remote_ip6[4]; /* Stored in network byte order */ + __u32 local_ip6[4]; /* Stored in network byte order */ + __u32 remote_port; /* Stored in network byte order */ + __u32 local_port; /* stored in host byte order */ + __u32 size; /* Total size of sk_msg */ + + __bpf_md_ptr(struct bpf_sock *, sk); /* current socket */ +}; + +struct sk_reuseport_md { + /* + * Start of directly accessible data. It begins from + * the tcp/udp header. + */ + __bpf_md_ptr(void *, data); + /* End of directly accessible data */ + __bpf_md_ptr(void *, data_end); + /* + * Total length of packet (starting from the tcp/udp header). + * Note that the directly accessible bytes (data_end - data) + * could be less than this "len". Those bytes could be + * indirectly read by a helper "bpf_skb_load_bytes()". + */ + __u32 len; + /* + * Eth protocol in the mac header (network byte order). e.g. + * ETH_P_IP(0x0800) and ETH_P_IPV6(0x86DD) + */ + __u32 eth_protocol; + __u32 ip_protocol; /* IP protocol. e.g. IPPROTO_TCP, IPPROTO_UDP */ + __u32 bind_inany; /* Is sock bound to an INANY address? */ + __u32 hash; /* A hash of the packet 4 tuples */ + /* When reuse->migrating_sk is NULL, it is selecting a sk for the + * new incoming connection request (e.g. selecting a listen sk for + * the received SYN in the TCP case). reuse->sk is one of the sk + * in the reuseport group. The bpf prog can use reuse->sk to learn + * the local listening ip/port without looking into the skb. + * + * When reuse->migrating_sk is not NULL, reuse->sk is closed and + * reuse->migrating_sk is the socket that needs to be migrated + * to another listening socket. migrating_sk could be a fullsock + * sk that is fully established or a reqsk that is in-the-middle + * of 3-way handshake. + */ + __bpf_md_ptr(struct bpf_sock *, sk); + __bpf_md_ptr(struct bpf_sock *, migrating_sk); +}; + +#define BPF_TAG_SIZE 8 + +struct bpf_prog_info { + __u32 type; + __u32 id; + __u8 tag[BPF_TAG_SIZE]; + __u32 jited_prog_len; + __u32 xlated_prog_len; + __aligned_u64 jited_prog_insns; + __aligned_u64 xlated_prog_insns; + __u64 load_time; /* ns since boottime */ + __u32 created_by_uid; + __u32 nr_map_ids; + __aligned_u64 map_ids; + char name[BPF_OBJ_NAME_LEN]; + __u32 ifindex; + __u32 gpl_compatible:1; + __u32 :31; /* alignment pad */ + __u64 netns_dev; + __u64 netns_ino; + __u32 nr_jited_ksyms; + __u32 nr_jited_func_lens; + __aligned_u64 jited_ksyms; + __aligned_u64 jited_func_lens; + __u32 btf_id; + __u32 func_info_rec_size; + __aligned_u64 func_info; + __u32 nr_func_info; + __u32 nr_line_info; + __aligned_u64 line_info; + __aligned_u64 jited_line_info; + __u32 nr_jited_line_info; + __u32 line_info_rec_size; + __u32 jited_line_info_rec_size; + __u32 nr_prog_tags; + __aligned_u64 prog_tags; + __u64 run_time_ns; + __u64 run_cnt; + __u64 recursion_misses; +} __attribute__((aligned(8))); + +struct bpf_map_info { + __u32 type; + __u32 id; + __u32 key_size; + __u32 value_size; + __u32 max_entries; + __u32 map_flags; + char name[BPF_OBJ_NAME_LEN]; + __u32 ifindex; + __u32 btf_vmlinux_value_type_id; + __u64 netns_dev; + __u64 netns_ino; + __u32 btf_id; + __u32 btf_key_type_id; + __u32 btf_value_type_id; +} __attribute__((aligned(8))); + +struct bpf_btf_info { + __aligned_u64 btf; + __u32 btf_size; + __u32 id; + __aligned_u64 name; + __u32 name_len; + __u32 kernel_btf; +} __attribute__((aligned(8))); + +struct bpf_link_info { + __u32 type; + __u32 id; + __u32 prog_id; + union { + struct { + __aligned_u64 tp_name; /* in/out: tp_name buffer ptr */ + __u32 tp_name_len; /* in/out: tp_name buffer len */ + } raw_tracepoint; + struct { + __u32 attach_type; + __u32 target_obj_id; /* prog_id for PROG_EXT, otherwise btf object id */ + __u32 target_btf_id; /* BTF type id inside the object */ + } tracing; + struct { + __u64 cgroup_id; + __u32 attach_type; + } cgroup; + struct { + __aligned_u64 target_name; /* in/out: target_name buffer ptr */ + __u32 target_name_len; /* in/out: target_name buffer len */ + union { + struct { + __u32 map_id; + } map; + }; + } iter; + struct { + __u32 netns_ino; + __u32 attach_type; + } netns; + struct { + __u32 ifindex; + } xdp; + }; +} __attribute__((aligned(8))); + +/* User bpf_sock_addr struct to access socket fields and sockaddr struct passed + * by user and intended to be used by socket (e.g. to bind to, depends on + * attach type). + */ +struct bpf_sock_addr { + __u32 user_family; /* Allows 4-byte read, but no write. */ + __u32 user_ip4; /* Allows 1,2,4-byte read and 4-byte write. + * Stored in network byte order. + */ + __u32 user_ip6[4]; /* Allows 1,2,4,8-byte read and 4,8-byte write. + * Stored in network byte order. + */ + __u32 user_port; /* Allows 1,2,4-byte read and 4-byte write. + * Stored in network byte order + */ + __u32 family; /* Allows 4-byte read, but no write */ + __u32 type; /* Allows 4-byte read, but no write */ + __u32 protocol; /* Allows 4-byte read, but no write */ + __u32 msg_src_ip4; /* Allows 1,2,4-byte read and 4-byte write. + * Stored in network byte order. + */ + __u32 msg_src_ip6[4]; /* Allows 1,2,4,8-byte read and 4,8-byte write. + * Stored in network byte order. + */ + __bpf_md_ptr(struct bpf_sock *, sk); +}; + +/* User bpf_sock_ops struct to access socket values and specify request ops + * and their replies. + * Some of this fields are in network (bigendian) byte order and may need + * to be converted before use (bpf_ntohl() defined in samples/bpf/bpf_endian.h). + * New fields can only be added at the end of this structure + */ +struct bpf_sock_ops { + __u32 op; + union { + __u32 args[4]; /* Optionally passed to bpf program */ + __u32 reply; /* Returned by bpf program */ + __u32 replylong[4]; /* Optionally returned by bpf prog */ + }; + __u32 family; + __u32 remote_ip4; /* Stored in network byte order */ + __u32 local_ip4; /* Stored in network byte order */ + __u32 remote_ip6[4]; /* Stored in network byte order */ + __u32 local_ip6[4]; /* Stored in network byte order */ + __u32 remote_port; /* Stored in network byte order */ + __u32 local_port; /* stored in host byte order */ + __u32 is_fullsock; /* Some TCP fields are only valid if + * there is a full socket. If not, the + * fields read as zero. + */ + __u32 snd_cwnd; + __u32 srtt_us; /* Averaged RTT << 3 in usecs */ + __u32 bpf_sock_ops_cb_flags; /* flags defined in uapi/linux/tcp.h */ + __u32 state; + __u32 rtt_min; + __u32 snd_ssthresh; + __u32 rcv_nxt; + __u32 snd_nxt; + __u32 snd_una; + __u32 mss_cache; + __u32 ecn_flags; + __u32 rate_delivered; + __u32 rate_interval_us; + __u32 packets_out; + __u32 retrans_out; + __u32 total_retrans; + __u32 segs_in; + __u32 data_segs_in; + __u32 segs_out; + __u32 data_segs_out; + __u32 lost_out; + __u32 sacked_out; + __u32 sk_txhash; + __u64 bytes_received; + __u64 bytes_acked; + __bpf_md_ptr(struct bpf_sock *, sk); + /* [skb_data, skb_data_end) covers the whole TCP header. + * + * BPF_SOCK_OPS_PARSE_HDR_OPT_CB: The packet received + * BPF_SOCK_OPS_HDR_OPT_LEN_CB: Not useful because the + * header has not been written. + * BPF_SOCK_OPS_WRITE_HDR_OPT_CB: The header and options have + * been written so far. + * BPF_SOCK_OPS_ACTIVE_ESTABLISHED_CB: The SYNACK that concludes + * the 3WHS. + * BPF_SOCK_OPS_PASSIVE_ESTABLISHED_CB: The ACK that concludes + * the 3WHS. + * + * bpf_load_hdr_opt() can also be used to read a particular option. + */ + __bpf_md_ptr(void *, skb_data); + __bpf_md_ptr(void *, skb_data_end); + __u32 skb_len; /* The total length of a packet. + * It includes the header, options, + * and payload. + */ + __u32 skb_tcp_flags; /* tcp_flags of the header. It provides + * an easy way to check for tcp_flags + * without parsing skb_data. + * + * In particular, the skb_tcp_flags + * will still be available in + * BPF_SOCK_OPS_HDR_OPT_LEN even though + * the outgoing header has not + * been written yet. + */ +}; + +/* Definitions for bpf_sock_ops_cb_flags */ +enum { + BPF_SOCK_OPS_RTO_CB_FLAG = (1<<0), + BPF_SOCK_OPS_RETRANS_CB_FLAG = (1<<1), + BPF_SOCK_OPS_STATE_CB_FLAG = (1<<2), + BPF_SOCK_OPS_RTT_CB_FLAG = (1<<3), + /* Call bpf for all received TCP headers. The bpf prog will be + * called under sock_ops->op == BPF_SOCK_OPS_PARSE_HDR_OPT_CB + * + * Please refer to the comment in BPF_SOCK_OPS_PARSE_HDR_OPT_CB + * for the header option related helpers that will be useful + * to the bpf programs. + * + * It could be used at the client/active side (i.e. connect() side) + * when the server told it that the server was in syncookie + * mode and required the active side to resend the bpf-written + * options. The active side can keep writing the bpf-options until + * it received a valid packet from the server side to confirm + * the earlier packet (and options) has been received. The later + * example patch is using it like this at the active side when the + * server is in syncookie mode. + * + * The bpf prog will usually turn this off in the common cases. + */ + BPF_SOCK_OPS_PARSE_ALL_HDR_OPT_CB_FLAG = (1<<4), + /* Call bpf when kernel has received a header option that + * the kernel cannot handle. The bpf prog will be called under + * sock_ops->op == BPF_SOCK_OPS_PARSE_HDR_OPT_CB. + * + * Please refer to the comment in BPF_SOCK_OPS_PARSE_HDR_OPT_CB + * for the header option related helpers that will be useful + * to the bpf programs. + */ + BPF_SOCK_OPS_PARSE_UNKNOWN_HDR_OPT_CB_FLAG = (1<<5), + /* Call bpf when the kernel is writing header options for the + * outgoing packet. The bpf prog will first be called + * to reserve space in a skb under + * sock_ops->op == BPF_SOCK_OPS_HDR_OPT_LEN_CB. Then + * the bpf prog will be called to write the header option(s) + * under sock_ops->op == BPF_SOCK_OPS_WRITE_HDR_OPT_CB. + * + * Please refer to the comment in BPF_SOCK_OPS_HDR_OPT_LEN_CB + * and BPF_SOCK_OPS_WRITE_HDR_OPT_CB for the header option + * related helpers that will be useful to the bpf programs. + * + * The kernel gets its chance to reserve space and write + * options first before the BPF program does. + */ + BPF_SOCK_OPS_WRITE_HDR_OPT_CB_FLAG = (1<<6), +/* Mask of all currently supported cb flags */ + BPF_SOCK_OPS_ALL_CB_FLAGS = 0x7F, +}; + +/* List of known BPF sock_ops operators. + * New entries can only be added at the end + */ +enum { + BPF_SOCK_OPS_VOID, + BPF_SOCK_OPS_TIMEOUT_INIT, /* Should return SYN-RTO value to use or + * -1 if default value should be used + */ + BPF_SOCK_OPS_RWND_INIT, /* Should return initial advertized + * window (in packets) or -1 if default + * value should be used + */ + BPF_SOCK_OPS_TCP_CONNECT_CB, /* Calls BPF program right before an + * active connection is initialized + */ + BPF_SOCK_OPS_ACTIVE_ESTABLISHED_CB, /* Calls BPF program when an + * active connection is + * established + */ + BPF_SOCK_OPS_PASSIVE_ESTABLISHED_CB, /* Calls BPF program when a + * passive connection is + * established + */ + BPF_SOCK_OPS_NEEDS_ECN, /* If connection's congestion control + * needs ECN + */ + BPF_SOCK_OPS_BASE_RTT, /* Get base RTT. The correct value is + * based on the path and may be + * dependent on the congestion control + * algorithm. In general it indicates + * a congestion threshold. RTTs above + * this indicate congestion + */ + BPF_SOCK_OPS_RTO_CB, /* Called when an RTO has triggered. + * Arg1: value of icsk_retransmits + * Arg2: value of icsk_rto + * Arg3: whether RTO has expired + */ + BPF_SOCK_OPS_RETRANS_CB, /* Called when skb is retransmitted. + * Arg1: sequence number of 1st byte + * Arg2: # segments + * Arg3: return value of + * tcp_transmit_skb (0 => success) + */ + BPF_SOCK_OPS_STATE_CB, /* Called when TCP changes state. + * Arg1: old_state + * Arg2: new_state + */ + BPF_SOCK_OPS_TCP_LISTEN_CB, /* Called on listen(2), right after + * socket transition to LISTEN state. + */ + BPF_SOCK_OPS_RTT_CB, /* Called on every RTT. + */ + BPF_SOCK_OPS_PARSE_HDR_OPT_CB, /* Parse the header option. + * It will be called to handle + * the packets received at + * an already established + * connection. + * + * sock_ops->skb_data: + * Referring to the received skb. + * It covers the TCP header only. + * + * bpf_load_hdr_opt() can also + * be used to search for a + * particular option. + */ + BPF_SOCK_OPS_HDR_OPT_LEN_CB, /* Reserve space for writing the + * header option later in + * BPF_SOCK_OPS_WRITE_HDR_OPT_CB. + * Arg1: bool want_cookie. (in + * writing SYNACK only) + * + * sock_ops->skb_data: + * Not available because no header has + * been written yet. + * + * sock_ops->skb_tcp_flags: + * The tcp_flags of the + * outgoing skb. (e.g. SYN, ACK, FIN). + * + * bpf_reserve_hdr_opt() should + * be used to reserve space. + */ + BPF_SOCK_OPS_WRITE_HDR_OPT_CB, /* Write the header options + * Arg1: bool want_cookie. (in + * writing SYNACK only) + * + * sock_ops->skb_data: + * Referring to the outgoing skb. + * It covers the TCP header + * that has already been written + * by the kernel and the + * earlier bpf-progs. + * + * sock_ops->skb_tcp_flags: + * The tcp_flags of the outgoing + * skb. (e.g. SYN, ACK, FIN). + * + * bpf_store_hdr_opt() should + * be used to write the + * option. + * + * bpf_load_hdr_opt() can also + * be used to search for a + * particular option that + * has already been written + * by the kernel or the + * earlier bpf-progs. + */ +}; + +/* List of TCP states. There is a build check in net/ipv4/tcp.c to detect + * changes between the TCP and BPF versions. Ideally this should never happen. + * If it does, we need to add code to convert them before calling + * the BPF sock_ops function. + */ +enum { + BPF_TCP_ESTABLISHED = 1, + BPF_TCP_SYN_SENT, + BPF_TCP_SYN_RECV, + BPF_TCP_FIN_WAIT1, + BPF_TCP_FIN_WAIT2, + BPF_TCP_TIME_WAIT, + BPF_TCP_CLOSE, + BPF_TCP_CLOSE_WAIT, + BPF_TCP_LAST_ACK, + BPF_TCP_LISTEN, + BPF_TCP_CLOSING, /* Now a valid state */ + BPF_TCP_NEW_SYN_RECV, + + BPF_TCP_MAX_STATES /* Leave at the end! */ +}; + +enum { + TCP_BPF_IW = 1001, /* Set TCP initial congestion window */ + TCP_BPF_SNDCWND_CLAMP = 1002, /* Set sndcwnd_clamp */ + TCP_BPF_DELACK_MAX = 1003, /* Max delay ack in usecs */ + TCP_BPF_RTO_MIN = 1004, /* Min delay ack in usecs */ + /* Copy the SYN pkt to optval + * + * BPF_PROG_TYPE_SOCK_OPS only. It is similar to the + * bpf_getsockopt(TCP_SAVED_SYN) but it does not limit + * to only getting from the saved_syn. It can either get the + * syn packet from: + * + * 1. the just-received SYN packet (only available when writing the + * SYNACK). It will be useful when it is not necessary to + * save the SYN packet for latter use. It is also the only way + * to get the SYN during syncookie mode because the syn + * packet cannot be saved during syncookie. + * + * OR + * + * 2. the earlier saved syn which was done by + * bpf_setsockopt(TCP_SAVE_SYN). + * + * The bpf_getsockopt(TCP_BPF_SYN*) option will hide where the + * SYN packet is obtained. + * + * If the bpf-prog does not need the IP[46] header, the + * bpf-prog can avoid parsing the IP header by using + * TCP_BPF_SYN. Otherwise, the bpf-prog can get both + * IP[46] and TCP header by using TCP_BPF_SYN_IP. + * + * >0: Total number of bytes copied + * -ENOSPC: Not enough space in optval. Only optlen number of + * bytes is copied. + * -ENOENT: The SYN skb is not available now and the earlier SYN pkt + * is not saved by setsockopt(TCP_SAVE_SYN). + */ + TCP_BPF_SYN = 1005, /* Copy the TCP header */ + TCP_BPF_SYN_IP = 1006, /* Copy the IP[46] and TCP header */ + TCP_BPF_SYN_MAC = 1007, /* Copy the MAC, IP[46], and TCP header */ +}; + +enum { + BPF_LOAD_HDR_OPT_TCP_SYN = (1ULL << 0), +}; + +/* args[0] value during BPF_SOCK_OPS_HDR_OPT_LEN_CB and + * BPF_SOCK_OPS_WRITE_HDR_OPT_CB. + */ +enum { + BPF_WRITE_HDR_TCP_CURRENT_MSS = 1, /* Kernel is finding the + * total option spaces + * required for an established + * sk in order to calculate the + * MSS. No skb is actually + * sent. + */ + BPF_WRITE_HDR_TCP_SYNACK_COOKIE = 2, /* Kernel is in syncookie mode + * when sending a SYN. + */ +}; + +struct bpf_perf_event_value { + __u64 counter; + __u64 enabled; + __u64 running; +}; + +enum { + BPF_DEVCG_ACC_MKNOD = (1ULL << 0), + BPF_DEVCG_ACC_READ = (1ULL << 1), + BPF_DEVCG_ACC_WRITE = (1ULL << 2), +}; + +enum { + BPF_DEVCG_DEV_BLOCK = (1ULL << 0), + BPF_DEVCG_DEV_CHAR = (1ULL << 1), +}; + +struct bpf_cgroup_dev_ctx { + /* access_type encoded as (BPF_DEVCG_ACC_* << 16) | BPF_DEVCG_DEV_* */ + __u32 access_type; + __u32 major; + __u32 minor; +}; + +struct bpf_raw_tracepoint_args { + __u64 args[0]; +}; + +/* DIRECT: Skip the FIB rules and go to FIB table associated with device + * OUTPUT: Do lookup from egress perspective; default is ingress + */ +enum { + BPF_FIB_LOOKUP_DIRECT = (1U << 0), + BPF_FIB_LOOKUP_OUTPUT = (1U << 1), +}; + +enum { + BPF_FIB_LKUP_RET_SUCCESS, /* lookup successful */ + BPF_FIB_LKUP_RET_BLACKHOLE, /* dest is blackholed; can be dropped */ + BPF_FIB_LKUP_RET_UNREACHABLE, /* dest is unreachable; can be dropped */ + BPF_FIB_LKUP_RET_PROHIBIT, /* dest not allowed; can be dropped */ + BPF_FIB_LKUP_RET_NOT_FWDED, /* packet is not forwarded */ + BPF_FIB_LKUP_RET_FWD_DISABLED, /* fwding is not enabled on ingress */ + BPF_FIB_LKUP_RET_UNSUPP_LWT, /* fwd requires encapsulation */ + BPF_FIB_LKUP_RET_NO_NEIGH, /* no neighbor entry for nh */ + BPF_FIB_LKUP_RET_FRAG_NEEDED, /* fragmentation required to fwd */ +}; + +struct bpf_fib_lookup { + /* input: network family for lookup (AF_INET, AF_INET6) + * output: network family of egress nexthop + */ + __u8 family; + + /* set if lookup is to consider L4 data - e.g., FIB rules */ + __u8 l4_protocol; + __be16 sport; + __be16 dport; + + union { /* used for MTU check */ + /* input to lookup */ + __u16 tot_len; /* L3 length from network hdr (iph->tot_len) */ + + /* output: MTU value */ + __u16 mtu_result; + }; + /* input: L3 device index for lookup + * output: device index from FIB lookup + */ + __u32 ifindex; + + union { + /* inputs to lookup */ + __u8 tos; /* AF_INET */ + __be32 flowinfo; /* AF_INET6, flow_label + priority */ + + /* output: metric of fib result (IPv4/IPv6 only) */ + __u32 rt_metric; + }; + + union { + __be32 ipv4_src; + __u32 ipv6_src[4]; /* in6_addr; network order */ + }; + + /* input to bpf_fib_lookup, ipv{4,6}_dst is destination address in + * network header. output: bpf_fib_lookup sets to gateway address + * if FIB lookup returns gateway route + */ + union { + __be32 ipv4_dst; + __u32 ipv6_dst[4]; /* in6_addr; network order */ + }; + + /* output */ + __be16 h_vlan_proto; + __be16 h_vlan_TCI; + __u8 smac[6]; /* ETH_ALEN */ + __u8 dmac[6]; /* ETH_ALEN */ +}; + +struct bpf_redir_neigh { + /* network family for lookup (AF_INET, AF_INET6) */ + __u32 nh_family; + /* network address of nexthop; skips fib lookup to find gateway */ + union { + __be32 ipv4_nh; + __u32 ipv6_nh[4]; /* in6_addr; network order */ + }; +}; + +/* bpf_check_mtu flags*/ +enum bpf_check_mtu_flags { + BPF_MTU_CHK_SEGS = (1U << 0), +}; + +enum bpf_check_mtu_ret { + BPF_MTU_CHK_RET_SUCCESS, /* check and lookup successful */ + BPF_MTU_CHK_RET_FRAG_NEEDED, /* fragmentation required to fwd */ + BPF_MTU_CHK_RET_SEGS_TOOBIG, /* GSO re-segmentation needed to fwd */ +}; + +enum bpf_task_fd_type { + BPF_FD_TYPE_RAW_TRACEPOINT, /* tp name */ + BPF_FD_TYPE_TRACEPOINT, /* tp name */ + BPF_FD_TYPE_KPROBE, /* (symbol + offset) or addr */ + BPF_FD_TYPE_KRETPROBE, /* (symbol + offset) or addr */ + BPF_FD_TYPE_UPROBE, /* filename + offset */ + BPF_FD_TYPE_URETPROBE, /* filename + offset */ +}; + +enum { + BPF_FLOW_DISSECTOR_F_PARSE_1ST_FRAG = (1U << 0), + BPF_FLOW_DISSECTOR_F_STOP_AT_FLOW_LABEL = (1U << 1), + BPF_FLOW_DISSECTOR_F_STOP_AT_ENCAP = (1U << 2), +}; + +struct bpf_flow_keys { + __u16 nhoff; + __u16 thoff; + __u16 addr_proto; /* ETH_P_* of valid addrs */ + __u8 is_frag; + __u8 is_first_frag; + __u8 is_encap; + __u8 ip_proto; + __be16 n_proto; + __be16 sport; + __be16 dport; + union { + struct { + __be32 ipv4_src; + __be32 ipv4_dst; + }; + struct { + __u32 ipv6_src[4]; /* in6_addr; network order */ + __u32 ipv6_dst[4]; /* in6_addr; network order */ + }; + }; + __u32 flags; + __be32 flow_label; +}; + +struct bpf_func_info { + __u32 insn_off; + __u32 type_id; +}; + +#define BPF_LINE_INFO_LINE_NUM(line_col) ((line_col) >> 10) +#define BPF_LINE_INFO_LINE_COL(line_col) ((line_col) & 0x3ff) + +struct bpf_line_info { + __u32 insn_off; + __u32 file_name_off; + __u32 line_off; + __u32 line_col; +}; + +struct bpf_spin_lock { + __u32 val; +}; + +struct bpf_sysctl { + __u32 write; /* Sysctl is being read (= 0) or written (= 1). + * Allows 1,2,4-byte read, but no write. + */ + __u32 file_pos; /* Sysctl file position to read from, write to. + * Allows 1,2,4-byte read an 4-byte write. + */ +}; + +struct bpf_sockopt { + __bpf_md_ptr(struct bpf_sock *, sk); + __bpf_md_ptr(void *, optval); + __bpf_md_ptr(void *, optval_end); + + __s32 level; + __s32 optname; + __s32 optlen; + __s32 retval; +}; + +struct bpf_pidns_info { + __u32 pid; + __u32 tgid; +}; + +/* User accessible data for SK_LOOKUP programs. Add new fields at the end. */ +struct bpf_sk_lookup { + union { + __bpf_md_ptr(struct bpf_sock *, sk); /* Selected socket */ + __u64 cookie; /* Non-zero if socket was selected in PROG_TEST_RUN */ + }; + + __u32 family; /* Protocol family (AF_INET, AF_INET6) */ + __u32 protocol; /* IP protocol (IPPROTO_TCP, IPPROTO_UDP) */ + __u32 remote_ip4; /* Network byte order */ + __u32 remote_ip6[4]; /* Network byte order */ + __u32 remote_port; /* Network byte order */ + __u32 local_ip4; /* Network byte order */ + __u32 local_ip6[4]; /* Network byte order */ + __u32 local_port; /* Host byte order */ +}; + +/* + * struct btf_ptr is used for typed pointer representation; the + * type id is used to render the pointer data as the appropriate type + * via the bpf_snprintf_btf() helper described above. A flags field - + * potentially to specify additional details about the BTF pointer + * (rather than its mode of display) - is included for future use. + * Display flags - BTF_F_* - are passed to bpf_snprintf_btf separately. + */ +struct btf_ptr { + void *ptr; + __u32 type_id; + __u32 flags; /* BTF ptr flags; unused at present. */ +}; + +/* + * Flags to control bpf_snprintf_btf() behaviour. + * - BTF_F_COMPACT: no formatting around type information + * - BTF_F_NONAME: no struct/union member names/types + * - BTF_F_PTR_RAW: show raw (unobfuscated) pointer values; + * equivalent to %px. + * - BTF_F_ZERO: show zero-valued struct/union members; they + * are not displayed by default + */ +enum { + BTF_F_COMPACT = (1ULL << 0), + BTF_F_NONAME = (1ULL << 1), + BTF_F_PTR_RAW = (1ULL << 2), + BTF_F_ZERO = (1ULL << 3), +}; + +#endif /* _UAPI__LINUX_BPF_H__ */ diff --git a/cmd/libsnap-confine-private/bpf/vendor/linux/bpf_common.h b/cmd/libsnap-confine-private/bpf/vendor/linux/bpf_common.h new file mode 100644 index 00000000..ee97668b --- /dev/null +++ b/cmd/libsnap-confine-private/bpf/vendor/linux/bpf_common.h @@ -0,0 +1,57 @@ +/* SPDX-License-Identifier: GPL-2.0 WITH Linux-syscall-note */ +#ifndef _UAPI__LINUX_BPF_COMMON_H__ +#define _UAPI__LINUX_BPF_COMMON_H__ + +/* Instruction classes */ +#define BPF_CLASS(code) ((code) & 0x07) +#define BPF_LD 0x00 +#define BPF_LDX 0x01 +#define BPF_ST 0x02 +#define BPF_STX 0x03 +#define BPF_ALU 0x04 +#define BPF_JMP 0x05 +#define BPF_RET 0x06 +#define BPF_MISC 0x07 + +/* ld/ldx fields */ +#define BPF_SIZE(code) ((code) & 0x18) +#define BPF_W 0x00 /* 32-bit */ +#define BPF_H 0x08 /* 16-bit */ +#define BPF_B 0x10 /* 8-bit */ +/* eBPF BPF_DW 0x18 64-bit */ +#define BPF_MODE(code) ((code) & 0xe0) +#define BPF_IMM 0x00 +#define BPF_ABS 0x20 +#define BPF_IND 0x40 +#define BPF_MEM 0x60 +#define BPF_LEN 0x80 +#define BPF_MSH 0xa0 + +/* alu/jmp fields */ +#define BPF_OP(code) ((code) & 0xf0) +#define BPF_ADD 0x00 +#define BPF_SUB 0x10 +#define BPF_MUL 0x20 +#define BPF_DIV 0x30 +#define BPF_OR 0x40 +#define BPF_AND 0x50 +#define BPF_LSH 0x60 +#define BPF_RSH 0x70 +#define BPF_NEG 0x80 +#define BPF_MOD 0x90 +#define BPF_XOR 0xa0 + +#define BPF_JA 0x00 +#define BPF_JEQ 0x10 +#define BPF_JGT 0x20 +#define BPF_JGE 0x30 +#define BPF_JSET 0x40 +#define BPF_SRC(code) ((code) & 0x08) +#define BPF_K 0x00 +#define BPF_X 0x08 + +#ifndef BPF_MAXINSNS +#define BPF_MAXINSNS 4096 +#endif + +#endif /* _UAPI__LINUX_BPF_COMMON_H__ */ diff --git a/cmd/libsnap-confine-private/cgroup-freezer-support.c b/cmd/libsnap-confine-private/cgroup-freezer-support.c new file mode 100644 index 00000000..e582f120 --- /dev/null +++ b/cmd/libsnap-confine-private/cgroup-freezer-support.c @@ -0,0 +1,126 @@ +/* + * Copyright (C) 2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +// For AT_EMPTY_PATH and O_PATH +#define _GNU_SOURCE + +#include "cgroup-freezer-support.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "cgroup-support.h" +#include "cleanup-funcs.h" +#include "string-utils.h" +#include "utils.h" + +static const char *freezer_cgroup_dir = "/sys/fs/cgroup/freezer"; + +void sc_cgroup_freezer_join(const char *snap_name, pid_t pid) +{ + char buf[PATH_MAX] = { 0 }; + sc_must_snprintf(buf, sizeof buf, "snap.%s", snap_name); + sc_cgroup_create_and_join(freezer_cgroup_dir, buf, pid); +} + +bool sc_cgroup_freezer_occupied(const char *snap_name) +{ + // Format the name of the cgroup hierarchy. + char buf[PATH_MAX] = { 0 }; + sc_must_snprintf(buf, sizeof buf, "snap.%s", snap_name); + + // Open the freezer cgroup directory. + int cgroup_fd SC_CLEANUP(sc_cleanup_close) = -1; + cgroup_fd = open(freezer_cgroup_dir, + O_PATH | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (cgroup_fd < 0) { + die("cannot open freezer cgroup (%s)", freezer_cgroup_dir); + } + // Open the proc directory. + int proc_fd SC_CLEANUP(sc_cleanup_close) = -1; + proc_fd = open("/proc", O_PATH | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (proc_fd < 0) { + die("cannot open /proc"); + } + // Open the hierarchy directory for the given snap. + int hierarchy_fd SC_CLEANUP(sc_cleanup_close) = -1; + hierarchy_fd = openat(cgroup_fd, buf, + O_PATH | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (hierarchy_fd < 0) { + if (errno == ENOENT) { + return false; + } + die("cannot open freezer cgroup hierarchy for snap %s", + snap_name); + } + // Open the "cgroup.procs" file. Alternatively we could open the "tasks" + // file and see per-thread data but we don't need that. + int cgroup_procs_fd SC_CLEANUP(sc_cleanup_close) = -1; + cgroup_procs_fd = openat(hierarchy_fd, "cgroup.procs", + O_RDONLY | O_NOFOLLOW | O_CLOEXEC); + if (cgroup_procs_fd < 0) { + die("cannot open cgroup.procs file for freezer cgroup hierarchy for snap %s", snap_name); + } + + FILE *cgroup_procs SC_CLEANUP(sc_cleanup_file) = NULL; + cgroup_procs = fdopen(cgroup_procs_fd, "r"); + if (cgroup_procs == NULL) { + die("cannot convert cgroups.procs file descriptor to FILE"); + } + cgroup_procs_fd = -1; // cgroup_procs_fd will now be closed by fclose. + + char *line_buf SC_CLEANUP(sc_cleanup_string) = NULL; + size_t line_buf_size = 0; + ssize_t num_read; + struct stat statbuf; + for (;;) { + num_read = getline(&line_buf, &line_buf_size, cgroup_procs); + if (num_read < 0 && errno != 0) { + die("cannot read next PID belonging to snap %s", + snap_name); + } + if (num_read <= 0) { + break; + } else { + if (line_buf[num_read - 1] == '\n') { + line_buf[num_read - 1] = '\0'; + } else { + die("could not find newline in cgroup.procs"); + } + } + debug("found process id: %s\n", line_buf); + + if (fstatat(proc_fd, line_buf, &statbuf, AT_SYMLINK_NOFOLLOW) < + 0) { + // The process may have died already. + if (errno != ENOENT) { + die("cannot stat /proc/%s", line_buf); + } + continue; + } + debug("found live process %s belonging to user %d", + line_buf, statbuf.st_uid); + return true; + } + + return false; +} diff --git a/cmd/libsnap-confine-private/cgroup-freezer-support.h b/cmd/libsnap-confine-private/cgroup-freezer-support.h new file mode 100644 index 00000000..306523b0 --- /dev/null +++ b/cmd/libsnap-confine-private/cgroup-freezer-support.h @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#ifndef SC_CGROUP_FREEZER_SUPPORT_H +#define SC_CGROUP_FREEZER_SUPPORT_H + +#include +#include "error.h" + +/** + * Join the freezer cgroup for the given snap. + * + * This function adds the specified task to the freezer cgroup specific to the + * given snap. The name of the cgroup is "snap.$snap_name". + * + * Interestingly we don't need to actually freeze the processes. The group + * allows us to track processes belonging to a given snap. This makes the + * measurement "are any processes of this snap still alive" very simple. + * + * The "cgroup.procs" file belonging to the cgroup contains the set of all the + * processes that originate from the given snap. Examining that file one can + * reliably determine if the set is empty or not. + * + * For more details please review: + * https://www.kernel.org/doc/Documentation/cgroup-v1/freezer-subsystem.txt +**/ +void sc_cgroup_freezer_join(const char *snap_name, pid_t pid); + +/** + * Check if a freezer cgroup for given snap has any processes belonging to a given user. + * + * This function examines the freezer cgroup called "snap.$snap_name" and looks + * at each of its processes. If any process exists then the function returns true. +**/ +// TODO: Support per user filtering for eventual per-user mount namespaces +bool sc_cgroup_freezer_occupied(const char *snap_name); + +#endif diff --git a/cmd/libsnap-confine-private/cgroup-support-test.c b/cmd/libsnap-confine-private/cgroup-support-test.c new file mode 100644 index 00000000..99d4cac0 --- /dev/null +++ b/cmd/libsnap-confine-private/cgroup-support-test.c @@ -0,0 +1,369 @@ +/* + * Copyright (C) 2021 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "cgroup-support.c" + +#include +#include +#include +#include +#include +#include + +#include "../libsnap-confine-private/cleanup-funcs.h" +#include "../libsnap-confine-private/test-utils.h" +#include "cgroup-support.h" + +static void sc_set_self_cgroup_path(const char *mock); + +static void sc_set_cgroup_root(const char *mock) { cgroup_dir = mock; } + +typedef struct _cgroupv2_is_tracking_fixture { + char *self_cgroup; + char *root; +} cgroupv2_is_tracking_fixture; + +static void cgroupv2_is_tracking_set_up(cgroupv2_is_tracking_fixture *fixture, gconstpointer user_data) { + GError *err = NULL; + int fd = g_file_open_tmp("s-c-unit-is-tracking-self-group.XXXXXX", &fixture->self_cgroup, &err); + g_assert_no_error(err); + g_assert_cmpint(fd, >=, 0); + g_close(fd, &err); + g_assert_no_error(err); + sc_set_self_cgroup_path(fixture->self_cgroup); + + fixture->root = g_dir_make_tmp("s-c-unit-test-root.XXXXXX", &err); + sc_set_cgroup_root(fixture->root); +} + +static void cgroupv2_is_tracking_tear_down(cgroupv2_is_tracking_fixture *fixture, gconstpointer user_data) { + GError *err = NULL; + + sc_set_self_cgroup_path("/proc/self/cgroup"); + /* mocked file may have been removed by the test */ + (void)g_remove(fixture->self_cgroup); + g_free(fixture->self_cgroup); + + sc_set_cgroup_root("/sys/fs/cgroup"); + char *cmd = g_strdup_printf("rm -rf %s", fixture->root); + g_debug("cleanup command: %s", cmd); + g_spawn_command_line_sync(cmd, NULL, NULL, NULL, &err); + g_free(cmd); + g_assert_no_error(err); + g_free(fixture->root); +} + +static void _test_sc_cgroupv2_is_tracking_happy(cgroupv2_is_tracking_fixture *fixture) { + /* there exist 3 groups with processes from a given snap */ + const char *dirs[] = { + "/foo/bar/baz/snap.foo.app.1234-1234.scope", + "/foo/bar/snap.foo.app.1111-1111.scope", + "/foo/bar/bad", + "/system.slice/snap.foo.bar.service", + "/user/slice/other/app", + }; + + for (size_t i = 0; i < sizeof dirs / sizeof dirs[0]; i++) { + char *np = g_build_filename(fixture->root, dirs[i], NULL); + int ret = g_mkdir_with_parents(np, 0755); + g_assert_cmpint(ret, ==, 0); + g_free(np); + } + + bool is_tracking = sc_cgroup_v2_is_tracking_snap("foo"); + g_assert_true(is_tracking); +} + +static void test_sc_cgroupv2_is_tracking_happy_scope(cgroupv2_is_tracking_fixture *fixture, gconstpointer user_data) { + g_assert_true(g_file_set_contents(fixture->self_cgroup, "0::/foo/bar/baz/snap.foo.app.1234-1234.scope", -1, NULL)); + + _test_sc_cgroupv2_is_tracking_happy(fixture); +} + +static void test_sc_cgroupv2_is_tracking_happy_service(cgroupv2_is_tracking_fixture *fixture, gconstpointer user_data) { + g_assert_true(g_file_set_contents(fixture->self_cgroup, "0::/system.slice/snap.foo.svc.service", -1, NULL)); + + _test_sc_cgroupv2_is_tracking_happy(fixture); +} + +static void test_sc_cgroupv2_is_tracking_just_own_group(cgroupv2_is_tracking_fixture *fixture, + gconstpointer user_data) { + g_assert_true(g_file_set_contents(fixture->self_cgroup, "0::/foo/bar/baz/snap.foo.app.1234-1234.scope", -1, NULL)); + + /* our group is the only one for this snap */ + const char *dirs[] = { + "/foo/bar/baz/snap.foo.app.1234-1234.scope", + "/foo/bar/bad", + "/system.slice/some/app/other", + "/user/slice/other/app", + }; + + for (size_t i = 0; i < sizeof dirs / sizeof dirs[0]; i++) { + char *np = g_build_filename(fixture->root, dirs[i], NULL); + int ret = g_mkdir_with_parents(np, 0755); + g_assert_cmpint(ret, ==, 0); + g_free(np); + } + + bool is_tracking = sc_cgroup_v2_is_tracking_snap("foo"); + /* our own group is skipped */ + g_assert_false(is_tracking); +} + +static void test_sc_cgroupv2_is_tracking_other_snaps(cgroupv2_is_tracking_fixture *fixture, gconstpointer user_data) { + g_assert_true(g_file_set_contents(fixture->self_cgroup, "0::/foo/bar/baz/snap.foo.app.1234-1234.scope", -1, NULL)); + + /* our group is the only one for this snap */ + const char *dirs[] = { + "/foo/bar/baz/snap.other.app.1234-1234.scope", + "/foo/bar/bad", + "/system.slice/some/app/snap.one-more.app.service", + "/user/slice/other/app", + }; + + for (size_t i = 0; i < sizeof dirs / sizeof dirs[0]; i++) { + char *np = g_build_filename(fixture->root, dirs[i], NULL); + int ret = g_mkdir_with_parents(np, 0755); + g_assert_cmpint(ret, ==, 0); + g_free(np); + } + + bool is_tracking = sc_cgroup_v2_is_tracking_snap("foo"); + /* our own group is skipped */ + g_assert_false(is_tracking); +} + +static void test_sc_cgroupv2_is_tracking_no_dirs(cgroupv2_is_tracking_fixture *fixture, gconstpointer user_data) { + g_assert_true(g_file_set_contents(fixture->self_cgroup, "0::/foo/bar/baz/snap.foo.app.scope", -1, NULL)); + + bool is_tracking = sc_cgroup_v2_is_tracking_snap("foo"); + g_assert_false(is_tracking); +} + +static void test_sc_cgroupv2_is_tracking_bad_self_group(cgroupv2_is_tracking_fixture *fixture, + gconstpointer user_data) { + /* trigger a failure in own group handling */ + g_assert_true(g_file_set_contents(fixture->self_cgroup, "", -1, NULL)); + + if (g_test_subprocess()) { + sc_cgroup_v2_is_tracking_snap("foo"); + } + g_test_trap_subprocess(NULL, 0, 0); + g_test_trap_assert_failed(); + g_test_trap_assert_stderr("cannot obtain own cgroup v2 group path\n"); +} + +static void test_sc_cgroupv2_is_tracking_bad_nesting(cgroupv2_is_tracking_fixture *fixture, gconstpointer user_data) { + g_assert_true(g_file_set_contents(fixture->self_cgroup, "0::/foo/bar/baz/snap.foo.app.scope", -1, NULL)); + + /* create a hierarchy so deep that it triggers the nesting error */ + char *prev_path = g_build_filename(fixture->root, NULL); + for (size_t i = 0; i < max_traversal_depth; i++) { + char *np = g_build_filename(prev_path, "nested", NULL); + int ret = g_mkdir_with_parents(np, 0755); + g_assert_cmpint(ret, ==, 0); + g_free(prev_path); + prev_path = np; + } + g_free(prev_path); + + if (g_test_subprocess()) { + sc_cgroup_v2_is_tracking_snap("foo"); + } + g_test_trap_subprocess(NULL, 0, 0); + g_test_trap_assert_failed(); + g_test_trap_assert_stderr("cannot traverse cgroups hierarchy deeper than 32 levels\n"); +} + +static void test_sc_cgroupv2_is_tracking_dir_permissions(cgroupv2_is_tracking_fixture *fixture, + gconstpointer user_data) { + if (geteuid() == 0) { + g_test_skip("the test will not work when running as root"); + return; + } + g_assert_true(g_file_set_contents(fixture->self_cgroup, "0::/foo/bar/baz/snap.foo.app.1234-1234.scope", -1, NULL)); + + /* there exist 2 groups with processes from a given snap */ + const char *dirs[] = { + "/foo/bar/bad", + "/foo/bar/bad/badperm", + }; + for (size_t i = 0; i < sizeof dirs / sizeof dirs[0]; i++) { + int mode = 0755; + if (g_str_has_suffix(dirs[i], "/badperm")) { + mode = 0000; + } + char *np = g_build_filename(fixture->root, dirs[i], NULL); + int ret = g_mkdir_with_parents(np, mode); + g_assert_cmpint(ret, ==, 0); + g_free(np); + } + + /* dies when hitting an error traversing the hierarchy */ + if (g_test_subprocess()) { + sc_cgroup_v2_is_tracking_snap("foo"); + } + g_test_trap_subprocess(NULL, 0, 0); + g_test_trap_assert_failed(); + g_test_trap_assert_stderr("cannot open directory entry \"badperm\": Permission denied\n"); +} + +static void test_sc_cgroupv2_is_tracking_no_cgroup_root(cgroupv2_is_tracking_fixture *fixture, + gconstpointer user_data) { + g_assert_true(g_file_set_contents(fixture->self_cgroup, "0::/foo/bar/baz/snap.foo.app.1234-1234.scope", -1, NULL)); + + sc_set_cgroup_root("/does/not/exist"); + + // does not die when cgroup root is not present + bool is_tracking = sc_cgroup_v2_is_tracking_snap("foo"); + g_assert_false(is_tracking); +} + +static void sc_set_self_cgroup_path(const char *mock) { self_cgroup = mock; } + +typedef struct _cgroupv2_own_group_fixture { + char *self_cgroup; +} cgroupv2_own_group_fixture; + +static void cgroupv2_own_group_set_up(cgroupv2_own_group_fixture *fixture, gconstpointer user_data) { + GError *err = NULL; + int fd = g_file_open_tmp("s-c-unit-test.XXXXXX", &fixture->self_cgroup, &err); + g_assert_no_error(err); + g_close(fd, &err); + g_assert_no_error(err); + sc_set_self_cgroup_path(fixture->self_cgroup); +} + +static void cgroupv2_own_group_tear_down(cgroupv2_own_group_fixture *fixture, gconstpointer user_data) { + sc_set_self_cgroup_path("/proc/self/cgroup"); + if (g_remove(fixture->self_cgroup) < 0) { + /* test may have removed the file */ + g_assert_cmpint(errno, ==, ENOENT); + } + g_free(fixture->self_cgroup); +} + +static void test_sc_cgroupv2_own_group_path_simple_happy_scope(cgroupv2_own_group_fixture *fixture, + gconstpointer user_data) { + char *p SC_CLEANUP(sc_cleanup_string) = NULL; + g_assert_true(g_file_set_contents(fixture->self_cgroup, (char *)user_data, -1, NULL)); + p = sc_cgroup_v2_own_path_full(); + g_assert_cmpstr(p, ==, "/foo/bar/baz.slice/snap.foo.bar.1234-1234.scope"); +} + +static void test_sc_cgroupv2_own_group_path_simple_happy_service(cgroupv2_own_group_fixture *fixture, + gconstpointer user_data) { + char *p SC_CLEANUP(sc_cleanup_string) = NULL; + g_assert_true(g_file_set_contents(fixture->self_cgroup, (char *)user_data, -1, NULL)); + p = sc_cgroup_v2_own_path_full(); + g_assert_cmpstr(p, ==, "/system.slice/snap.foo.bar.service"); +} + +static void test_sc_cgroupv2_own_group_path_empty(cgroupv2_own_group_fixture *fixture, gconstpointer user_data) { + char *p SC_CLEANUP(sc_cleanup_string) = NULL; + g_assert_true(g_file_set_contents(fixture->self_cgroup, (char *)user_data, -1, NULL)); + p = sc_cgroup_v2_own_path_full(); + g_assert_null(p); +} + +static void _test_sc_cgroupv2_own_group_path_die_with_message(const char *msg) { + if (g_test_subprocess()) { + char *p = NULL; + p = sc_cgroup_v2_own_path_full(); + /* not reached */ + sc_cleanup_string(&p); + } + g_test_trap_subprocess(NULL, 0, 0); + g_test_trap_assert_failed(); + g_test_trap_assert_stderr(msg); +} + +static void test_sc_cgroupv2_own_group_path_die(cgroupv2_own_group_fixture *fixture, gconstpointer user_data) { + g_assert_true(g_file_set_contents(fixture->self_cgroup, (char *)user_data, -1, NULL)); + _test_sc_cgroupv2_own_group_path_die_with_message("unexpected content of group entry 0::\n"); +} + +static void test_sc_cgroupv2_own_group_path_no_file(cgroupv2_own_group_fixture *fixture, gconstpointer user_data) { + /* make sure that the file is removed if it exists */ + (void)g_remove(fixture->self_cgroup); + _test_sc_cgroupv2_own_group_path_die_with_message("cannot open *\n"); +} + +static void test_sc_cgroupv2_own_group_path_permission(cgroupv2_own_group_fixture *fixture, gconstpointer user_data) { + if (geteuid() == 0) { + g_test_skip("the test will not work when running as root"); + return; + } + int ret = g_chmod(fixture->self_cgroup, 0000); + g_assert_cmpint(ret, ==, 0); + _test_sc_cgroupv2_own_group_path_die_with_message("cannot open *: Permission denied\n"); +} + +static void __attribute__((constructor)) init(void) { + g_test_add("/cgroup/v2/own_path_full_newline", cgroupv2_own_group_fixture, + "0::/foo/bar/baz.slice/snap.foo.bar.1234-1234.scope\n", cgroupv2_own_group_set_up, + test_sc_cgroupv2_own_group_path_simple_happy_scope, cgroupv2_own_group_tear_down); + g_test_add("/cgroup/v2/own_path_full_no_newline", cgroupv2_own_group_fixture, + "0::/foo/bar/baz.slice/snap.foo.bar.1234-1234.scope", cgroupv2_own_group_set_up, + test_sc_cgroupv2_own_group_path_simple_happy_scope, cgroupv2_own_group_tear_down); + g_test_add("/cgroup/v2/own_path_full_firstline", cgroupv2_own_group_fixture, + "0::/foo/bar/baz.slice/snap.foo.bar.1234-1234.scope\n" + "0::/bad\n", + cgroupv2_own_group_set_up, test_sc_cgroupv2_own_group_path_simple_happy_scope, + cgroupv2_own_group_tear_down); + g_test_add("/cgroup/v2/own_path_full_ignore_non_unified", cgroupv2_own_group_fixture, + "1::/ignored\n" + "0::/foo/bar/baz.slice/snap.foo.bar.1234-1234.scope\n", + cgroupv2_own_group_set_up, test_sc_cgroupv2_own_group_path_simple_happy_scope, + cgroupv2_own_group_tear_down); + g_test_add("/cgroup/v2/own_path_full_service", cgroupv2_own_group_fixture, + "0::/system.slice/snap.foo.bar.service\n", cgroupv2_own_group_set_up, + test_sc_cgroupv2_own_group_path_simple_happy_service, cgroupv2_own_group_tear_down); + g_test_add("/cgroup/v2/own_path_full_empty", cgroupv2_own_group_fixture, "", cgroupv2_own_group_set_up, + test_sc_cgroupv2_own_group_path_empty, cgroupv2_own_group_tear_down); + g_test_add("/cgroup/v2/own_path_full_not_found", cgroupv2_own_group_fixture, + /* missing 0:: group */ + "1::/ignored\n" + "2::/foo/bar/baz.slice\n", + cgroupv2_own_group_set_up, test_sc_cgroupv2_own_group_path_empty, cgroupv2_own_group_tear_down); + g_test_add("/cgroup/v2/own_path_full_die", cgroupv2_own_group_fixture, "0::", cgroupv2_own_group_set_up, + test_sc_cgroupv2_own_group_path_die, cgroupv2_own_group_tear_down); + g_test_add("/cgroup/v2/own_path_full_no_file", cgroupv2_own_group_fixture, NULL, cgroupv2_own_group_set_up, + test_sc_cgroupv2_own_group_path_no_file, cgroupv2_own_group_tear_down); + g_test_add("/cgroup/v2/own_path_full_permission", cgroupv2_own_group_fixture, NULL, cgroupv2_own_group_set_up, + test_sc_cgroupv2_own_group_path_permission, cgroupv2_own_group_tear_down); + + g_test_add("/cgroup/v2/is_tracking_happy_scope", cgroupv2_is_tracking_fixture, NULL, cgroupv2_is_tracking_set_up, + test_sc_cgroupv2_is_tracking_happy_scope, cgroupv2_is_tracking_tear_down); + g_test_add("/cgroup/v2/is_tracking_happy_service", cgroupv2_is_tracking_fixture, NULL, cgroupv2_is_tracking_set_up, + test_sc_cgroupv2_is_tracking_happy_service, cgroupv2_is_tracking_tear_down); + g_test_add("/cgroup/v2/is_tracking_just_own", cgroupv2_is_tracking_fixture, NULL, cgroupv2_is_tracking_set_up, + test_sc_cgroupv2_is_tracking_just_own_group, cgroupv2_is_tracking_tear_down); + g_test_add("/cgroup/v2/is_tracking_only_other_snaps", cgroupv2_is_tracking_fixture, NULL, + cgroupv2_is_tracking_set_up, test_sc_cgroupv2_is_tracking_other_snaps, cgroupv2_is_tracking_tear_down); + g_test_add("/cgroup/v2/is_tracking_empty_groups", cgroupv2_is_tracking_fixture, NULL, cgroupv2_is_tracking_set_up, + test_sc_cgroupv2_is_tracking_no_dirs, cgroupv2_is_tracking_tear_down); + g_test_add("/cgroup/v2/is_tracking_bad_self_group", cgroupv2_is_tracking_fixture, NULL, cgroupv2_is_tracking_set_up, + test_sc_cgroupv2_is_tracking_bad_self_group, cgroupv2_is_tracking_tear_down); + g_test_add("/cgroup/v2/is_tracking_bad_dir_permissions", cgroupv2_is_tracking_fixture, NULL, + cgroupv2_is_tracking_set_up, test_sc_cgroupv2_is_tracking_dir_permissions, + cgroupv2_is_tracking_tear_down); + g_test_add("/cgroup/v2/is_tracking_bad_nesting", cgroupv2_is_tracking_fixture, NULL, cgroupv2_is_tracking_set_up, + test_sc_cgroupv2_is_tracking_bad_nesting, cgroupv2_is_tracking_tear_down); + g_test_add("/cgroup/v2/is_tracking_no_cgroup_root", cgroupv2_is_tracking_fixture, NULL, cgroupv2_is_tracking_set_up, + test_sc_cgroupv2_is_tracking_no_cgroup_root, cgroupv2_is_tracking_tear_down); +} diff --git a/cmd/libsnap-confine-private/cgroup-support.c b/cmd/libsnap-confine-private/cgroup-support.c new file mode 100644 index 00000000..730b7efe --- /dev/null +++ b/cmd/libsnap-confine-private/cgroup-support.c @@ -0,0 +1,241 @@ +/* + * Copyright (C) 2019-2021 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#define _GNU_SOURCE + +#include "cgroup-support.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "cleanup-funcs.h" +#include "string-utils.h" +#include "utils.h" + +void sc_cgroup_create_and_join(const char *parent, const char *name, pid_t pid) { + int parent_fd SC_CLEANUP(sc_cleanup_close) = -1; + parent_fd = open(parent, O_PATH | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (parent_fd < 0) { + die("cannot open cgroup hierarchy %s", parent); + } + // Since we may be running from a setuid but not setgid executable, switch + // to the effective group to root so that the mkdirat call creates a cgroup + // that is always owned by root.root. + sc_identity old = sc_set_effective_identity(sc_root_group_identity()); + if (mkdirat(parent_fd, name, 0755) < 0 && errno != EEXIST) { + die("cannot create cgroup hierarchy %s/%s", parent, name); + } + (void)sc_set_effective_identity(old); + int hierarchy_fd SC_CLEANUP(sc_cleanup_close) = -1; + hierarchy_fd = openat(parent_fd, name, O_PATH | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (hierarchy_fd < 0) { + die("cannot open cgroup hierarchy %s/%s", parent, name); + } + // Open the cgroup.procs file. + int procs_fd SC_CLEANUP(sc_cleanup_close) = -1; + procs_fd = openat(hierarchy_fd, "cgroup.procs", O_WRONLY | O_NOFOLLOW | O_CLOEXEC); + if (procs_fd < 0) { + die("cannot open file %s/%s/cgroup.procs", parent, name); + } + // Write the process (task) number to the procs file. Linux task IDs are + // limited to 2^29 so a long int is enough to represent it. + // See include/linux/threads.h in the kernel source tree for details. + char buf[22] = {0}; // 2^64 base10 + 2 for NUL and '-' for long + int n = sc_must_snprintf(buf, sizeof buf, "%ld", (long)pid); + if (write(procs_fd, buf, n) < n) { + die("cannot move process %ld to cgroup hierarchy %s/%s", (long)pid, parent, name); + } + debug("moved process %ld to cgroup hierarchy %s/%s", (long)pid, parent, name); +} + +static const char *cgroup_dir = "/sys/fs/cgroup"; + +// from statfs(2) +#ifndef CGROUP2_SUPER_MAGIC +#define CGROUP2_SUPER_MAGIC 0x63677270 +#endif + +// Detect if we are running in cgroup v2 unified mode (as opposed to +// hybrid or legacy) The algorithm is described in +// https://systemd.io/CGROUP_DELEGATION/ +bool sc_cgroup_is_v2(void) { + struct statfs buf; + + if (statfs(cgroup_dir, &buf) != 0) { + if (errno == ENOENT) { + return false; + } + die("cannot statfs %s", cgroup_dir); + } + if (buf.f_type == CGROUP2_SUPER_MAGIC) { + return true; + } + return false; +} + +static const size_t max_traversal_depth = 32; + +static bool traverse_looking_for_prefix_in_dir(DIR *root, const char *prefix, const char *skip, size_t depth) { + if (depth > max_traversal_depth) { + die("cannot traverse cgroups hierarchy deeper than %zu levels", max_traversal_depth); + } + while (true) { + errno = 0; + struct dirent *ent = readdir(root); + if (ent == NULL) { + // is this an error? + if (errno != 0) { + if (errno == ENOENT) { + // the processes may exit and the group entries may go away at + // any time + // the entries may go away at any time + break; + } + die("cannot read directory entry"); + } + break; + } + if (ent->d_type != DT_DIR) { + continue; + } + if (sc_streq(ent->d_name, "..") || sc_streq(ent->d_name, ".")) { + // we don't want to go up or process the current directory again + continue; + } + if (sc_streq(ent->d_name, skip)) { + // we were asked to skip this group + continue; + } + if (sc_startswith(ent->d_name, prefix)) { + debug("found matching prefix in \"%s\"", ent->d_name); + // the directory starts with our prefix + return true; + } + // entfd is consumed by fdopendir() and freed with closedir() + int entfd = openat(dirfd(root), ent->d_name, O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (entfd == -1) { + if (errno == ENOENT) { + // the processes may exit and the group entries may go away at + // any time + return false; + } + die("cannot open directory entry \"%s\"", ent->d_name); + } + // takes ownership of the file descriptor + DIR *entdir SC_CLEANUP(sc_cleanup_closedir) = fdopendir(entfd); + if (entdir == NULL) { + // we have the fd, so ENOENT isn't possible here + die("cannot fdopendir directory \"%s\"", ent->d_name); + } + bool found = traverse_looking_for_prefix_in_dir(entdir, prefix, skip, depth + 1); + if (found == true) { + return true; + } + } + return false; +} + +bool sc_cgroup_v2_is_tracking_snap(const char *snap_instance) { + debug("is cgroup tracking snap %s?", snap_instance); + char tracking_group_name[PATH_MAX] = {0}; + // tracking groups created by snap run chain have a format: + // snap....scope, while the groups corresponding to snap + // services created by systemd are named like this: + // snap...service + sc_must_snprintf(tracking_group_name, sizeof tracking_group_name, "snap.%s.", snap_instance); + + // when running with cgroup v2, the snap run chain or systemd would create a + // tracking cgroup which the current process would execute in and would + // match the pattern we are looking for, thus it needs to be skipped + char *own_group SC_CLEANUP(sc_cleanup_string) = sc_cgroup_v2_own_path_full(); + if (own_group == NULL) { + die("cannot obtain own cgroup v2 group path"); + } + debug("own group: %s", own_group); + char *just_leaf = strrchr(own_group, '/'); + if (just_leaf == NULL) { + die("cannot obtain the leaf group path"); + } + // pointing at /, advance to the next char + just_leaf += 1; + + // this would otherwise be inherently racy, but the caller is expected to + // keep the snap instance lock, thus preventing new apps of that snap from + // starting; note that we can still return false positive if the currently + // running process exits but we look at the hierarchy before systemd has + // cleaned up the group + + debug("opening cgroup root dir at %s", cgroup_dir); + DIR *root SC_CLEANUP(sc_cleanup_closedir) = opendir(cgroup_dir); + if (root == NULL) { + if (errno == ENOENT) { + return false; + } + die("cannot open cgroup root dir"); + } + // traverse the cgroup hierarchy tree looking for other groups that + // correspond to the snap (i.e. their name matches the pattern), but skip + // our own group in the process + return traverse_looking_for_prefix_in_dir(root, tracking_group_name, just_leaf, 1); +} + +static const char *self_cgroup = "/proc/self/cgroup"; + +char *sc_cgroup_v2_own_path_full(void) { + FILE *in SC_CLEANUP(sc_cleanup_file) = fopen(self_cgroup, "r"); + if (in == NULL) { + die("cannot open %s", self_cgroup); + } + + char *own_group = NULL; + + while (true) { + char *line SC_CLEANUP(sc_cleanup_string) = NULL; + size_t linesz = 0; + ssize_t sz = getline(&line, &linesz, in); + if (sz < 0 && errno != 0) { + die("cannot read line from %s", self_cgroup); + } + if (sz < 0) { + // end of file + break; + } + if (!sc_startswith(line, "0::")) { + continue; + } + size_t len = strlen(line); + if (len <= 3) { + die("unexpected content of group entry %s", line); + } + // \n does not normally appear inside the group path, but if it did, it + // would be escaped anyway + char *newline = strchr(line, '\n'); + if (newline != NULL) { + *newline = '\0'; + } + own_group = sc_strdup(line + 3); + break; + } + return own_group; +} diff --git a/cmd/libsnap-confine-private/cgroup-support.h b/cmd/libsnap-confine-private/cgroup-support.h new file mode 100644 index 00000000..bf178ee1 --- /dev/null +++ b/cmd/libsnap-confine-private/cgroup-support.h @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2019-2021 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#ifndef SC_CGROUP_SUPPORT_H +#define SC_CGROUP_SUPPORT_H + +#include +#include + +/** + * sc_cgroup_create_and_join joins, perhaps creating, a cgroup hierarchy. + * + * The code assumes that an existing hierarchy rooted at "parent". It follows + * up with a sub-hierarchy called "name", creating it if necessary. The created + * sub-hierarchy is made to belong to root.root and the specified process is + * moved there. + **/ +void sc_cgroup_create_and_join(const char *parent, const char *name, pid_t pid); + +/** + * sc_cgroup_is_v2() returns true if running on cgroups v2 + * + **/ +bool sc_cgroup_is_v2(void); + +/** + * sc_cgroup_is_tracking_snap checks whether any snap process other than the + * caller are currently being tracked in a cgroup. + * + * Note that this call will traverse the cgroups hierarchy looking for a group + * name with a specific prefix corresponding to the snap name. This is + * inherently racy. The caller must have taken the per snap instance lock to + * prevent new applications of that snap from being started. However, it is + * still possible that the application may exit but the cgroup has not been + * cleaned up yet, in which case this call will return a false positive. + * + * It is possible that the current process is already being tracked in cgroup, + * in which case the code will skip its own group. + */ +bool sc_cgroup_v2_is_tracking_snap(const char *snap_instance); + +/** + * sc_cgroup_v2_own_path_full return the full path of the owning cgroup as + * reported by the kernel. + * + * Returns the full path of the group in the unified hierarchy relative to its + * root. The string is owned by the caller. + */ +char *sc_cgroup_v2_own_path_full(void); + +#endif diff --git a/cmd/libsnap-confine-private/classic-test.c b/cmd/libsnap-confine-private/classic-test.c new file mode 100644 index 00000000..3002dc91 --- /dev/null +++ b/cmd/libsnap-confine-private/classic-test.c @@ -0,0 +1,222 @@ +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "classic.h" +#include "classic.c" + +#include + +/* restore_os_release is an internal helper for mock_os_release */ +static void restore_os_release(gpointer *old) +{ + unlink(os_release); + os_release = (const char *)old; +} + +/* mock_os_release replaces the presence and contents of /etc/os-release + as seen by classic.c. The mocked value may be NULL to have the code refer + to an absent file. */ +static void mock_os_release(const char *mocked) +{ + const char *old = os_release; + if (mocked != NULL) { + os_release = "os-release.test"; + g_assert_true(g_file_set_contents + (os_release, mocked, -1, NULL)); + } else { + os_release = "os-release.missing"; + } + g_test_queue_destroy((GDestroyNotify) restore_os_release, + (gpointer) old); +} + +/* restore_meta_snap_yaml is an internal helper for mock_meta_snap_yaml */ +static void restore_meta_snap_yaml(gpointer *old) +{ + unlink(meta_snap_yaml); + meta_snap_yaml = (const char *)old; +} + +/* mock_meta_snap_yaml replaces the presence and contents of /meta/snap.yaml + as seen by classic.c. The mocked value may be NULL to have the code refer + to an absent file. */ +static void mock_meta_snap_yaml(const char *mocked) +{ + const char *old = meta_snap_yaml; + if (mocked != NULL) { + meta_snap_yaml = "snap-yaml.test"; + g_assert_true(g_file_set_contents + (meta_snap_yaml, mocked, -1, NULL)); + } else { + meta_snap_yaml = "snap-yaml.missing"; + } + g_test_queue_destroy((GDestroyNotify) restore_meta_snap_yaml, + (gpointer) old); +} + +static const char *os_release_classic = "" + "NAME=\"Ubuntu\"\n" + "VERSION=\"17.04 (Zesty Zapus)\"\n" "ID=ubuntu\n" "ID_LIKE=debian\n"; + +static void test_is_on_classic(void) +{ + mock_os_release(os_release_classic); + mock_meta_snap_yaml(NULL); + g_assert_cmpint(sc_classify_distro(), ==, SC_DISTRO_CLASSIC); +} + +static const char *os_release_core16 = "" + "NAME=\"Ubuntu Core\"\n" "VERSION_ID=\"16\"\n" "ID=ubuntu-core\n"; + +static const char *meta_snap_yaml_core16 = "" + "name: core\n" + "version: 16-something\n" "type: core\n" "architectures: [amd64]\n"; + +static void test_is_on_core_on16(void) +{ + mock_os_release(os_release_core16); + mock_meta_snap_yaml(meta_snap_yaml_core16); + g_assert_cmpint(sc_classify_distro(), ==, SC_DISTRO_CORE16); +} + +static const char *os_release_core18 = "" + "NAME=\"Ubuntu Core\"\n" "VERSION_ID=\"18\"\n" "ID=ubuntu-core\n"; + +static const char *meta_snap_yaml_core18 = "" + "name: core18\n" "type: base\n" "architectures: [amd64]\n"; + +static void test_is_on_core_on18(void) +{ + mock_os_release(os_release_core18); + mock_meta_snap_yaml(meta_snap_yaml_core18); + g_assert_cmpint(sc_classify_distro(), ==, SC_DISTRO_CORE_OTHER); +} + +const char *os_release_core20 = "" + "NAME=\"Ubuntu Core\"\n" "VERSION_ID=\"20\"\n" "ID=ubuntu-core\n"; + +static const char *meta_snap_yaml_core20 = "" + "name: core20\n" "type: base\n" "architectures: [amd64]\n"; + +static void test_is_on_core_on20(void) +{ + mock_os_release(os_release_core20); + mock_meta_snap_yaml(meta_snap_yaml_core20); + g_assert_cmpint(sc_classify_distro(), ==, SC_DISTRO_CORE_OTHER); +} + +static const char *os_release_classic_with_long_line = "" + "NAME=\"Ubuntu\"\n" + "VERSION=\"17.04 (Zesty Zapus)\"\n" + "ID=ubuntu\n" + "ID_LIKE=debian\n" + "LONG=line.line.line.line.line.line.line.line.line.line.line.line.line.line.line.line.line.line.line.line.line.line.line.line.line.line.line.line.line.line.line.line.line.line.line.line.line.line.line.line.line.line.line.line.line.line.line.line.line.line.line.line."; + +static void test_is_on_classic_with_long_line(void) +{ + mock_os_release(os_release_classic_with_long_line); + mock_meta_snap_yaml(NULL); + g_assert_cmpint(sc_classify_distro(), ==, SC_DISTRO_CLASSIC); +} + +static const char *os_release_fedora_base = "" + "NAME=Fedora\nID=fedora\nVARIANT_ID=snappy\n"; + +static const char *meta_snap_yaml_fedora_base = "" + "name: fedora29\n" "type: base\n" "architectures: [amd64]\n"; + +static void test_is_on_fedora_base(void) +{ + mock_os_release(os_release_fedora_base); + mock_meta_snap_yaml(meta_snap_yaml_fedora_base); + g_assert_cmpint(sc_classify_distro(), ==, SC_DISTRO_CORE_OTHER); +} + +static const char *os_release_fedora_ws = "" + "NAME=Fedora\nID=fedora\nVARIANT_ID=workstation\n"; + +static void test_is_on_fedora_ws(void) +{ + mock_os_release(os_release_fedora_ws); + mock_meta_snap_yaml(NULL); + g_assert_cmpint(sc_classify_distro(), ==, SC_DISTRO_CLASSIC); +} + +static const char *os_release_custom = "" + "NAME=\"Custom Distribution\"\nID=custom\n"; + +static const char *meta_snap_yaml_custom = "" + "name: custom\n" + "version: rolling\n" + "summary: Runtime environment based on Custom Distribution\n" + "type: base\n" "architectures: [amd64]\n"; + +static void test_is_on_custom_base(void) +{ + mock_os_release(os_release_custom); + + /* Without /meta/snap.yaml we treat "Custom Distribution" as classic. */ + mock_meta_snap_yaml(NULL); + g_assert_cmpint(sc_classify_distro(), ==, SC_DISTRO_CLASSIC); + + /* With /meta/snap.yaml we treat it as core instead. */ + mock_meta_snap_yaml(meta_snap_yaml_custom); + g_assert_cmpint(sc_classify_distro(), ==, SC_DISTRO_CORE_OTHER); +} + +static const char *os_release_debian_like_valid = "" + "ID=my-fun-distro\n" "ID_LIKE=debian\n"; + +static const char *os_release_debian_like_quoted_valid = "" + "ID=my-fun-distro\n" "ID_LIKE=\"debian\"\n"; + +/* actual debian only sets ID=debian */ +static const char *os_release_actual_debian_valid = "ID=debian\n"; + +static const char *os_release_invalid = "garbage\n"; + +static void test_is_debian_like(void) +{ + mock_os_release(os_release_debian_like_valid); + g_assert_true(sc_is_debian_like()); + + mock_os_release(os_release_debian_like_quoted_valid); + g_assert_true(sc_is_debian_like()); + + mock_os_release(os_release_actual_debian_valid); + g_assert_true(sc_is_debian_like()); + + mock_os_release(os_release_fedora_ws); + g_assert_false(sc_is_debian_like()); + + mock_os_release(os_release_invalid); + g_assert_false(sc_is_debian_like()); +} + +static void __attribute__((constructor)) init(void) +{ + g_test_add_func("/classic/on-classic", test_is_on_classic); + g_test_add_func("/classic/on-classic-with-long-line", + test_is_on_classic_with_long_line); + g_test_add_func("/classic/on-core-on16", test_is_on_core_on16); + g_test_add_func("/classic/on-core-on18", test_is_on_core_on18); + g_test_add_func("/classic/on-core-on20", test_is_on_core_on20); + g_test_add_func("/classic/on-fedora-base", test_is_on_fedora_base); + g_test_add_func("/classic/on-fedora-ws", test_is_on_fedora_ws); + g_test_add_func("/classic/on-custom-base", test_is_on_custom_base); + g_test_add_func("/classic/is-debian-like", test_is_debian_like); +} diff --git a/cmd/libsnap-confine-private/classic.c b/cmd/libsnap-confine-private/classic.c new file mode 100644 index 00000000..720f4755 --- /dev/null +++ b/cmd/libsnap-confine-private/classic.c @@ -0,0 +1,91 @@ +#include "config.h" +#include "classic.h" +#include "../libsnap-confine-private/cleanup-funcs.h" +#include "../libsnap-confine-private/infofile.h" +#include "../libsnap-confine-private/string-utils.h" + +#include +#include +#include +#include + +static const char *os_release = "/etc/os-release"; +static const char *meta_snap_yaml = "/meta/snap.yaml"; + +sc_distro sc_classify_distro(void) +{ + FILE *f SC_CLEANUP(sc_cleanup_file) = fopen(os_release, "r"); + if (f == NULL) { + return SC_DISTRO_CLASSIC; + } + + bool is_core = false; + int core_version = 0; + char buf[255] = { 0 }; + + while (fgets(buf, sizeof buf, f) != NULL) { + size_t len = strlen(buf); + if (len > 0 && buf[len - 1] == '\n') { + buf[len - 1] = '\0'; + } + if (sc_streq(buf, "ID=\"ubuntu-core\"") + || sc_streq(buf, "ID=ubuntu-core")) { + is_core = true; + } else if (sc_streq(buf, "VERSION_ID=\"16\"") + || sc_streq(buf, "VERSION_ID=16")) { + core_version = 16; + } else if (sc_streq(buf, "VARIANT_ID=\"snappy\"") + || sc_streq(buf, "VARIANT_ID=snappy")) { + is_core = true; + } + } + + if (!is_core) { + /* Since classic systems don't have a /meta/snap.yaml file the simple + presence of that file qualifies as SC_DISTRO_CORE_OTHER. */ + if (access(meta_snap_yaml, F_OK) == 0) { + is_core = true; + } + } + + if (is_core) { + if (core_version == 16) { + return SC_DISTRO_CORE16; + } + return SC_DISTRO_CORE_OTHER; + } else { + return SC_DISTRO_CLASSIC; + } +} + +bool sc_is_debian_like(void) +{ + FILE *f SC_CLEANUP(sc_cleanup_file) = fopen(os_release, "r"); + if (f == NULL) { + return false; + } + const char *const id_keys_to_try[] = { + "ID", /* actual debian only sets ID */ + "ID_LIKE", /* distros based on debian */ + }; + size_t id_keys_to_try_len = + sizeof id_keys_to_try / sizeof *id_keys_to_try; + for (size_t i = 0; i < id_keys_to_try_len; i++) { + if (fseek(f, 0L, SEEK_SET) == -1) { + return false; + } + char *id_val SC_CLEANUP(sc_cleanup_string) = NULL; + struct sc_error *err SC_CLEANUP(sc_cleanup_error) = NULL; + int rc = + sc_infofile_get_key(f, id_keys_to_try[i], &id_val, &err); + if (rc != 0) { + /* only if sc_infofile_get_key failed */ + return false; + } + if (sc_streq(id_val, "\"debian\"") + || sc_streq(id_val, "debian")) { + return true; + } + } + return false; +} diff --git a/cmd/libsnap-confine-private/classic.h b/cmd/libsnap-confine-private/classic.h new file mode 100644 index 00000000..2db073a1 --- /dev/null +++ b/cmd/libsnap-confine-private/classic.h @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2015 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +#ifndef SNAP_CONFINE_CLASSIC_H +#define SNAP_CONFINE_CLASSIC_H + +#include + +// Location of the host filesystem directory in the core snap. +#define SC_HOSTFS_DIR "/var/lib/snapd/hostfs" + +typedef enum sc_distro { + SC_DISTRO_CORE16, // As present in both "core" and later on in "core16" + SC_DISTRO_CORE_OTHER, // Any core distribution. + SC_DISTRO_CLASSIC, // Any classic distribution. +} sc_distro; + +sc_distro sc_classify_distro(void); + +// Returns true if it's a Debian-like distro as determined via /etc/os-release +// and the "ID_LIKE" key in there. +bool sc_is_debian_like(void); + +#endif diff --git a/cmd/libsnap-confine-private/cleanup-funcs-test.c b/cmd/libsnap-confine-private/cleanup-funcs-test.c new file mode 100644 index 00000000..cbd8a764 --- /dev/null +++ b/cmd/libsnap-confine-private/cleanup-funcs-test.c @@ -0,0 +1,202 @@ +/* + * Copyright (C) 2015 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "cleanup-funcs.h" +#include "cleanup-funcs.c" + +#include +#include + +#include +#include +#include +#include +#include + +static int called = 0; + +static void cleanup_fn(int *ptr) +{ + called = 1; +} + +// Test that cleanup functions are applied as expected +static void test_cleanup_sanity(void) +{ + { + int test SC_CLEANUP(cleanup_fn); + test = 0; + test++; + } + g_assert_cmpint(called, ==, 1); +} + +static void test_cleanup_string(void) +{ + /* It is safe to use with a NULL pointer to a string. */ + sc_cleanup_string(NULL); + + /* It is safe to use with a NULL string. */ + char *str = NULL; + sc_cleanup_string(&str); + + /* It is safe to use with a non-NULL string. */ + str = malloc(1); + g_assert_nonnull(str); + sc_cleanup_string(&str); + g_assert_null(str); +} + +static void test_cleanup_file(void) +{ + /* It is safe to use with a NULL pointer to a FILE. */ + sc_cleanup_file(NULL); + + /* It is safe to use with a NULL FILE. */ + FILE *f = NULL; + sc_cleanup_file(&f); + + /* It is safe to use with a non-NULL FILE. */ + f = fmemopen(NULL, 10, "rt"); + g_assert_nonnull(f); + sc_cleanup_file(&f); + g_assert_null(f); +} + +static void test_cleanup_endmntent(void) +{ + /* It is safe to use with a NULL pointer to a FILE. */ + sc_cleanup_endmntent(NULL); + + /* It is safe to use with a NULL FILE. */ + FILE *f = NULL; + sc_cleanup_endmntent(&f); + + /* It is safe to use with a non-NULL FILE. */ + GError *err = NULL; + char *mock_fstab = NULL; + gint mock_fstab_fd = + g_file_open_tmp("s-c-test-fstab-mock.XXXXXX", &mock_fstab, &err); + g_assert_no_error(err); + g_assert_cmpint(mock_fstab_fd, >=, 0); + g_assert_true(g_close(mock_fstab_fd, NULL)); + /* XXX: not strictly needed as the test only calls setmntent */ + const char *mock_fstab_data = "/dev/foo / ext4 defaults 0 1"; + g_assert_true(g_file_set_contents + (mock_fstab, mock_fstab_data, -1, NULL)); + + f = setmntent(mock_fstab, "rt"); + g_assert_nonnull(f); + sc_cleanup_endmntent(&f); + g_assert_null(f); + + g_remove(mock_fstab); + + g_free(mock_fstab); +} + +static void test_cleanup_closedir(void) +{ + /* It is safe to use with a NULL pointer to a DIR. */ + sc_cleanup_closedir(NULL); + + /* It is safe to use with a NULL DIR. */ + DIR *d = NULL; + sc_cleanup_closedir(&d); + + /* It is safe to use with a non-NULL DIR. */ + d = opendir("."); + g_assert_nonnull(d); + sc_cleanup_closedir(&d); + g_assert_null(d); +} + +static void test_cleanup_close(void) +{ + /* It is safe to use with a NULL pointer to an int. */ + sc_cleanup_close(NULL); + + /* It is safe to use with a -1 file descriptor. */ + int fd = -1; + sc_cleanup_close(&fd); + + /* It is safe to use with a non-invalid file descriptor. */ + /* Timerfd is a simple to use and widely available object that can be + * created and closed without interacting with the filesystem. */ + fd = timerfd_create(CLOCK_MONOTONIC, TFD_CLOEXEC); + g_assert_cmpint(fd, !=, -1); + sc_cleanup_close(&fd); + g_assert_cmpint(fd, ==, -1); +} + +static void test_cleanup_deep_strv(void) +{ + /* It is safe to use with a NULL pointer */ + sc_cleanup_deep_strv(NULL); + + char **argses = NULL; + /* It is OK if the pointer value is NULL */ + sc_cleanup_deep_strv(&argses); + g_assert_null(argses); + + /* It is safe to call with an empty array */ + argses = calloc(10, sizeof(char *)); + g_assert_nonnull(argses); + sc_cleanup_deep_strv(&argses); + + /* And of course the typical case works as well */ + argses = calloc(10, sizeof(char *)); + g_assert_nonnull(argses); + for (int i = 0; i < 9; i++) { + argses[i] = strdup("hello"); + } + sc_cleanup_deep_strv(&argses); + g_assert_null(argses); +} + +static void test_cleanup_shallow_strv(void) +{ + /* It is safe to use with a NULL pointer */ + sc_cleanup_shallow_strv(NULL); + + const char **argses = NULL; + /* It is ok of the pointer value is NULL */ + sc_cleanup_shallow_strv(&argses); + g_assert_null(argses); + + argses = calloc(10, sizeof(char *)); + g_assert_nonnull(argses); + /* Fill with bogus pointers so attempts to free them would segfault */ + for (int i = 0; i < 10; i++) { + argses[i] = (char *)0x100 + i; + } + sc_cleanup_shallow_strv(&argses); + g_assert_null(argses); + /* If we are alive at this point, most likely only the array was free'd */ +} + +static void __attribute__((constructor)) init(void) +{ + g_test_add_func("/cleanup/sanity", test_cleanup_sanity); + g_test_add_func("/cleanup/string", test_cleanup_string); + g_test_add_func("/cleanup/file", test_cleanup_file); + g_test_add_func("/cleanup/endmntent", test_cleanup_endmntent); + g_test_add_func("/cleanup/closedir", test_cleanup_closedir); + g_test_add_func("/cleanup/close", test_cleanup_close); + g_test_add_func("/cleanup/deep_strv", test_cleanup_deep_strv); + g_test_add_func("/cleanup/shallow_strv", test_cleanup_shallow_strv); +} diff --git a/cmd/libsnap-confine-private/cleanup-funcs.c b/cmd/libsnap-confine-private/cleanup-funcs.c new file mode 100644 index 00000000..e9752b06 --- /dev/null +++ b/cmd/libsnap-confine-private/cleanup-funcs.c @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2015 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "cleanup-funcs.h" + +#include +#include + +void sc_cleanup_string(char **ptr) +{ + if (ptr != NULL && *ptr != NULL) { + free(*ptr); + *ptr = NULL; + } +} + +void sc_cleanup_deep_strv(char ***ptr) +{ + if (ptr != NULL && *ptr != NULL) { + for (char **str = *ptr; *str != NULL; str++) { + free(*str); + } + free(*ptr); + *ptr = NULL; + } +} + +void sc_cleanup_shallow_strv(const char ***ptr) +{ + if (ptr != NULL && *ptr != NULL) { + free(*ptr); + *ptr = NULL; + } +} + +void sc_cleanup_file(FILE **ptr) +{ + if (ptr != NULL && *ptr != NULL) { + fclose(*ptr); + *ptr = NULL; + } +} + +void sc_cleanup_endmntent(FILE **ptr) +{ + if (ptr != NULL && *ptr != NULL) { + endmntent(*ptr); + *ptr = NULL; + } +} + +void sc_cleanup_closedir(DIR **ptr) +{ + if (ptr != NULL && *ptr != NULL) { + closedir(*ptr); + *ptr = NULL; + } +} + +void sc_cleanup_close(int *ptr) +{ + if (ptr != NULL && *ptr != -1) { + close(*ptr); + *ptr = -1; + } +} diff --git a/cmd/libsnap-confine-private/cleanup-funcs.h b/cmd/libsnap-confine-private/cleanup-funcs.h new file mode 100644 index 00000000..1bdebd68 --- /dev/null +++ b/cmd/libsnap-confine-private/cleanup-funcs.h @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2015 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#ifndef SNAP_CONFINE_CLEANUP_FUNCS_H +#define SNAP_CONFINE_CLEANUP_FUNCS_H + +#ifdef HAVE_CONFIG_H +#include "config.h" +#endif // HAVE_CONFIG_H + +#include +#include +#include +#include + +// SC_CLEANUP will run the given cleanup function when the variable next +// to it goes out of scope. +#define SC_CLEANUP(n) __attribute__((cleanup(n))) + +/** + * Free a dynamically allocated string. + * + * This function is designed to be used with SC_CLEANUP() macro. + * The variable MUST be initialized for correct operation. + * The safe initialisation value is NULL. + **/ +void sc_cleanup_string(char **ptr); + +/** + * Free a dynamically allocated string vector. + * + * Both the vector itself and all the strings contained inside it will be + * freed. It's assumed that the strings have been allocated with malloc(). + * This function is designed to be used with SC_CLEANUP() macro. + * The variable MUST be initialized for correct operation. + * The safe initialisation value is NULL. + */ +void sc_cleanup_deep_strv(char ***ptr); + +/** + * Shallow free a dynamically allocated string vector. + * + * The strings in the vector will not be freed. + * This function is designed to be used with SC_CLEANUP() macro. + * The variable MUST be initialized for correct operation. + * The safe initialisation value is NULL. + */ +void sc_cleanup_shallow_strv(const char ***ptr); + +/** + * Close an open file. + * + * This function is designed to be used with SC_CLEANUP() macro. + * The variable MUST be initialized for correct operation. + * The safe initialisation value is NULL. + **/ +void sc_cleanup_file(FILE ** ptr); + +/** + * Close an open file with endmntent(3) + * + * This function is designed to be used with SC_CLEANUP() macro. + * The variable MUST be initialized for correct operation. + * The safe initialisation value is NULL. + **/ +void sc_cleanup_endmntent(FILE ** ptr); + +/** + * Close an open directory with closedir(3) + * + * This function is designed to be used with SC_CLEANUP() macro. + * The variable MUST be initialized for correct operation. + * The safe initialisation value is NULL. + **/ +void sc_cleanup_closedir(DIR ** ptr); + +/** + * Close an open file descriptor with close(2) + * + * This function is designed to be used with SC_CLEANUP() macro. + * The variable MUST be initialized for correct operation. + * The safe initialisation value is -1. + **/ +void sc_cleanup_close(int *ptr); + +#endif diff --git a/cmd/libsnap-confine-private/device-cgroup-support.c b/cmd/libsnap-confine-private/device-cgroup-support.c new file mode 100644 index 00000000..0676933c --- /dev/null +++ b/cmd/libsnap-confine-private/device-cgroup-support.c @@ -0,0 +1,780 @@ +/* + * Copyright (C) 2021 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +#include "config.h" + +#include +#include +#include +#include +#include +#include + +#include "cgroup-support.h" +#include "cleanup-funcs.h" +#include "snap.h" +#include "string-utils.h" +#include "utils.h" + +#ifdef ENABLE_BPF +#include "bpf-support.h" +#include "bpf/bpf-insn.h" +#endif +#include "device-cgroup-support.h" + +typedef struct sc_cgroup_fds { + int devices_allow_fd; + int devices_deny_fd; + int cgroup_procs_fd; +} sc_cgroup_fds; + +static sc_cgroup_fds sc_cgroup_fds_new(void) { + /* Note that -1 is the neutral value for a file descriptor. + * This is relevant as a cleanup handler for sc_cgroup_fds, + * closes all file descriptors that are not -1. */ + sc_cgroup_fds empty = {-1, -1, -1}; + return empty; +} + +struct sc_device_cgroup { + bool is_v2; + char *security_tag; + union { + struct { + sc_cgroup_fds fds; + } v1; + struct { + int devmap_fd; + int prog_fd; + char *tag; + struct rlimit old_limit; + } v2; + }; +}; + +__attribute__((format(printf, 2, 3))) static void sc_dprintf(int fd, const char *format, ...); + +static int sc_udev_open_cgroup_v1(const char *security_tag, int flags, sc_cgroup_fds *fds); +static void sc_cleanup_cgroup_fds(sc_cgroup_fds *fds); + +static int _sc_cgroup_v1_init(sc_device_cgroup *self, int flags) { + self->v1.fds = sc_cgroup_fds_new(); + + /* are we creating the group or just using whatever there is? */ + const bool from_existing = (flags & SC_DEVICE_CGROUP_FROM_EXISTING) != 0; + /* initialize to something sane */ + if (sc_udev_open_cgroup_v1(self->security_tag, flags, &self->v1.fds) < 0) { + if (from_existing) { + return -1; + } + die("cannot prepare cgroup v1 device hierarchy"); + } + /* Only deny devices if we are not using an existing group - + * if we deny devices for an existing group that we just opened, + * we risk denying access to a device that a currently running process + * is about to access and should legitimately have access to. + * A concrete example of this is when this function is used by snap-device-helper + * when a new udev device event is triggered and we are adding that device + * to the snap's device cgroup. At this point, a running application may be + * accessing other devices which it should have access to (such as /dev/null + * or one of the other common, default devices) we would deny access to that + * existing device by re-creating the allow list of devices every time. + * */ + if (!from_existing) { + /* starting a device cgroup from scratch, so deny device access by + * default. + * + * Write 'a' to devices.deny to remove all existing devices that were added + * in previous launcher invocations, then add the static and assigned + * devices. This ensures that at application launch the cgroup only has + * what is currently assigned. */ + sc_dprintf(self->v1.fds.devices_deny_fd, "a"); + } + return 0; +} + +static void _sc_cgroup_v1_close(sc_device_cgroup *self) { sc_cleanup_cgroup_fds(&self->v1.fds); } + +static void _sc_cgroup_v1_action(int fd, int kind, int major, int minor) { + if ((uint32_t)minor != SC_DEVICE_MINOR_ANY) { + sc_dprintf(fd, "%c %u:%u rwm\n", (kind == S_IFCHR) ? 'c' : 'b', major, minor); + } else { + /* use a mask to allow/deny all minor devices for that major */ + sc_dprintf(fd, "%c %u:* rwm\n", (kind == S_IFCHR) ? 'c' : 'b', major); + } +} + +static void _sc_cgroup_v1_allow(sc_device_cgroup *self, int kind, int major, int minor) { + _sc_cgroup_v1_action(self->v1.fds.devices_allow_fd, kind, major, minor); +} + +static void _sc_cgroup_v1_deny(sc_device_cgroup *self, int kind, int major, int minor) { + _sc_cgroup_v1_action(self->v1.fds.devices_deny_fd, kind, major, minor); +} + +static void _sc_cgroup_v1_attach_pid(sc_device_cgroup *self, pid_t pid) { + sc_dprintf(self->v1.fds.cgroup_procs_fd, "%i\n", pid); +} + +/** + * sc_cgroup_v2_device_key is the key in the map holding allowed devices + */ +struct sc_cgroup_v2_device_key { + uint8_t type; + uint32_t major; + uint32_t minor; +} __attribute__((packed)); +typedef struct sc_cgroup_v2_device_key sc_cgroup_v2_device_key; + +/** + * sc_cgroup_v2_device_value holds the value stored in the map + * + * Note that this type is just a helper, the map cannot be used as a set with 0 + * sized value so we always store something in it (specifically value 1) in the + * map. + */ +typedef uint8_t sc_cgroup_v2_device_value; + +#ifdef ENABLE_BPF +static int load_devcgroup_prog(int map_fd) { + /* Basic rules about registers: + * r0 - return value of built in functions and exit code of the program + * r1-r5 - respective arguments to built in functions, clobbered by calls + * r6-r9 - general purpose, preserved by callees + * r10 - read only, stack pointer + * Stack is 512 bytes. + * + * The function declaration implementing a device cgroup program looks like + * this: + * int program(struct bpf_cgroup_dev_ctx * ctx) + * where *ctx is passed in r1, while the result goes to r0 + */ + + /* just a placeholder for map value where the value is 1 byte, but + * effectively ignored at this time. Ideally it should be possible to use + * the map as a set with 0 sized key, but this is currently unsupported by + * the kernel (as of 5.13) */ + sc_cgroup_v2_device_value map_value __attribute__((unused)); + /* we need to place the key structure on the stack and pull a nasty hack + * here, the structure is packed and its size isn't aligned to multiples of + * 4; if we place it on a stack at an address aligned to 4 bytes, the + * starting offsets of major and minor would be unaligned; however, the + * first field of the structure is 1 byte, so we can put the structure at 4 + * byte aligned address -1 and thus major and minor end up aligned without + * too much hassle; since we are doing the stack management ourselves have + * the key structure start at the offset that meets the alignment properties + * described above and such that the whole structure fits on the stack (even + * with some spare room) */ + size_t key_start = 17; + struct bpf_insn prog[] = { + /* r1 holds pointer to bpf_cgroup_dev_ctx */ + /* initialize r0 */ + BPF_MOV64_IMM(BPF_REG_0, 0), /* r0 = 0 */ + /* make some place on the stack for the key */ + BPF_MOV64_REG(BPF_REG_6, BPF_REG_10), /* r6 = r10 (sp) */ + /* r6 = where the key starts on the stack */ + BPF_ALU64_IMM(BPF_ADD, BPF_REG_6, -key_start), /* r6 = sp + (-key start offset) */ + /* copy major to our key */ + BPF_LDX_MEM(BPF_W, BPF_REG_2, BPF_REG_1, + offsetof(struct bpf_cgroup_dev_ctx, major)), /* r2 = *(u32)(r1->major) */ + BPF_STX_MEM(BPF_W, BPF_REG_6, BPF_REG_2, + offsetof(struct sc_cgroup_v2_device_key, major)), /* *(r6 + offsetof(major)) = r2 */ + /* copy minor to our key */ + BPF_LDX_MEM(BPF_W, BPF_REG_2, BPF_REG_1, + offsetof(struct bpf_cgroup_dev_ctx, minor)), /* r2 = *(u32)(r1->minor) */ + BPF_STX_MEM(BPF_W, BPF_REG_6, BPF_REG_2, + offsetof(struct sc_cgroup_v2_device_key, minor)), /* *(r6 + offsetof(minor)) = r2 */ + /* copy device access_type to r2 */ + BPF_LDX_MEM(BPF_W, BPF_REG_2, BPF_REG_1, + offsetof(struct bpf_cgroup_dev_ctx, access_type)), /* r2 = *(u32*)(r1->access_type) */ + /* access_type is encoded as (BPF_DEVCG_ACC_* << 16) | BPF_DEVCG_DEV_*, + * but we only care about type */ + BPF_ALU32_IMM(BPF_AND, BPF_REG_2, 0xffff), /* r2 = r2 & 0xffff */ + /* is it a block device? */ + BPF_JMP_IMM(BPF_JNE, BPF_REG_2, BPF_DEVCG_DEV_BLOCK, 2), /* if (r2 != BPF_DEVCG_DEV_BLOCK) goto pc + 2 */ + BPF_ST_MEM(BPF_B, BPF_REG_6, offsetof(struct sc_cgroup_v2_device_key, type), + 'b'), /* *(uint8*)(r6->type) = 'b' */ + BPF_JMP_A(5), + BPF_JMP_IMM(BPF_JNE, BPF_REG_2, BPF_DEVCG_DEV_CHAR, 2), /* if (r2 != BPF_DEVCG_DEV_CHAR) goto pc + 2 */ + BPF_ST_MEM(BPF_B, BPF_REG_6, offsetof(struct sc_cgroup_v2_device_key, type), + 'c'), /* *(uint8*)(r6->type) = 'c' */ + BPF_JMP_A(2), + /* unknown device type */ + BPF_MOV64_IMM(BPF_REG_0, 0), /* r0 = 0 */ + BPF_EXIT_INSN(), + /* back on happy path, prepare arguments for map lookup */ + BPF_LD_MAP_FD(BPF_REG_1, map_fd), + BPF_MOV64_REG(BPF_REG_2, BPF_REG_6), /* r2 = (struct key *) r6, */ + BPF_RAW_INSN(BPF_JMP | BPF_CALL, 0, 0, 0, BPF_FUNC_map_lookup_elem), /* r0 = bpf_map_lookup_elem(, + &key) */ + BPF_JMP_IMM(BPF_JEQ, BPF_REG_0, 0, 1), /* if (value_ptr == 0) goto pc + 1 */ + /* we found an exact match */ + BPF_JMP_A(5), /* else goto pc + 5 */ + /* maybe the minor number is using 0xffffffff (any) mask */ + BPF_ST_MEM(BPF_W, BPF_REG_6, offsetof(struct sc_cgroup_v2_device_key, minor), UINT32_MAX), + BPF_LD_MAP_FD(BPF_REG_1, map_fd), + BPF_MOV64_REG(BPF_REG_2, BPF_REG_6), /* r2 = (struct key *) r6, */ + BPF_RAW_INSN(BPF_JMP | BPF_CALL, 0, 0, 0, BPF_FUNC_map_lookup_elem), /* r0 = bpf_map_lookup_elem(, + &key) */ + BPF_JMP_IMM(BPF_JEQ, BPF_REG_0, 0, 2), /* if (value_ptr == 0) goto pc + 2 */ + /* we found a match with any minor number for that type|major */ + BPF_MOV64_IMM(BPF_REG_0, 1), /* r0 = 1 */ + BPF_JMP_A(1), + BPF_MOV64_IMM(BPF_REG_0, 0), /* r0 = 0 */ + BPF_EXIT_INSN(), + }; + + char log_buf[4096] = {0}; + + int prog_fd = + bpf_load_prog(BPF_PROG_TYPE_CGROUP_DEVICE, prog, sizeof(prog) / sizeof(prog[0]), log_buf, sizeof(log_buf)); + if (prog_fd < 0) { + die("cannot load program:\n%s\n", log_buf); + } + return prog_fd; +} + +static void _sc_cleanup_v2_device_key(sc_cgroup_v2_device_key **keyptr) { + if (keyptr == NULL || *keyptr == NULL) { + return; + } + free(*keyptr); + *keyptr = NULL; +} + +static void _sc_cgroup_v2_set_memlock_limit(struct rlimit limit) { + /* we may be setting the limit over the current max, which requires root + * privileges or CAP_SYS_RESOURCE */ + if (setrlimit(RLIMIT_MEMLOCK, &limit) < 0) { + die("cannot set memlock limit to %llu:%llu", (long long unsigned int)limit.rlim_cur, + (long long unsigned int)limit.rlim_max); + } +} + +// _sc_cgroup_v2_adjust_memlock_limit updates the memlock limit which used to be +// consulted by pre 5.11 kernels when creating BPF maps or loading BPF programs. +// It has been observed that some systems (eg. Debian using 5.10 kernel) have +// the default limit set to 64k, which combined with an older way of accounting +// of memory use by BPF objects, renders snap-confine unable to create the BPF +// map. The situation is made worse by the fact that there is no right value +// here, for example older systemd set the limit to 64MB while newer versions +// set it even higher). Returns the old limit setting. +static struct rlimit _sc_cgroup_v2_adjust_memlock_limit(void) { + struct rlimit old_limit = {0}; + + if (getrlimit(RLIMIT_MEMLOCK, &old_limit) < 0) { + die("cannot obtain the current memlock limit"); + } + /* this should be more than enough for creating the map and loading the + * filtering program */ + const rlim_t min_memlock_limit = 512 * 1024; + if (old_limit.rlim_max >= min_memlock_limit) { + return old_limit; + } + debug("adjusting memlock limit to %llu", (long long unsigned int)min_memlock_limit); + struct rlimit limit = { + .rlim_cur = min_memlock_limit, + .rlim_max = min_memlock_limit, + }; + _sc_cgroup_v2_set_memlock_limit(limit); + return old_limit; +} + +static bool _sc_is_snap_cgroup(const char *group) { + /* make a copy as basename may modify its input */ + char copy[PATH_MAX] = {0}; + strncpy(copy, group, sizeof(copy) - 1); + char *leaf = basename(copy); + if (!sc_startswith(leaf, "snap.")) { + return false; + } + if (!sc_endswith(leaf, ".service") && !sc_endswith(leaf, ".scope")) { + return false; + } + return true; +} + +static int _sc_cgroup_v2_init_bpf(sc_device_cgroup *self, int flags) { + self->v2.devmap_fd = -1; + self->v2.prog_fd = -1; + + /* fix the memlock limit if needed, this affects creating maps */ + self->v2.old_limit = _sc_cgroup_v2_adjust_memlock_limit(); + + const bool from_existing = (flags & SC_DEVICE_CGROUP_FROM_EXISTING) != 0; + + self->v2.tag = sc_strdup(self->security_tag); + /* bpffs is unhappy about dots in the name, replace all with underscores */ + for (char *c = strchr(self->v2.tag, '.'); c != NULL; c = strchr(c, '.')) { + *c = '_'; + } + + char path[PATH_MAX] = {0}; + static const char bpf_base[] = "/sys/fs/bpf"; + sc_must_snprintf(path, sizeof path, "%s/snap/%s", bpf_base, self->v2.tag); + + /* we expect bpffs to be mounted at /sys/fs/bpf, which should have been done + * by systemd, but some systems out there are a weird mix of older userland + * and new kernels, in which case the assumptions about the state of the + * system no longer hold and we may need to mount bpffs ourselves */ + if (!bpf_path_is_bpffs("/sys/fs/bpf")) { + debug("/sys/fs/bpf is not a bpffs mount"); + /* bpffs isn't mounted at the usual place, or die if that fails */ + bpf_mount_bpffs("/sys/fs/bpf"); + debug("bpffs mounted at /sys/fs/bpf"); + } + + /* Using 0000 permissions to avoid a race condition; we'll set the right + * permissions after chmod. */ + int bpf_fd = open(bpf_base, O_PATH | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (bpf_fd < 0) { + die("cannot open %s", bpf_base); + } + + if (mkdirat(bpf_fd, "snap", 0000) == 0) { + /* the new directory must be owned by root:root. */ + if (fchownat(bpf_fd, "snap", 0, 0, AT_SYMLINK_NOFOLLOW) < 0) { + die("cannot set root ownership on %s/snap directory", bpf_base); + } + if (fchmodat(bpf_fd, "snap", 0700, AT_SYMLINK_NOFOLLOW) < 0) { + /* On Debian, this fails with "operation not supported. But it + * should not be a critical error, we can also leave with 0000 + * permissions. */ + if (errno != ENOTSUP) { + die("cannot set 0700 permissions on %s/snap directory", bpf_base); + } + } + } else if (errno != EEXIST) { + die("cannot create %s/snap directory", bpf_base); + } + close(bpf_fd); + + /* and obtain a file descriptor to the map, also as root */ + int devmap_fd = bpf_get_by_path(path); + /* keep a copy of errno in case it gets clobbered */ + int get_by_path_errno = errno; + /* XXX: this should be more than enough keys */ + const size_t max_entries = 500; + if (devmap_fd < 0) { + if (get_by_path_errno != ENOENT) { + die("cannot get existing device map"); + } + if (from_existing) { + debug("device map not present, not creating one"); + /* restore the errno so that the caller sees ENOENT */ + errno = get_by_path_errno; + /* there is no map, and we haven't been asked to setup a new cgroup */ + return -1; + } + debug("device map not present yet"); + /* map not created and pinned yet */ + const size_t value_size = 1; + /* kernels used to do account of BPF memory using rlimit memlock pool, + * thus on older kernels (seen on 5.10), the map effectively locks 11 + * pages (45k) of memlock memory, while on newer kernels (5.11+) only 2 (8k) */ + /* NOTE: the new file map must be owned by root:root. */ + devmap_fd = bpf_create_map(BPF_MAP_TYPE_HASH, sizeof(struct sc_cgroup_v2_device_key), value_size, max_entries); + if (devmap_fd < 0) { + die("cannot create bpf map"); + } + debug("got bpf map at fd: %d", devmap_fd); + /* the map can only be referenced by a fd like object which is valid + * here and referenced by the BPF program that we'll load; by pinning + * the map to a well known path, it is possible to obtain a reference to + * it from another process, which is used by snap-device-helper to + * dynamically update device access permissions; the downside is a tiny + * bit of kernel memory still in use as, even once all BPF programs + * referencing the map go away with their respective cgroups, the map + * will stay around as it is still referenced by the path */ + if (bpf_pin_to_path(devmap_fd, path) < 0) { + /* we checked that the map did not exist, so fail on EEXIST too */ + die("cannot pin map to %s", path); + } + } else if (!from_existing) { + /* the devices access map exists, and we have been asked to setup a + * cgroup, so clear the old map first so it was like it never existed */ + + debug("found existing device map"); + /* the v1 implementation blocks all devices by default and then adds + * each assigned one individually, however for v2 there's no way to drop + * all the contents of the map, so we need to find out what keys are + * there in the map */ + + /* first collect all keys in the map */ + sc_cgroup_v2_device_key *existing_keys SC_CLEANUP(_sc_cleanup_v2_device_key) = + calloc(max_entries, sizeof(sc_cgroup_v2_device_key)); + if (existing_keys == NULL) { + die("cannot allocate keys map"); + } + /* 'current' key is zeroed, such that no entry can match it and thus + * we'll iterate over the keys from the beginning */ + sc_cgroup_v2_device_key key = {0}; + size_t existing_count = 0; + while (true) { + sc_cgroup_v2_device_key next = {0}; + if (existing_count >= max_entries) { + die("too many elements in the map"); + } + if (existing_count > 0) { + /* grab the previous key */ + key = existing_keys[existing_count - 1]; + } + int ret = bpf_map_get_next_key(devmap_fd, &key, &next); + if (ret == -1) { + if (errno != ENOENT) { + die("cannot lookup existing device map keys"); + } + /* we are done */ + break; + } + existing_keys[existing_count] = next; + existing_count++; + } + debug("found %zu existing entries in devices map", existing_count); + if (existing_count > 0) { +#if 0 + /* XXX: we should be doing a batch delete of elements, however: + * - on Arch with 5.13 kernel I'm getting EINVAL + * - the linux/bpf.h header present during build on 16.04 does not + * support batch operations + */ + if (bpf_map_delete_batch(devmap_fd, existing_keys, existing_count) < 0) { + die("cannot dump all elements from devices map"); + } +#endif + for (size_t i = 0; i < existing_count; i++) { + sc_cgroup_v2_device_key key = existing_keys[i]; + debug("delete key for %c %d:%d", key.type, key.major, key.minor); + if (bpf_map_delete_elem(devmap_fd, &key) < 0) { + die("cannot delete device map entry for %c %d:%d", key.type, key.major, key.minor); + } + } + } + } + + if (!from_existing) { + /* load and attach the BPF program */ + int prog_fd = load_devcgroup_prog(devmap_fd); + /* keep track of the program */ + self->v2.prog_fd = prog_fd; + } + + self->v2.devmap_fd = devmap_fd; + + return 0; +} + +static void _sc_cgroup_v2_close_bpf(sc_device_cgroup *self) { + /* restore the old limit */ + _sc_cgroup_v2_set_memlock_limit(self->v2.old_limit); + + sc_cleanup_string(&self->v2.tag); + /* the map is pinned to a per-snap-application file and referenced by the + * program */ + sc_cleanup_close(&self->v2.devmap_fd); + sc_cleanup_close(&self->v2.prog_fd); +} + +static void _sc_cgroup_v2_allow_bpf(sc_device_cgroup *self, int kind, int major, int minor) { + struct sc_cgroup_v2_device_key key = { + .major = major, + .minor = minor, + .type = (kind == S_IFCHR) ? 'c' : 'b', + }; + sc_cgroup_v2_device_value value = 1; + debug("v2 allow %c %u:%u", (char)key.type, key.major, key.minor); + if (bpf_update_map(self->v2.devmap_fd, &key, &value) < 0) { + die("cannot update device map for key %c %u:%u", key.type, key.major, key.minor); + } +} + +static void _sc_cgroup_v2_deny_bpf(sc_device_cgroup *self, int kind, int major, int minor) { + struct sc_cgroup_v2_device_key key = { + .major = major, + .minor = minor, + .type = (kind == S_IFCHR) ? 'c' : 'b', + }; + debug("v2 deny %c %u:%u", (char)key.type, key.major, key.minor); + if (bpf_map_delete_elem(self->v2.devmap_fd, &key) < 0 && errno != ENOENT) { + die("cannot delete device map entry for key %c %u:%u", key.type, key.major, key.minor); + } +} + +static void _sc_cgroup_v2_attach_pid_bpf(sc_device_cgroup *self, pid_t pid) { + /* we are setting up device filtering for ourselves */ + if (pid != getpid()) { + die("internal error: cannot attach device cgroup to other process than current"); + } + if (self->v2.prog_fd == -1) { + die("internal error: BPF program not loaded"); + } + + char *own_group SC_CLEANUP(sc_cleanup_string) = sc_cgroup_v2_own_path_full(); + if (own_group == NULL) { + die("cannot obtain own group path"); + } + debug("process in cgroup %s", own_group); + + if (!_sc_is_snap_cgroup(own_group)) { + /* we cannot proceed to install a device filtering program when the + * process is not in a snap specific cgroup, as we would effectively + * lock down the group that can be shared with other processes or even + * the whole desktop session */ + die("%s is not a snap cgroup", own_group); + } + + char own_group_full_path[PATH_MAX] = {0}; + sc_must_snprintf(own_group_full_path, sizeof(own_group_full_path), "/sys/fs/cgroup/%s", own_group); + + int cgroup_fd SC_CLEANUP(sc_cleanup_close) = -1; + cgroup_fd = open(own_group_full_path, O_PATH | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW); + if (cgroup_fd < 0) { + die("cannot open own cgroup directory %s", own_group_full_path); + } + debug("cgroup %s opened at %d", own_group_full_path, cgroup_fd); + + /* attach the program to the cgroup */ + int attach = bpf_prog_attach(BPF_CGROUP_DEVICE, cgroup_fd, self->v2.prog_fd); + if (attach < 0) { + die("cannot attach cgroup program"); + } +} +#endif /* ENABLE_BPF */ + +static void _sc_cgroup_v2_close(sc_device_cgroup *self) { +#ifdef ENABLE_BPF + _sc_cgroup_v2_close_bpf(self); +#endif +} + +static void _sc_cgroup_v2_allow(sc_device_cgroup *self, int kind, int major, int minor) { +#ifdef ENABLE_BPF + _sc_cgroup_v2_allow_bpf(self, kind, major, minor); +#else + die("device cgroup v2 is not enabled"); +#endif +} + +static void _sc_cgroup_v2_deny(sc_device_cgroup *self, int kind, int major, int minor) { +#ifdef ENABLE_BPF + _sc_cgroup_v2_deny_bpf(self, kind, major, minor); +#else + die("device cgroup v2 is not enabled"); +#endif +} + +static void _sc_cgroup_v2_attach_pid(sc_device_cgroup *self, pid_t pid) { +#ifdef ENABLE_BPF + _sc_cgroup_v2_attach_pid_bpf(self, pid); +#else + die("device cgroup v2 is not enabled"); +#endif +} + +static int _sc_cgroup_v2_init(sc_device_cgroup *self, int flags) { +#ifdef ENABLE_BPF + return _sc_cgroup_v2_init_bpf(self, flags); +#else + if ((flags & SC_DEVICE_CGROUP_FROM_EXISTING) != 0) { + errno = ENOSYS; + return -1; + } + die("device cgroup v2 is not enabled"); + return -1; +#endif +} + +static void sc_device_cgroup_close(sc_device_cgroup *self); + +sc_device_cgroup *sc_device_cgroup_new(const char *security_tag, int flags) { + sc_device_cgroup *self = calloc(1, sizeof(sc_device_cgroup)); + if (self == NULL) { + die("cannot allocate device cgroup wrapper"); + } + self->is_v2 = sc_cgroup_is_v2(); + self->security_tag = sc_strdup(security_tag); + + int ret = 0; + if (self->is_v2) { + ret = _sc_cgroup_v2_init(self, flags); + } else { + ret = _sc_cgroup_v1_init(self, flags); + } + + if (ret < 0) { + sc_device_cgroup_close(self); + return NULL; + } + return self; +} + +static void sc_device_cgroup_close(sc_device_cgroup *self) { + if (self->is_v2) { + _sc_cgroup_v2_close(self); + } else { + _sc_cgroup_v1_close(self); + } + sc_cleanup_string(&self->security_tag); + free(self); +} + +void sc_device_cgroup_cleanup(sc_device_cgroup **self) { + if (*self == NULL) { + return; + } + sc_device_cgroup_close(*self); + *self = NULL; +} + +int sc_device_cgroup_allow(sc_device_cgroup *self, int kind, int major, int minor) { + if (kind != S_IFCHR && kind != S_IFBLK) { + die("unsupported device kind 0x%04x", kind); + } + if (self->is_v2) { + _sc_cgroup_v2_allow(self, kind, major, minor); + } else { + _sc_cgroup_v1_allow(self, kind, major, minor); + } + return 0; +} + +int sc_device_cgroup_deny(sc_device_cgroup *self, int kind, int major, int minor) { + if (kind != S_IFCHR && kind != S_IFBLK) { + die("unsupported device kind 0x%04x", kind); + } + if (self->is_v2) { + _sc_cgroup_v2_deny(self, kind, major, minor); + } else { + _sc_cgroup_v1_deny(self, kind, major, minor); + } + return 0; +} + +int sc_device_cgroup_attach_pid(sc_device_cgroup *self, pid_t pid) { + if (self->is_v2) { + _sc_cgroup_v2_attach_pid(self, pid); + } else { + _sc_cgroup_v1_attach_pid(self, pid); + } + return 0; +} + +static void sc_dprintf(int fd, const char *format, ...) { + va_list ap1; + va_list ap2; + int n_expected, n_actual; + + va_start(ap1, format); + va_copy(ap2, ap1); + n_expected = vsnprintf(NULL, 0, format, ap2); + n_actual = vdprintf(fd, format, ap1); + if (n_actual == -1 || n_expected != n_actual) { + die("cannot write to fd %d", fd); + } + va_end(ap2); + va_end(ap1); +} + +static int sc_udev_open_cgroup_v1(const char *security_tag, int flags, sc_cgroup_fds *fds) { + /* Open /sys/fs/cgroup */ + const char *cgroup_path = "/sys/fs/cgroup"; + int SC_CLEANUP(sc_cleanup_close) cgroup_fd = -1; + cgroup_fd = open(cgroup_path, O_PATH | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW); + if (cgroup_fd < 0) { + die("cannot open %s", cgroup_path); + } + + const bool from_existing = (flags & SC_DEVICE_CGROUP_FROM_EXISTING) != 0; + /* Open devices relative to /sys/fs/cgroup */ + const char *devices_relpath = "devices"; + int SC_CLEANUP(sc_cleanup_close) devices_fd = -1; + devices_fd = openat(cgroup_fd, devices_relpath, O_PATH | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW); + if (devices_fd < 0) { + die("cannot open %s/%s", cgroup_path, devices_relpath); + } + + const char *security_tag_relpath = security_tag; + if (!from_existing) { + /* Open snap.$SNAP_NAME.$APP_NAME relative to /sys/fs/cgroup/devices, + * creating the directory if necessary. + * Using 0000 permissions to avoid a race condition; we'll set the + * right permissions after chmod. */ + if (mkdirat(devices_fd, security_tag_relpath, 0000) == 0) { + /* the new directory must be owned by root:root. */ + if (fchownat(devices_fd, security_tag_relpath, 0, 0, AT_SYMLINK_NOFOLLOW) < 0) { + die("cannot set root ownership on %s/%s/%s", cgroup_path, devices_relpath, security_tag_relpath); + } + if (fchmodat(devices_fd, security_tag_relpath, 0755, 0) < 0) { + die("cannot set 0755 permissions on %s/%s/%s", cgroup_path, devices_relpath, security_tag_relpath); + } + } else if (errno != EEXIST) { + die("cannot create directory %s/%s/%s", cgroup_path, devices_relpath, security_tag_relpath); + } + } + + int SC_CLEANUP(sc_cleanup_close) security_tag_fd = -1; + security_tag_fd = openat(devices_fd, security_tag_relpath, O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW); + if (security_tag_fd < 0) { + if (from_existing && errno == ENOENT) { + return -1; + } + die("cannot open %s/%s/%s", cgroup_path, devices_relpath, security_tag_relpath); + } + + int SC_CLEANUP(sc_cleanup_close) devices_allow_fd = -1; + int SC_CLEANUP(sc_cleanup_close) devices_deny_fd = -1; + int SC_CLEANUP(sc_cleanup_close) cgroup_procs_fd = -1; + + /* Open device files relative to /sys/fs/cgroup/devices/snap.$SNAP_NAME.$APP_NAME */ + struct device_file_t { + int *fd; + const char *relpath; + } device_files[] = {{&devices_allow_fd, "devices.allow"}, + {&devices_deny_fd, "devices.deny"}, + {&cgroup_procs_fd, "cgroup.procs"}, + {NULL, NULL}}; + + for (struct device_file_t *device_file = device_files; device_file->fd != NULL; device_file++) { + int fd = openat(security_tag_fd, device_file->relpath, O_WRONLY | O_CLOEXEC | O_NOFOLLOW); + if (fd < 0) { + if (from_existing && errno == ENOENT) { + return -1; + } + die("cannot open %s/%s/%s/%s", cgroup_path, devices_relpath, security_tag_relpath, device_file->relpath); + } + *device_file->fd = fd; + } + + /* Everything worked so pack the result and "move" the descriptors over so + * that they are not closed by the cleanup functions associated with the + * individual variables. */ + fds->devices_allow_fd = devices_allow_fd; + fds->devices_deny_fd = devices_deny_fd; + fds->cgroup_procs_fd = cgroup_procs_fd; + /* Reset the locals so that they are not closed by the cleanup handlers. */ + devices_allow_fd = -1; + devices_deny_fd = -1; + cgroup_procs_fd = -1; + return 0; +} + +static void sc_cleanup_cgroup_fds(sc_cgroup_fds *fds) { + if (fds != NULL) { + sc_cleanup_close(&fds->devices_allow_fd); + sc_cleanup_close(&fds->devices_deny_fd); + sc_cleanup_close(&fds->cgroup_procs_fd); + } +} diff --git a/cmd/libsnap-confine-private/device-cgroup-support.h b/cmd/libsnap-confine-private/device-cgroup-support.h new file mode 100644 index 00000000..a4364057 --- /dev/null +++ b/cmd/libsnap-confine-private/device-cgroup-support.h @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2021 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#ifndef SNAP_CONFINE_DEVICE_CGROUP_SUPPORT_H +#define SNAP_CONFINE_DEVICE_CGROUP_SUPPORT_H + +#include +#include + +struct sc_device_cgroup; +typedef struct sc_device_cgroup sc_device_cgroup; + +enum { + /* when creating a device cgroup wrapped, do not set up a new cgroup but + * rather use an existing one */ + SC_DEVICE_CGROUP_FROM_EXISTING = 1, +}; + +/** + * sc_device_cgroup_new returns a new cgroup device wrapper that is suitable for + * the current system. Flags can contain SC_DEVICE_CGROUP_FROM_EXISTING in which + * case an existing cgroup will be used, and a -1 return value with errno set to + * ENOENT indicates that the group was not found. Otherwise, a new device cgroup + * for a given tag will be set up. + */ +sc_device_cgroup* sc_device_cgroup_new(const char* security_tag, int flags); +/** + * sc_device_cgroup_cleanup disposes of the cgroup wrapper and is suitable for + * use with SC_CLEANUP + */ +void sc_device_cgroup_cleanup(sc_device_cgroup** self); + +/** + * SC_DEVICE_MINOR_ANY is used to indicate any minor device. + */ +static const uint32_t SC_DEVICE_MINOR_ANY = UINT32_MAX; + +/** + * sc_device_cgroup_allow sets up the cgroup to allow access to a given device + * or a set of devices if SC_MINOR_ANY is passed as the minor number. The kind + * must be one of S_IFCHR, S_IFBLK. + */ +int sc_device_cgroup_allow(sc_device_cgroup* self, int kind, int major, int minor); + +/** + * sc_device_cgroup_deny sets up the cgroup to deny access to a given device or + * a set of devices if SC_MINOR_ANY is passed as the minor number. The kind must + * be one of S_IFCHR, S_IFBLK. + */ +int sc_device_cgroup_deny(sc_device_cgroup* self, int kind, int major, int minor); + +/** + * sc_device_cgroup_attach_pid attaches given process ID to the associated + * cgroup. + */ +int sc_device_cgroup_attach_pid(sc_device_cgroup* self, pid_t pid); + +#endif /* SNAP_CONFINE_DEVICE_CGROUP_SUPPORT_H */ diff --git a/cmd/libsnap-confine-private/error-test.c b/cmd/libsnap-confine-private/error-test.c new file mode 100644 index 00000000..75c44b1c --- /dev/null +++ b/cmd/libsnap-confine-private/error-test.c @@ -0,0 +1,292 @@ +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "error.h" +#include "error.c" + +#include +#include + +static void test_sc_error_init(void) +{ + struct sc_error *err; + // Create an error + err = sc_error_init("domain", 42, "printer is on %s", "fire"); + g_assert_nonnull(err); + g_test_queue_destroy((GDestroyNotify) sc_error_free, err); + + // Inspect the exposed attributes + g_assert_cmpstr(sc_error_domain(err), ==, "domain"); + g_assert_cmpint(sc_error_code(err), ==, 42); + g_assert_cmpstr(sc_error_msg(err), ==, "printer is on fire"); +} + +static void test_sc_error_init_from_errno(void) +{ + struct sc_error *err; + // Create an error + err = sc_error_init_from_errno(ENOENT, "printer is on %s", "fire"); + g_assert_nonnull(err); + g_test_queue_destroy((GDestroyNotify) sc_error_free, err); + + // Inspect the exposed attributes + g_assert_cmpstr(sc_error_domain(err), ==, SC_ERRNO_DOMAIN); + g_assert_cmpint(sc_error_code(err), ==, ENOENT); + g_assert_cmpstr(sc_error_msg(err), ==, "printer is on fire"); +} + +static void test_sc_error_init_simple(void) +{ + struct sc_error *err; + // Create an error + err = sc_error_init_simple("hello %s", "errors"); + g_assert_nonnull(err); + g_test_queue_destroy((GDestroyNotify) sc_error_free, err); + + // Inspect the exposed attributes + g_assert_cmpstr(sc_error_domain(err), ==, SC_LIBSNAP_DOMAIN); + g_assert_cmpint(sc_error_code(err), ==, 0); + g_assert_cmpstr(sc_error_msg(err), ==, "hello errors"); +} + +static void test_sc_error_init_api_misuse(void) +{ + struct sc_error *err; + // Create an error + err = sc_error_init_api_misuse("foo cannot be %d", 42); + g_assert_nonnull(err); + g_test_queue_destroy((GDestroyNotify) sc_error_free, err); + + // Inspect the exposed attributes + g_assert_cmpstr(sc_error_domain(err), ==, SC_LIBSNAP_DOMAIN); + g_assert_cmpint(sc_error_code(err), ==, SC_API_MISUSE); + g_assert_cmpstr(sc_error_msg(err), ==, "foo cannot be 42"); +} + +static void test_sc_error_cleanup(void) +{ + // Check that sc_error_cleanup() is safe to use. + + // Cleanup is safe on NULL errors. + struct sc_error *err = NULL; + sc_cleanup_error(&err); + + // Cleanup is safe on non-NULL errors. + err = sc_error_init("domain", 123, "msg"); + g_assert_nonnull(err); + sc_cleanup_error(&err); + g_assert_null(err); +} + +static void test_sc_error_domain__NULL(void) +{ + // Check that sc_error_domain() dies if called with NULL error. + if (g_test_subprocess()) { + // NOTE: the code below fools gcc 5.4 but your mileage may vary. + struct sc_error *err = NULL; + const char *domain = sc_error_domain(err); + (void)(domain); + g_test_message("expected not to reach this place"); + g_test_fail(); + return; + } + g_test_trap_subprocess(NULL, 0, 0); + g_test_trap_assert_failed(); + g_test_trap_assert_stderr + ("cannot obtain error domain from NULL error\n"); +} + +static void test_sc_error_code__NULL(void) +{ + // Check that sc_error_code() dies if called with NULL error. + if (g_test_subprocess()) { + // NOTE: the code below fools gcc 5.4 but your mileage may vary. + struct sc_error *err = NULL; + int code = sc_error_code(err); + (void)(code); + g_test_message("expected not to reach this place"); + g_test_fail(); + return; + } + g_test_trap_subprocess(NULL, 0, 0); + g_test_trap_assert_failed(); + g_test_trap_assert_stderr("cannot obtain error code from NULL error\n"); +} + +static void test_sc_error_msg__NULL(void) +{ + // Check that sc_error_msg() dies if called with NULL error. + if (g_test_subprocess()) { + // NOTE: the code below fools gcc 5.4 but your mileage may vary. + struct sc_error *err = NULL; + const char *msg = sc_error_msg(err); + (void)(msg); + g_test_message("expected not to reach this place"); + g_test_fail(); + return; + } + g_test_trap_subprocess(NULL, 0, 0); + g_test_trap_assert_failed(); + g_test_trap_assert_stderr + ("cannot obtain error message from NULL error\n"); +} + +static void test_sc_die_on_error__NULL(void) +{ + // Check that sc_die_on_error() does nothing if called with NULL error. + if (g_test_subprocess()) { + sc_die_on_error(NULL); + return; + } + g_test_trap_subprocess(NULL, 0, 0); + g_test_trap_assert_passed(); +} + +static void test_sc_die_on_error__regular(void) +{ + // Check that sc_die_on_error() dies if called with an error. + if (g_test_subprocess()) { + struct sc_error *err = + sc_error_init("domain", 42, "just testing"); + sc_die_on_error(err); + return; + } + g_test_trap_subprocess(NULL, 0, 0); + g_test_trap_assert_failed(); + g_test_trap_assert_stderr("just testing\n"); +} + +static void test_sc_die_on_error__errno(void) +{ + // Check that sc_die_on_error() dies if called with an errno-based error. + if (g_test_subprocess()) { + struct sc_error *err = + sc_error_init_from_errno(ENOENT, "just testing"); + sc_die_on_error(err); + return; + } + g_test_trap_subprocess(NULL, 0, 0); + g_test_trap_assert_failed(); + g_test_trap_assert_stderr("just testing: No such file or directory\n"); +} + +static void test_sc_error_forward__nothing(void) +{ + // Check that forwarding NULL does exactly that. + struct sc_error *recipient = (void *)0xDEADBEEF; + struct sc_error *err = NULL; + int rc; + rc = sc_error_forward(&recipient, err); + g_assert_null(recipient); + g_assert_cmpint(rc, ==, 0); +} + +static void test_sc_error_forward__something_somewhere(void) +{ + // Check that forwarding a real error works OK. + struct sc_error *recipient = NULL; + struct sc_error *err = sc_error_init("domain", 42, "just testing"); + int rc; + g_test_queue_destroy((GDestroyNotify) sc_error_free, err); + g_assert_nonnull(err); + rc = sc_error_forward(&recipient, err); + g_assert_nonnull(recipient); + g_assert_cmpint(rc, ==, -1); +} + +static void test_sc_error_forward__something_nowhere(void) +{ + // Check that forwarding a real error nowhere calls die() + if (g_test_subprocess()) { + // NOTE: the code below fools gcc 5.4 but your mileage may vary. + struct sc_error **err_ptr = NULL; + struct sc_error *err = + sc_error_init("domain", 42, "just testing"); + g_test_queue_destroy((GDestroyNotify) sc_error_free, err); + g_assert_nonnull(err); + sc_error_forward(err_ptr, err); + g_test_message("expected not to reach this place"); + g_test_fail(); + return; + } + g_test_trap_subprocess(NULL, 0, 0); + g_test_trap_assert_failed(); + g_test_trap_assert_stderr("just testing\n"); +} + +static void test_sc_error_match__typical(void) +{ + // NULL error doesn't match anything. + g_assert_false(sc_error_match(NULL, "domain", 42)); + + // Non-NULL error matches if domain and code both match. + struct sc_error *err = sc_error_init("domain", 42, "just testing"); + g_test_queue_destroy((GDestroyNotify) sc_error_free, err); + g_assert_true(sc_error_match(err, "domain", 42)); + g_assert_false(sc_error_match(err, "domain", 1)); + g_assert_false(sc_error_match(err, "other-domain", 42)); + g_assert_false(sc_error_match(err, "other-domain", 1)); +} + +static void test_sc_error_match__NULL_domain(void) +{ + // Using a NULL domain is a fatal bug. + if (g_test_subprocess()) { + // NOTE: the code below fools gcc 5.4 but your mileage may vary. + struct sc_error *err = NULL; + const char *domain = NULL; + g_assert_false(sc_error_match(err, domain, 42)); + g_test_message("expected not to reach this place"); + g_test_fail(); + return; + } + g_test_trap_subprocess(NULL, 0, 0); + g_test_trap_assert_failed(); + g_test_trap_assert_stderr("cannot match error to a NULL domain\n"); +} + +static void __attribute__((constructor)) init(void) +{ + g_test_add_func("/error/sc_error_init", test_sc_error_init); + g_test_add_func("/error/sc_error_init_from_errno", + test_sc_error_init_from_errno); + g_test_add_func("/error/sc_error_init_simple", + test_sc_error_init_simple); + g_test_add_func("/error/sc_error_init_api_misue", + test_sc_error_init_api_misuse); + g_test_add_func("/error/sc_error_cleanup", test_sc_error_cleanup); + g_test_add_func("/error/sc_error_domain/NULL", + test_sc_error_domain__NULL); + g_test_add_func("/error/sc_error_code/NULL", test_sc_error_code__NULL); + g_test_add_func("/error/sc_error_msg/NULL", test_sc_error_msg__NULL); + g_test_add_func("/error/sc_die_on_error/NULL", + test_sc_die_on_error__NULL); + g_test_add_func("/error/sc_die_on_error/regular", + test_sc_die_on_error__regular); + g_test_add_func("/error/sc_die_on_error/errno", + test_sc_die_on_error__errno); + g_test_add_func("/error/sc_error_formward/nothing", + test_sc_error_forward__nothing); + g_test_add_func("/error/sc_error_formward/something_somewhere", + test_sc_error_forward__something_somewhere); + g_test_add_func("/error/sc_error_formward/something_nowhere", + test_sc_error_forward__something_nowhere); + g_test_add_func("/error/sc_error_match/typical", + test_sc_error_match__typical); + g_test_add_func("/error/sc_error_match/NULL_domain", + test_sc_error_match__NULL_domain); +} diff --git a/cmd/libsnap-confine-private/error.c b/cmd/libsnap-confine-private/error.c new file mode 100644 index 00000000..fe4f9fbd --- /dev/null +++ b/cmd/libsnap-confine-private/error.c @@ -0,0 +1,165 @@ +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +#include "error.h" + +// To get vasprintf +#define _GNU_SOURCE + +#include "utils.h" + +#include +#include +#include +#include + +static sc_error *sc_error_initv(const char *domain, int code, + const char *msgfmt, va_list ap) +{ + // Set errno in case we die. + errno = 0; + sc_error *err = calloc(1, sizeof *err); + if (err == NULL) { + die("cannot allocate memory for error object"); + } + err->domain = domain; + err->code = code; + if (vasprintf(&err->msg, msgfmt, ap) == -1) { + die("cannot format error message"); + } + return err; +} + +sc_error *sc_error_init(const char *domain, int code, const char *msgfmt, ...) +{ + va_list ap; + va_start(ap, msgfmt); + sc_error *err = sc_error_initv(domain, code, msgfmt, ap); + va_end(ap); + return err; +} + +sc_error *sc_error_init_from_errno(int errno_copy, const char *msgfmt, ...) +{ + va_list ap; + va_start(ap, msgfmt); + sc_error *err = sc_error_initv(SC_ERRNO_DOMAIN, errno_copy, msgfmt, ap); + va_end(ap); + return err; +} + +sc_error *sc_error_init_simple(const char *msgfmt, ...) +{ + va_list ap; + va_start(ap, msgfmt); + sc_error *err = sc_error_initv(SC_LIBSNAP_DOMAIN, + SC_UNSPECIFIED_ERROR, msgfmt, ap); + va_end(ap); + return err; +} + +sc_error *sc_error_init_api_misuse(const char *msgfmt, ...) +{ + va_list ap; + va_start(ap, msgfmt); + sc_error *err = sc_error_initv(SC_LIBSNAP_DOMAIN, + SC_API_MISUSE, msgfmt, ap); + va_end(ap); + return err; +} + +const char *sc_error_domain(sc_error *err) +{ + // Set errno in case we die. + errno = 0; + if (err == NULL) { + die("cannot obtain error domain from NULL error"); + } + return err->domain; +} + +int sc_error_code(sc_error *err) +{ + // Set errno in case we die. + errno = 0; + if (err == NULL) { + die("cannot obtain error code from NULL error"); + } + return err->code; +} + +const char *sc_error_msg(sc_error *err) +{ + // Set errno in case we die. + errno = 0; + if (err == NULL) { + die("cannot obtain error message from NULL error"); + } + return err->msg; +} + +void sc_error_free(sc_error *err) +{ + if (err != NULL) { + free(err->msg); + err->msg = NULL; + free(err); + } +} + +void sc_cleanup_error(sc_error **ptr) +{ + sc_error_free(*ptr); + *ptr = NULL; +} + +void sc_die_on_error(sc_error *error) +{ + if (error != NULL) { + if (strcmp(sc_error_domain(error), SC_ERRNO_DOMAIN) == 0) { + fprintf(stderr, "%s: %s\n", sc_error_msg(error), + strerror(sc_error_code(error))); + } else { + fprintf(stderr, "%s\n", sc_error_msg(error)); + } + sc_error_free(error); + exit(1); + } +} + +int sc_error_forward(sc_error **recipient, sc_error *error) +{ + if (recipient != NULL) { + *recipient = error; + } else { + sc_die_on_error(error); + } + return error != NULL ? -1 : 0; +} + +bool sc_error_match(sc_error *error, const char *domain, int code) +{ + // Set errno in case we die. + errno = 0; + if (domain == NULL) { + die("cannot match error to a NULL domain"); + } + if (error == NULL) { + return false; + } + return strcmp(sc_error_domain(error), domain) == 0 + && sc_error_code(error) == code; +} diff --git a/cmd/libsnap-confine-private/error.h b/cmd/libsnap-confine-private/error.h new file mode 100644 index 00000000..569f02c9 --- /dev/null +++ b/cmd/libsnap-confine-private/error.h @@ -0,0 +1,208 @@ +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#ifndef SNAP_CONFINE_ERROR_H +#define SNAP_CONFINE_ERROR_H + +#include + +#define SC_GCC_VERSION (__GNUC__ * 10000 + __GNUC_MINOR__ * 100 + __GNUC_PATCHLEVEL__) + +/** + * The attribute returns_nonnull is only supported by GCC versions >= 4.9.0. + * Enable building of snap-confine on platforms that are stuck with older + * GCC versions. + **/ +#if SC_GCC_VERSION >= 40900 +#define SC_APPEND_RETURNS_NONNULL , returns_nonnull +#else +#define SC_APPEND_RETURNS_NONNULL +#endif + +/** + * This module defines APIs for simple error management. + * + * Errors are allocated objects that can be returned and passed around from + * functions. Errors carry a formatted message and optionally a scoped error + * code. The code is coped with a string "domain" that simply acts as a + * namespace for various interacting modules. + **/ + +/** + * Error structure. + **/ +typedef struct sc_error { + // Error domain defines a scope for particular error codes. + const char *domain; + // Code differentiates particular errors for the programmer. + // The code may be zero if the particular meaning is not relevant. + int code; + // Message carries a formatted description of the problem. + char *msg; +} sc_error; + +/** + * Error domain for errors related to system errno. + **/ +#define SC_ERRNO_DOMAIN "errno" + +/** + * Error domain for errors in the libsnap-confine-private library. + **/ +#define SC_LIBSNAP_DOMAIN "libsnap-confine-private" + +/** sc_libsnap_error represents distinct error codes used by libsnap-confine-private library. */ +typedef enum sc_libsnap_error { + /** SC_UNSPECIFIED_ERROR indicates an error not worthy of a distinct code. */ + SC_UNSPECIFIED_ERROR = 0, + /** SC_API_MISUSE indicates that public API was called incorrectly. */ + SC_API_MISUSE, + /** SC_BUG indicates that private API was called incorrectly. */ + SC_BUG, +} sc_libsnap_error; + +/** + * Initialize a new error object. + * + * The domain is a cookie-like string that allows the caller to distinguish + * between "namespaces" of error codes. It should be a static string that is + * provided by the caller. Both the domain and the error code can be retrieved + * later. + * + * This function calls die() in case of memory allocation failure. + **/ +__attribute__((warn_unused_result, + format(printf, 3, 4) SC_APPEND_RETURNS_NONNULL)) +sc_error *sc_error_init(const char *domain, int code, const char *msgfmt, ...); + +/** + * Initialize an unspecified error with formatted message. + * + * This is just syntactic sugar for sc_error_init(SC_LIBSNAP_ERROR, + * SC_UNSPECIFIED_ERROR, msgfmt, ...) which is repeated often. + **/ +__attribute__((warn_unused_result, + format(printf, 1, 2) SC_APPEND_RETURNS_NONNULL)) +sc_error *sc_error_init_simple(const char *msgfmt, ...); + +/** + * Initialize an API misuse error with formatted message. + * + * This is just syntactic sugar for sc_error_init(SC_LIBSNAP_DOMAIN, + * SC_API_MISUSE, msgfmt, ...) which is repeated often. + **/ +__attribute__((warn_unused_result, + format(printf, 1, 2) SC_APPEND_RETURNS_NONNULL)) +sc_error *sc_error_init_api_misuse(const char *msgfmt, ...); + +/** + * Initialize an errno-based error. + * + * The error carries a copy of errno and a custom error message as designed by + * the caller. See sc_error_init() for a more complete description. + * + * This function calls die() in case of memory allocation failure. + **/ +__attribute__((warn_unused_result, + format(printf, 2, 3) SC_APPEND_RETURNS_NONNULL)) +sc_error *sc_error_init_from_errno(int errno_copy, const char *msgfmt, ...); + +/** + * Get the error domain out of an error object. + * + * The error domain acts as a namespace for error codes. + * No change of ownership takes place. + **/ +__attribute__((warn_unused_result SC_APPEND_RETURNS_NONNULL)) +const char *sc_error_domain(sc_error *err); + +/** + * Get the error code out of an error object. + * + * The error code is scoped by the error domain. + * + * An error code of zero is special-cased to indicate that no particular error + * code is reserved for this error and it's not something that the programmer + * can rely on programmatically. This can be used to return an error message + * without having to allocate a distinct code for each one. + **/ +__attribute__((warn_unused_result)) +int sc_error_code(sc_error *err); + +/** + * Get the error message out of an error object. + * + * The error message is bound to the life-cycle of the error object. + * No change of ownership takes place. + **/ +__attribute__((warn_unused_result SC_APPEND_RETURNS_NONNULL)) +const char *sc_error_msg(sc_error *err); + +/** + * Free an error object. + * + * The error object can be NULL. + **/ +void sc_error_free(sc_error *error); + +/** + * Cleanup an error with sc_error_free() + * + * This function is designed to be used with + * __attribute__((cleanup(sc_cleanup_error))). + **/ +__attribute__((nonnull)) +void sc_cleanup_error(sc_error **ptr); + +/** + * + * Die if there's an error. + * + * This function is a correct way to die() if the passed error is not NULL. + * + * The error message is derived from the data in the error, using the special + * errno domain to provide additional information if that is available. + **/ +void sc_die_on_error(sc_error *error); + +/** + * Forward an error to the caller. + * + * This tries to forward an error to the caller. If this is impossible because + * the caller did not provide a location for the error to be stored then the + * sc_die_on_error() is called as a safety measure. + * + * Change of ownership takes place and the error is now stored in the recipient. + * + * The return value -1 if error is non-NULL and 0 otherwise. The return value + * makes it convenient to `return sc_error_forward(err_out, err);` as the last + * line of a function. + **/ +// NOTE: There's no nonnull(1) attribute as the recipient *can* be NULL. With +// the attribute in place GCC optimizes some things out and tests fail. +int sc_error_forward(sc_error **recipient, sc_error *error); + +/** + * Check if a given error matches the specified domain and code. + * + * It is okay to match a NULL error, the function simply returns false in that + * case. The domain cannot be NULL though. + **/ +__attribute__((warn_unused_result)) +bool sc_error_match(sc_error *error, const char *domain, int code); + +#endif diff --git a/cmd/libsnap-confine-private/fault-injection-test.c b/cmd/libsnap-confine-private/fault-injection-test.c new file mode 100644 index 00000000..c54a958c --- /dev/null +++ b/cmd/libsnap-confine-private/fault-injection-test.c @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "fault-injection.h" +#include "fault-injection.c" + +#include +#include + +static bool broken(struct sc_fault_state *state, void *ptr) +{ + return true; +} + +static bool broken_alter_msg(struct sc_fault_state *state, void *ptr) +{ + char **s = ptr; + *s = "broken"; + return true; +} + +static void test_fault_injection(void) +{ + g_assert_false(sc_faulty("foo", NULL)); + + sc_break("foo", broken); + g_assert_true(sc_faulty("foo", NULL)); + + sc_reset_faults(); + g_assert_false(sc_faulty("foo", NULL)); + + const char *msg = NULL; + if (!sc_faulty("foo", &msg)) { + msg = "working"; + } + g_assert_cmpstr(msg, ==, "working"); + + sc_break("foo", broken_alter_msg); + if (!sc_faulty("foo", &msg)) { + msg = "working"; + } + g_assert_cmpstr(msg, ==, "broken"); + sc_reset_faults(); +} + +static void __attribute__((constructor)) init(void) +{ + g_test_add_func("/fault-injection", test_fault_injection); +} diff --git a/cmd/libsnap-confine-private/fault-injection.c b/cmd/libsnap-confine-private/fault-injection.c new file mode 100644 index 00000000..c8486843 --- /dev/null +++ b/cmd/libsnap-confine-private/fault-injection.c @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "fault-injection.h" + +#ifdef _ENABLE_FAULT_INJECTION + +#include +#include + +struct sc_fault { + const char *name; + struct sc_fault *next; + sc_fault_fn fn; + struct sc_fault_state state; +}; + +static struct sc_fault *sc_faults = NULL; + +bool sc_faulty(const char *name, void *ptr) +{ + for (struct sc_fault * fault = sc_faults; fault != NULL; + fault = fault->next) { + if (strcmp(name, fault->name) == 0) { + bool is_faulty = fault->fn(&fault->state, ptr); + fault->state.ncalls++; + return is_faulty; + } + } + return false; +} + +void sc_break(const char *name, sc_fault_fn fn) +{ + struct sc_fault *fault = calloc(1, sizeof *fault); + if (fault == NULL) { + abort(); + } + fault->name = name; + fault->next = sc_faults; + fault->fn = fn; + fault->state.ncalls = 0; + sc_faults = fault; +} + +void sc_reset_faults(void) +{ + struct sc_fault *next_fault; + for (struct sc_fault * fault = sc_faults; fault != NULL; + fault = next_fault) { + next_fault = fault->next; + free(fault); + } + sc_faults = NULL; +} + +#else // ifndef _ENABLE_FAULT_INJECTION + +bool sc_faulty(const char *name, void *ptr) +{ + return false; +} + +#endif // ifndef _ENABLE_FAULT_INJECTION diff --git a/cmd/libsnap-confine-private/fault-injection.h b/cmd/libsnap-confine-private/fault-injection.h new file mode 100644 index 00000000..417ab881 --- /dev/null +++ b/cmd/libsnap-confine-private/fault-injection.h @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#ifndef SNAP_CONFINE_FAULT_INJECTION_H +#define SNAP_CONFINE_FAULT_INJECTION_H + +#include + +/** + * Check for an injected fault. + * + * The name of the fault must match what was passed to sc_break(). The second + * argument can be modified by the fault callback function. The return value + * indicates if a fault was injected. It is assumed that once a fault was + * injected the passed pointer was used to modify the state in useful way. + * + * When the pre-processor macro _ENABLE_FAULT_INJECTION is not defined this + * function always returns false and does nothing at all. + **/ +bool sc_faulty(const char *name, void *ptr); + +#ifdef _ENABLE_FAULT_INJECTION + +struct sc_fault_state; + +typedef bool (*sc_fault_fn)(struct sc_fault_state * state, void *ptr); + +struct sc_fault_state { + int ncalls; +}; + +/** + * Inject a fault for testing. + * + * The name of the fault must match the expected calls to sc_faulty(). The + * second argument is a callback that is invoked each time sc_faulty() is + * called. It is designed to inspect an argument passed to sc_faulty() and as + * well as the state of the fault injection point and return a boolean + * indicating that a fault has occurred. + * + * After testing faults should be reset using sc_reset_faults(). + **/ + +void sc_break(const char *name, sc_fault_fn fn); + +/** + * Remove all the injected faults. + **/ +void sc_reset_faults(void); + +#endif // ifndef _ENABLE_FAULT_INJECTION + +#endif diff --git a/cmd/libsnap-confine-private/feature-test.c b/cmd/libsnap-confine-private/feature-test.c new file mode 100644 index 00000000..1cbe2f9c --- /dev/null +++ b/cmd/libsnap-confine-private/feature-test.c @@ -0,0 +1,118 @@ +/* + * Copyright (C) 2018 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "feature.h" +#include "feature.c" + +#include + +#include + +#include "string-utils.h" +#include "test-utils.h" + +static char *sc_testdir(void) +{ + char *d = g_dir_make_tmp(NULL, NULL); + g_assert_nonnull(d); + g_test_queue_free(d); + g_test_queue_destroy((GDestroyNotify) rm_rf_tmp, d); + return d; +} + +// Set the feature flag directory to given value, useful for cleanup handlers. +static void set_feature_flag_dir(const char *dir) +{ + feature_flag_dir = dir; +} + +// Mock the location of the feature flag directory. +static void sc_mock_feature_flag_dir(const char *d) +{ + g_test_queue_destroy((GDestroyNotify) set_feature_flag_dir, + (void *)feature_flag_dir); + set_feature_flag_dir(d); +} + +static void test_feature_enabled__missing_dir(void) +{ + const char *d = sc_testdir(); + char subd[PATH_MAX]; + sc_must_snprintf(subd, sizeof subd, "%s/absent", d); + sc_mock_feature_flag_dir(subd); + g_assert_false(sc_feature_enabled(SC_FEATURE_PER_USER_MOUNT_NAMESPACE)); +} + +static void test_feature_enabled__missing_file(void) +{ + const char *d = sc_testdir(); + sc_mock_feature_flag_dir(d); + g_assert_false(sc_feature_enabled(SC_FEATURE_PER_USER_MOUNT_NAMESPACE)); +} + +static void test_feature_enabled__present_file(void) +{ + const char *d = sc_testdir(); + sc_mock_feature_flag_dir(d); + char pname[PATH_MAX]; + sc_must_snprintf(pname, sizeof pname, "%s/per-user-mount-namespace", d); + g_assert_true(g_file_set_contents(pname, "", -1, NULL)); + + g_assert_true(sc_feature_enabled(SC_FEATURE_PER_USER_MOUNT_NAMESPACE)); +} + +static void test_feature_parallel_instances(void) +{ + const char *d = sc_testdir(); + sc_mock_feature_flag_dir(d); + + g_assert_false(sc_feature_enabled(SC_FEATURE_PARALLEL_INSTANCES)); + + char pname[PATH_MAX]; + sc_must_snprintf(pname, sizeof pname, "%s/parallel-instances", d); + g_assert_true(g_file_set_contents(pname, "", -1, NULL)); + + g_assert_true(sc_feature_enabled(SC_FEATURE_PARALLEL_INSTANCES)); +} + +static void test_feature_hidden_snap_folder(void) +{ + const char *d = sc_testdir(); + sc_mock_feature_flag_dir(d); + + g_assert_false(sc_feature_enabled(SC_FEATURE_HIDDEN_SNAP_FOLDER)); + + char pname[PATH_MAX]; + sc_must_snprintf(pname, sizeof pname, "%s/hidden-snap-folder", d); + g_assert_true(g_file_set_contents(pname, "", -1, NULL)); + + g_assert_true(sc_feature_enabled(SC_FEATURE_HIDDEN_SNAP_FOLDER)); +} + +static void __attribute__((constructor)) init(void) +{ + g_test_add_func("/feature/missing_dir", + test_feature_enabled__missing_dir); + g_test_add_func("/feature/missing_file", + test_feature_enabled__missing_file); + g_test_add_func("/feature/present_file", + test_feature_enabled__present_file); + g_test_add_func("/feature/parallel_instances", + test_feature_parallel_instances); + g_test_add_func("/feature/hidden_snap_folder", + test_feature_hidden_snap_folder); +} diff --git a/cmd/libsnap-confine-private/feature.c b/cmd/libsnap-confine-private/feature.c new file mode 100644 index 00000000..32c2074d --- /dev/null +++ b/cmd/libsnap-confine-private/feature.c @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2018 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#define _GNU_SOURCE + +#include "feature.h" + +#include +#include +#include +#include +#include + +#include "cleanup-funcs.h" +#include "utils.h" + +static const char *feature_flag_dir = "/var/lib/snapd/features"; + +bool sc_feature_enabled(sc_feature_flag flag) +{ + const char *file_name; + switch (flag) { + case SC_FEATURE_PER_USER_MOUNT_NAMESPACE: + file_name = "per-user-mount-namespace"; + break; + case SC_FEATURE_REFRESH_APP_AWARENESS: + file_name = "refresh-app-awareness"; + break; + case SC_FEATURE_PARALLEL_INSTANCES: + file_name = "parallel-instances"; + break; + case SC_FEATURE_HIDDEN_SNAP_FOLDER: + file_name = "hidden-snap-folder"; + break; + default: + die("unknown feature flag code %d", flag); + } + + int dirfd SC_CLEANUP(sc_cleanup_close) = -1; + dirfd = + open(feature_flag_dir, + O_CLOEXEC | O_DIRECTORY | O_NOFOLLOW | O_PATH); + if (dirfd < 0 && errno == ENOENT) { + return false; + } + if (dirfd < 0) { + die("cannot open path %s", feature_flag_dir); + } + + struct stat file_info; + if (fstatat(dirfd, file_name, &file_info, AT_SYMLINK_NOFOLLOW) < 0) { + if (errno == ENOENT) { + return false; + } + die("cannot inspect file %s/%s", feature_flag_dir, file_name); + } + + return S_ISREG(file_info.st_mode); +} diff --git a/cmd/libsnap-confine-private/feature.h b/cmd/libsnap-confine-private/feature.h new file mode 100644 index 00000000..017c2fb0 --- /dev/null +++ b/cmd/libsnap-confine-private/feature.h @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2018 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#ifndef SNAP_CONFINE_FEATURE_H +#define SNAP_CONFINE_FEATURE_H + +#include + +typedef enum sc_feature_flag { + SC_FEATURE_PER_USER_MOUNT_NAMESPACE = 1 << 0, + SC_FEATURE_REFRESH_APP_AWARENESS = 1 << 1, + SC_FEATURE_PARALLEL_INSTANCES = 1 << 2, + SC_FEATURE_HIDDEN_SNAP_FOLDER = 1 << 3, +} sc_feature_flag; + +/** + * sc_feature_enabled returns true if a given feature flag has been activated + * by the user via "snap set core experimental.xxx=true". This is determined by + * testing the presence of a file in /var/lib/snapd/features/ that is named + * after the flag name. +**/ +bool sc_feature_enabled(sc_feature_flag flag); + +#endif diff --git a/cmd/libsnap-confine-private/infofile-test.c b/cmd/libsnap-confine-private/infofile-test.c new file mode 100644 index 00000000..9afd276d --- /dev/null +++ b/cmd/libsnap-confine-private/infofile-test.c @@ -0,0 +1,300 @@ +/* + * Copyright (C) 2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "infofile.h" + +#include +#include + +#include "infofile.c" + +static void test_infofile_get_key(void) { + int rc; + sc_error *err; + + char text[] = + "key=value\n" + "other-key=other-value\n" + "# a comment\n" + "dup-key=value-one\n" + "dup-key=value-two\n"; + FILE *stream = fmemopen(text, sizeof text - 1, "r"); + g_assert_nonnull(stream); + + char *value; + + /* Caller must provide the stream to scan. */ + rc = sc_infofile_get_key(NULL, "key", &value, &err); + g_assert_cmpint(rc, ==, -1); + g_assert_nonnull(err); + g_assert_cmpstr(sc_error_domain(err), ==, SC_LIBSNAP_DOMAIN); + g_assert_cmpint(sc_error_code(err), ==, SC_API_MISUSE); + g_assert_cmpstr(sc_error_msg(err), ==, "stream cannot be NULL"); + sc_error_free(err); + + /* Caller must provide the key to look for. */ + rc = sc_infofile_get_key(stream, NULL, &value, &err); + g_assert_cmpint(rc, ==, -1); + g_assert_nonnull(err); + g_assert_cmpstr(sc_error_domain(err), ==, SC_LIBSNAP_DOMAIN); + g_assert_cmpint(sc_error_code(err), ==, SC_API_MISUSE); + g_assert_cmpstr(sc_error_msg(err), ==, "key cannot be NULL"); + sc_error_free(err); + + /* Caller must provide storage for the value. */ + rc = sc_infofile_get_key(stream, "key", NULL, &err); + g_assert_cmpint(rc, ==, -1); + g_assert_nonnull(err); + g_assert_cmpstr(sc_error_domain(err), ==, SC_LIBSNAP_DOMAIN); + g_assert_cmpint(sc_error_code(err), ==, SC_API_MISUSE); + g_assert_cmpstr(sc_error_msg(err), ==, "value cannot be NULL"); + sc_error_free(err); + + /* Keys that are not found get NULL values. */ + value = (void *)0xfefefefe; + rewind(stream); + rc = sc_infofile_get_key(stream, "missing-key", &value, &err); + g_assert_cmpint(rc, ==, 0); + g_assert_null(err); + g_assert_null(value); + + /* Keys that are found get strdup-duplicated values. */ + value = NULL; + rewind(stream); + rc = sc_infofile_get_key(stream, "key", &value, &err); + g_assert_cmpint(rc, ==, 0); + g_assert_null(err); + g_assert_nonnull(value); + g_assert_cmpstr(value, ==, "value"); + free(value); + + /* When duplicate keys are present the first value is extracted. */ + char *dup_value; + rewind(stream); + rc = sc_infofile_get_key(stream, "dup-key", &dup_value, &err); + g_assert_cmpint(rc, ==, 0); + g_assert_null(err); + g_assert_nonnull(dup_value); + g_assert_cmpstr(dup_value, ==, "value-one"); + free(dup_value); + + fclose(stream); + + /* Key without a value. */ + char *tricky_value; + char tricky1[] = "key\n"; + stream = fmemopen(tricky1, sizeof tricky1 - 1, "r"); + g_assert_nonnull(stream); + rc = sc_infofile_get_key(stream, "key", &tricky_value, &err); + g_assert_cmpint(rc, ==, -1); + g_assert_nonnull(err); + g_assert_cmpstr(sc_error_domain(err), ==, SC_LIBSNAP_DOMAIN); + g_assert_cmpint(sc_error_code(err), ==, 0); + g_assert_cmpstr(sc_error_msg(err), ==, "line 1 is not a key=value assignment"); + g_assert_null(tricky_value); + sc_error_free(err); + fclose(stream); + + /* Key-value pair with embedded NUL byte. */ + char tricky2[] = "key=value\0garbage\n"; + stream = fmemopen(tricky2, sizeof tricky2 - 1, "r"); + g_assert_nonnull(stream); + rc = sc_infofile_get_key(stream, "key", &tricky_value, &err); + g_assert_cmpint(rc, ==, -1); + g_assert_nonnull(err); + g_assert_cmpstr(sc_error_domain(err), ==, SC_LIBSNAP_DOMAIN); + g_assert_cmpint(sc_error_code(err), ==, 0); + g_assert_cmpstr(sc_error_msg(err), ==, "line 1 contains NUL byte"); + g_assert_null(tricky_value); + sc_error_free(err); + fclose(stream); + + /* Key with empty value but without trailing newline. */ + char tricky3[] = "key="; + stream = fmemopen(tricky3, sizeof tricky3 - 1, "r"); + g_assert_nonnull(stream); + rc = sc_infofile_get_key(stream, "key", &tricky_value, &err); + g_assert_cmpint(rc, ==, -1); + g_assert_nonnull(err); + g_assert_cmpstr(sc_error_domain(err), ==, SC_LIBSNAP_DOMAIN); + g_assert_cmpint(sc_error_code(err), ==, 0); + g_assert_cmpstr(sc_error_msg(err), ==, "line 1 does not end with a newline"); + g_assert_null(tricky_value); + sc_error_free(err); + fclose(stream); + + /* Key with empty value with a trailing newline (which is also valid). */ + char tricky4[] = "key=\n"; + stream = fmemopen(tricky4, sizeof tricky4 - 1, "r"); + g_assert_nonnull(stream); + rc = sc_infofile_get_key(stream, "key", &tricky_value, &err); + g_assert_cmpint(rc, ==, 0); + g_assert_null(err); + g_assert_cmpstr(tricky_value, ==, ""); + sc_error_free(err); + fclose(stream); + free(tricky_value); + + /* The equals character alone (key is empty) */ + char tricky5[] = "=\n"; + stream = fmemopen(tricky5, sizeof tricky5 - 1, "r"); + g_assert_nonnull(stream); + rc = sc_infofile_get_key(stream, "key", &tricky_value, &err); + g_assert_cmpint(rc, ==, -1); + g_assert_nonnull(err); + g_assert_cmpstr(sc_error_domain(err), ==, SC_LIBSNAP_DOMAIN); + g_assert_cmpint(sc_error_code(err), ==, 0); + g_assert_cmpstr(sc_error_msg(err), ==, "line 1 contains empty key"); + g_assert_null(tricky_value); + sc_error_free(err); + fclose(stream); + + /* Unexpected section */ + char tricky6[] = "[section]\n"; + stream = fmemopen(tricky6, sizeof tricky6 - 1, "r"); + g_assert_nonnull(stream); + rc = sc_infofile_get_key(stream, "key", &tricky_value, &err); + g_assert_cmpint(rc, ==, -1); + g_assert_nonnull(err); + g_assert_cmpstr(sc_error_domain(err), ==, SC_LIBSNAP_DOMAIN); + g_assert_cmpint(sc_error_code(err), ==, 0); + g_assert_cmpstr(sc_error_msg(err), ==, "line 1 contains unexpected section"); + g_assert_null(tricky_value); + sc_error_free(err); + fclose(stream); +} + +static void test_infofile_get_ini_key(void) { + int rc; + sc_error *err; + + char text[] = + "[section1]\n" + "key=value\n" + "# this is a section comment\n" + "[section2]\n" + "key2=value-two\n" + "other-key2=other-value-two\n" + "key=value-one-two\n"; + FILE *stream = fmemopen(text, sizeof text - 1, "r"); + g_assert_nonnull(stream); + + char *value; + + /* Key in matching in the first section */ + value = NULL; + rewind(stream); + rc = sc_infofile_get_ini_section_key(stream, "section1", "key", &value, &err); + g_assert_cmpint(rc, ==, 0); + g_assert_null(err); + g_assert_nonnull(value); + g_assert_cmpstr(value, ==, "value"); + free(value); + + /* Key matching in the second section */ + value = NULL; + rewind(stream); + rc = sc_infofile_get_ini_section_key(stream, "section2", "key2", &value, &err); + g_assert_cmpint(rc, ==, 0); + g_assert_null(err); + g_assert_nonnull(value); + g_assert_cmpstr(value, ==, "value-two"); + free(value); + + /* Key matching in the second section (identical to the key from 1st section) */ + value = NULL; + rewind(stream); + rc = sc_infofile_get_ini_section_key(stream, "section2", "key", &value, &err); + g_assert_cmpint(rc, ==, 0); + g_assert_null(err); + g_assert_nonnull(value); + g_assert_cmpstr(value, ==, "value-one-two"); + free(value); + + /* No matching section */ + value = NULL; + rewind(stream); + rc = sc_infofile_get_ini_section_key(stream, "section-x", "key", &value, &err); + g_assert_cmpint(rc, ==, 0); + g_assert_null(err); + g_assert_null(value); + + /* Invalid empty section name */ + value = NULL; + rewind(stream); + rc = sc_infofile_get_ini_section_key(stream, "", "key", &value, &err); + g_assert_cmpint(rc, ==, -1); + g_assert_nonnull(err); + g_assert_cmpstr(sc_error_domain(err), ==, SC_LIBSNAP_DOMAIN); + g_assert_cmpint(sc_error_code(err), ==, SC_API_MISUSE); + g_assert_cmpstr(sc_error_msg(err), ==, "section name cannot be empty"); + g_assert_null(value); + sc_error_free(err); + + /* Malformed section */ + value = NULL; + char malformed[] = "[section\n"; + stream = fmemopen(malformed, sizeof malformed - 1, "r"); + g_assert_nonnull(stream); + rc = sc_infofile_get_ini_section_key(stream, "section", "key", &value, &err); + g_assert_cmpint(rc, ==, -1); + g_assert_nonnull(err); + g_assert_cmpstr(sc_error_domain(err), ==, SC_LIBSNAP_DOMAIN); + g_assert_cmpint(sc_error_code(err), ==, 0); + g_assert_cmpstr(sc_error_msg(err), ==, "line 1 is not a valid ini section"); + g_assert_null(value); + sc_error_free(err); + fclose(stream); +} + +static void test_infofile_only_comments(void) { + int rc; + sc_error *err; + + char text[] = + "# this is a section comment\n" + "# this is another comment\n"; + + char *value = NULL; + + FILE *stream = fmemopen(text, sizeof text - 1, "r"); + g_assert_nonnull(stream); + value = NULL; + rewind(stream); + rc = sc_infofile_get_ini_section_key(stream, "section1", "key", &value, &err); + g_assert_cmpint(rc, ==, 0); + g_assert_null(err); + g_assert_null(value); + fclose(stream); + + /* try again with the other API */ + stream = fmemopen(text, sizeof text - 1, "r"); + g_assert_nonnull(stream); + value = NULL; + rewind(stream); + rc = sc_infofile_get_key(stream, "key", &value, &err); + g_assert_cmpint(rc, ==, 0); + g_assert_null(err); + g_assert_null(value); + fclose(stream); +} + +static void __attribute__((constructor)) init(void) { + g_test_add_func("/infofile/get_key", test_infofile_get_key); + g_test_add_func("/infofile/get_ini_key", test_infofile_get_ini_key); + g_test_add_func("/infofile/only_comments", test_infofile_only_comments); +} diff --git a/cmd/libsnap-confine-private/infofile.c b/cmd/libsnap-confine-private/infofile.c new file mode 100644 index 00000000..7d762199 --- /dev/null +++ b/cmd/libsnap-confine-private/infofile.c @@ -0,0 +1,149 @@ +/* + * Copyright (C) 2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "infofile.h" + +#include +#include +#include +#include + +#include "../libsnap-confine-private/cleanup-funcs.h" +#include "../libsnap-confine-private/error.h" +#include "../libsnap-confine-private/string-utils.h" +#include "../libsnap-confine-private/utils.h" + +int sc_infofile_get_key(FILE *stream, const char *key, char **value, sc_error **err_out) { + return sc_infofile_get_ini_section_key(stream, NULL, key, value, err_out); +} + +int sc_infofile_get_ini_section_key(FILE *stream, const char *section, const char *key, char **value, + sc_error **err_out) { + sc_error *err = NULL; + size_t line_size = 0; + char *line_buf SC_CLEANUP(sc_cleanup_string) = NULL; + + if (stream == NULL) { + err = sc_error_init_api_misuse("stream cannot be NULL"); + goto out; + } + if (key == NULL) { + err = sc_error_init_api_misuse("key cannot be NULL"); + goto out; + } + if (value == NULL) { + err = sc_error_init_api_misuse("value cannot be NULL"); + goto out; + } + if (section != NULL && strlen(section) == 0) { + err = sc_error_init_api_misuse("section name cannot be empty"); + goto out; + } + + /* Store NULL in case we don't find the key. + * This makes the value always well-defined. */ + *value = NULL; + + bool section_matched = false; + + /* This loop advances through subsequent lines. */ + for (int lineno = 1;; ++lineno) { + errno = 0; + ssize_t nread = getline(&line_buf, &line_size, stream); + if (nread < 0 && errno != 0) { + err = sc_error_init_from_errno(errno, "cannot read beyond line %d", lineno); + goto out; + } + if (nread <= 0) { + break; /* There is nothing more to read. */ + } + /* NOTE: beyond this line the buffer is never empty (ie, nread > 0). */ + + /* Guard against malformed input that may contain NUL bytes that + * would confuse the code below. */ + if (memchr(line_buf, '\0', nread) != NULL) { + err = sc_error_init_simple("line %d contains NUL byte", lineno); + goto out; + } + /* Guard against non-strictly formatted input that doesn't contain + * trailing newline. */ + if (line_buf[nread - 1] != '\n') { + err = sc_error_init(SC_LIBSNAP_DOMAIN, 0, "line %d does not end with a newline", lineno); + goto out; + } + /* Replace the trailing newline character with the NUL byte. */ + line_buf[nread - 1] = '\0'; + + if (line_buf[0] == '#') { + /* A comment, advance to next line. */ + continue; + } + + /* Handle ini sections (if requested via non-null section name) */ + if (line_buf[0] == '[') { + if (section == NULL) { + err = sc_error_init_simple("line %d contains unexpected section", lineno); + goto out; + } + section_matched = false; + char *start_section_name = line_buf + 1; + // skip the leading [ and trailing \0 + char *end_section_name = memchr(start_section_name, ']', nread - 2); + if (end_section_name == NULL) { + err = sc_error_init_simple("line %d is not a valid ini section", lineno); + goto out; + } + /* Replace closing ']' with string terminator byte */ + *end_section_name = '\0'; + if (sc_streq(start_section_name, section)) { + section_matched = true; + } + /* Advance to next line */ + continue; + } + + /* Skip this line until we are in a matching section */ + if (section != NULL && !section_matched) { + continue; + } + + /* Guard against malformed input that does not contain '=' byte */ + char *eq_ptr = memchr(line_buf, '=', nread); + if (eq_ptr == NULL) { + err = sc_error_init_simple("line %d is not a key=value assignment", lineno); + goto out; + } + /* Guard against malformed input with empty key. */ + if (eq_ptr == line_buf) { + err = sc_error_init_simple("line %d contains empty key", lineno); + goto out; + } + /* Replace the first '=' with string terminator byte. */ + *eq_ptr = '\0'; + + /* If the key matches the one we are looking for, store it and stop scanning. */ + const char *scanned_key = line_buf; + const char *scanned_value = eq_ptr + 1; + if (sc_streq(scanned_key, key)) { + *value = sc_strdup(scanned_value); + break; + } + } + +out: + return sc_error_forward(err_out, err); +} diff --git a/cmd/libsnap-confine-private/infofile.h b/cmd/libsnap-confine-private/infofile.h new file mode 100644 index 00000000..629776c9 --- /dev/null +++ b/cmd/libsnap-confine-private/infofile.h @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#ifndef SNAP_CONFINE_INFOFILE_H +#define SNAP_CONFINE_INFOFILE_H + +#include + +#include "../libsnap-confine-private/error.h" + +/** + * sc_infofile_get_key extracts a single value of a key=value pair from a given + * stream. + * + * On success the return value is zero and err_out, if not NULL, value is + * dereferenced and set to NULL. On failure the return value is -1 is and + * detailed error information is stored by dereferencing err_out. If an error + * occurs and err_out is NULL then the program dies, printing the error message. + **/ +int sc_infofile_get_key(FILE *stream, const char *key, char **value, sc_error **err_out); + +/** + * sc_infofile_get_ini_section_key extracts a single value of a key=value pair + * from a given ini section of the stream. + * + * On success the return value is zero and err_out, if not NULL, value is + * dereferenced and set to NULL. On failure the return value is -1 is and + * detailed error information is stored by dereferencing err_out. If an error + * occurs and err_out is NULL then the program dies, printing the error message. + **/ +int sc_infofile_get_ini_section_key(FILE *stream, const char *section, const char *key, char **value, + sc_error **err_out); + +#endif diff --git a/cmd/libsnap-confine-private/locking-test.c b/cmd/libsnap-confine-private/locking-test.c new file mode 100644 index 00000000..72378a43 --- /dev/null +++ b/cmd/libsnap-confine-private/locking-test.c @@ -0,0 +1,164 @@ +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "locking.h" +#include "locking.c" + +#include "../libsnap-confine-private/cleanup-funcs.h" +#include "../libsnap-confine-private/test-utils.h" + +#include + +#include +#include + +// Set alternate locking directory +static void sc_set_lock_dir(const char *dir) +{ + sc_lock_dir = dir; +} + +// A variant of unsetenv that is compatible with GDestroyNotify +static void my_unsetenv(const char *k) +{ + unsetenv(k); +} + +// Use temporary directory for locking. +// +// The directory is automatically reset to the real value at the end of the +// test. +static const char *sc_test_use_fake_lock_dir(void) +{ + char *lock_dir = NULL; + if (g_test_subprocess()) { + // Check if the environment variable is set. If so then someone is already + // managing the temporary directory and we should not create a new one. + lock_dir = getenv("SNAP_CONFINE_LOCK_DIR"); + g_assert_nonnull(lock_dir); + } else { + lock_dir = g_dir_make_tmp(NULL, NULL); + g_assert_nonnull(lock_dir); + g_test_queue_free(lock_dir); + g_assert_cmpint(setenv("SNAP_CONFINE_LOCK_DIR", lock_dir, 0), + ==, 0); + g_test_queue_destroy((GDestroyNotify) my_unsetenv, + "SNAP_CONFINE_LOCK_DIR"); + g_test_queue_destroy((GDestroyNotify) rm_rf_tmp, lock_dir); + } + g_test_queue_destroy((GDestroyNotify) sc_set_lock_dir, SC_LOCK_DIR); + sc_set_lock_dir(lock_dir); + return lock_dir; +} + +// Check that locking a namespace actually flock's the mutex with LOCK_EX +static void test_sc_lock_unlock(void) +{ + if (geteuid() != 0) { + g_test_skip("this test only runs as root"); + return; + } + + const char *lock_dir = sc_test_use_fake_lock_dir(); + int fd = sc_lock_generic("foo", 123); + // Construct the name of the lock file + char *lock_file SC_CLEANUP(sc_cleanup_string) = NULL; + lock_file = g_strdup_printf("%s/foo.123.lock", lock_dir); + // Open the lock file again to obtain a separate file descriptor. + // According to flock(2) locks are associated with an open file table entry + // so this descriptor will be separate and can compete for the same lock. + int lock_fd SC_CLEANUP(sc_cleanup_close) = -1; + lock_fd = open(lock_file, O_RDWR | O_CLOEXEC | O_NOFOLLOW); + g_assert_cmpint(lock_fd, !=, -1); + // The non-blocking lock operation should fail with EWOULDBLOCK as the lock + // file is locked by sc_nlock_ns_mutex() already. + int err = flock(lock_fd, LOCK_EX | LOCK_NB); + int saved_errno = errno; + g_assert_cmpint(err, ==, -1); + g_assert_cmpint(saved_errno, ==, EWOULDBLOCK); + // Unlock the lock. + sc_unlock(fd); + // Re-attempt the locking operation. This time it should succeed. + err = flock(lock_fd, LOCK_EX | LOCK_NB); + g_assert_cmpint(err, ==, 0); +} + +// Check that holding a lock is properly detected. +static void test_sc_verify_snap_lock__locked(void) +{ + if (geteuid() != 0) { + g_test_skip("this test only runs as root"); + return; + } + + (void)sc_test_use_fake_lock_dir(); + int fd = sc_lock_snap("foo"); + sc_verify_snap_lock("foo"); + sc_unlock(fd); +} + +// Check that holding a lock is properly detected. +static void test_sc_verify_snap_lock__unlocked(void) +{ + if (geteuid() != 0) { + g_test_skip("this test only runs as root"); + return; + } + + (void)sc_test_use_fake_lock_dir(); + if (g_test_subprocess()) { + sc_verify_snap_lock("foo"); + return; + } + g_test_trap_subprocess(NULL, 0, 0); + g_test_trap_assert_failed(); + g_test_trap_assert_stderr + ("unexpectedly managed to acquire exclusive lock over snap foo\n"); +} + +static void test_sc_enable_sanity_timeout(void) +{ + if (geteuid() != 0) { + g_test_skip("this test only runs as root"); + return; + } + + if (g_test_subprocess()) { + sc_enable_sanity_timeout(); + debug("waiting..."); + usleep(7 * G_USEC_PER_SEC); + debug("woke up"); + sc_disable_sanity_timeout(); + return; + } + g_test_trap_subprocess(NULL, 1 * G_USEC_PER_SEC, + G_TEST_SUBPROCESS_INHERIT_STDERR); + g_test_trap_assert_failed(); + g_test_trap_assert_stderr + ("sanity timeout expired: Interrupted system call\n"); +} + +static void __attribute__((constructor)) init(void) +{ + g_test_add_func("/locking/sc_lock_unlock", test_sc_lock_unlock); + g_test_add_func("/locking/sc_enable_sanity_timeout", + test_sc_enable_sanity_timeout); + g_test_add_func("/locking/sc_verify_snap_lock__locked", + test_sc_verify_snap_lock__locked); + g_test_add_func("/locking/sc_verify_snap_lock__unlocked", + test_sc_verify_snap_lock__unlocked); +} diff --git a/cmd/libsnap-confine-private/locking.c b/cmd/libsnap-confine-private/locking.c new file mode 100644 index 00000000..6ab36067 --- /dev/null +++ b/cmd/libsnap-confine-private/locking.c @@ -0,0 +1,208 @@ +/* + * Copyright (C) 2017-2018 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#ifdef HAVE_CONFIG_H +#include "config.h" +#endif + +#include "locking.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../libsnap-confine-private/cleanup-funcs.h" +#include "../libsnap-confine-private/string-utils.h" +#include "../libsnap-confine-private/utils.h" + +// SANITY_TIMEOUT is the timeout in seconds that is used when +// "sc_enable_sanity_timeout()" is called +static const int SANITY_TIMEOUT = 30; + +/** + * Flag indicating that a sanity timeout has expired. + **/ +static volatile sig_atomic_t sanity_timeout_expired = 0; + +/** + * Signal handler for SIGALRM that sets sanity_timeout_expired flag to 1. + **/ +static void sc_SIGALRM_handler(int signum) +{ + sanity_timeout_expired = 1; +} + +void sc_enable_sanity_timeout(void) +{ + sanity_timeout_expired = 0; + struct sigaction act = {.sa_handler = sc_SIGALRM_handler }; + if (sigemptyset(&act.sa_mask) < 0) { + die("cannot initialize POSIX signal set"); + } + // NOTE: we are using sigaction so that we can explicitly control signal + // flags and *not* pass the SA_RESTART flag. The intent is so that any + // system call we may be sleeping on to gets interrupted. + act.sa_flags = 0; + if (sigaction(SIGALRM, &act, NULL) < 0) { + die("cannot install signal handler for SIGALRM"); + } + alarm(SANITY_TIMEOUT); + debug("sanity timeout initialized and set for %i seconds", + SANITY_TIMEOUT); +} + +void sc_disable_sanity_timeout(void) +{ + if (sanity_timeout_expired) { + die("sanity timeout expired"); + } + alarm(0); + struct sigaction act = {.sa_handler = SIG_DFL }; + if (sigemptyset(&act.sa_mask) < 0) { + die("cannot initialize POSIX signal set"); + } + if (sigaction(SIGALRM, &act, NULL) < 0) { + die("cannot uninstall signal handler for SIGALRM"); + } + debug("sanity timeout reset and disabled"); +} + +#define SC_LOCK_DIR "/run/snapd/lock" + +static const char *sc_lock_dir = SC_LOCK_DIR; + +static int get_lock_directory(void) +{ + // Create (if required) and open the lock directory. + debug("creating lock directory %s (if missing)", sc_lock_dir); + sc_identity old = sc_set_effective_identity(sc_root_group_identity()); + if (sc_nonfatal_mkpath(sc_lock_dir, 0755) < 0) { + die("cannot create lock directory %s", sc_lock_dir); + } + debug("opening lock directory %s", sc_lock_dir); + int dir_fd = + open(sc_lock_dir, O_DIRECTORY | O_PATH | O_CLOEXEC | O_NOFOLLOW); + (void)sc_set_effective_identity(old); + if (dir_fd < 0) { + die("cannot open lock directory"); + } + return dir_fd; +} + +static void get_lock_name(char *lock_fname, size_t size, const char *scope, + uid_t uid) +{ + if (uid == 0) { + // The root user doesn't have a per-user mount namespace. + // Doing so would be confusing for services which use $SNAP_DATA + // as home, and not in $SNAP_USER_DATA. + sc_must_snprintf(lock_fname, size, "%s.lock", scope ? : ""); + } else { + sc_must_snprintf(lock_fname, size, "%s.%d.lock", + scope ? : "", uid); + } +} + +static int open_lock(const char *scope, uid_t uid) +{ + int dir_fd SC_CLEANUP(sc_cleanup_close) = -1; + char lock_fname[PATH_MAX] = { 0 }; + int lock_fd; + + dir_fd = get_lock_directory(); + get_lock_name(lock_fname, sizeof lock_fname, scope, uid); + + // Open the lock file and acquire an exclusive lock. + debug("opening lock file: %s/%s", sc_lock_dir, lock_fname); + sc_identity old = sc_set_effective_identity(sc_root_group_identity()); + lock_fd = openat(dir_fd, lock_fname, + O_CREAT | O_RDWR | O_CLOEXEC | O_NOFOLLOW, 0600); + (void)sc_set_effective_identity(old); + if (lock_fd < 0) { + die("cannot open lock file: %s/%s", sc_lock_dir, lock_fname); + } + return lock_fd; +} + +static int sc_lock_generic(const char *scope, uid_t uid) +{ + int lock_fd = open_lock(scope, uid); + sc_enable_sanity_timeout(); + debug("acquiring exclusive lock (scope %s, uid %d)", + scope ? : "(global)", uid); + if (flock(lock_fd, LOCK_EX) < 0) { + sc_disable_sanity_timeout(); + close(lock_fd); + die("cannot acquire exclusive lock (scope %s, uid %d)", + scope ? : "(global)", uid); + } else { + sc_disable_sanity_timeout(); + } + return lock_fd; +} + +int sc_lock_global(void) +{ + return sc_lock_generic(NULL, 0); +} + +int sc_lock_snap(const char *snap_name) +{ + return sc_lock_generic(snap_name, 0); +} + +void sc_verify_snap_lock(const char *snap_name) +{ + int lock_fd, retval; + + lock_fd = open_lock(snap_name, 0); + debug("trying to verify whether exclusive lock over snap %s is held", + snap_name); + retval = flock(lock_fd, LOCK_EX | LOCK_NB); + if (retval == 0) { + /* We managed to grab the lock, the lock was not held! */ + flock(lock_fd, LOCK_UN); + close(lock_fd); + errno = 0; + die("unexpectedly managed to acquire exclusive lock over snap %s", snap_name); + } + if (retval < 0 && errno != EWOULDBLOCK) { + die("cannot verify exclusive lock over snap %s", snap_name); + } + /* We tried but failed to grab the lock because the file is already locked. + * Good, this is what we expected. */ +} + +int sc_lock_snap_user(const char *snap_name, uid_t uid) +{ + return sc_lock_generic(snap_name, uid); +} + +void sc_unlock(int lock_fd) +{ + // Release the lock and finish. + debug("releasing lock %d", lock_fd); + if (flock(lock_fd, LOCK_UN) < 0) { + die("cannot release lock %d", lock_fd); + } + close(lock_fd); +} diff --git a/cmd/libsnap-confine-private/locking.h b/cmd/libsnap-confine-private/locking.h new file mode 100644 index 00000000..b900528a --- /dev/null +++ b/cmd/libsnap-confine-private/locking.h @@ -0,0 +1,107 @@ +/* + * Copyright (C) 2017-2018 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +#ifndef SNAP_CONFINE_LOCKING_H +#define SNAP_CONFINE_LOCKING_H + +// Include config.h which pulls in _GNU_SOURCE which in turn allows sys/types.h +// to define O_PATH. Since locking.h is included from locking.c this is +// required to see O_PATH there. +#ifdef HAVE_CONFIG_H +#include "config.h" +#endif + +#include + +/** + * Obtain a flock-based, exclusive, globally scoped, lock. + * + * The actual lock is placed in "/run/snap/ns/.lock" + * + * If the lock cannot be acquired for three seconds (via + * sc_enable_sanity_timeout) then the function fails and the process dies. + * + * The return value needs to be passed to sc_unlock(), there is no need to + * check for errors as the function will die() on any problem. + **/ +int sc_lock_global(void); + +/** + * Obtain a flock-based, exclusive, snap-scoped, lock. + * + * The actual lock is placed in "/run/snapd/ns/$SNAP_NAME.lock" + * It should be acquired only when holding the global lock. + * + * If the lock cannot be acquired for three seconds (via + * sc_enable_sanity_timeout) then the function fails and the process dies. + * + * The return value needs to be passed to sc_unlock(), there is no need to + * check for errors as the function will die() on any problem. + **/ +int sc_lock_snap(const char *snap_name); + +/** + * Verify that a flock-based, exclusive, snap-scoped, lock is held. + * + * If the lock is not held the process dies. The details about the lock + * are exactly the same as for sc_lock_snap(). + **/ +void sc_verify_snap_lock(const char *snap_name); + +/** + * Obtain a flock-based, exclusive, snap-scoped, lock. + * + * The actual lock is placed in "/run/snapd/ns/$SNAP_NAME.$UID.lock" + * It should be acquired only when holding the snap-specific lock. + * + * If the lock cannot be acquired for three seconds (via + * sc_enable_sanity_timeout) then the function fails and the process dies. + * The return value needs to be passed to sc_unlock(), there is no need to + * check for errors as the function will die() on any problem. + **/ +int sc_lock_snap_user(const char *snap_name, uid_t uid); + +/** + * Release a flock-based lock. + * + * All kinds of locks can be unlocked the same way. This function simply + * unlocks the lock and closes the file descriptor. + **/ +void sc_unlock(int lock_fd); + +/** + * Enable a sanity-check timeout. + * + * The timeout is based on good-old alarm(2) and is intended to break a + * suspended system call, such as flock, after a few seconds. The built-in + * timeout is primed for three seconds. After that any sleeping system calls + * are interrupted and a flag is set. + * + * The call should be paired with sc_disable_sanity_check_timeout() that + * disables the alarm and acts on the flag, aborting the process if the timeout + * gets exceeded. + **/ +void sc_enable_sanity_timeout(void); + +/** + * Disable sanity-check timeout and abort the process if it expired. + * + * This call has to be paired with sc_enable_sanity_timeout(), see the function + * description for more details. + **/ +void sc_disable_sanity_timeout(void); + +#endif // SNAP_CONFINE_LOCKING_H diff --git a/cmd/libsnap-confine-private/mount-opt-test.c b/cmd/libsnap-confine-private/mount-opt-test.c new file mode 100644 index 00000000..befa770e --- /dev/null +++ b/cmd/libsnap-confine-private/mount-opt-test.c @@ -0,0 +1,346 @@ +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "mount-opt.h" +#include "mount-opt.c" + +#include +#include + +#include + +static void test_sc_mount_opt2str(void) +{ + char buf[1000] = { 0 }; + g_assert_cmpstr(sc_mount_opt2str(buf, sizeof buf, 0), ==, ""); + g_assert_cmpstr(sc_mount_opt2str(buf, sizeof buf, MS_RDONLY), ==, "ro"); + g_assert_cmpstr(sc_mount_opt2str(buf, sizeof buf, MS_NOSUID), ==, + "nosuid"); + g_assert_cmpstr(sc_mount_opt2str(buf, sizeof buf, MS_NODEV), ==, + "nodev"); + g_assert_cmpstr(sc_mount_opt2str(buf, sizeof buf, MS_NOEXEC), ==, + "noexec"); + g_assert_cmpstr(sc_mount_opt2str(buf, sizeof buf, MS_SYNCHRONOUS), ==, + "sync"); + g_assert_cmpstr(sc_mount_opt2str(buf, sizeof buf, MS_REMOUNT), ==, + "remount"); + g_assert_cmpstr(sc_mount_opt2str(buf, sizeof buf, MS_MANDLOCK), ==, + "mand"); + g_assert_cmpstr(sc_mount_opt2str(buf, sizeof buf, MS_DIRSYNC), ==, + "dirsync"); + g_assert_cmpstr(sc_mount_opt2str(buf, sizeof buf, MS_NOATIME), ==, + "noatime"); + g_assert_cmpstr(sc_mount_opt2str(buf, sizeof buf, MS_NODIRATIME), ==, + "nodiratime"); + g_assert_cmpstr(sc_mount_opt2str(buf, sizeof buf, MS_BIND), ==, "bind"); + g_assert_cmpstr(sc_mount_opt2str(buf, sizeof buf, MS_REC | MS_BIND), ==, + "rbind"); + g_assert_cmpstr(sc_mount_opt2str(buf, sizeof buf, MS_MOVE), ==, "move"); + g_assert_cmpstr(sc_mount_opt2str(buf, sizeof buf, MS_SILENT), ==, + "silent"); + g_assert_cmpstr(sc_mount_opt2str(buf, sizeof buf, MS_POSIXACL), ==, + "acl"); + g_assert_cmpstr(sc_mount_opt2str(buf, sizeof buf, MS_UNBINDABLE), ==, + "unbindable"); + g_assert_cmpstr(sc_mount_opt2str(buf, sizeof buf, MS_PRIVATE), ==, + "private"); + g_assert_cmpstr(sc_mount_opt2str(buf, sizeof buf, MS_REC | MS_PRIVATE), + ==, "rprivate"); + g_assert_cmpstr(sc_mount_opt2str(buf, sizeof buf, MS_SLAVE), ==, + "slave"); + g_assert_cmpstr(sc_mount_opt2str(buf, sizeof buf, MS_REC | MS_SLAVE), + ==, "rslave"); + g_assert_cmpstr(sc_mount_opt2str(buf, sizeof buf, MS_SHARED), ==, + "shared"); + g_assert_cmpstr(sc_mount_opt2str(buf, sizeof buf, MS_REC | MS_SHARED), + ==, "rshared"); + g_assert_cmpstr(sc_mount_opt2str(buf, sizeof buf, MS_RELATIME), ==, + "relatime"); + g_assert_cmpstr(sc_mount_opt2str(buf, sizeof buf, MS_KERNMOUNT), ==, + "kernmount"); + g_assert_cmpstr(sc_mount_opt2str(buf, sizeof buf, MS_I_VERSION), ==, + "iversion"); + g_assert_cmpstr(sc_mount_opt2str(buf, sizeof buf, MS_STRICTATIME), ==, + "strictatime"); + g_assert_cmpstr(sc_mount_opt2str(buf, sizeof buf, MS_LAZYTIME), ==, + "lazytime"); + // MS_NOSEC is not defined in userspace + // MS_BORN is not defined in userspace + g_assert_cmpstr(sc_mount_opt2str(buf, sizeof buf, MS_ACTIVE), ==, + "active"); + g_assert_cmpstr(sc_mount_opt2str(buf, sizeof buf, MS_NOUSER), ==, + "nouser"); + g_assert_cmpstr(sc_mount_opt2str(buf, sizeof buf, 0x300), ==, "0x300"); + // random compositions do work + g_assert_cmpstr(sc_mount_opt2str + (buf, sizeof buf, MS_RDONLY | MS_NOEXEC | MS_BIND), ==, + "ro,noexec,bind"); +} + +static void test_sc_mount_cmd(void) +{ + char cmd[10000] = { 0 }; + + // Typical mount + sc_mount_cmd(cmd, sizeof cmd, "/dev/sda3", "/mnt", "ext4", MS_RDONLY, + NULL); + g_assert_cmpstr(cmd, ==, "mount -t ext4 -o ro /dev/sda3 /mnt"); + + // Bind mount + sc_mount_cmd(cmd, sizeof cmd, "/source", "/target", NULL, MS_BIND, + NULL); + g_assert_cmpstr(cmd, ==, "mount --bind /source /target"); + + // + recursive + sc_mount_cmd(cmd, sizeof cmd, "/source", "/target", NULL, + MS_BIND | MS_REC, NULL); + g_assert_cmpstr(cmd, ==, "mount --rbind /source /target"); + + // Shared subtree mount + sc_mount_cmd(cmd, sizeof cmd, "/place", "none", NULL, MS_SHARED, NULL); + g_assert_cmpstr(cmd, ==, "mount --make-shared /place"); + + sc_mount_cmd(cmd, sizeof cmd, "/place", "none", NULL, MS_SLAVE, NULL); + g_assert_cmpstr(cmd, ==, "mount --make-slave /place"); + + sc_mount_cmd(cmd, sizeof cmd, "/place", "none", NULL, MS_PRIVATE, NULL); + g_assert_cmpstr(cmd, ==, "mount --make-private /place"); + + sc_mount_cmd(cmd, sizeof cmd, "/place", "none", NULL, MS_UNBINDABLE, + NULL); + g_assert_cmpstr(cmd, ==, "mount --make-unbindable /place"); + + // + recursive + sc_mount_cmd(cmd, sizeof cmd, "/place", "none", NULL, + MS_SHARED | MS_REC, NULL); + g_assert_cmpstr(cmd, ==, "mount --make-rshared /place"); + + sc_mount_cmd(cmd, sizeof cmd, "/place", "none", NULL, MS_SLAVE | MS_REC, + NULL); + g_assert_cmpstr(cmd, ==, "mount --make-rslave /place"); + + sc_mount_cmd(cmd, sizeof cmd, "/place", "none", NULL, + MS_PRIVATE | MS_REC, NULL); + g_assert_cmpstr(cmd, ==, "mount --make-rprivate /place"); + + sc_mount_cmd(cmd, sizeof cmd, "/place", "none", NULL, + MS_UNBINDABLE | MS_REC, NULL); + g_assert_cmpstr(cmd, ==, "mount --make-runbindable /place"); + + // Move + sc_mount_cmd(cmd, sizeof cmd, "/from", "/to", NULL, MS_MOVE, NULL); + g_assert_cmpstr(cmd, ==, "mount --move /from /to"); + + // Monster (invalid but let's format it) + char from[PATH_MAX] = { 0 }; + char to[PATH_MAX] = { 0 }; + for (int i = 1; i < PATH_MAX - 1; ++i) { + from[i] = 'a'; + to[i] = 'b'; + } + from[0] = '/'; + to[0] = '/'; + from[PATH_MAX - 1] = 0; + to[PATH_MAX - 1] = 0; + int opts = MS_BIND | MS_MOVE | MS_SHARED | MS_SLAVE | MS_PRIVATE | + MS_UNBINDABLE | MS_REC | MS_RDONLY | MS_NOSUID | MS_NODEV | + MS_NOEXEC | MS_SYNCHRONOUS | MS_REMOUNT | MS_MANDLOCK | MS_DIRSYNC | + MS_NOATIME | MS_NODIRATIME | MS_BIND | MS_SILENT | MS_POSIXACL | + MS_RELATIME | MS_KERNMOUNT | MS_I_VERSION | MS_STRICTATIME | + MS_LAZYTIME; + const char *fstype = "fstype"; + sc_mount_cmd(cmd, sizeof cmd, from, to, fstype, opts, NULL); + const char *expected = + "mount -t fstype " + "--rbind --move --make-rshared --make-rslave --make-rprivate --make-runbindable " + "-o ro,nosuid,nodev,noexec,sync,remount,mand,dirsync,noatime,nodiratime,silent," + "acl,relatime,kernmount,iversion,strictatime,lazytime " + "/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa " + "/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + g_assert_cmpstr(cmd, ==, expected); +} + +static void test_sc_umount_cmd(void) +{ + char cmd[1000] = { 0 }; + + // Typical umount + sc_umount_cmd(cmd, sizeof cmd, "/mnt/foo", 0); + g_assert_cmpstr(cmd, ==, "umount /mnt/foo"); + + // Force + sc_umount_cmd(cmd, sizeof cmd, "/mnt/foo", MNT_FORCE); + g_assert_cmpstr(cmd, ==, "umount --force /mnt/foo"); + + // Detach + sc_umount_cmd(cmd, sizeof cmd, "/mnt/foo", MNT_DETACH); + g_assert_cmpstr(cmd, ==, "umount --lazy /mnt/foo"); + + // Expire + sc_umount_cmd(cmd, sizeof cmd, "/mnt/foo", MNT_EXPIRE); + g_assert_cmpstr(cmd, ==, "umount --expire /mnt/foo"); + + // O_NOFOLLOW variant for umount + sc_umount_cmd(cmd, sizeof cmd, "/mnt/foo", UMOUNT_NOFOLLOW); + g_assert_cmpstr(cmd, ==, "umount --no-follow /mnt/foo"); + + // Everything at once + sc_umount_cmd(cmd, sizeof cmd, "/mnt/foo", + MNT_FORCE | MNT_DETACH | MNT_EXPIRE | UMOUNT_NOFOLLOW); + g_assert_cmpstr(cmd, ==, + "umount --force --lazy --expire --no-follow /mnt/foo"); +} + +static bool broken_mount(struct sc_fault_state *state, void *ptr) +{ + errno = EACCES; + return true; +} + +static void test_sc_do_mount(gconstpointer snap_debug) +{ + if (g_test_subprocess()) { + sc_break("mount", broken_mount); + if (GPOINTER_TO_INT(snap_debug) == 1) { + g_assert_true(g_setenv + ("SNAP_CONFINE_DEBUG", "1", true)); + } + sc_do_mount("/foo", "/bar", "ext4", MS_RDONLY, NULL); + + g_test_message("expected sc_do_mount not to return"); + sc_reset_faults(); + g_test_fail(); + return; + } + g_test_trap_subprocess(NULL, 0, 0); + g_test_trap_assert_failed(); + if (GPOINTER_TO_INT(snap_debug) == 0) { + g_test_trap_assert_stderr + ("cannot perform operation: mount -t ext4 -o ro /foo /bar: Permission denied\n"); + } else { + /* with snap_debug the debug output hides the actual mount commands *but* + * they are still shown if there was an error + */ + g_test_trap_assert_stderr + ("DEBUG: performing operation: (disabled) use debug build to see details\n" + "cannot perform operation: mount -t ext4 -o ro /foo /bar: Permission denied\n"); + } +} + +static bool broken_umount(struct sc_fault_state *state, void *ptr) +{ + errno = EACCES; + return true; +} + +static void test_sc_do_umount(gconstpointer snap_debug) +{ + if (g_test_subprocess()) { + sc_break("umount", broken_umount); + if (GPOINTER_TO_INT(snap_debug) == 1) { + g_assert_true(g_setenv + ("SNAP_CONFINE_DEBUG", "1", true)); + } + sc_do_umount("/foo", MNT_DETACH); + + g_test_message("expected sc_do_umount not to return"); + sc_reset_faults(); + g_test_fail(); + return; + } + g_test_trap_subprocess(NULL, 0, 0); + g_test_trap_assert_failed(); + if (GPOINTER_TO_INT(snap_debug) == 0) { + g_test_trap_assert_stderr + ("cannot perform operation: umount --lazy /foo: Permission denied\n"); + } else { + /* with snap_debug the debug output hides the actual mount commands *but* + * they are still shown if there was an error + */ + g_test_trap_assert_stderr + ("DEBUG: performing operation: (disabled) use debug build to see details\n" + "cannot perform operation: umount --lazy /foo: Permission denied\n"); + } +} + +static bool missing_mount(struct sc_fault_state *state, void *ptr) +{ + errno = ENOENT; + return true; +} + +static void test_sc_do_optional_mount_missing(void) +{ + sc_break("mount", missing_mount); + bool ok = sc_do_optional_mount("/foo", "/bar", "ext4", MS_RDONLY, NULL); + g_assert_false(ok); + sc_reset_faults(); +} + +static void test_sc_do_optional_mount_failure(gconstpointer snap_debug) +{ + if (g_test_subprocess()) { + sc_break("mount", broken_mount); + if (GPOINTER_TO_INT(snap_debug) == 1) { + g_assert_true(g_setenv + ("SNAP_CONFINE_DEBUG", "1", true)); + } + (void)sc_do_optional_mount("/foo", "/bar", "ext4", MS_RDONLY, + NULL); + + g_test_message("expected sc_do_mount not to return"); + sc_reset_faults(); + g_test_fail(); + return; + } + g_test_trap_subprocess(NULL, 0, 0); + g_test_trap_assert_failed(); + if (GPOINTER_TO_INT(snap_debug) == 0) { + g_test_trap_assert_stderr + ("cannot perform operation: mount -t ext4 -o ro /foo /bar: Permission denied\n"); + } else { + /* with snap_debug the debug output hides the actual mount commands *but* + * they are still shown if there was an error + */ + g_test_trap_assert_stderr + ("DEBUG: performing operation: (disabled) use debug build to see details\n" + "cannot perform operation: mount -t ext4 -o ro /foo /bar: Permission denied\n"); + } +} + +static void __attribute__((constructor)) init(void) +{ + g_test_add_func("/mount/sc_mount_opt2str", test_sc_mount_opt2str); + g_test_add_func("/mount/sc_mount_cmd", test_sc_mount_cmd); + g_test_add_func("/mount/sc_umount_cmd", test_sc_umount_cmd); + g_test_add_data_func("/mount/sc_do_mount", GINT_TO_POINTER(0), + test_sc_do_mount); + g_test_add_data_func("/mount/sc_do_umount", GINT_TO_POINTER(0), + test_sc_do_umount); + g_test_add_data_func("/mount/sc_do_mount_with_debug", + GINT_TO_POINTER(1), test_sc_do_mount); + g_test_add_data_func("/mount/sc_do_umount_with_debug", + GINT_TO_POINTER(1), test_sc_do_umount); + g_test_add_func("/mount/sc_do_optional_mount_missing", + test_sc_do_optional_mount_missing); + g_test_add_data_func("/mount/sc_do_optional_mount_failure", + GINT_TO_POINTER(0), + test_sc_do_optional_mount_failure); + g_test_add_data_func("/mount/sc_do_optional_mount_failure_with_debug", + GINT_TO_POINTER(1), + test_sc_do_optional_mount_failure); +} diff --git a/cmd/libsnap-confine-private/mount-opt.c b/cmd/libsnap-confine-private/mount-opt.c new file mode 100644 index 00000000..2b2c7a1a --- /dev/null +++ b/cmd/libsnap-confine-private/mount-opt.c @@ -0,0 +1,338 @@ +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "mount-opt.h" + +#include +#include +#include +#include +#include +#include + +#include "fault-injection.h" +#include "privs.h" +#include "string-utils.h" +#include "utils.h" + +const char *sc_mount_opt2str(char *buf, size_t buf_size, unsigned long flags) +{ + unsigned long used = 0; + sc_string_init(buf, buf_size); + +#define F(FLAG, TEXT) do { \ + if (flags & (FLAG)) { \ + sc_string_append(buf, buf_size, #TEXT ","); flags ^= (FLAG); \ + } \ + } while (0) + + F(MS_RDONLY, ro); + F(MS_NOSUID, nosuid); + F(MS_NODEV, nodev); + F(MS_NOEXEC, noexec); + F(MS_SYNCHRONOUS, sync); + F(MS_REMOUNT, remount); + F(MS_MANDLOCK, mand); + F(MS_DIRSYNC, dirsync); + F(MS_NOATIME, noatime); + F(MS_NODIRATIME, nodiratime); + if (flags & MS_BIND) { + if (flags & MS_REC) { + sc_string_append(buf, buf_size, "rbind,"); + used |= MS_REC; + } else { + sc_string_append(buf, buf_size, "bind,"); + } + flags ^= MS_BIND; + } + F(MS_MOVE, move); + // The MS_REC flag handled separately by affected flags (MS_BIND, + // MS_PRIVATE, MS_SLAVE, MS_SHARED) + // XXX: kernel has MS_VERBOSE, glibc has MS_SILENT, both use the same constant + F(MS_SILENT, silent); + F(MS_POSIXACL, acl); + F(MS_UNBINDABLE, unbindable); + if (flags & MS_PRIVATE) { + if (flags & MS_REC) { + sc_string_append(buf, buf_size, "rprivate,"); + used |= MS_REC; + } else { + sc_string_append(buf, buf_size, "private,"); + } + flags ^= MS_PRIVATE; + } + if (flags & MS_SLAVE) { + if (flags & MS_REC) { + sc_string_append(buf, buf_size, "rslave,"); + used |= MS_REC; + } else { + sc_string_append(buf, buf_size, "slave,"); + } + flags ^= MS_SLAVE; + } + if (flags & MS_SHARED) { + if (flags & MS_REC) { + sc_string_append(buf, buf_size, "rshared,"); + used |= MS_REC; + } else { + sc_string_append(buf, buf_size, "shared,"); + } + flags ^= MS_SHARED; + } + flags ^= used; // this is just for MS_REC + F(MS_RELATIME, relatime); + F(MS_KERNMOUNT, kernmount); + F(MS_I_VERSION, iversion); + F(MS_STRICTATIME, strictatime); +#ifndef MS_LAZYTIME +#define MS_LAZYTIME (1<<25) +#endif + F(MS_LAZYTIME, lazytime); +#ifndef MS_NOSEC +#define MS_NOSEC (1 << 28) +#endif + F(MS_NOSEC, nosec); +#ifndef MS_BORN +#define MS_BORN (1 << 29) +#endif + F(MS_BORN, born); + F(MS_ACTIVE, active); + F(MS_NOUSER, nouser); +#undef F + // Render any flags that are unaccounted for. + if (flags) { + char of[128] = { 0 }; + sc_must_snprintf(of, sizeof of, "%#lx", flags); + sc_string_append(buf, buf_size, of); + } + // Chop the excess comma from the end. + size_t len = strnlen(buf, buf_size); + if (len > 0 && buf[len - 1] == ',') { + buf[len - 1] = 0; + } + return buf; +} + +const char *sc_mount_cmd(char *buf, size_t buf_size, const char *source, const char + *target, const char *fs_type, unsigned long mountflags, const + void *data) +{ + sc_string_init(buf, buf_size); + sc_string_append(buf, buf_size, "mount"); + + // Add filesysystem type if it's there and doesn't have the special value "none" + if (fs_type != NULL && strncmp(fs_type, "none", 5) != 0) { + sc_string_append(buf, buf_size, " -t "); + sc_string_append(buf, buf_size, fs_type); + } + // Check for some special, dedicated options, that aren't represented with + // the generic mount option argument (mount -o ...), by collecting those + // options that we will display as command line arguments in + // used_special_flags. This is used below to filter out these arguments + // from mount_flags when calling sc_mount_opt2str(). + int used_special_flags = 0; + + // Bind-ounts (bind) + if (mountflags & MS_BIND) { + if (mountflags & MS_REC) { + sc_string_append(buf, buf_size, " --rbind"); + used_special_flags |= MS_REC; + } else { + sc_string_append(buf, buf_size, " --bind"); + } + used_special_flags |= MS_BIND; + } + // Moving mount point location (move) + if (mountflags & MS_MOVE) { + sc_string_append(buf, buf_size, " --move"); + used_special_flags |= MS_MOVE; + } + // Shared subtree operations (shared, slave, private, unbindable). + if (MS_SHARED & mountflags) { + if (mountflags & MS_REC) { + sc_string_append(buf, buf_size, " --make-rshared"); + used_special_flags |= MS_REC; + } else { + sc_string_append(buf, buf_size, " --make-shared"); + } + used_special_flags |= MS_SHARED; + } + + if (MS_SLAVE & mountflags) { + if (mountflags & MS_REC) { + sc_string_append(buf, buf_size, " --make-rslave"); + used_special_flags |= MS_REC; + } else { + sc_string_append(buf, buf_size, " --make-slave"); + } + used_special_flags |= MS_SLAVE; + } + + if (MS_PRIVATE & mountflags) { + if (mountflags & MS_REC) { + sc_string_append(buf, buf_size, " --make-rprivate"); + used_special_flags |= MS_REC; + } else { + sc_string_append(buf, buf_size, " --make-private"); + } + used_special_flags |= MS_PRIVATE; + } + + if (MS_UNBINDABLE & mountflags) { + if (mountflags & MS_REC) { + sc_string_append(buf, buf_size, " --make-runbindable"); + used_special_flags |= MS_REC; + } else { + sc_string_append(buf, buf_size, " --make-unbindable"); + } + used_special_flags |= MS_UNBINDABLE; + } + // If regular option syntax exists then use it. + if (mountflags & ~used_special_flags) { + char opts_buf[1000] = { 0 }; + sc_mount_opt2str(opts_buf, sizeof opts_buf, mountflags & + ~used_special_flags); + sc_string_append(buf, buf_size, " -o "); + sc_string_append(buf, buf_size, opts_buf); + } + // Add source and target locations + if (source != NULL && strncmp(source, "none", 5) != 0) { + sc_string_append(buf, buf_size, " "); + sc_string_append(buf, buf_size, source); + } + if (target != NULL && strncmp(target, "none", 5) != 0) { + sc_string_append(buf, buf_size, " "); + sc_string_append(buf, buf_size, target); + } + + return buf; +} + +const char *sc_umount_cmd(char *buf, size_t buf_size, const char *target, + int flags) +{ + sc_string_init(buf, buf_size); + sc_string_append(buf, buf_size, "umount"); + + if (flags & MNT_FORCE) { + sc_string_append(buf, buf_size, " --force"); + } + + if (flags & MNT_DETACH) { + sc_string_append(buf, buf_size, " --lazy"); + } + if (flags & MNT_EXPIRE) { + // NOTE: there's no real command line option for MNT_EXPIRE + sc_string_append(buf, buf_size, " --expire"); + } + if (flags & UMOUNT_NOFOLLOW) { + // NOTE: there's no real command line option for UMOUNT_NOFOLLOW + sc_string_append(buf, buf_size, " --no-follow"); + } + if (target != NULL) { + sc_string_append(buf, buf_size, " "); + sc_string_append(buf, buf_size, target); + } + + return buf; +} + +#ifndef SNAP_CONFINE_DEBUG_BUILD +static const char *use_debug_build = + "(disabled) use debug build to see details"; +#endif + +static bool sc_do_mount_ex(const char *source, const char *target, + const char *fs_type, + unsigned long mountflags, const void *data, + bool optional) +{ + char buf[10000] = { 0 }; + const char *mount_cmd = NULL; + + if (sc_is_debug_enabled()) { +#ifdef SNAP_CONFINE_DEBUG_BUILD + mount_cmd = sc_mount_cmd(buf, sizeof(buf), source, + target, fs_type, mountflags, data); +#else + mount_cmd = use_debug_build; +#endif + debug("performing operation: %s", mount_cmd); + } + if (sc_faulty("mount", NULL) + || mount(source, target, fs_type, mountflags, data) < 0) { + int saved_errno = errno; + if (optional && saved_errno == ENOENT) { + // The special-cased value that is allowed to fail. + return false; + } + // Drop privileges so that we can compute our nice error message + // without risking an attack on one of the string functions there. + sc_privs_drop(); + + // Compute the equivalent mount command. + mount_cmd = sc_mount_cmd(buf, sizeof(buf), source, + target, fs_type, mountflags, data); + // Restore errno and die. + errno = saved_errno; + die("cannot perform operation: %s", mount_cmd); + } + return true; +} + +void sc_do_mount(const char *source, const char *target, + const char *fs_type, unsigned long mountflags, + const void *data) +{ + (void)sc_do_mount_ex(source, target, fs_type, mountflags, data, false); +} + +bool sc_do_optional_mount(const char *source, const char *target, + const char *fs_type, unsigned long mountflags, + const void *data) +{ + return sc_do_mount_ex(source, target, fs_type, mountflags, data, true); +} + +void sc_do_umount(const char *target, int flags) +{ + char buf[10000] = { 0 }; + const char *umount_cmd = NULL; + + if (sc_is_debug_enabled()) { +#ifdef SNAP_CONFINE_DEBUG_BUILD + umount_cmd = sc_umount_cmd(buf, sizeof(buf), target, flags); +#else + umount_cmd = use_debug_build; +#endif + debug("performing operation: %s", umount_cmd); + } + if (sc_faulty("umount", NULL) || umount2(target, flags) < 0) { + // Save errno as ensure can clobber it. + int saved_errno = errno; + + // Drop privileges so that we can compute our nice error message + // without risking an attack on one of the string functions there. + sc_privs_drop(); + + // Compute the equivalent umount command. + umount_cmd = sc_umount_cmd(buf, sizeof(buf), target, flags); + // Restore errno and die. + errno = saved_errno; + die("cannot perform operation: %s", umount_cmd); + } +} diff --git a/cmd/libsnap-confine-private/mount-opt.h b/cmd/libsnap-confine-private/mount-opt.h new file mode 100644 index 00000000..03c50ffd --- /dev/null +++ b/cmd/libsnap-confine-private/mount-opt.h @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#ifndef SNAP_CONFINE_MOUNT_OPT_H +#define SNAP_CONFINE_MOUNT_OPT_H + +#include +#include + +/** + * Convert flags for mount(2) system call to a string representation. + **/ +const char *sc_mount_opt2str(char *buf, size_t buf_size, unsigned long flags); + +/** + * Compute an equivalent mount(8) command from mount(2) arguments. + * + * This function serves as a human-readable representation of the mount system + * call. The return value is a string that looks like a shell mount command. + * + * Note that the returned command is may not be a valid mount command. No + * sanity checking is performed on the mount flags, source or destination + * arguments. + * + * The returned value is always buf, it is provided as a convenience. + **/ +const char *sc_mount_cmd(char *buf, size_t buf_size, const char *source, const char + *target, const char *fs_type, unsigned long mountflags, + const void *data); + +/** + * Compute an equivalent umount(8) command from umount2(2) arguments. + * + * This function serves as a human-readable representation of the unmount + * system call. The return value is a string that looks like a shell unmount + * command. + * + * Note that some flags are not surfaced at umount command line level. For + * those flags a fake option is synthesized. + * + * Note that the returned command is may not be a valid umount command. No + * sanity checking is performed on the mount flags, source or destination + * arguments. + * + * The returned value is always buf, it is provided as a convenience. + **/ +const char *sc_umount_cmd(char *buf, size_t buf_size, const char *target, + int flags); + +/** + * A thin wrapper around mount(2) with logging and error checks. + **/ +void sc_do_mount(const char *source, const char *target, + const char *fs_type, unsigned long mountflags, + const void *data); + +/** + * A thin wrapper around mount(2) with logging and error checks. + * + * This variant is allowed to silently fail when mount fails with ENOENT. + * That is, it can be used to perform mount operations and if either the source + * or the destination is not present, carry on as if nothing had happened. + * + * The return value indicates if the operation was successful or not. + **/ +bool sc_do_optional_mount(const char *source, const char *target, + const char *fs_type, unsigned long mountflags, + const void *data); + +/** + * A thin wrapper around umount(2) with logging and error checks. + **/ +void sc_do_umount(const char *target, int flags); + +#endif // SNAP_CONFINE_MOUNT_OPT_H diff --git a/cmd/libsnap-confine-private/mountinfo-test.c b/cmd/libsnap-confine-private/mountinfo-test.c new file mode 100644 index 00000000..43dc237c --- /dev/null +++ b/cmd/libsnap-confine-private/mountinfo-test.c @@ -0,0 +1,310 @@ +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "mountinfo.h" +#include "mountinfo.c" + +#include + +static void test_parse_mountinfo_entry__sysfs(void) +{ + const char *line = + "19 25 0:18 / /sys rw,nosuid,nodev,noexec,relatime shared:7 - sysfs sysfs rw"; + sc_mountinfo_entry *entry = sc_parse_mountinfo_entry(line); + g_assert_nonnull(entry); + g_test_queue_destroy((GDestroyNotify) sc_free_mountinfo_entry, entry); + g_assert_cmpint(entry->mount_id, ==, 19); + g_assert_cmpint(entry->parent_id, ==, 25); + g_assert_cmpint(entry->dev_major, ==, 0); + g_assert_cmpint(entry->dev_minor, ==, 18); + g_assert_cmpstr(entry->root, ==, "/"); + g_assert_cmpstr(entry->mount_dir, ==, "/sys"); + g_assert_cmpstr(entry->mount_opts, ==, + "rw,nosuid,nodev,noexec,relatime"); + g_assert_cmpstr(entry->optional_fields, ==, "shared:7"); + g_assert_cmpstr(entry->fs_type, ==, "sysfs"); + g_assert_cmpstr(entry->mount_source, ==, "sysfs"); + g_assert_cmpstr(entry->super_opts, ==, "rw"); + g_assert_null(entry->next); +} + +// Parse the /run/snapd/ns bind mount (over itself) +// Note that /run is itself a tmpfs mount point. +static void test_parse_mountinfo_entry__snapd_ns(void) +{ + const char *line = + "104 23 0:19 /snapd/ns /run/snapd/ns rw,nosuid,noexec,relatime - tmpfs tmpfs rw,size=99840k,mode=755"; + sc_mountinfo_entry *entry = sc_parse_mountinfo_entry(line); + g_assert_nonnull(entry); + g_test_queue_destroy((GDestroyNotify) sc_free_mountinfo_entry, entry); + g_assert_cmpint(entry->mount_id, ==, 104); + g_assert_cmpint(entry->parent_id, ==, 23); + g_assert_cmpint(entry->dev_major, ==, 0); + g_assert_cmpint(entry->dev_minor, ==, 19); + g_assert_cmpstr(entry->root, ==, "/snapd/ns"); + g_assert_cmpstr(entry->mount_dir, ==, "/run/snapd/ns"); + g_assert_cmpstr(entry->mount_opts, ==, "rw,nosuid,noexec,relatime"); + g_assert_cmpstr(entry->optional_fields, ==, ""); + g_assert_cmpstr(entry->fs_type, ==, "tmpfs"); + g_assert_cmpstr(entry->mount_source, ==, "tmpfs"); + g_assert_cmpstr(entry->super_opts, ==, "rw,size=99840k,mode=755"); + g_assert_null(entry->next); +} + +static void test_parse_mountinfo_entry__snapd_mnt(void) +{ + const char *line = + "256 104 0:3 mnt:[4026532509] /run/snapd/ns/hello-world.mnt rw - nsfs nsfs rw"; + sc_mountinfo_entry *entry = sc_parse_mountinfo_entry(line); + g_assert_nonnull(entry); + g_test_queue_destroy((GDestroyNotify) sc_free_mountinfo_entry, entry); + g_assert_cmpint(entry->mount_id, ==, 256); + g_assert_cmpint(entry->parent_id, ==, 104); + g_assert_cmpint(entry->dev_major, ==, 0); + g_assert_cmpint(entry->dev_minor, ==, 3); + g_assert_cmpstr(entry->root, ==, "mnt:[4026532509]"); + g_assert_cmpstr(entry->mount_dir, ==, "/run/snapd/ns/hello-world.mnt"); + g_assert_cmpstr(entry->mount_opts, ==, "rw"); + g_assert_cmpstr(entry->optional_fields, ==, ""); + g_assert_cmpstr(entry->fs_type, ==, "nsfs"); + g_assert_cmpstr(entry->mount_source, ==, "nsfs"); + g_assert_cmpstr(entry->super_opts, ==, "rw"); + g_assert_null(entry->next); +} + +static void test_parse_mountinfo_entry__garbage(void) +{ + const char *line = "256 104 0:3"; + sc_mountinfo_entry *entry = sc_parse_mountinfo_entry(line); + g_assert_null(entry); +} + +static void test_parse_mountinfo_entry__no_tags(void) +{ + const char *line = + "1 2 3:4 root mount-dir mount-opts - fs-type mount-source super-opts"; + sc_mountinfo_entry *entry = sc_parse_mountinfo_entry(line); + g_assert_nonnull(entry); + g_test_queue_destroy((GDestroyNotify) sc_free_mountinfo_entry, entry); + g_assert_cmpint(entry->mount_id, ==, 1); + g_assert_cmpint(entry->parent_id, ==, 2); + g_assert_cmpint(entry->dev_major, ==, 3); + g_assert_cmpint(entry->dev_minor, ==, 4); + g_assert_cmpstr(entry->root, ==, "root"); + g_assert_cmpstr(entry->mount_dir, ==, "mount-dir"); + g_assert_cmpstr(entry->mount_opts, ==, "mount-opts"); + g_assert_cmpstr(entry->optional_fields, ==, ""); + g_assert_cmpstr(entry->fs_type, ==, "fs-type"); + g_assert_cmpstr(entry->mount_source, ==, "mount-source"); + g_assert_cmpstr(entry->super_opts, ==, "super-opts"); + g_assert_null(entry->next); +} + +static void test_parse_mountinfo_entry__one_tag(void) +{ + const char *line = + "1 2 3:4 root mount-dir mount-opts tag:1 - fs-type mount-source super-opts"; + sc_mountinfo_entry *entry = sc_parse_mountinfo_entry(line); + g_assert_nonnull(entry); + g_test_queue_destroy((GDestroyNotify) sc_free_mountinfo_entry, entry); + g_assert_cmpint(entry->mount_id, ==, 1); + g_assert_cmpint(entry->parent_id, ==, 2); + g_assert_cmpint(entry->dev_major, ==, 3); + g_assert_cmpint(entry->dev_minor, ==, 4); + g_assert_cmpstr(entry->root, ==, "root"); + g_assert_cmpstr(entry->mount_dir, ==, "mount-dir"); + g_assert_cmpstr(entry->mount_opts, ==, "mount-opts"); + g_assert_cmpstr(entry->optional_fields, ==, "tag:1"); + g_assert_cmpstr(entry->fs_type, ==, "fs-type"); + g_assert_cmpstr(entry->mount_source, ==, "mount-source"); + g_assert_cmpstr(entry->super_opts, ==, "super-opts"); + g_assert_null(entry->next); +} + +static void test_parse_mountinfo_entry__many_tags(void) +{ + const char *line = + "1 2 3:4 root mount-dir mount-opts tag:1 tag:2 tag:3 tag:4 - fs-type mount-source super-opts"; + sc_mountinfo_entry *entry = sc_parse_mountinfo_entry(line); + g_assert_nonnull(entry); + g_test_queue_destroy((GDestroyNotify) sc_free_mountinfo_entry, entry); + g_assert_cmpint(entry->mount_id, ==, 1); + g_assert_cmpint(entry->parent_id, ==, 2); + g_assert_cmpint(entry->dev_major, ==, 3); + g_assert_cmpint(entry->dev_minor, ==, 4); + g_assert_cmpstr(entry->root, ==, "root"); + g_assert_cmpstr(entry->mount_dir, ==, "mount-dir"); + g_assert_cmpstr(entry->mount_opts, ==, "mount-opts"); + g_assert_cmpstr(entry->optional_fields, ==, "tag:1 tag:2 tag:3 tag:4"); + g_assert_cmpstr(entry->fs_type, ==, "fs-type"); + g_assert_cmpstr(entry->mount_source, ==, "mount-source"); + g_assert_cmpstr(entry->super_opts, ==, "super-opts"); + g_assert_null(entry->next); +} + +static void test_parse_mountinfo_entry__empty_source(void) +{ + const char *line = + "304 301 0:45 / /snap/test-snapd-content-advanced-plug/x1 rw,relatime - tmpfs rw"; + sc_mountinfo_entry *entry = sc_parse_mountinfo_entry(line); + g_assert_nonnull(entry); + g_test_queue_destroy((GDestroyNotify) sc_free_mountinfo_entry, entry); + g_assert_cmpint(entry->mount_id, ==, 304); + g_assert_cmpint(entry->parent_id, ==, 301); + g_assert_cmpint(entry->dev_major, ==, 0); + g_assert_cmpint(entry->dev_minor, ==, 45); + g_assert_cmpstr(entry->root, ==, "/"); + g_assert_cmpstr(entry->mount_dir, ==, + "/snap/test-snapd-content-advanced-plug/x1"); + g_assert_cmpstr(entry->mount_opts, ==, "rw,relatime"); + g_assert_cmpstr(entry->optional_fields, ==, ""); + g_assert_cmpstr(entry->fs_type, ==, "tmpfs"); + g_assert_cmpstr(entry->mount_source, ==, ""); + g_assert_cmpstr(entry->super_opts, ==, "rw"); + g_assert_null(entry->next); +} + +static void test_parse_mountinfo_entry__octal_escaping(void) +{ + const char *line; + struct sc_mountinfo_entry *entry; + + // The kernel escapes spaces as \040 + line = "2 1 0:54 / /tmp rw - tmpfs tricky\\040path rw"; + entry = sc_parse_mountinfo_entry(line); + g_test_queue_destroy((GDestroyNotify) sc_free_mountinfo_entry, entry); + g_assert_nonnull(entry); + g_assert_cmpstr(entry->mount_source, ==, "tricky path"); + + // kernel escapes newlines as \012 + line = "2 1 0:54 / /tmp rw - tmpfs tricky\\012path rw"; + entry = sc_parse_mountinfo_entry(line); + g_test_queue_destroy((GDestroyNotify) sc_free_mountinfo_entry, entry); + g_assert_nonnull(entry); + g_assert_cmpstr(entry->mount_source, ==, "tricky\npath"); + + // kernel escapes tabs as \011 + line = "2 1 0:54 / /tmp rw - tmpfs tricky\\011path rw"; + entry = sc_parse_mountinfo_entry(line); + g_test_queue_destroy((GDestroyNotify) sc_free_mountinfo_entry, entry); + g_assert_nonnull(entry); + g_assert_cmpstr(entry->mount_source, ==, "tricky\tpath"); + + // kernel escapes forward slashes as \057 + line = "2 1 0:54 / /tmp rw - tmpfs tricky\\057path rw"; + entry = sc_parse_mountinfo_entry(line); + g_test_queue_destroy((GDestroyNotify) sc_free_mountinfo_entry, entry); + g_assert_nonnull(entry); + g_assert_cmpstr(entry->mount_source, ==, "tricky/path"); +} + +static void test_parse_mountinfo_entry__broken_octal_escaping(void) +{ + // Invalid octal escape sequences are left intact. + const char *line = + "2074 27 0:54 / /tmp/strange-dir rw,relatime shared:1039 - tmpfs no\\888thing rw\\"; + struct sc_mountinfo_entry *entry = sc_parse_mountinfo_entry(line); + g_assert_nonnull(entry); + g_test_queue_destroy((GDestroyNotify) sc_free_mountinfo_entry, entry); + g_assert_cmpint(entry->mount_id, ==, 2074); + g_assert_cmpint(entry->parent_id, ==, 27); + g_assert_cmpint(entry->dev_major, ==, 0); + g_assert_cmpint(entry->dev_minor, ==, 54); + g_assert_cmpstr(entry->root, ==, "/"); + g_assert_cmpstr(entry->mount_dir, ==, "/tmp/strange-dir"); + g_assert_cmpstr(entry->mount_opts, ==, "rw,relatime"); + g_assert_cmpstr(entry->optional_fields, ==, "shared:1039"); + g_assert_cmpstr(entry->fs_type, ==, "tmpfs"); + g_assert_cmpstr(entry->mount_source, ==, "no\\888thing"); + g_assert_cmpstr(entry->super_opts, ==, "rw\\"); + g_assert_null(entry->next); +} + +static void test_parse_mountinfo_entry__unescaped_whitespace(void) +{ + // The kernel does not escape '\r' + const char *line = + "2074 27 0:54 / /tmp/strange\rdir rw,relatime shared:1039 - tmpfs tmpfs rw"; + struct sc_mountinfo_entry *entry = sc_parse_mountinfo_entry(line); + g_assert_nonnull(entry); + g_test_queue_destroy((GDestroyNotify) sc_free_mountinfo_entry, entry); + g_assert_cmpint(entry->mount_id, ==, 2074); + g_assert_cmpint(entry->parent_id, ==, 27); + g_assert_cmpint(entry->dev_major, ==, 0); + g_assert_cmpint(entry->dev_minor, ==, 54); + g_assert_cmpstr(entry->root, ==, "/"); + g_assert_cmpstr(entry->mount_dir, ==, "/tmp/strange\rdir"); + g_assert_cmpstr(entry->mount_opts, ==, "rw,relatime"); + g_assert_cmpstr(entry->optional_fields, ==, "shared:1039"); + g_assert_cmpstr(entry->fs_type, ==, "tmpfs"); + g_assert_cmpstr(entry->mount_source, ==, "tmpfs"); + g_assert_cmpstr(entry->super_opts, ==, "rw"); + g_assert_null(entry->next); +} + +static void test_parse_mountinfo_entry__broken_9p_superblock(void) +{ + // Spaces in superblock options + const char *line = + "1146 77 0:149 / /Docker/host rw,noatime - 9p drvfs rw,dirsync,aname=drvfs;path=C:\\Program Files\\Docker\\Docker\\resources;symlinkroot=/mnt/,mmap,access=client,msize=262144,trans=virtio"; + struct sc_mountinfo_entry *entry = sc_parse_mountinfo_entry(line); + g_assert_nonnull(entry); + g_test_queue_destroy((GDestroyNotify) sc_free_mountinfo_entry, entry); + g_assert_cmpint(entry->mount_id, ==, 1146); + g_assert_cmpint(entry->parent_id, ==, 77); + g_assert_cmpint(entry->dev_major, ==, 0); + g_assert_cmpint(entry->dev_minor, ==, 149); + g_assert_cmpstr(entry->root, ==, "/"); + g_assert_cmpstr(entry->mount_dir, ==, "/Docker/host"); + g_assert_cmpstr(entry->mount_opts, ==, "rw,noatime"); + g_assert_cmpstr(entry->optional_fields, ==, ""); + g_assert_cmpstr(entry->fs_type, ==, "9p"); + g_assert_cmpstr(entry->mount_source, ==, "drvfs"); + g_assert_cmpstr(entry->super_opts, ==, + "rw,dirsync,aname=drvfs;path=C:\\Program Files\\Docker\\Docker\\resources;symlinkroot=/mnt/,mmap,access=client,msize=262144,trans=virtio"); + g_assert_null(entry->next); +} + +static void __attribute__((constructor)) init(void) +{ + g_test_add_func("/mountinfo/parse_mountinfo_entry/sysfs", + test_parse_mountinfo_entry__sysfs); + g_test_add_func("/mountinfo/parse_mountinfo_entry/snapd-ns", + test_parse_mountinfo_entry__snapd_ns); + g_test_add_func("/mountinfo/parse_mountinfo_entry/snapd-mnt", + test_parse_mountinfo_entry__snapd_mnt); + g_test_add_func("/mountinfo/parse_mountinfo_entry/garbage", + test_parse_mountinfo_entry__garbage); + g_test_add_func("/mountinfo/parse_mountinfo_entry/no_tags", + test_parse_mountinfo_entry__no_tags); + g_test_add_func("/mountinfo/parse_mountinfo_entry/one_tags", + test_parse_mountinfo_entry__one_tag); + g_test_add_func("/mountinfo/parse_mountinfo_entry/many_tags", + test_parse_mountinfo_entry__many_tags); + g_test_add_func + ("/mountinfo/parse_mountinfo_entry/empty_source", + test_parse_mountinfo_entry__empty_source); + g_test_add_func("/mountinfo/parse_mountinfo_entry/octal_escaping", + test_parse_mountinfo_entry__octal_escaping); + g_test_add_func + ("/mountinfo/parse_mountinfo_entry/broken_octal_escaping", + test_parse_mountinfo_entry__broken_octal_escaping); + g_test_add_func("/mountinfo/parse_mountinfo_entry/unescaped_whitespace", + test_parse_mountinfo_entry__unescaped_whitespace); + g_test_add_func("/mountinfo/parse_mountinfo_entry/broken_9p_superblock", + test_parse_mountinfo_entry__broken_9p_superblock); +} diff --git a/cmd/libsnap-confine-private/mountinfo.c b/cmd/libsnap-confine-private/mountinfo.c new file mode 100644 index 00000000..a3b9a196 --- /dev/null +++ b/cmd/libsnap-confine-private/mountinfo.c @@ -0,0 +1,352 @@ +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "mountinfo.h" + +#include +#include +#include +#include +#include + +#include "cleanup-funcs.h" + +/** + * Parse a single mountinfo entry (line). + * + * The format, described by Linux kernel documentation, is as follows: + * + * 36 35 98:0 /mnt1 /mnt2 rw,noatime master:1 - ext3 /dev/root rw,errors=continue + * (1)(2)(3) (4) (5) (6) (7) (8) (9) (10) (11) + * + * (1) mount ID: unique identifier of the mount (may be reused after umount) + * (2) parent ID: ID of parent (or of self for the top of the mount tree) + * (3) major:minor: value of st_dev for files on filesystem + * (4) root: root of the mount within the filesystem + * (5) mount point: mount point relative to the process's root + * (6) mount options: per mount options + * (7) optional fields: zero or more fields of the form "tag[:value]" + * (8) separator: marks the end of the optional fields + * (9) filesystem type: name of filesystem of the form "type[.subtype]" + * (10) mount source: filesystem specific information or "none" + * (11) super options: per super block options + **/ +static sc_mountinfo_entry *sc_parse_mountinfo_entry(const char *line) + __attribute__((nonnull(1))); + +/** + * Free a sc_mountinfo structure and all its entries. + **/ +static void sc_free_mountinfo(sc_mountinfo * info) + __attribute__((nonnull(1))); + +/** + * Free a sc_mountinfo entry. + **/ +static void sc_free_mountinfo_entry(sc_mountinfo_entry * entry) + __attribute__((nonnull(1))); + +sc_mountinfo_entry *sc_first_mountinfo_entry(sc_mountinfo *info) +{ + return info->first; +} + +sc_mountinfo_entry *sc_next_mountinfo_entry(sc_mountinfo_entry *entry) +{ + return entry->next; +} + +sc_mountinfo *sc_parse_mountinfo(const char *fname) +{ + sc_mountinfo *info = calloc(1, sizeof *info); + if (info == NULL) { + return NULL; + } + if (fname == NULL) { + fname = "/proc/self/mountinfo"; + } + FILE *f SC_CLEANUP(sc_cleanup_file) = NULL; + f = fopen(fname, "rt"); + if (f == NULL) { + free(info); + return NULL; + } + char *line SC_CLEANUP(sc_cleanup_string) = NULL; + size_t line_size = 0; + sc_mountinfo_entry *entry, *last = NULL; + for (;;) { + errno = 0; + if (getline(&line, &line_size, f) == -1) { + if (errno != 0) { + sc_free_mountinfo(info); + return NULL; + } + break; + }; + entry = sc_parse_mountinfo_entry(line); + if (entry == NULL) { + sc_free_mountinfo(info); + return NULL; + } + if (last != NULL) { + last->next = entry; + } else { + info->first = entry; + } + last = entry; + } + return info; +} + +static void show_buffers(const char *line, int offset, + sc_mountinfo_entry *entry) +{ +#ifdef MOUNTINFO_DEBUG + fprintf(stderr, "Input buffer (first), with offset arrow\n"); + fprintf(stderr, "Output buffer (second)\n"); + + fputc(' ', stderr); + for (int i = 0; i < offset - 1; ++i) + fputc('-', stderr); + fputc('v', stderr); + fputc('\n', stderr); + + fprintf(stderr, ">%s<\n", line); + + fputc('>', stderr); + for (size_t i = 0; i < strlen(line); ++i) { + int c = entry->line_buf[i]; + fputc(c == 0 ? '@' : c == 1 ? '#' : c, stderr); + } + fputc('<', stderr); + fputc('\n', stderr); + + fputc('>', stderr); + for (size_t i = 0; i < strlen(line); ++i) + fputc('=', stderr); + fputc('<', stderr); + fputc('\n', stderr); +#endif // MOUNTINFO_DEBUG +} + +static bool is_octal_digit(char c) +{ + return c >= '0' && c <= '7'; +} + +static char *parse_next_string_field_ex(sc_mountinfo_entry *entry, + const char *line, size_t *offset, + bool allow_spaces_in_field) +{ + const char *input = &line[*offset]; + char *output = &entry->line_buf[*offset]; + size_t input_idx = 0; // reading index + size_t output_idx = 0; // writing index + + // Scan characters until we run out of memory to scan or we find a + // space. The kernel uses simple octal escape sequences for the + // following: space, tab, newline, backwards slash. Everything else is + // copied verbatim. + for (;;) { + int c = input[input_idx]; + if (c == '\0') { + // The string is over before we see anything then + // return NULL. This is an indication of end-of-input + // to the caller. + if (output_idx == 0) { + return NULL; + } + // The scanned line is NUL terminated. This ensures that the + // terminator is copied to the output buffer. + output[output_idx] = '\0'; + // NOTE: we must not advance the reading index since we + // reached the end of the buffer. + break; + } else if (c == ' ' && !allow_spaces_in_field) { + // Fields are space delimited or end-of-string terminated. + // Represent either as the end-of-string marker, skip over it, + // and stop parsing by terminating the output, then + // breaking out of the loop but advancing the reading + // index which is needed for subsequent calls. + // + // XXX: The last field may contain spaces. + output[output_idx] = '\0'; + input_idx++; + break; + } else if (c == '\\') { + // Three *more* octal digits required for the escape + // sequence. For reference see mangle_path() in + // fs/seq_file.c. Note that is_octal_digit returns + // false on the string terminator character NUL and the + // short-circuiting behavior of && makes this check + // correct even if '\\' is the last character of the + // string. + const char *s = &input[input_idx]; + if (is_octal_digit(s[1]) && is_octal_digit(s[2]) + && is_octal_digit(s[3])) { + // Unescape the octal value encoded in s[1], + // s[2] and s[3]. Because we are working with + // byte values there are no issues related to + // byte order. + output[output_idx++] = + ((s[1] - '0') << 6) | + ((s[2] - '0') << 3) | ((s[3] - '0')); + // Advance the reading index by the length of the escape + // sequence. + input_idx += 4; + } else { + // Partial escape sequence, copy verbatim and + // continue (since we don't use this). + output[output_idx++] = c; + input_idx++; + } + } else { + // All other characters are simply copied verbatim. + output[output_idx++] = c; + input_idx++; + } + } + *offset += input_idx; +#ifdef MOUNTINFO_DEBUG + fprintf(stderr, + "\nscanned: >%s< (%zd bytes), input idx: %zd, output idx: %zd\n", + output, strlen(output), input_idx, output_idx); +#endif + show_buffers(line, *offset, entry); + return output; +} + +// Return the next space separated string field in the given line +static char *parse_next_string_field(sc_mountinfo_entry *entry, + const char *line, size_t *offset) +{ + return parse_next_string_field_ex(entry, line, offset, false); +} + +// Return the last string field in the given line, this means the field +// is allowed to contain spaces (' ', 0x20) +static char *parse_last_string_field(sc_mountinfo_entry *entry, + const char *line, size_t *offset) +{ + return parse_next_string_field_ex(entry, line, offset, true); +} + +static sc_mountinfo_entry *sc_parse_mountinfo_entry(const char *line) +{ + // NOTE: the sc_mountinfo structure is allocated along with enough extra + // storage to hold the whole line we are parsing. This is used as backing + // store for all text fields. + // + // The idea is that since the line has a given length and we are only after + // set of substrings we can easily predict the amount of required space + // (after all, it is just a set of non-overlapping substrings) and append + // it to the allocated entry structure. + // + // The parsing code below, specifically parse_next_string_field(), uses + // this extra memory to hold data parsed from the original line. In the + // end, the result is similar to using strtok except that the source and + // destination buffers are separate. + // + // At the end of the parsing process, the input buffer (line) and the + // output buffer (entry->line_buf) are the same except for where spaces + // were converted into NUL bytes (string terminators) and except for the + // leading part of the buffer that contains mount_id, parent_id, dev_major + // and dev_minor integer fields that are parsed separately. + // + // If MOUNTINFO_DEBUG is defined then extra debugging is printed to stderr + // and this allows for visual analysis of what is going on. + sc_mountinfo_entry *entry = calloc(1, sizeof *entry + strlen(line) + 1); + if (entry == NULL) { + return NULL; + } +#ifdef MOUNTINFO_DEBUG + // Poison the buffer with '\1' bytes that are printed as '#' characters + // by show_buffers() below. This is "unaltered" memory. + memset(entry->line_buf, 1, strlen(line)); +#endif // MOUNTINFO_DEBUG + int nscanned, initial_offset = 0; + size_t offset = 0; + nscanned = sscanf(line, "%d %d %u:%u %n", + &entry->mount_id, &entry->parent_id, + &entry->dev_major, &entry->dev_minor, + &initial_offset); + if (nscanned != 4) + goto fail; + offset += initial_offset; + + show_buffers(line, offset, entry); + + if ((entry->root = + parse_next_string_field(entry, line, &offset)) == NULL) + goto fail; + if ((entry->mount_dir = + parse_next_string_field(entry, line, &offset)) == NULL) + goto fail; + if ((entry->mount_opts = + parse_next_string_field(entry, line, &offset)) == NULL) + goto fail; + entry->optional_fields = &entry->line_buf[0] + offset; + // NOTE: This ensures that optional_fields is never NULL. If this changes, + // must adjust all callers of parse_mountinfo_entry() accordingly. + for (int field_num = 0;; ++field_num) { + char *opt_field = parse_next_string_field(entry, line, &offset); + if (opt_field == NULL) + goto fail; + if (strcmp(opt_field, "-") == 0) { + opt_field[0] = 0; + break; + } + if (field_num > 0) { + opt_field[-1] = ' '; + } + } + if ((entry->fs_type = + parse_next_string_field(entry, line, &offset)) == NULL) + goto fail; + if ((entry->mount_source = + parse_next_string_field(entry, line, &offset)) == NULL) + goto fail; + if ((entry->super_opts = + parse_last_string_field(entry, line, &offset)) == NULL) + goto fail; + return entry; + fail: + free(entry); + return NULL; +} + +void sc_cleanup_mountinfo(sc_mountinfo **ptr) +{ + if (*ptr != NULL) { + sc_free_mountinfo(*ptr); + *ptr = NULL; + } +} + +static void sc_free_mountinfo(sc_mountinfo *info) +{ + sc_mountinfo_entry *entry, *next; + for (entry = info->first; entry != NULL; entry = next) { + next = entry->next; + sc_free_mountinfo_entry(entry); + } + free(info); +} + +static void sc_free_mountinfo_entry(sc_mountinfo_entry *entry) +{ + free(entry); +} diff --git a/cmd/libsnap-confine-private/mountinfo.h b/cmd/libsnap-confine-private/mountinfo.h new file mode 100644 index 00000000..3c0c573f --- /dev/null +++ b/cmd/libsnap-confine-private/mountinfo.h @@ -0,0 +1,134 @@ +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef SNAP_CONFINE_MOUNTINFO_H +#define SNAP_CONFINE_MOUNTINFO_H + +/** + * Structure describing a single entry in /proc/self/sc_mountinfo + **/ +typedef struct sc_mountinfo_entry { + /** + * The mount identifier of a given mount entry. + **/ + int mount_id; + /** + * The parent mount identifier of a given mount entry. + **/ + int parent_id; + unsigned dev_major, dev_minor; + /** + * The root directory of a given mount entry. + **/ + char *root; + /** + * The mount point of a given mount entry. + **/ + char *mount_dir; + /** + * The mount options of a given mount entry. + **/ + char *mount_opts; + /** + * Optional tagged data associated of a given mount entry. + * + * The return value is a string (possibly empty but never NULL) in the format + * tag[:value]. Known tags are: + * + * "shared:X": + * mount is shared in peer group X + * "master:X": + * mount is slave to peer group X + * "propagate_from:X" + * mount is slave and receives propagation from peer group X (*) + * "unbindable": + * mount is unbindable + * + * (*) X is the closest dominant peer group under the process's root. + * If X is the immediate master of the mount, or if there's no dominant peer + * group under the same root, then only the "master:X" field is present and not + * the "propagate_from:X" field. + **/ + char *optional_fields; + /** + * The file system type of a given mount entry. + **/ + char *fs_type; + /** + * The source of a given mount entry. + **/ + char *mount_source; + /** + * The super block options of a given mount entry. + **/ + char *super_opts; + + struct sc_mountinfo_entry *next; + + // Buffer holding all of the text data above. + // + // The buffer must be the last element of the structure. It is allocated + // along with the structure itself and does not need to be freed + // separately. + char line_buf[0]; +} sc_mountinfo_entry; + +/** + * Structure describing entire /proc/self/sc_mountinfo file + **/ +typedef struct sc_mountinfo { + sc_mountinfo_entry *first; +} sc_mountinfo; + +/** + * Parse a file in according to sc_mountinfo syntax. + * + * The argument can be used to parse an arbitrary file. NULL can be used to + * implicitly parse /proc/self/sc_mountinfo, that is the mount information + * associated with the current process. + **/ +sc_mountinfo *sc_parse_mountinfo(const char *fname); + +/** + * Free a sc_mountinfo structure. + * + * This function is designed to be used with __attribute__((cleanup)) so it + * takes a pointer to the freed object (which is also a pointer). + **/ +void sc_cleanup_mountinfo(sc_mountinfo ** ptr) + __attribute__((nonnull(1))); + +/** + * Get the first sc_mountinfo entry. + * + * The returned value may be NULL if the parsed file contained no entries. The + * returned value is bound to the lifecycle of the whole sc_mountinfo structure + * and should not be freed explicitly. + **/ +sc_mountinfo_entry *sc_first_mountinfo_entry(sc_mountinfo * info) + __attribute__((nonnull(1))); + +/** + * Get the next sc_mountinfo entry. + * + * The returned value is a pointer to the next sc_mountinfo entry or NULL if this + * was the last entry. The returned value is bound to the lifecycle of the + * whole sc_mountinfo structure and should not be freed explicitly. + **/ +sc_mountinfo_entry *sc_next_mountinfo_entry(sc_mountinfo_entry * entry) + __attribute__((nonnull(1))); + +#endif diff --git a/cmd/libsnap-confine-private/panic-test.c b/cmd/libsnap-confine-private/panic-test.c new file mode 100644 index 00000000..4e9659ab --- /dev/null +++ b/cmd/libsnap-confine-private/panic-test.c @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "panic.h" +#include "panic.c" + +#include + +static void test_panic(void) +{ + if (g_test_subprocess()) { + errno = 0; + sc_panic("death message"); + g_test_message("expected die not to return"); + g_test_fail(); + return; + } + g_test_trap_subprocess(NULL, 0, 0); + g_test_trap_assert_failed(); + g_test_trap_assert_stderr("death message\n"); +} + +static void test_panic_with_errno(void) +{ + if (g_test_subprocess()) { + errno = EPERM; + sc_panic("death message"); + g_test_message("expected die not to return"); + g_test_fail(); + return; + } + g_test_trap_subprocess(NULL, 0, 0); + g_test_trap_assert_failed(); + g_test_trap_assert_stderr("death message: Operation not permitted\n"); +} + +static void custom_panic_msg(const char *fmt, va_list ap, int errno_copy) +{ + fprintf(stderr, "PANIC: "); + vfprintf(stderr, fmt, ap); + fprintf(stderr, " (errno: %d)", errno_copy); + fprintf(stderr, "\n"); +} + +static void custom_panic_exit(void) +{ + fprintf(stderr, "EXITING\n"); + exit(2); +} + +static void test_panic_customization(void) +{ + if (g_test_subprocess()) { + sc_set_panic_msg_fn(custom_panic_msg); + sc_set_panic_exit_fn(custom_panic_exit); + errno = 123; + sc_panic("death message"); + g_test_message("expected die not to return"); + g_test_fail(); + return; + } + g_test_trap_subprocess(NULL, 0, 0); + g_test_trap_assert_failed(); + g_test_trap_assert_stderr("PANIC: death message (errno: 123)\n" + "EXITING\n"); + // NOTE: g_test doesn't offer facilities to observe the exit code. +} + +static void __attribute__((constructor)) init(void) +{ + g_test_add_func("/panic/panic", test_panic); + g_test_add_func("/panic/panic_with_errno", test_panic_with_errno); + g_test_add_func("/panic/panic_customization", test_panic_customization); +} diff --git a/cmd/libsnap-confine-private/panic.c b/cmd/libsnap-confine-private/panic.c new file mode 100644 index 00000000..5a80b89c --- /dev/null +++ b/cmd/libsnap-confine-private/panic.c @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "panic.h" + +#include +#include +#include +#include +#include +#include + +static sc_panic_exit_fn panic_exit_fn = NULL; +static sc_panic_msg_fn panic_msg_fn = NULL; + +void sc_panic(const char *fmt, ...) { + va_list ap; + va_start(ap, fmt); + sc_panicv(fmt, ap); + va_end(ap); +} + +void sc_panicv(const char *fmt, va_list ap) { + int errno_copy = errno; + + if (panic_msg_fn != NULL) { + panic_msg_fn(fmt, ap, errno_copy); + } else { + vfprintf(stderr, fmt, ap); + if (errno != 0) { + fprintf(stderr, ": %s\n", strerror(errno_copy)); + } else { + fprintf(stderr, "\n"); + } + } + + if (panic_exit_fn != NULL) { + panic_exit_fn(); + } + exit(1); +} + +sc_panic_exit_fn sc_set_panic_exit_fn(sc_panic_exit_fn fn) { + sc_panic_exit_fn old = panic_exit_fn; + panic_exit_fn = fn; + return old; +} + +sc_panic_msg_fn sc_set_panic_msg_fn(sc_panic_msg_fn fn) { + sc_panic_msg_fn old = panic_msg_fn; + panic_msg_fn = fn; + return old; +} diff --git a/cmd/libsnap-confine-private/panic.h b/cmd/libsnap-confine-private/panic.h new file mode 100644 index 00000000..c25fcd78 --- /dev/null +++ b/cmd/libsnap-confine-private/panic.h @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#ifndef SC_PANIC_H +#define SC_PANIC_H + +#include + +/** + * sc_panic is an exit-with-message utility function. + * + * The function takes a printf-like format string that is formatted and printed + * somehow. The function then terminates the process by calling exit. Both + * aspects can be customized. + * + * The particular nature of the exit can be customized by calling + * sc_set_panic_action. The panic action is a function that is called before + * attempting to exit. + * + * The way the error message is formatted and printed can be customized by + * calling sc_set_panic_format_fn(). By default the error is printed to + * standard error. If the error is related to a system call failure then errno + * can be set to a non-zero value just prior to calling sc_panic. The value + * will then be used when crafting the error message. + **/ +__attribute__((noreturn, format(printf, 1, 2))) void sc_panic(const char *fmt, ...); + +/** + * sc_panicv is a variant of sc_panic with an argument list. + **/ +__attribute__((noreturn)) void sc_panicv(const char *fmt, va_list ap); + +/** + * sc_panic_exit_fn is the type of the exit function used by sc_panic(). + **/ +typedef void (*sc_panic_exit_fn)(void); + +/** + * sc_set_panic_exit_fn sets the panic exit function. + * + * When sc_panic is called it will eventually exit the running process. Just + * prior to that, it will call the panic exit function, if one has been set. + * + * If exiting the process is undesired, for example while running in intrd as + * pid 1, during the system shutdown phase, then a process can set the panic + * exit function. Note that if the specified function returns then panic will + * proceed to call exit(3) anyway. + * + * The old exit function, if any, is returned. + **/ +sc_panic_exit_fn sc_set_panic_exit_fn(sc_panic_exit_fn fn); + +/** + * sc_panic_msg_fn is the type of the format function used by sc_panic(). + **/ +typedef void (*sc_panic_msg_fn)(const char *fmt, va_list ap, int errno_copy); + +/** + * sc_set_panic_msg_fn sets the panic message function. + * + * When sc_panic is called it will attempt to print an error message to + * standard error. The message includes information provided by the caller: the + * format string, the argument vector for a printf-like function as well as a + * copy of the system errno value, which may be zero if the error is not + * originated by a system call error. + * + * If custom formatting of the error message is desired, for example while + * running in initrd as pid 1, during the system shutdown phase, then a process + * can set the panic message function. Once set the function takes over the + * responsibility of printing an error message (in whatever form is + * appropriate). + * + * The old message function, if any, is returned. + **/ +sc_panic_msg_fn sc_set_panic_msg_fn(sc_panic_msg_fn fn); + +#endif diff --git a/cmd/libsnap-confine-private/privs-test.c b/cmd/libsnap-confine-private/privs-test.c new file mode 100644 index 00000000..3c2f818f --- /dev/null +++ b/cmd/libsnap-confine-private/privs-test.c @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "privs.h" +#include "privs.c" + +#include + +// Test that dropping permissions really works +static void test_sc_privs_drop(void) +{ + if (geteuid() != 0 || getuid() == 0) { + g_test_skip("run this test after chown root.root; chmod u+s"); + return; + } + if (getegid() != 0 || getgid() == 0) { + g_test_skip("run this test after chown root.root; chmod g+s"); + return; + } + if (g_test_subprocess()) { + // We start as a regular user with effective-root identity. + g_assert_cmpint(getuid(), !=, 0); + g_assert_cmpint(getgid(), !=, 0); + + g_assert_cmpint(geteuid(), ==, 0); + g_assert_cmpint(getegid(), ==, 0); + + // We drop the privileges. + sc_privs_drop(); + + // The we are no longer root. + g_assert_cmpint(getuid(), !=, 0); + g_assert_cmpint(geteuid(), !=, 0); + g_assert_cmpint(getgid(), !=, 0); + g_assert_cmpint(getegid(), !=, 0); + + // We don't have any supplementary groups. + gid_t groups[2]; + int num_groups = getgroups(1, groups); + g_assert_cmpint(num_groups, ==, 1); + g_assert_cmpint(groups[0], ==, getgid()); + + // All done. + return; + } + g_test_trap_subprocess(NULL, 0, G_TEST_SUBPROCESS_INHERIT_STDERR); + g_test_trap_assert_passed(); +} + +static void __attribute__((constructor)) init(void) +{ + g_test_add_func("/privs/sc_privs_drop", test_sc_privs_drop); +} diff --git a/cmd/libsnap-confine-private/privs.c b/cmd/libsnap-confine-private/privs.c new file mode 100644 index 00000000..18b9587d --- /dev/null +++ b/cmd/libsnap-confine-private/privs.c @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "privs.h" + +#define _GNU_SOURCE + +#include + +#include +#include +#include +#include +#include + +#include "utils.h" + +static bool sc_has_capability(const char *cap_name) +{ + // Lookup capability with the given name. + cap_value_t cap; + if (cap_from_name(cap_name, &cap) < 0) { + die("cannot resolve capability name %s", cap_name); + } + // Get the capability state of the current process. + cap_t caps; + if ((caps = cap_get_proc()) == NULL) { + die("cannot obtain capability state (cap_get_proc)"); + } + // Read the effective value of the flag we're dealing with + cap_flag_value_t cap_flags_value; + if (cap_get_flag(caps, cap, CAP_EFFECTIVE, &cap_flags_value) < 0) { + cap_free(caps); // don't bother checking, we die anyway. + die("cannot obtain value of capability flag (cap_get_flag)"); + } + // Free the representation of the capability state of the current process. + if (cap_free(caps) < 0) { + die("cannot free capability flag (cap_free)"); + } + // Check if the effective bit of the capability is set. + return cap_flags_value == CAP_SET; +} + +void sc_privs_drop(void) +{ + gid_t gid = getgid(); + uid_t uid = getuid(); + + // Drop extra group membership if we can. + if (sc_has_capability("cap_setgid")) { + gid_t gid_list[1] = { gid }; + if (setgroups(1, gid_list) < 0) { + die("cannot set supplementary group identifiers"); + } + } + // Switch to real group ID + if (setgid(getgid()) < 0) { + die("cannot set group identifier to %d", gid); + } + // Switch to real user ID + if (setuid(getuid()) < 0) { + die("cannot set user identifier to %d", uid); + } +} diff --git a/cmd/libsnap-confine-private/privs.h b/cmd/libsnap-confine-private/privs.h new file mode 100644 index 00000000..41a4eb5e --- /dev/null +++ b/cmd/libsnap-confine-private/privs.h @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#ifndef SNAP_CONFINE_PRIVS_H +#define SNAP_CONFINE_PRIVS_H + +/** + * Permanently drop elevated permissions. + * + * If the user has elevated permission as a result of running a setuid root + * application then such permission are permanently dropped. + * + * The set of dropped permissions include: + * - user and group identifier + * - supplementary group identifiers + * + * The function ensures that the elevated permission are dropped or dies if + * this cannot be achieved. Note that only the elevated permissions are + * dropped. When the process itself was started by root then this function does + * nothing at all. + **/ +void sc_privs_drop(void); + +#endif diff --git a/cmd/libsnap-confine-private/secure-getenv-test.c b/cmd/libsnap-confine-private/secure-getenv-test.c new file mode 100644 index 00000000..61b42502 --- /dev/null +++ b/cmd/libsnap-confine-private/secure-getenv-test.c @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "secure-getenv.h" +#include "secure-getenv.c" + +#include + +// TODO: write some tests diff --git a/cmd/libsnap-confine-private/secure-getenv.c b/cmd/libsnap-confine-private/secure-getenv.c new file mode 100644 index 00000000..cc8b4dc8 --- /dev/null +++ b/cmd/libsnap-confine-private/secure-getenv.c @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +#include "secure-getenv.h" + +#include +#include + +#ifndef HAVE_SECURE_GETENV +char *secure_getenv(const char *name) +{ + unsigned long secure = getauxval(AT_SECURE); + if (secure != 0) { + return NULL; + } + return getenv(name); +} +#endif // ! HAVE_SECURE_GETENV diff --git a/cmd/libsnap-confine-private/secure-getenv.h b/cmd/libsnap-confine-private/secure-getenv.h new file mode 100644 index 00000000..93a76eac --- /dev/null +++ b/cmd/libsnap-confine-private/secure-getenv.h @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +#ifndef SNAP_CONFINE_SECURE_GETENV_H +#define SNAP_CONFINE_SECURE_GETENV_H + +#ifdef HAVE_CONFIG_H +#include "config.h" +#endif + +#ifndef HAVE_SECURE_GETENV +/** + * Secure version of getenv() + * + * This version returns NULL if the process is running within a secure context. + * This is exactly the same as the GNU extension to the standard library. It is + * only used when glibc is not available. + **/ +char *secure_getenv(const char *name) + __attribute__((nonnull(1), warn_unused_result)); +#endif // ! HAVE_SECURE_GETENV + +#endif diff --git a/cmd/libsnap-confine-private/snap-test.c b/cmd/libsnap-confine-private/snap-test.c new file mode 100644 index 00000000..133f99e2 --- /dev/null +++ b/cmd/libsnap-confine-private/snap-test.c @@ -0,0 +1,627 @@ +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "snap.h" +#include "snap.c" + +#include + +static void test_sc_security_tag_validate(void) +{ + // First, test the names we know are good + g_assert_true(sc_security_tag_validate("snap.name.app", "name")); + g_assert_true(sc_security_tag_validate + ("snap.network-manager.NetworkManager", + "network-manager")); + g_assert_true(sc_security_tag_validate("snap.f00.bar-baz1", "f00")); + g_assert_true(sc_security_tag_validate("snap.foo.hook.bar", "foo")); + g_assert_true(sc_security_tag_validate("snap.foo.hook.bar-baz", "foo")); + g_assert_true(sc_security_tag_validate + ("snap.foo_instance.bar-baz", "foo_instance")); + g_assert_true(sc_security_tag_validate + ("snap.foo_instance.hook.bar-baz", "foo_instance")); + g_assert_true(sc_security_tag_validate + ("snap.foo_bar.hook.bar-baz", "foo_bar")); + + // Now, test the names we know are bad + g_assert_false(sc_security_tag_validate + ("pkg-foo.bar.0binary-bar+baz", "bar")); + g_assert_false(sc_security_tag_validate("pkg-foo_bar_1.1", "")); + g_assert_false(sc_security_tag_validate("appname/..", "")); + g_assert_false(sc_security_tag_validate("snap", "")); + g_assert_false(sc_security_tag_validate("snap.", "")); + g_assert_false(sc_security_tag_validate("snap.name", "name")); + g_assert_false(sc_security_tag_validate("snap.name.", "name")); + g_assert_false(sc_security_tag_validate("snap.name.app.", "name")); + g_assert_false(sc_security_tag_validate("snap.name.hook.", "name")); + g_assert_false(sc_security_tag_validate("snap!name.app", "!name")); + g_assert_false(sc_security_tag_validate("snap.-name.app", "-name")); + g_assert_false(sc_security_tag_validate("snap.name!app", "name!")); + g_assert_false(sc_security_tag_validate("snap.name.-app", "name")); + g_assert_false(sc_security_tag_validate + ("snap.name.app!hook.foo", "name")); + g_assert_false(sc_security_tag_validate + ("snap.name.app.hook!foo", "name")); + g_assert_false(sc_security_tag_validate + ("snap.name.app.hook.-foo", "name")); + g_assert_false(sc_security_tag_validate + ("snap.name.app.hook.f00", "name")); + g_assert_false(sc_security_tag_validate("sna.pname.app", "pname")); + g_assert_false(sc_security_tag_validate("snap.n@me.app", "n@me")); + g_assert_false(sc_security_tag_validate("SNAP.name.app", "name")); + g_assert_false(sc_security_tag_validate("snap.Name.app", "Name")); + // This used to be false but it's now allowed. + g_assert_true(sc_security_tag_validate("snap.0name.app", "0name")); + g_assert_false(sc_security_tag_validate("snap.-name.app", "-name")); + g_assert_false(sc_security_tag_validate("snap.name.@app", "name")); + g_assert_false(sc_security_tag_validate(".name.app", "name")); + g_assert_false(sc_security_tag_validate("snap..name.app", ".name")); + g_assert_false(sc_security_tag_validate("snap.name..app", "name.")); + g_assert_false(sc_security_tag_validate("snap.name.app..", "name")); + // These contain invalid instance key + g_assert_false(sc_security_tag_validate("snap.foo_.bar-baz", "foo")); + g_assert_false(sc_security_tag_validate + ("snap.foo_toolonginstance.bar-baz", "foo")); + g_assert_false(sc_security_tag_validate + ("snap.foo_inst@nace.bar-baz", "foo")); + g_assert_false(sc_security_tag_validate + ("snap.foo_in-stan-ce.bar-baz", "foo")); + g_assert_false(sc_security_tag_validate + ("snap.foo_in stan.bar-baz", "foo")); + + // Test names that are both good, but snap name doesn't match security tag + g_assert_false(sc_security_tag_validate("snap.foo.hook.bar", "fo")); + g_assert_false(sc_security_tag_validate("snap.foo.hook.bar", "fooo")); + g_assert_false(sc_security_tag_validate("snap.foo.hook.bar", "snap")); + g_assert_false(sc_security_tag_validate("snap.foo.hook.bar", "bar")); + g_assert_false(sc_security_tag_validate + ("snap.foo_instance.bar", "foo_bar")); + + // Regression test 12to8 + g_assert_true(sc_security_tag_validate("snap.12to8.128to8", "12to8")); + g_assert_true(sc_security_tag_validate + ("snap.123test.123test", "123test")); + g_assert_true(sc_security_tag_validate + ("snap.123test.hook.configure", "123test")); + + // regression test snap.eon-edg-shb-pulseaudio.hook.connect-plug-i2c + g_assert_true(sc_security_tag_validate + ("snap.foo.hook.connect-plug-i2c", "foo")); + + // Security tag that's too long. The extra +2 is for the string + // terminator and to allow us to make the tag too long to validate. + char long_tag[SNAP_SECURITY_TAG_MAX_LEN + 2]; + memset(long_tag, 'b', sizeof long_tag); + memcpy(long_tag, "snap.foo.b", sizeof "snap.foo.b" - 1); + long_tag[sizeof long_tag - 1] = '\0'; + g_assert_true(strlen(long_tag) == SNAP_SECURITY_TAG_MAX_LEN + 1); + g_assert_false(sc_security_tag_validate(long_tag, "foo")); + + // If we make it one byte shorter it will be valid. + long_tag[sizeof long_tag - 2] = '\0'; + g_assert_true(sc_security_tag_validate(long_tag, "foo")); + +} + +static void test_sc_is_hook_security_tag(void) +{ + // First, test the names we know are good + g_assert_true(sc_is_hook_security_tag("snap.foo.hook.bar")); + g_assert_true(sc_is_hook_security_tag("snap.foo.hook.bar-baz")); + g_assert_true(sc_is_hook_security_tag + ("snap.foo_instance.hook.bar-baz")); + g_assert_true(sc_is_hook_security_tag("snap.foo_bar.hook.bar-baz")); + g_assert_true(sc_is_hook_security_tag("snap.foo_bar.hook.f00")); + g_assert_true(sc_is_hook_security_tag("snap.foo_bar.hook.f-0-0")); + + // Now, test the names we know are not valid hook security tags + g_assert_false(sc_is_hook_security_tag("snap.foo_instance.bar-baz")); + g_assert_false(sc_is_hook_security_tag("snap.name.app!hook.foo")); + g_assert_false(sc_is_hook_security_tag("snap.name.app.hook!foo")); + g_assert_false(sc_is_hook_security_tag("snap.name.app.hook.-foo")); + g_assert_false(sc_is_hook_security_tag("snap.foo_bar.hook.0abcd")); + g_assert_false(sc_is_hook_security_tag("snap.foo.hook.abc--")); + g_assert_false(sc_is_hook_security_tag("snap.foo_bar.hook.!foo")); + g_assert_false(sc_is_hook_security_tag("snap.foo_bar.hook.-foo")); + g_assert_false(sc_is_hook_security_tag("snap.foo_bar.hook!foo")); + g_assert_false(sc_is_hook_security_tag("snap.foo_bar.!foo")); +} + +static void test_sc_snap_or_instance_name_validate(gconstpointer data) +{ + typedef void (*validate_func_t)(const char *, sc_error **); + + validate_func_t validate = (validate_func_t) data; + bool is_instance = + (validate == sc_instance_name_validate) ? true : false; + + sc_error *err = NULL; + + // Smoke test, a valid snap name + validate("hello-world", &err); + g_assert_null(err); + + // Smoke test: invalid character + validate("hello world", &err); + g_assert_nonnull(err); + g_assert_true(sc_error_match + (err, SC_SNAP_DOMAIN, SC_SNAP_INVALID_NAME)); + g_assert_cmpstr(sc_error_msg(err), ==, + "snap name must use lower case letters, digits or dashes"); + sc_error_free(err); + + // Smoke test: no letters + validate("", &err); + g_assert_nonnull(err); + g_assert_true(sc_error_match + (err, SC_SNAP_DOMAIN, SC_SNAP_INVALID_NAME)); + g_assert_cmpstr(sc_error_msg(err), ==, + "snap name must contain at least one letter"); + sc_error_free(err); + + // Smoke test: leading dash + validate("-foo", &err); + g_assert_nonnull(err); + g_assert_true(sc_error_match + (err, SC_SNAP_DOMAIN, SC_SNAP_INVALID_NAME)); + g_assert_cmpstr(sc_error_msg(err), ==, + "snap name cannot start with a dash"); + sc_error_free(err); + + // Smoke test: trailing dash + validate("foo-", &err); + g_assert_nonnull(err); + g_assert_true(sc_error_match + (err, SC_SNAP_DOMAIN, SC_SNAP_INVALID_NAME)); + g_assert_cmpstr(sc_error_msg(err), ==, + "snap name cannot end with a dash"); + sc_error_free(err); + + // Smoke test: double dash + validate("f--oo", &err); + g_assert_nonnull(err); + g_assert_true(sc_error_match + (err, SC_SNAP_DOMAIN, SC_SNAP_INVALID_NAME)); + g_assert_cmpstr(sc_error_msg(err), ==, + "snap name cannot contain two consecutive dashes"); + sc_error_free(err); + + // Smoke test: NULL name is not valid + validate(NULL, &err); + g_assert_nonnull(err); + // the only case when instance name validation diverges from snap name + // validation + if (!is_instance) { + g_assert_true(sc_error_match + (err, SC_SNAP_DOMAIN, SC_SNAP_INVALID_NAME)); + g_assert_cmpstr(sc_error_msg(err), ==, + "snap name cannot be NULL"); + } else { + g_assert_true(sc_error_match + (err, SC_SNAP_DOMAIN, + SC_SNAP_INVALID_INSTANCE_NAME)); + g_assert_cmpstr(sc_error_msg(err), ==, + "snap instance name cannot be NULL"); + } + sc_error_free(err); + + const char *valid_names[] = { + "aa", "aaa", "aaaa", + "a-a", "aa-a", "a-aa", "a-b-c", + "a0", "a-0", "a-0a", + "01game", "1-or-2" + }; + for (size_t i = 0; i < sizeof valid_names / sizeof *valid_names; ++i) { + g_test_message("checking valid snap name: %s", valid_names[i]); + validate(valid_names[i], &err); + g_assert_null(err); + } + const char *invalid_names[] = { + // name cannot be empty + "", + // too short + "a", + // names cannot be too long + "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "xxxxxxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxx", + "1111111111111111111111111111111111111111x", + "x1111111111111111111111111111111111111111", + "x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x", + // dashes alone are not a name + "-", "--", + // double dashes in a name are not allowed + "a--a", + // name should not end with a dash + "a-", + // name cannot have any spaces in it + "a ", " a", "a a", + // a number alone is not a name + "0", "123", "1-2-3", + // identifier must be plain ASCII + "日本語", "한글", "ру́сский язы́к", + }; + for (size_t i = 0; i < sizeof invalid_names / sizeof *invalid_names; + ++i) { + g_test_message("checking invalid snap name: >%s<", + invalid_names[i]); + validate(invalid_names[i], &err); + g_assert_nonnull(err); + g_assert_true(sc_error_match + (err, SC_SNAP_DOMAIN, SC_SNAP_INVALID_NAME)); + sc_error_free(err); + } + // Regression test: 12to8 and 123test + validate("12to8", &err); + g_assert_null(err); + validate("123test", &err); + g_assert_null(err); + + // In case we switch to a regex, here's a test that could break things. + const char good_bad_name[] = "u-94903713687486543234157734673284536758"; + char varname[sizeof good_bad_name] = { 0 }; + for (size_t i = 3; i <= sizeof varname - 1; i++) { + g_assert_nonnull(memcpy(varname, good_bad_name, i)); + varname[i] = 0; + g_test_message("checking valid snap name: >%s<", varname); + validate(varname, &err); + g_assert_null(err); + sc_error_free(err); + } +} + +static void test_sc_snap_name_validate__respects_error_protocol(void) +{ + if (g_test_subprocess()) { + sc_snap_name_validate("hello world", NULL); + g_test_message("expected sc_snap_name_validate to return"); + g_test_fail(); + return; + } + g_test_trap_subprocess(NULL, 0, 0); + g_test_trap_assert_failed(); + g_test_trap_assert_stderr + ("snap name must use lower case letters, digits or dashes\n"); +} + +static void test_sc_instance_name_validate(void) +{ + sc_error *err = NULL; + + sc_instance_name_validate("hello-world", &err); + g_assert_null(err); + sc_instance_name_validate("hello-world_foo", &err); + g_assert_null(err); + + // just the separator + sc_instance_name_validate("_", &err); + g_assert_nonnull(err); + g_assert_true(sc_error_match + (err, SC_SNAP_DOMAIN, SC_SNAP_INVALID_NAME)); + g_assert_cmpstr(sc_error_msg(err), ==, + "snap name must contain at least one letter"); + sc_error_free(err); + + // just name, with separator, missing instance key + sc_instance_name_validate("hello-world_", &err); + g_assert_nonnull(err); + g_assert_true(sc_error_match + (err, SC_SNAP_DOMAIN, SC_SNAP_INVALID_INSTANCE_KEY)); + g_assert_cmpstr(sc_error_msg(err), ==, + "instance key must contain at least one letter or digit"); + sc_error_free(err); + + // only separator and instance key, missing name + sc_instance_name_validate("_bar", &err); + g_assert_nonnull(err); + g_assert_true(sc_error_match + (err, SC_SNAP_DOMAIN, SC_SNAP_INVALID_NAME)); + g_assert_cmpstr(sc_error_msg(err), ==, + "snap name must contain at least one letter"); + sc_error_free(err); + + sc_instance_name_validate("", &err); + g_assert_nonnull(err); + g_assert_true(sc_error_match + (err, SC_SNAP_DOMAIN, SC_SNAP_INVALID_NAME)); + g_assert_cmpstr(sc_error_msg(err), ==, + "snap name must contain at least one letter"); + sc_error_free(err); + + // third separator + sc_instance_name_validate("foo_bar_baz", &err); + g_assert_nonnull(err); + g_assert_true(sc_error_match + (err, SC_SNAP_DOMAIN, SC_SNAP_INVALID_INSTANCE_NAME)); + g_assert_cmpstr(sc_error_msg(err), ==, + "snap instance name can contain only one underscore"); + sc_error_free(err); + + // too long, 52 + sc_instance_name_validate + ("0123456789012345678901234567890123456789012345678901", &err); + g_assert_nonnull(err); + g_assert_true(sc_error_match + (err, SC_SNAP_DOMAIN, SC_SNAP_INVALID_INSTANCE_NAME)); + g_assert_cmpstr(sc_error_msg(err), ==, + "snap instance name can be at most 51 characters long"); + sc_error_free(err); + + const char *valid_names[] = { + "aa", "aaa", "aaaa", + "aa_a", "aa_1", "aa_123", "aa_0123456789", + }; + for (size_t i = 0; i < sizeof valid_names / sizeof *valid_names; ++i) { + g_test_message("checking valid instance name: %s", + valid_names[i]); + sc_instance_name_validate(valid_names[i], &err); + g_assert_null(err); + } + const char *invalid_names[] = { + // too short + "a", + // only letters and digits in the instance key + "a_--23))", "a_ ", "a_091234#", "a_123_456", + // up to 10 characters for the instance key + "a_01234567891", "a_0123456789123", + // snap name must not be more than 40 characters, regardless of instance + // key + "01234567890123456789012345678901234567890_foobar", + "01234567890123456789-01234567890123456789_foobar", + // instance key must be plain ASCII + "foobar_日本語", + // way too many underscores + "foobar_baz_zed_daz", + "foobar______", + }; + for (size_t i = 0; i < sizeof invalid_names / sizeof *invalid_names; + ++i) { + g_test_message("checking invalid instance name: >%s<", + invalid_names[i]); + sc_instance_name_validate(invalid_names[i], &err); + g_assert_nonnull(err); + sc_error_free(err); + } +} + +static void test_sc_snap_drop_instance_key_no_dest(void) +{ + if (g_test_subprocess()) { + sc_snap_drop_instance_key("foo_bar", NULL, 0); + return; + } + g_test_trap_subprocess(NULL, 0, 0); + g_test_trap_assert_failed(); + +} + +static void test_sc_snap_drop_instance_key_short_dest(void) +{ + if (g_test_subprocess()) { + char dest[10] = { 0 }; + sc_snap_drop_instance_key("foo-foo-foo-foo-foo_bar", dest, + sizeof dest); + return; + } + g_test_trap_subprocess(NULL, 0, 0); + g_test_trap_assert_failed(); +} + +static void test_sc_snap_drop_instance_key_short_dest2(void) +{ + if (g_test_subprocess()) { + char dest[3] = { 0 }; // "foo" sans the nil byte + sc_snap_drop_instance_key("foo", dest, sizeof dest); + return; + } + g_test_trap_subprocess(NULL, 0, 0); + g_test_trap_assert_failed(); +} + +static void test_sc_snap_drop_instance_key_no_name(void) +{ + if (g_test_subprocess()) { + char dest[10] = { 0 }; + sc_snap_drop_instance_key(NULL, dest, sizeof dest); + return; + } + g_test_trap_subprocess(NULL, 0, 0); + g_test_trap_assert_failed(); +} + +static void test_sc_snap_drop_instance_key_short_dest_max(void) +{ + if (g_test_subprocess()) { + char dest[SNAP_NAME_LEN + 1] = { 0 }; + /* 40 chars (max valid length), pretend dest is the same length, no space for terminator */ + sc_snap_drop_instance_key + ("01234567890123456789012345678901234567890", dest, + sizeof dest - 1); + return; + } + g_test_trap_subprocess(NULL, 0, 0); + g_test_trap_assert_failed(); +} + +static void test_sc_snap_drop_instance_key_basic(void) +{ + char name[SNAP_NAME_LEN + 1] = { 0xff }; + + sc_snap_drop_instance_key("foo_bar", name, sizeof name); + g_assert_cmpstr(name, ==, "foo"); + + memset(name, 0xff, sizeof name); + sc_snap_drop_instance_key("foo-bar_bar", name, sizeof name); + g_assert_cmpstr(name, ==, "foo-bar"); + + memset(name, 0xff, sizeof name); + sc_snap_drop_instance_key("foo-bar", name, sizeof name); + g_assert_cmpstr(name, ==, "foo-bar"); + + memset(name, 0xff, sizeof name); + sc_snap_drop_instance_key("_baz", name, sizeof name); + g_assert_cmpstr(name, ==, ""); + + memset(name, 0xff, sizeof name); + sc_snap_drop_instance_key("foo", name, sizeof name); + g_assert_cmpstr(name, ==, "foo"); + + memset(name, 0xff, sizeof name); + /* 40 chars - snap name length */ + sc_snap_drop_instance_key("0123456789012345678901234567890123456789", + name, sizeof name); + g_assert_cmpstr(name, ==, "0123456789012345678901234567890123456789"); +} + +static void test_sc_snap_split_instance_name_trailing_nil(void) +{ + if (g_test_subprocess()) { + char dest[3] = { 0 }; + // pretend there is no place for trailing \0 + sc_snap_split_instance_name("_", NULL, 0, dest, 0); + return; + } + g_test_trap_subprocess(NULL, 0, 0); + g_test_trap_assert_failed(); +} + +static void test_sc_snap_split_instance_name_short_instance_dest(void) +{ + if (g_test_subprocess()) { + char dest[10] = { 0 }; + sc_snap_split_instance_name("foo_barbarbarbar", NULL, 0, + dest, sizeof dest); + return; + } + g_test_trap_subprocess(NULL, 0, 0); + g_test_trap_assert_failed(); +} + +static void test_sc_snap_split_instance_name_basic(void) +{ + char name[SNAP_NAME_LEN + 1] = { 0xff }; + char instance[20] = { 0xff }; + + sc_snap_split_instance_name("foo_bar", name, sizeof name, instance, + sizeof instance); + g_assert_cmpstr(name, ==, "foo"); + g_assert_cmpstr(instance, ==, "bar"); + + memset(name, 0xff, sizeof name); + memset(instance, 0xff, sizeof instance); + sc_snap_split_instance_name("foo-bar_bar", name, sizeof name, instance, + sizeof instance); + g_assert_cmpstr(name, ==, "foo-bar"); + g_assert_cmpstr(instance, ==, "bar"); + + memset(name, 0xff, sizeof name); + memset(instance, 0xff, sizeof instance); + sc_snap_split_instance_name("foo-bar", name, sizeof name, instance, + sizeof instance); + g_assert_cmpstr(name, ==, "foo-bar"); + g_assert_cmpstr(instance, ==, ""); + + memset(name, 0xff, sizeof name); + memset(instance, 0xff, sizeof instance); + sc_snap_split_instance_name("_baz", name, sizeof name, instance, + sizeof instance); + g_assert_cmpstr(name, ==, ""); + g_assert_cmpstr(instance, ==, "baz"); + + memset(name, 0xff, sizeof name); + memset(instance, 0xff, sizeof instance); + sc_snap_split_instance_name("foo", name, sizeof name, instance, + sizeof instance); + g_assert_cmpstr(name, ==, "foo"); + g_assert_cmpstr(instance, ==, ""); + + memset(name, 0xff, sizeof name); + sc_snap_split_instance_name("foo_bar", name, sizeof name, NULL, 0); + g_assert_cmpstr(name, ==, "foo"); + + memset(instance, 0xff, sizeof instance); + sc_snap_split_instance_name("foo_bar", NULL, 0, instance, + sizeof instance); + g_assert_cmpstr(instance, ==, "bar"); + + memset(name, 0xff, sizeof name); + memset(instance, 0xff, sizeof instance); + sc_snap_split_instance_name("hello_world_surprise", name, sizeof name, + instance, sizeof instance); + g_assert_cmpstr(name, ==, "hello"); + g_assert_cmpstr(instance, ==, "world_surprise"); + + memset(name, 0xff, sizeof name); + memset(instance, 0xff, sizeof instance); + sc_snap_split_instance_name("", name, sizeof name, instance, + sizeof instance); + g_assert_cmpstr(name, ==, ""); + g_assert_cmpstr(instance, ==, ""); + + memset(name, 0xff, sizeof name); + memset(instance, 0xff, sizeof instance); + sc_snap_split_instance_name("_", name, sizeof name, instance, + sizeof instance); + g_assert_cmpstr(name, ==, ""); + g_assert_cmpstr(instance, ==, ""); + + memset(name, 0xff, sizeof name); + memset(instance, 0xff, sizeof instance); + sc_snap_split_instance_name("foo_", name, sizeof name, instance, + sizeof instance); + g_assert_cmpstr(name, ==, "foo"); + g_assert_cmpstr(instance, ==, ""); +} + +static void __attribute__((constructor)) init(void) +{ + g_test_add_func("/snap/sc_security_tag_validate", + test_sc_security_tag_validate); + g_test_add_func("/snap/sc_is_hook_security_tag", + test_sc_is_hook_security_tag); + + g_test_add_data_func("/snap/sc_snap_name_validate", + sc_snap_name_validate, + test_sc_snap_or_instance_name_validate); + g_test_add_func("/snap/sc_snap_name_validate/respects_error_protocol", + test_sc_snap_name_validate__respects_error_protocol); + + g_test_add_data_func("/snap/sc_instance_name_validate/just_name", + sc_instance_name_validate, + test_sc_snap_or_instance_name_validate); + g_test_add_func("/snap/sc_instance_name_validate/full", + test_sc_instance_name_validate); + + g_test_add_func("/snap/sc_snap_drop_instance_key/basic", + test_sc_snap_drop_instance_key_basic); + g_test_add_func("/snap/sc_snap_drop_instance_key/no_dest", + test_sc_snap_drop_instance_key_no_dest); + g_test_add_func("/snap/sc_snap_drop_instance_key/no_name", + test_sc_snap_drop_instance_key_no_name); + g_test_add_func("/snap/sc_snap_drop_instance_key/short_dest", + test_sc_snap_drop_instance_key_short_dest); + g_test_add_func("/snap/sc_snap_drop_instance_key/short_dest2", + test_sc_snap_drop_instance_key_short_dest2); + g_test_add_func("/snap/sc_snap_drop_instance_key/short_dest_max", + test_sc_snap_drop_instance_key_short_dest_max); + + g_test_add_func("/snap/sc_snap_split_instance_name/basic", + test_sc_snap_split_instance_name_basic); + g_test_add_func("/snap/sc_snap_split_instance_name/trailing_nil", + test_sc_snap_split_instance_name_trailing_nil); + g_test_add_func("/snap/sc_snap_split_instance_name/short_instance_dest", + test_sc_snap_split_instance_name_short_instance_dest); +} diff --git a/cmd/libsnap-confine-private/snap.c b/cmd/libsnap-confine-private/snap.c new file mode 100644 index 00000000..b8291948 --- /dev/null +++ b/cmd/libsnap-confine-private/snap.c @@ -0,0 +1,323 @@ +/* + * Copyright (C) 2015 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +#include "config.h" +#include "snap.h" + +#include +#include +#include +#include +#include +#include + +#include "utils.h" +#include "string-utils.h" +#include "cleanup-funcs.h" + +bool sc_security_tag_validate(const char *security_tag, const char *snap_name) +{ + /* Don't even check overly long tags. */ + if (strlen(security_tag) > SNAP_SECURITY_TAG_MAX_LEN) { + return false; + } + const char *whitelist_re = + "^snap\\.([a-z0-9](-?[a-z0-9])*(_[a-z0-9]{1,10})?)\\.([a-zA-Z0-9](-?[a-zA-Z0-9])*|hook\\.[a-z](-?[a-z0-9])*)$"; + regex_t re; + if (regcomp(&re, whitelist_re, REG_EXTENDED) != 0) + die("can not compile regex %s", whitelist_re); + + // first capture is for verifying the full security tag, second capture + // for verifying the snap_name is correct for this security tag + regmatch_t matches[2]; + int status = + regexec(&re, security_tag, sizeof matches / sizeof *matches, + matches, 0); + regfree(&re); + + // Fail if no match or if snap name wasn't captured in the 2nd match group + if (status != 0 || matches[1].rm_so < 0) { + return false; + } + + size_t len = matches[1].rm_eo - matches[1].rm_so; + return len == strlen(snap_name) + && strncmp(security_tag + matches[1].rm_so, snap_name, len) == 0; +} + +bool sc_is_hook_security_tag(const char *security_tag) +{ + const char *whitelist_re = + "^snap\\.[a-z](-?[a-z0-9])*(_[a-z0-9]{1,10})?\\.(hook\\.[a-z](-?[a-z0-9])*)$"; + + regex_t re; + if (regcomp(&re, whitelist_re, REG_EXTENDED | REG_NOSUB) != 0) + die("can not compile regex %s", whitelist_re); + + int status = regexec(&re, security_tag, 0, NULL, 0); + regfree(&re); + + return status == 0; +} + +static int skip_lowercase_letters(const char **p) +{ + int skipped = 0; + for (const char *c = *p; *c >= 'a' && *c <= 'z'; ++c) { + skipped += 1; + } + *p = (*p) + skipped; + return skipped; +} + +static int skip_digits(const char **p) +{ + int skipped = 0; + for (const char *c = *p; *c >= '0' && *c <= '9'; ++c) { + skipped += 1; + } + *p = (*p) + skipped; + return skipped; +} + +static int skip_one_char(const char **p, char c) +{ + if (**p == c) { + *p += 1; + return 1; + } + return 0; +} + +void sc_instance_name_validate(const char *instance_name, sc_error **errorp) +{ + // NOTE: This function should be synchronized with the two other + // implementations: validate_instance_name and snap.ValidateInstanceName. + sc_error *err = NULL; + + // Ensure that name is not NULL + if (instance_name == NULL) { + err = + sc_error_init(SC_SNAP_DOMAIN, SC_SNAP_INVALID_INSTANCE_NAME, + "snap instance name cannot be NULL"); + goto out; + } + + if (strlen(instance_name) > SNAP_INSTANCE_LEN) { + err = + sc_error_init(SC_SNAP_DOMAIN, SC_SNAP_INVALID_INSTANCE_NAME, + "snap instance name can be at most %d characters long", + SNAP_INSTANCE_LEN); + goto out; + } + // instance name length + 1 extra overflow + 1 NULL + char s[SNAP_INSTANCE_LEN + 1 + 1] = { 0 }; + strncpy(s, instance_name, sizeof(s) - 1); + + char *t = s; + const char *snap_name = strsep(&t, "_"); + const char *instance_key = strsep(&t, "_"); + const char *third_separator = strsep(&t, "_"); + if (third_separator != NULL) { + err = + sc_error_init(SC_SNAP_DOMAIN, SC_SNAP_INVALID_INSTANCE_NAME, + "snap instance name can contain only one underscore"); + goto out; + } + + sc_snap_name_validate(snap_name, &err); + if (err != NULL) { + goto out; + } + // When the instance_name is a normal snap name, instance_key will be + // NULL, so only validate instance_key when we found one. + if (instance_key != NULL) { + sc_instance_key_validate(instance_key, &err); + } + + out: + sc_error_forward(errorp, err); +} + +void sc_instance_key_validate(const char *instance_key, sc_error **errorp) +{ + // NOTE: see snap.ValidateInstanceName for reference of a valid instance key + // format + sc_error *err = NULL; + + // Ensure that name is not NULL + if (instance_key == NULL) { + err = sc_error_init(SC_SNAP_DOMAIN, SC_SNAP_INVALID_NAME, + "instance key cannot be NULL"); + goto out; + } + // This is a regexp-free routine hand-coding the following pattern: + // + // "^[a-z]{1,10}$" + // + // The only motivation for not using regular expressions is so that we don't + // run untrusted input against a potentially complex regular expression + // engine. + int i = 0; + for (i = 0; instance_key[i] != '\0'; i++) { + if (islower(instance_key[i]) || isdigit(instance_key[i])) { + continue; + } + err = + sc_error_init(SC_SNAP_DOMAIN, SC_SNAP_INVALID_INSTANCE_KEY, + "instance key must use lower case letters or digits"); + goto out; + } + + if (i == 0) { + err = + sc_error_init(SC_SNAP_DOMAIN, SC_SNAP_INVALID_INSTANCE_KEY, + "instance key must contain at least one letter or digit"); + } else if (i > SNAP_INSTANCE_KEY_LEN) { + err = + sc_error_init(SC_SNAP_DOMAIN, SC_SNAP_INVALID_INSTANCE_KEY, + "instance key must be shorter than 10 characters"); + } + out: + sc_error_forward(errorp, err); +} + +void sc_snap_name_validate(const char *snap_name, sc_error **errorp) +{ + // NOTE: This function should be synchronized with the two other + // implementations: validate_snap_name and snap.ValidateName. + sc_error *err = NULL; + + // Ensure that name is not NULL + if (snap_name == NULL) { + err = sc_error_init(SC_SNAP_DOMAIN, SC_SNAP_INVALID_NAME, + "snap name cannot be NULL"); + goto out; + } + // This is a regexp-free routine hand-codes the following pattern: + // + // "^([a-z0-9]+-?)*[a-z](-?[a-z0-9])*$" + // + // The only motivation for not using regular expressions is so that we + // don't run untrusted input against a potentially complex regular + // expression engine. + const char *p = snap_name; + if (skip_one_char(&p, '-')) { + err = sc_error_init(SC_SNAP_DOMAIN, SC_SNAP_INVALID_NAME, + "snap name cannot start with a dash"); + goto out; + } + bool got_letter = false; + int n = 0, m; + for (; *p != '\0';) { + if ((m = skip_lowercase_letters(&p)) > 0) { + n += m; + got_letter = true; + continue; + } + if ((m = skip_digits(&p)) > 0) { + n += m; + continue; + } + if (skip_one_char(&p, '-') > 0) { + n++; + if (*p == '\0') { + err = + sc_error_init(SC_SNAP_DOMAIN, + SC_SNAP_INVALID_NAME, + "snap name cannot end with a dash"); + goto out; + } + if (skip_one_char(&p, '-') > 0) { + err = + sc_error_init(SC_SNAP_DOMAIN, + SC_SNAP_INVALID_NAME, + "snap name cannot contain two consecutive dashes"); + goto out; + } + continue; + } + err = sc_error_init(SC_SNAP_DOMAIN, SC_SNAP_INVALID_NAME, + "snap name must use lower case letters, digits or dashes"); + goto out; + } + if (!got_letter) { + err = sc_error_init(SC_SNAP_DOMAIN, SC_SNAP_INVALID_NAME, + "snap name must contain at least one letter"); + goto out; + } + if (n < 2) { + err = sc_error_init(SC_SNAP_DOMAIN, SC_SNAP_INVALID_NAME, + "snap name must be longer than 1 character"); + goto out; + } + if (n > SNAP_NAME_LEN) { + err = sc_error_init(SC_SNAP_DOMAIN, SC_SNAP_INVALID_NAME, + "snap name must be shorter than 40 characters"); + goto out; + } + + out: + sc_error_forward(errorp, err); +} + +void sc_snap_drop_instance_key(const char *instance_name, char *snap_name, + size_t snap_name_size) +{ + sc_snap_split_instance_name(instance_name, snap_name, snap_name_size, + NULL, 0); +} + +void sc_snap_split_instance_name(const char *instance_name, char *snap_name, + size_t snap_name_size, char *instance_key, + size_t instance_key_size) +{ + if (instance_name == NULL) { + die("internal error: cannot split instance name when it is unset"); + } + if (snap_name == NULL && instance_key == NULL) { + die("internal error: cannot split instance name when both snap name and instance key are unset"); + } + + const char *pos = strchr(instance_name, '_'); + const char *instance_key_start = ""; + size_t snap_name_len = 0; + size_t instance_key_len = 0; + if (pos == NULL) { + snap_name_len = strlen(instance_name); + } else { + snap_name_len = pos - instance_name; + instance_key_start = pos + 1; + instance_key_len = strlen(instance_key_start); + } + + if (snap_name != NULL) { + if (snap_name_len >= snap_name_size) { + die("snap name buffer too small"); + } + + memcpy(snap_name, instance_name, snap_name_len); + snap_name[snap_name_len] = '\0'; + } + + if (instance_key != NULL) { + if (instance_key_len >= instance_key_size) { + die("instance key buffer too small"); + } + memcpy(instance_key, instance_key_start, instance_key_len); + instance_key[instance_key_len] = '\0'; + } +} diff --git a/cmd/libsnap-confine-private/snap.h b/cmd/libsnap-confine-private/snap.h new file mode 100644 index 00000000..31d0777d --- /dev/null +++ b/cmd/libsnap-confine-private/snap.h @@ -0,0 +1,136 @@ +/* + * Copyright (C) 2015 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#ifndef SNAP_CONFINE_SNAP_H +#define SNAP_CONFINE_SNAP_H + +#include +#include + +#include "error.h" + +/** + * Error domain for errors related to the snap module. + **/ +#define SC_SNAP_DOMAIN "snap" + +enum { + /** The name of the snap is not valid. */ + SC_SNAP_INVALID_NAME = 1, + /** The instance key of the snap is not valid. */ + SC_SNAP_INVALID_INSTANCE_KEY = 2, + /** The instance of the snap is not valid. */ + SC_SNAP_INVALID_INSTANCE_NAME = 3, +}; + +/* SNAP_NAME_LEN is the maximum length of a snap name, enforced by snapd and the + * store. */ +#define SNAP_NAME_LEN 40 +/* SNAP_INSTANCE_KEY_LEN is the maximum length of instance key, enforced locally + * by snapd. */ +#define SNAP_INSTANCE_KEY_LEN 10 +/* SNAP_INSTANCE_LEN is the maximum length of snap instance name, composed of + * the snap name, separator '_' and the instance key, enforced locally by + * snapd. */ +#define SNAP_INSTANCE_LEN (SNAP_NAME_LEN + 1 + SNAP_INSTANCE_KEY_LEN) +/* SNAP_SECURITY_TAG_MAX_LEN is the maximum length of a security tag string + * (not buffer). This is an upper limit. In practice the security tag name is + * bound by SNAP_NAME_LEN, SNAP_INSTANCE_KEY_LEN, maximum length of an + * application name as well as a constant overhead of "snap", the optional + * "hook" and the "." characters connecting the components. */ +#define SNAP_SECURITY_TAG_MAX_LEN 256 + +/** + * Validate the given snap name. + * + * Valid name cannot be NULL and must match a regular expression describing the + * strict naming requirements. Please refer to snapd source code for details. + * + * The error protocol is observed so if the caller doesn't provide an outgoing + * error pointer the function will die on any error. + **/ +void sc_snap_name_validate(const char *snap_name, struct sc_error **errorp); + +/** + * Validate the given instance key. + * + * Valid instance key cannot be NULL and must match a regular expression + * describing the strict naming requirements. Please refer to snapd source code + * for details. + * + * The error protocol is observed so if the caller doesn't provide an outgoing + * error pointer the function will die on any error. + **/ +void sc_instance_key_validate(const char *instance_key, + struct sc_error **errorp); + +/** + * Validate the given snap instance name. + * + * Valid instance name must be composed of a valid snap name and a valid + * instance key. + * + * The error protocol is observed so if the caller doesn't provide an outgoing + * error pointer the function will die on any error. + **/ +void sc_instance_name_validate(const char *instance_name, + struct sc_error **errorp); + +/** + * Validate security tag against strict naming requirements and snap name. + * + * The executable name is of form: + * snap..(|hook.) + * - must start with lowercase letter, then may contain + * lowercase alphanumerics and '-'; it must match snap_name + * - may contain alphanumerics and '-' + * - snap, just-snap => just-snap + **/ +void sc_snap_drop_instance_key(const char *instance_name, char *snap_name, + size_t snap_name_size); + +/** + * Extract snap name and instance key out of an instance name. + * + * A snap may be installed multiple times in parallel under distinct instance + * names. This function extracts the snap name and instance key out of the + * instance name. One of snap_name, instance_key must be non-NULL. + * + * For example: + * name_instance => "name" & "instance" + * just-name => "just-name" & "" + * + **/ +void sc_snap_split_instance_name(const char *instance_name, char *snap_name, + size_t snap_name_size, char *instance_key, + size_t instance_key_size); + +#endif diff --git a/cmd/libsnap-confine-private/string-utils-test.c b/cmd/libsnap-confine-private/string-utils-test.c new file mode 100644 index 00000000..6c8e3f8c --- /dev/null +++ b/cmd/libsnap-confine-private/string-utils-test.c @@ -0,0 +1,877 @@ +/* + * Copyright (C) 2016-2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "string-utils.h" +#include "string-utils.c" + +#include + +static void test_sc_streq(void) +{ + g_assert_false(sc_streq(NULL, NULL)); + g_assert_false(sc_streq(NULL, "text")); + g_assert_false(sc_streq("text", NULL)); + g_assert_false(sc_streq("foo", "bar")); + g_assert_false(sc_streq("foo", "barbar")); + g_assert_false(sc_streq("foofoo", "bar")); + g_assert_true(sc_streq("text", "text")); + g_assert_true(sc_streq("", "")); +} + +static void test_sc_endswith(void) +{ + // NULL doesn't end with anything, nothing ends with NULL + g_assert_false(sc_endswith("", NULL)); + g_assert_false(sc_endswith(NULL, "")); + g_assert_false(sc_endswith(NULL, NULL)); + // Empty string ends with an empty string + g_assert_true(sc_endswith("", "")); + // Ends-with (matches) + g_assert_true(sc_endswith("foobar", "bar")); + g_assert_true(sc_endswith("foobar", "ar")); + g_assert_true(sc_endswith("foobar", "r")); + g_assert_true(sc_endswith("foobar", "")); + g_assert_true(sc_endswith("bar", "bar")); + // Ends-with (non-matches) + g_assert_false(sc_endswith("foobar", "quux")); + g_assert_false(sc_endswith("", "bar")); + g_assert_false(sc_endswith("b", "bar")); + g_assert_false(sc_endswith("ba", "bar")); +} + +static void test_sc_startswith(void) +{ + // NULL doesn't start with anything, nothing starts with NULL + g_assert_false(sc_startswith("", NULL)); + g_assert_false(sc_startswith(NULL, "")); + g_assert_false(sc_startswith(NULL, NULL)); + // Empty string starts with an empty string + g_assert_true(sc_startswith("", "")); + // Starts-with (matches) + g_assert_true(sc_startswith("foobar", "foo")); + g_assert_true(sc_startswith("foobar", "fo")); + g_assert_true(sc_startswith("foobar", "f")); + g_assert_true(sc_startswith("foobar", "")); + g_assert_true(sc_startswith("bar", "bar")); + // Starts-with (non-matches) + g_assert_false(sc_startswith("foobar", "quux")); + g_assert_false(sc_startswith("", "bar")); + g_assert_false(sc_startswith("b", "bar")); + g_assert_false(sc_startswith("ba", "bar")); +} + +static void test_sc_must_snprintf(void) +{ + char buf[5] = { 0 }; + sc_must_snprintf(buf, sizeof buf, "1234"); + g_assert_cmpstr(buf, ==, "1234"); +} + +static void test_sc_must_snprintf__fail(void) +{ + if (g_test_subprocess()) { + char buf[5]; + sc_must_snprintf(buf, sizeof buf, "12345"); + g_test_message("expected sc_must_snprintf not to return"); + g_test_fail(); + return; + } + g_test_trap_subprocess(NULL, 0, 0); + g_test_trap_assert_failed(); + g_test_trap_assert_stderr("cannot format string: 1234\n"); +} + +// Check that appending to a buffer works OK. +static void test_sc_string_append(void) +{ + union { + char bigbuf[6]; + struct { + signed char canary1; + char buf[4]; + signed char canary2; + }; + } data = { + .buf = { + 'f', '\0', 0xFF, 0xFF},.canary1 = ~0,.canary2 = ~0, + }; + + // Sanity check, ensure that the layout of structures is as spelled above. + // (first canary1, then buf and finally canary2. + g_assert_cmpint(((char *)&data.buf[0]) - ((char *)&data.canary1), ==, + 1); + g_assert_cmpint(((char *)&data.buf[4]) - ((char *)&data.canary2), ==, + 0); + + sc_string_append(data.buf, sizeof data.buf, "oo"); + + // Check that we didn't corrupt either canary. + g_assert_cmpint(data.canary1, ==, ~0); + g_assert_cmpint(data.canary2, ==, ~0); + + // Check that we got the result that was expected. + g_assert_cmpstr(data.buf, ==, "foo"); +} + +// Check that appending an empty string to a full buffer is valid. +static void test_sc_string_append__empty_to_full(void) +{ + union { + char bigbuf[6]; + struct { + signed char canary1; + char buf[4]; + signed char canary2; + }; + } data = { + .buf = { + 'f', 'o', 'o', '\0'},.canary1 = ~0,.canary2 = ~0, + }; + + // Sanity check, ensure that the layout of structures is as spelled above. + // (first canary1, then buf and finally canary2. + g_assert_cmpint(((char *)&data.buf[0]) - ((char *)&data.canary1), ==, + 1); + g_assert_cmpint(((char *)&data.buf[4]) - ((char *)&data.canary2), ==, + 0); + + sc_string_append(data.buf, sizeof data.buf, ""); + + // Check that we didn't corrupt either canary. + g_assert_cmpint(data.canary1, ==, ~0); + g_assert_cmpint(data.canary2, ==, ~0); + + // Check that we got the result that was expected. + g_assert_cmpstr(data.buf, ==, "foo"); +} + +// Check that the overflow detection works. +static void test_sc_string_append__overflow(void) +{ + if (g_test_subprocess()) { + char buf[4] = { 0 }; +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Warray-bounds" + // Try to append a string that's one character too long. + sc_string_append(buf, sizeof buf, "1234"); +#pragma GCC diagnostic pop + g_test_message("expected sc_string_append not to return"); + g_test_fail(); + return; + } + g_test_trap_subprocess(NULL, 0, 0); + g_test_trap_assert_failed(); + g_test_trap_assert_stderr + ("cannot append string: str is too long or unterminated\n"); +} + +// Check that the uninitialized buffer detection works. +static void test_sc_string_append__uninitialized_buf(void) +{ + if (g_test_subprocess()) { + char buf[4] = { 0xFF, 0xFF, 0xFF, 0xFF }; + + // Try to append a string to a buffer which is not a valic C-string. + sc_string_append(buf, sizeof buf, ""); + + g_test_message("expected sc_string_append not to return"); + g_test_fail(); + return; + } + g_test_trap_subprocess(NULL, 0, 0); + g_test_trap_assert_failed(); + g_test_trap_assert_stderr + ("cannot append string: dst is unterminated\n"); +} + +// Check that `buf' cannot be NULL. +static void test_sc_string_append__NULL_buf(void) +{ + if (g_test_subprocess()) { + char buf[4]; + + sc_string_append(NULL, sizeof buf, "foo"); + + g_test_message("expected sc_string_append not to return"); + g_test_fail(); + return; + } + g_test_trap_subprocess(NULL, 0, 0); + g_test_trap_assert_failed(); + g_test_trap_assert_stderr("cannot append string: buffer is NULL\n"); +} + +// Check that `src' cannot be NULL. +static void test_sc_string_append__NULL_str(void) +{ + if (g_test_subprocess()) { + char buf[4]; + + sc_string_append(buf, sizeof buf, NULL); + + g_test_message("expected sc_string_append not to return"); + g_test_fail(); + return; + } + g_test_trap_subprocess(NULL, 0, 0); + g_test_trap_assert_failed(); + g_test_trap_assert_stderr("cannot append string: string is NULL\n"); +} + +static void test_sc_string_init__normal(void) +{ + char buf[1] = { 0xFF }; + + sc_string_init(buf, sizeof buf); + g_assert_cmpint(buf[0], ==, 0); +} + +static void test_sc_string_init__empty_buf(void) +{ + if (g_test_subprocess()) { + char buf[1] = { 0xFF }; + + sc_string_init(buf, 0); + + g_test_message("expected sc_string_init not to return"); + g_test_fail(); + return; + } + g_test_trap_subprocess(NULL, 0, 0); + g_test_trap_assert_failed(); + g_test_trap_assert_stderr + ("cannot initialize string, buffer is too small\n"); +} + +static void test_sc_string_init__NULL_buf(void) +{ + if (g_test_subprocess()) { + sc_string_init(NULL, 1); + + g_test_message("expected sc_string_init not to return"); + g_test_fail(); + return; + } + g_test_trap_subprocess(NULL, 0, 0); + g_test_trap_assert_failed(); + g_test_trap_assert_stderr("cannot initialize string, buffer is NULL\n"); +} + +static void test_sc_string_append_char__uninitialized_buf(void) +{ + if (g_test_subprocess()) { + char buf[2] = { 0xFF, 0xFF }; + sc_string_append_char(buf, sizeof buf, 'a'); + + g_test_message("expected sc_string_append_char not to return"); + g_test_fail(); + return; + } + g_test_trap_subprocess(NULL, 0, 0); + g_test_trap_assert_failed(); + g_test_trap_assert_stderr + ("cannot append character: dst is unterminated\n"); +} + +static void test_sc_string_append_char__NULL_buf(void) +{ + if (g_test_subprocess()) { + sc_string_append_char(NULL, 2, 'a'); + + g_test_message("expected sc_string_append_char not to return"); + g_test_fail(); + return; + } + g_test_trap_subprocess(NULL, 0, 0); + g_test_trap_assert_failed(); + g_test_trap_assert_stderr("cannot append character: buffer is NULL\n"); +} + +static void test_sc_string_append_char__overflow(void) +{ + if (g_test_subprocess()) { + char buf[1] = { 0 }; +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Warray-bounds" + sc_string_append_char(buf, sizeof buf, 'a'); +#pragma GCC diagnostic pop + g_test_message("expected sc_string_append_char not to return"); + g_test_fail(); + return; + } + g_test_trap_subprocess(NULL, 0, 0); + g_test_trap_assert_failed(); + g_test_trap_assert_stderr + ("cannot append character: not enough space\n"); +} + +static void test_sc_string_append_char__invalid_zero(void) +{ + if (g_test_subprocess()) { + char buf[2] = { 0 }; + sc_string_append_char(buf, sizeof buf, '\0'); + + g_test_message("expected sc_string_append_char not to return"); + g_test_fail(); + return; + } + g_test_trap_subprocess(NULL, 0, 0); + g_test_trap_assert_failed(); + g_test_trap_assert_stderr + ("cannot append character: cannot append string terminator\n"); +} + +static void test_sc_string_append_char__normal(void) +{ + char buf[16]; + size_t len; + sc_string_init(buf, sizeof buf); + + len = sc_string_append_char(buf, sizeof buf, 'h'); + g_assert_cmpstr(buf, ==, "h"); + g_assert_cmpint(len, ==, 1); + len = sc_string_append_char(buf, sizeof buf, 'e'); + g_assert_cmpstr(buf, ==, "he"); + g_assert_cmpint(len, ==, 2); + len = sc_string_append_char(buf, sizeof buf, 'l'); + g_assert_cmpstr(buf, ==, "hel"); + g_assert_cmpint(len, ==, 3); + len = sc_string_append_char(buf, sizeof buf, 'l'); + g_assert_cmpstr(buf, ==, "hell"); + g_assert_cmpint(len, ==, 4); + len = sc_string_append_char(buf, sizeof buf, 'o'); + g_assert_cmpstr(buf, ==, "hello"); + g_assert_cmpint(len, ==, 5); +} + +static void test_sc_string_append_char_pair__uninitialized_buf(void) +{ + if (g_test_subprocess()) { + char buf[3] = { 0xFF, 0xFF, 0xFF }; + sc_string_append_char_pair(buf, sizeof buf, 'a', 'b'); + + g_test_message + ("expected sc_string_append_char_pair not to return"); + g_test_fail(); + return; + } + g_test_trap_subprocess(NULL, 0, 0); + g_test_trap_assert_failed(); + g_test_trap_assert_stderr + ("cannot append character pair: dst is unterminated\n"); +} + +static void test_sc_string_append_char_pair__NULL_buf(void) +{ + if (g_test_subprocess()) { + sc_string_append_char_pair(NULL, 3, 'a', 'b'); + + g_test_message + ("expected sc_string_append_char_pair not to return"); + g_test_fail(); + return; + } + g_test_trap_subprocess(NULL, 0, 0); + g_test_trap_assert_failed(); + g_test_trap_assert_stderr + ("cannot append character pair: buffer is NULL\n"); +} + +static void test_sc_string_append_char_pair__overflow(void) +{ + if (g_test_subprocess()) { + char buf[2] = { 0 }; +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Warray-bounds" + sc_string_append_char_pair(buf, sizeof buf, 'a', 'b'); +#pragma GCC diagnostic pop + g_test_message + ("expected sc_string_append_char_pair not to return"); + g_test_fail(); + return; + } + g_test_trap_subprocess(NULL, 0, 0); + g_test_trap_assert_failed(); + g_test_trap_assert_stderr + ("cannot append character pair: not enough space\n"); +} + +static void test_sc_string_append_char_pair__invalid_zero_c1(void) +{ + if (g_test_subprocess()) { + char buf[3] = { 0 }; + sc_string_append_char_pair(buf, sizeof buf, '\0', 'a'); + + g_test_message + ("expected sc_string_append_char_pair not to return"); + g_test_fail(); + return; + } + g_test_trap_subprocess(NULL, 0, 0); + g_test_trap_assert_failed(); + g_test_trap_assert_stderr + ("cannot append character pair: cannot append string terminator\n"); +} + +static void test_sc_string_append_char_pair__invalid_zero_c2(void) +{ + if (g_test_subprocess()) { + char buf[3] = { 0 }; + sc_string_append_char_pair(buf, sizeof buf, 'a', '\0'); + + g_test_message + ("expected sc_string_append_char_pair not to return"); + g_test_fail(); + return; + } + g_test_trap_subprocess(NULL, 0, 0); + g_test_trap_assert_failed(); + g_test_trap_assert_stderr + ("cannot append character pair: cannot append string terminator\n"); +} + +static void test_sc_string_append_char_pair__normal(void) +{ + char buf[16]; + size_t len; + sc_string_init(buf, sizeof buf); + + len = sc_string_append_char_pair(buf, sizeof buf, 'h', 'e'); + g_assert_cmpstr(buf, ==, "he"); + g_assert_cmpint(len, ==, 2); + len = sc_string_append_char_pair(buf, sizeof buf, 'l', 'l'); + g_assert_cmpstr(buf, ==, "hell"); + g_assert_cmpint(len, ==, 4); + len = sc_string_append_char_pair(buf, sizeof buf, 'o', '!'); + g_assert_cmpstr(buf, ==, "hello!"); + g_assert_cmpint(len, ==, 6); +} + +static void test_sc_string_quote_NULL_str(void) +{ + if (g_test_subprocess()) { + char buf[16] = { 0 }; + sc_string_quote(buf, sizeof buf, NULL); + + g_test_message("expected sc_string_quote not to return"); + g_test_fail(); + return; + } + g_test_trap_subprocess(NULL, 0, 0); + g_test_trap_assert_failed(); + g_test_trap_assert_stderr("cannot quote string: string is NULL\n"); +} + +static void test_quoting_of(bool tested[], int c, const char *expected) +{ + char buf[16]; + + g_assert_cmpint(c, >=, 0); + g_assert_cmpint(c, <=, 255); + + // Create an input string with one character. + char input[2] = { (unsigned char)c, 0 }; + sc_string_quote(buf, sizeof buf, input); + + // Ensure it was quoted as we expected. + g_assert_cmpstr(buf, ==, expected); + + tested[c] = true; +} + +static void test_sc_string_quote(void) +{ +#define DQ "\"" + char buf[16]; + bool is_tested[256] = { false }; + + // Exhaustive test for quoting of every 8bit input. This is very verbose + // but the goal is to have a very obvious and correct test that ensures no + // edge case is lost. + // + // block 1: 0x00 - 0x0f + test_quoting_of(is_tested, 0x00, DQ "" DQ); + test_quoting_of(is_tested, 0x01, DQ "\\x01" DQ); + test_quoting_of(is_tested, 0x02, DQ "\\x02" DQ); + test_quoting_of(is_tested, 0x03, DQ "\\x03" DQ); + test_quoting_of(is_tested, 0x04, DQ "\\x04" DQ); + test_quoting_of(is_tested, 0x05, DQ "\\x05" DQ); + test_quoting_of(is_tested, 0x06, DQ "\\x06" DQ); + test_quoting_of(is_tested, 0x07, DQ "\\x07" DQ); + test_quoting_of(is_tested, 0x08, DQ "\\x08" DQ); + test_quoting_of(is_tested, 0x09, DQ "\\t" DQ); + test_quoting_of(is_tested, 0x0a, DQ "\\n" DQ); + test_quoting_of(is_tested, 0x0b, DQ "\\v" DQ); + test_quoting_of(is_tested, 0x0c, DQ "\\x0c" DQ); + test_quoting_of(is_tested, 0x0d, DQ "\\r" DQ); + test_quoting_of(is_tested, 0x0e, DQ "\\x0e" DQ); + test_quoting_of(is_tested, 0x0f, DQ "\\x0f" DQ); + // block 2: 0x10 - 0x1f + test_quoting_of(is_tested, 0x10, DQ "\\x10" DQ); + test_quoting_of(is_tested, 0x11, DQ "\\x11" DQ); + test_quoting_of(is_tested, 0x12, DQ "\\x12" DQ); + test_quoting_of(is_tested, 0x13, DQ "\\x13" DQ); + test_quoting_of(is_tested, 0x14, DQ "\\x14" DQ); + test_quoting_of(is_tested, 0x15, DQ "\\x15" DQ); + test_quoting_of(is_tested, 0x16, DQ "\\x16" DQ); + test_quoting_of(is_tested, 0x17, DQ "\\x17" DQ); + test_quoting_of(is_tested, 0x18, DQ "\\x18" DQ); + test_quoting_of(is_tested, 0x19, DQ "\\x19" DQ); + test_quoting_of(is_tested, 0x1a, DQ "\\x1a" DQ); + test_quoting_of(is_tested, 0x1b, DQ "\\x1b" DQ); + test_quoting_of(is_tested, 0x1c, DQ "\\x1c" DQ); + test_quoting_of(is_tested, 0x1d, DQ "\\x1d" DQ); + test_quoting_of(is_tested, 0x1e, DQ "\\x1e" DQ); + test_quoting_of(is_tested, 0x1f, DQ "\\x1f" DQ); + // block 3: 0x20 - 0x2f + test_quoting_of(is_tested, 0x20, DQ " " DQ); + test_quoting_of(is_tested, 0x21, DQ "!" DQ); + test_quoting_of(is_tested, 0x22, DQ "\\\"" DQ); + test_quoting_of(is_tested, 0x23, DQ "#" DQ); + test_quoting_of(is_tested, 0x24, DQ "$" DQ); + test_quoting_of(is_tested, 0x25, DQ "%" DQ); + test_quoting_of(is_tested, 0x26, DQ "&" DQ); + test_quoting_of(is_tested, 0x27, DQ "'" DQ); + test_quoting_of(is_tested, 0x28, DQ "(" DQ); + test_quoting_of(is_tested, 0x29, DQ ")" DQ); + test_quoting_of(is_tested, 0x2a, DQ "*" DQ); + test_quoting_of(is_tested, 0x2b, DQ "+" DQ); + test_quoting_of(is_tested, 0x2c, DQ "," DQ); + test_quoting_of(is_tested, 0x2d, DQ "-" DQ); + test_quoting_of(is_tested, 0x2e, DQ "." DQ); + test_quoting_of(is_tested, 0x2f, DQ "/" DQ); + // block 4: 0x30 - 0x3f + test_quoting_of(is_tested, 0x30, DQ "0" DQ); + test_quoting_of(is_tested, 0x31, DQ "1" DQ); + test_quoting_of(is_tested, 0x32, DQ "2" DQ); + test_quoting_of(is_tested, 0x33, DQ "3" DQ); + test_quoting_of(is_tested, 0x34, DQ "4" DQ); + test_quoting_of(is_tested, 0x35, DQ "5" DQ); + test_quoting_of(is_tested, 0x36, DQ "6" DQ); + test_quoting_of(is_tested, 0x37, DQ "7" DQ); + test_quoting_of(is_tested, 0x38, DQ "8" DQ); + test_quoting_of(is_tested, 0x39, DQ "9" DQ); + test_quoting_of(is_tested, 0x3a, DQ ":" DQ); + test_quoting_of(is_tested, 0x3b, DQ ";" DQ); + test_quoting_of(is_tested, 0x3c, DQ "<" DQ); + test_quoting_of(is_tested, 0x3d, DQ "=" DQ); + test_quoting_of(is_tested, 0x3e, DQ ">" DQ); + test_quoting_of(is_tested, 0x3f, DQ "?" DQ); + // block 5: 0x40 - 0x4f + test_quoting_of(is_tested, 0x40, DQ "@" DQ); + test_quoting_of(is_tested, 0x41, DQ "A" DQ); + test_quoting_of(is_tested, 0x42, DQ "B" DQ); + test_quoting_of(is_tested, 0x43, DQ "C" DQ); + test_quoting_of(is_tested, 0x44, DQ "D" DQ); + test_quoting_of(is_tested, 0x45, DQ "E" DQ); + test_quoting_of(is_tested, 0x46, DQ "F" DQ); + test_quoting_of(is_tested, 0x47, DQ "G" DQ); + test_quoting_of(is_tested, 0x48, DQ "H" DQ); + test_quoting_of(is_tested, 0x49, DQ "I" DQ); + test_quoting_of(is_tested, 0x4a, DQ "J" DQ); + test_quoting_of(is_tested, 0x4b, DQ "K" DQ); + test_quoting_of(is_tested, 0x4c, DQ "L" DQ); + test_quoting_of(is_tested, 0x4d, DQ "M" DQ); + test_quoting_of(is_tested, 0x4e, DQ "N" DQ); + test_quoting_of(is_tested, 0x4f, DQ "O" DQ); + // block 6: 0x50 - 0x5f + test_quoting_of(is_tested, 0x50, DQ "P" DQ); + test_quoting_of(is_tested, 0x51, DQ "Q" DQ); + test_quoting_of(is_tested, 0x52, DQ "R" DQ); + test_quoting_of(is_tested, 0x53, DQ "S" DQ); + test_quoting_of(is_tested, 0x54, DQ "T" DQ); + test_quoting_of(is_tested, 0x55, DQ "U" DQ); + test_quoting_of(is_tested, 0x56, DQ "V" DQ); + test_quoting_of(is_tested, 0x57, DQ "W" DQ); + test_quoting_of(is_tested, 0x58, DQ "X" DQ); + test_quoting_of(is_tested, 0x59, DQ "Y" DQ); + test_quoting_of(is_tested, 0x5a, DQ "Z" DQ); + test_quoting_of(is_tested, 0x5b, DQ "[" DQ); + test_quoting_of(is_tested, 0x5c, DQ "\\\\" DQ); + test_quoting_of(is_tested, 0x5d, DQ "]" DQ); + test_quoting_of(is_tested, 0x5e, DQ "^" DQ); + test_quoting_of(is_tested, 0x5f, DQ "_" DQ); + // block 7: 0x60 - 0x6f + test_quoting_of(is_tested, 0x60, DQ "`" DQ); + test_quoting_of(is_tested, 0x61, DQ "a" DQ); + test_quoting_of(is_tested, 0x62, DQ "b" DQ); + test_quoting_of(is_tested, 0x63, DQ "c" DQ); + test_quoting_of(is_tested, 0x64, DQ "d" DQ); + test_quoting_of(is_tested, 0x65, DQ "e" DQ); + test_quoting_of(is_tested, 0x66, DQ "f" DQ); + test_quoting_of(is_tested, 0x67, DQ "g" DQ); + test_quoting_of(is_tested, 0x68, DQ "h" DQ); + test_quoting_of(is_tested, 0x69, DQ "i" DQ); + test_quoting_of(is_tested, 0x6a, DQ "j" DQ); + test_quoting_of(is_tested, 0x6b, DQ "k" DQ); + test_quoting_of(is_tested, 0x6c, DQ "l" DQ); + test_quoting_of(is_tested, 0x6d, DQ "m" DQ); + test_quoting_of(is_tested, 0x6e, DQ "n" DQ); + test_quoting_of(is_tested, 0x6f, DQ "o" DQ); + // block 8: 0x70 - 0x7f + test_quoting_of(is_tested, 0x70, DQ "p" DQ); + test_quoting_of(is_tested, 0x71, DQ "q" DQ); + test_quoting_of(is_tested, 0x72, DQ "r" DQ); + test_quoting_of(is_tested, 0x73, DQ "s" DQ); + test_quoting_of(is_tested, 0x74, DQ "t" DQ); + test_quoting_of(is_tested, 0x75, DQ "u" DQ); + test_quoting_of(is_tested, 0x76, DQ "v" DQ); + test_quoting_of(is_tested, 0x77, DQ "w" DQ); + test_quoting_of(is_tested, 0x78, DQ "x" DQ); + test_quoting_of(is_tested, 0x79, DQ "y" DQ); + test_quoting_of(is_tested, 0x7a, DQ "z" DQ); + test_quoting_of(is_tested, 0x7b, DQ "{" DQ); + test_quoting_of(is_tested, 0x7c, DQ "|" DQ); + test_quoting_of(is_tested, 0x7d, DQ "}" DQ); + test_quoting_of(is_tested, 0x7e, DQ "~" DQ); + test_quoting_of(is_tested, 0x7f, DQ "\\x7f" DQ); + // block 9 (8-bit): 0x80 - 0x8f + test_quoting_of(is_tested, 0x80, DQ "\\x80" DQ); + test_quoting_of(is_tested, 0x81, DQ "\\x81" DQ); + test_quoting_of(is_tested, 0x82, DQ "\\x82" DQ); + test_quoting_of(is_tested, 0x83, DQ "\\x83" DQ); + test_quoting_of(is_tested, 0x84, DQ "\\x84" DQ); + test_quoting_of(is_tested, 0x85, DQ "\\x85" DQ); + test_quoting_of(is_tested, 0x86, DQ "\\x86" DQ); + test_quoting_of(is_tested, 0x87, DQ "\\x87" DQ); + test_quoting_of(is_tested, 0x88, DQ "\\x88" DQ); + test_quoting_of(is_tested, 0x89, DQ "\\x89" DQ); + test_quoting_of(is_tested, 0x8a, DQ "\\x8a" DQ); + test_quoting_of(is_tested, 0x8b, DQ "\\x8b" DQ); + test_quoting_of(is_tested, 0x8c, DQ "\\x8c" DQ); + test_quoting_of(is_tested, 0x8d, DQ "\\x8d" DQ); + test_quoting_of(is_tested, 0x8e, DQ "\\x8e" DQ); + test_quoting_of(is_tested, 0x8f, DQ "\\x8f" DQ); + // block 10 (8-bit): 0x90 - 0x9f + test_quoting_of(is_tested, 0x90, DQ "\\x90" DQ); + test_quoting_of(is_tested, 0x91, DQ "\\x91" DQ); + test_quoting_of(is_tested, 0x92, DQ "\\x92" DQ); + test_quoting_of(is_tested, 0x93, DQ "\\x93" DQ); + test_quoting_of(is_tested, 0x94, DQ "\\x94" DQ); + test_quoting_of(is_tested, 0x95, DQ "\\x95" DQ); + test_quoting_of(is_tested, 0x96, DQ "\\x96" DQ); + test_quoting_of(is_tested, 0x97, DQ "\\x97" DQ); + test_quoting_of(is_tested, 0x98, DQ "\\x98" DQ); + test_quoting_of(is_tested, 0x99, DQ "\\x99" DQ); + test_quoting_of(is_tested, 0x9a, DQ "\\x9a" DQ); + test_quoting_of(is_tested, 0x9b, DQ "\\x9b" DQ); + test_quoting_of(is_tested, 0x9c, DQ "\\x9c" DQ); + test_quoting_of(is_tested, 0x9d, DQ "\\x9d" DQ); + test_quoting_of(is_tested, 0x9e, DQ "\\x9e" DQ); + test_quoting_of(is_tested, 0x9f, DQ "\\x9f" DQ); + // block 11 (8-bit): 0xa0 - 0xaf + test_quoting_of(is_tested, 0xa0, DQ "\\xa0" DQ); + test_quoting_of(is_tested, 0xa1, DQ "\\xa1" DQ); + test_quoting_of(is_tested, 0xa2, DQ "\\xa2" DQ); + test_quoting_of(is_tested, 0xa3, DQ "\\xa3" DQ); + test_quoting_of(is_tested, 0xa4, DQ "\\xa4" DQ); + test_quoting_of(is_tested, 0xa5, DQ "\\xa5" DQ); + test_quoting_of(is_tested, 0xa6, DQ "\\xa6" DQ); + test_quoting_of(is_tested, 0xa7, DQ "\\xa7" DQ); + test_quoting_of(is_tested, 0xa8, DQ "\\xa8" DQ); + test_quoting_of(is_tested, 0xa9, DQ "\\xa9" DQ); + test_quoting_of(is_tested, 0xaa, DQ "\\xaa" DQ); + test_quoting_of(is_tested, 0xab, DQ "\\xab" DQ); + test_quoting_of(is_tested, 0xac, DQ "\\xac" DQ); + test_quoting_of(is_tested, 0xad, DQ "\\xad" DQ); + test_quoting_of(is_tested, 0xae, DQ "\\xae" DQ); + test_quoting_of(is_tested, 0xaf, DQ "\\xaf" DQ); + // block 12 (8-bit): 0xb0 - 0xbf + test_quoting_of(is_tested, 0xb0, DQ "\\xb0" DQ); + test_quoting_of(is_tested, 0xb1, DQ "\\xb1" DQ); + test_quoting_of(is_tested, 0xb2, DQ "\\xb2" DQ); + test_quoting_of(is_tested, 0xb3, DQ "\\xb3" DQ); + test_quoting_of(is_tested, 0xb4, DQ "\\xb4" DQ); + test_quoting_of(is_tested, 0xb5, DQ "\\xb5" DQ); + test_quoting_of(is_tested, 0xb6, DQ "\\xb6" DQ); + test_quoting_of(is_tested, 0xb7, DQ "\\xb7" DQ); + test_quoting_of(is_tested, 0xb8, DQ "\\xb8" DQ); + test_quoting_of(is_tested, 0xb9, DQ "\\xb9" DQ); + test_quoting_of(is_tested, 0xba, DQ "\\xba" DQ); + test_quoting_of(is_tested, 0xbb, DQ "\\xbb" DQ); + test_quoting_of(is_tested, 0xbc, DQ "\\xbc" DQ); + test_quoting_of(is_tested, 0xbd, DQ "\\xbd" DQ); + test_quoting_of(is_tested, 0xbe, DQ "\\xbe" DQ); + test_quoting_of(is_tested, 0xbf, DQ "\\xbf" DQ); + // block 13 (8-bit): 0xc0 - 0xcf + test_quoting_of(is_tested, 0xc0, DQ "\\xc0" DQ); + test_quoting_of(is_tested, 0xc1, DQ "\\xc1" DQ); + test_quoting_of(is_tested, 0xc2, DQ "\\xc2" DQ); + test_quoting_of(is_tested, 0xc3, DQ "\\xc3" DQ); + test_quoting_of(is_tested, 0xc4, DQ "\\xc4" DQ); + test_quoting_of(is_tested, 0xc5, DQ "\\xc5" DQ); + test_quoting_of(is_tested, 0xc6, DQ "\\xc6" DQ); + test_quoting_of(is_tested, 0xc7, DQ "\\xc7" DQ); + test_quoting_of(is_tested, 0xc8, DQ "\\xc8" DQ); + test_quoting_of(is_tested, 0xc9, DQ "\\xc9" DQ); + test_quoting_of(is_tested, 0xca, DQ "\\xca" DQ); + test_quoting_of(is_tested, 0xcb, DQ "\\xcb" DQ); + test_quoting_of(is_tested, 0xcc, DQ "\\xcc" DQ); + test_quoting_of(is_tested, 0xcd, DQ "\\xcd" DQ); + test_quoting_of(is_tested, 0xce, DQ "\\xce" DQ); + test_quoting_of(is_tested, 0xcf, DQ "\\xcf" DQ); + // block 14 (8-bit): 0xd0 - 0xdf + test_quoting_of(is_tested, 0xd0, DQ "\\xd0" DQ); + test_quoting_of(is_tested, 0xd1, DQ "\\xd1" DQ); + test_quoting_of(is_tested, 0xd2, DQ "\\xd2" DQ); + test_quoting_of(is_tested, 0xd3, DQ "\\xd3" DQ); + test_quoting_of(is_tested, 0xd4, DQ "\\xd4" DQ); + test_quoting_of(is_tested, 0xd5, DQ "\\xd5" DQ); + test_quoting_of(is_tested, 0xd6, DQ "\\xd6" DQ); + test_quoting_of(is_tested, 0xd7, DQ "\\xd7" DQ); + test_quoting_of(is_tested, 0xd8, DQ "\\xd8" DQ); + test_quoting_of(is_tested, 0xd9, DQ "\\xd9" DQ); + test_quoting_of(is_tested, 0xda, DQ "\\xda" DQ); + test_quoting_of(is_tested, 0xdb, DQ "\\xdb" DQ); + test_quoting_of(is_tested, 0xdc, DQ "\\xdc" DQ); + test_quoting_of(is_tested, 0xdd, DQ "\\xdd" DQ); + test_quoting_of(is_tested, 0xde, DQ "\\xde" DQ); + test_quoting_of(is_tested, 0xdf, DQ "\\xdf" DQ); + // block 15 (8-bit): 0xe0 - 0xef + test_quoting_of(is_tested, 0xe0, DQ "\\xe0" DQ); + test_quoting_of(is_tested, 0xe1, DQ "\\xe1" DQ); + test_quoting_of(is_tested, 0xe2, DQ "\\xe2" DQ); + test_quoting_of(is_tested, 0xe3, DQ "\\xe3" DQ); + test_quoting_of(is_tested, 0xe4, DQ "\\xe4" DQ); + test_quoting_of(is_tested, 0xe5, DQ "\\xe5" DQ); + test_quoting_of(is_tested, 0xe6, DQ "\\xe6" DQ); + test_quoting_of(is_tested, 0xe7, DQ "\\xe7" DQ); + test_quoting_of(is_tested, 0xe8, DQ "\\xe8" DQ); + test_quoting_of(is_tested, 0xe9, DQ "\\xe9" DQ); + test_quoting_of(is_tested, 0xea, DQ "\\xea" DQ); + test_quoting_of(is_tested, 0xeb, DQ "\\xeb" DQ); + test_quoting_of(is_tested, 0xec, DQ "\\xec" DQ); + test_quoting_of(is_tested, 0xed, DQ "\\xed" DQ); + test_quoting_of(is_tested, 0xee, DQ "\\xee" DQ); + test_quoting_of(is_tested, 0xef, DQ "\\xef" DQ); + // block 16 (8-bit): 0xf0 - 0xff + test_quoting_of(is_tested, 0xf0, DQ "\\xf0" DQ); + test_quoting_of(is_tested, 0xf1, DQ "\\xf1" DQ); + test_quoting_of(is_tested, 0xf2, DQ "\\xf2" DQ); + test_quoting_of(is_tested, 0xf3, DQ "\\xf3" DQ); + test_quoting_of(is_tested, 0xf4, DQ "\\xf4" DQ); + test_quoting_of(is_tested, 0xf5, DQ "\\xf5" DQ); + test_quoting_of(is_tested, 0xf6, DQ "\\xf6" DQ); + test_quoting_of(is_tested, 0xf7, DQ "\\xf7" DQ); + test_quoting_of(is_tested, 0xf8, DQ "\\xf8" DQ); + test_quoting_of(is_tested, 0xf9, DQ "\\xf9" DQ); + test_quoting_of(is_tested, 0xfa, DQ "\\xfa" DQ); + test_quoting_of(is_tested, 0xfb, DQ "\\xfb" DQ); + test_quoting_of(is_tested, 0xfc, DQ "\\xfc" DQ); + test_quoting_of(is_tested, 0xfd, DQ "\\xfd" DQ); + test_quoting_of(is_tested, 0xfe, DQ "\\xfe" DQ); + test_quoting_of(is_tested, 0xff, DQ "\\xff" DQ); + + // Ensure the search was exhaustive. + for (int i = 0; i <= 0xff; ++i) { + g_assert_true(is_tested[i]); + } + + // Few extra tests (repeated) for specific things. + + // Smoke test + sc_string_quote(buf, sizeof buf, "hello 123"); + g_assert_cmpstr(buf, ==, DQ "hello 123" DQ); + + // Whitespace + sc_string_quote(buf, sizeof buf, "\n"); + g_assert_cmpstr(buf, ==, DQ "\\n" DQ); + sc_string_quote(buf, sizeof buf, "\r"); + g_assert_cmpstr(buf, ==, DQ "\\r" DQ); + sc_string_quote(buf, sizeof buf, "\t"); + g_assert_cmpstr(buf, ==, DQ "\\t" DQ); + sc_string_quote(buf, sizeof buf, "\v"); + g_assert_cmpstr(buf, ==, DQ "\\v" DQ); + + // Escape character itself + sc_string_quote(buf, sizeof buf, "\\"); + g_assert_cmpstr(buf, ==, DQ "\\\\" DQ); + + // Double quote character + sc_string_quote(buf, sizeof buf, "\""); + g_assert_cmpstr(buf, ==, DQ "\\\"" DQ); + +#undef DQ +} + +static void test_sc_strdup(void) +{ + char *s = sc_strdup("snap install everything"); + g_assert_nonnull(s); + g_assert_cmpstr(s, ==, "snap install everything"); + free(s); +} + +static void __attribute__((constructor)) init(void) +{ + g_test_add_func("/string-utils/sc_streq", test_sc_streq); + g_test_add_func("/string-utils/sc_endswith", test_sc_endswith); + g_test_add_func("/string-utils/sc_startswith", test_sc_startswith); + g_test_add_func("/string-utils/sc_must_snprintf", + test_sc_must_snprintf); + g_test_add_func("/string-utils/sc_must_snprintf/fail", + test_sc_must_snprintf__fail); + g_test_add_func("/string-utils/sc_string_append/normal", + test_sc_string_append); + g_test_add_func("/string-utils/sc_string_append/empty_to_full", + test_sc_string_append__empty_to_full); + g_test_add_func("/string-utils/sc_string_append/overflow", + test_sc_string_append__overflow); + g_test_add_func("/string-utils/sc_string_append/uninitialized_buf", + test_sc_string_append__uninitialized_buf); + g_test_add_func("/string-utils/sc_string_append/NULL_buf", + test_sc_string_append__NULL_buf); + g_test_add_func("/string-utils/sc_string_append/NULL_str", + test_sc_string_append__NULL_str); + g_test_add_func("/string-utils/sc_string_init/normal", + test_sc_string_init__normal); + g_test_add_func("/string-utils/sc_string_init/empty_buf", + test_sc_string_init__empty_buf); + g_test_add_func("/string-utils/sc_string_init/NULL_buf", + test_sc_string_init__NULL_buf); + g_test_add_func + ("/string-utils/sc_string_append_char__uninitialized_buf", + test_sc_string_append_char__uninitialized_buf); + g_test_add_func("/string-utils/sc_string_append_char__NULL_buf", + test_sc_string_append_char__NULL_buf); + g_test_add_func("/string-utils/sc_string_append_char__overflow", + test_sc_string_append_char__overflow); + g_test_add_func("/string-utils/sc_string_append_char__invalid_zero", + test_sc_string_append_char__invalid_zero); + g_test_add_func("/string-utils/sc_string_append_char__normal", + test_sc_string_append_char__normal); + g_test_add_func + ("/string-utils/sc_string_append_char_pair__NULL_buf", + test_sc_string_append_char_pair__NULL_buf); + g_test_add_func("/string-utils/sc_string_append_char_pair__overflow", + test_sc_string_append_char_pair__overflow); + g_test_add_func + ("/string-utils/sc_string_append_char_pair__invalid_zero_c1", + test_sc_string_append_char_pair__invalid_zero_c1); + g_test_add_func + ("/string-utils/sc_string_append_char_pair__invalid_zero_c2", + test_sc_string_append_char_pair__invalid_zero_c2); + g_test_add_func("/string-utils/sc_string_append_char_pair__normal", + test_sc_string_append_char_pair__normal); + g_test_add_func("/string-utils/sc_string_quote__NULL_buf", + test_sc_string_quote_NULL_str); + g_test_add_func + ("/string-utils/sc_string_append_char_pair__uninitialized_buf", + test_sc_string_append_char_pair__uninitialized_buf); + g_test_add_func("/string-utils/sc_string_quote", test_sc_string_quote); + g_test_add_func("/string-utils/sc_strdup", test_sc_strdup); +} diff --git a/cmd/libsnap-confine-private/string-utils.c b/cmd/libsnap-confine-private/string-utils.c new file mode 100644 index 00000000..4c8dd290 --- /dev/null +++ b/cmd/libsnap-confine-private/string-utils.c @@ -0,0 +1,269 @@ +/* + * Copyright (C) 2016-2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "string-utils.h" + +#include +#include +#include +#include + +#include "utils.h" + +bool sc_streq(const char *a, const char *b) +{ + if (!a || !b) { + return false; + } + + return strcmp(a, b) == 0; +} + +bool sc_endswith(const char *str, const char *suffix) +{ + if (!str || !suffix) { + return false; + } + + size_t xlen = strlen(suffix); + size_t slen = strlen(str); + + if (slen < xlen) { + return false; + } + + return strncmp(str - xlen + slen, suffix, xlen) == 0; +} + +bool sc_startswith(const char *str, const char *prefix) +{ + if (!str || !prefix) { + return false; + } + + size_t xlen = strlen(prefix); + return strncmp(str, prefix, xlen) == 0; +} + +char *sc_strdup(const char *str) +{ + // Set errno in case we die. + errno = 0; + size_t len; + char *copy; + if (str == NULL) { + die("cannot duplicate NULL string"); + } + len = strlen(str); + copy = malloc(len + 1); + if (copy == NULL) { + die("cannot allocate string copy (len: %zd)", len); + } + memcpy(copy, str, len + 1); + return copy; +} + +int sc_must_snprintf(char *str, size_t size, const char *format, ...) +{ + // Set errno in case we die. + errno = 0; + int n; + + va_list va; + va_start(va, format); + n = vsnprintf(str, size, format, va); + va_end(va); + + if (n < 0 || (size_t)n >= size) + die("cannot format string: %s", str); + + return n; +} + +size_t sc_string_append(char *dst, size_t dst_size, const char *str) +{ + // Set errno in case we die. + errno = 0; + if (dst == NULL) { + die("cannot append string: buffer is NULL"); + } + if (str == NULL) { + die("cannot append string: string is NULL"); + } + size_t dst_len = strnlen(dst, dst_size); + if (dst_len == dst_size) { + die("cannot append string: dst is unterminated"); + } + + size_t max_str_len = dst_size - dst_len; + size_t str_len = strnlen(str, max_str_len); + if (str_len == max_str_len) { + die("cannot append string: str is too long or unterminated"); + } + // Append the string + memcpy(dst + dst_len, str, str_len); + // Ensure we are terminated + dst[dst_len + str_len] = '\0'; + // return the new size + return strlen(dst); +} + +size_t sc_string_append_char(char *dst, size_t dst_size, char c) +{ + // Set errno in case we die. + errno = 0; + if (dst == NULL) { + die("cannot append character: buffer is NULL"); + } + size_t dst_len = strnlen(dst, dst_size); + if (dst_len == dst_size) { + die("cannot append character: dst is unterminated"); + } + size_t max_str_len = dst_size - dst_len; + if (max_str_len < 2) { + die("cannot append character: not enough space"); + } + if (c == 0) { + die("cannot append character: cannot append string terminator"); + } + // Append the character and terminate the string. + dst[dst_len + 0] = c; + dst[dst_len + 1] = '\0'; + // Return the new size + return dst_len + 1; +} + +size_t sc_string_append_char_pair(char *dst, size_t dst_size, char c1, char c2) +{ + // Set errno in case we die. + errno = 0; + if (dst == NULL) { + die("cannot append character pair: buffer is NULL"); + } + size_t dst_len = strnlen(dst, dst_size); + if (dst_len == dst_size) { + die("cannot append character pair: dst is unterminated"); + } + size_t max_str_len = dst_size - dst_len; + if (max_str_len < 3) { + die("cannot append character pair: not enough space"); + } + if (c1 == 0 || c2 == 0) { + die("cannot append character pair: cannot append string terminator"); + } + // Append the two characters and terminate the string. + dst[dst_len + 0] = c1; + dst[dst_len + 1] = c2; + dst[dst_len + 2] = '\0'; + // Return the new size + return dst_len + 2; +} + +void sc_string_init(char *buf, size_t buf_size) +{ + errno = 0; + if (buf == NULL) { + die("cannot initialize string, buffer is NULL"); + } + if (buf_size == 0) { + die("cannot initialize string, buffer is too small"); + } + buf[0] = '\0'; +} + +void sc_string_quote(char *buf, size_t buf_size, const char *str) +{ + // Set errno in case we die. + errno = 0; + if (str == NULL) { + die("cannot quote string: string is NULL"); + } + const char *hex = "0123456789abcdef"; + // NOTE: this also checks buf/buf_size sanity so that we don't have to. + sc_string_init(buf, buf_size); + sc_string_append_char(buf, buf_size, '"'); + for (unsigned char c; (c = *str) != 0; ++str) { + switch (c) { + // Pass ASCII letters and digits unmodified. + case '0' ... '9': + case 'A' ... 'Z': + case 'a' ... 'z': + // Pass most of the punctuation unmodified. + case ' ': + case '!': + case '#': + case '$': + case '%': + case '&': + case '(': + case ')': + case '*': + case '+': + case ',': + case '-': + case '.': + case '/': + case ':': + case ';': + case '<': + case '=': + case '>': + case '?': + case '@': + case '[': + case '\'': + case ']': + case '^': + case '_': + case '`': + case '{': + case '|': + case '}': + case '~': + sc_string_append_char(buf, buf_size, c); + break; + // Escape special whitespace characters. + case '\n': + sc_string_append_char_pair(buf, buf_size, '\\', 'n'); + break; + case '\r': + sc_string_append_char_pair(buf, buf_size, '\\', 'r'); + break; + case '\t': + sc_string_append_char_pair(buf, buf_size, '\\', 't'); + break; + case '\v': + sc_string_append_char_pair(buf, buf_size, '\\', 'v'); + break; + // Escape the escape character. + case '\\': + sc_string_append_char_pair(buf, buf_size, '\\', '\\'); + break; + // Escape double quote character. + case '"': + sc_string_append_char_pair(buf, buf_size, '\\', '"'); + break; + // Escape everything else as a generic hexadecimal escape string. + default: + sc_string_append_char_pair(buf, buf_size, '\\', 'x'); + sc_string_append_char_pair(buf, buf_size, hex[c >> 4], + hex[c & 15]); + break; + } + } + sc_string_append_char(buf, buf_size, '"'); +} diff --git a/cmd/libsnap-confine-private/string-utils.h b/cmd/libsnap-confine-private/string-utils.h new file mode 100644 index 00000000..c04764b0 --- /dev/null +++ b/cmd/libsnap-confine-private/string-utils.h @@ -0,0 +1,116 @@ +/* + * Copyright (C) 2016-2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#ifndef SNAP_CONFINE_STRING_UTILS_H +#define SNAP_CONFINE_STRING_UTILS_H + +#include +#include + +/** + * Check if two strings are equal. + **/ +bool sc_streq(const char *a, const char *b); + +/** + * Check if a string has a given suffix. + **/ +bool sc_endswith(const char *str, const char *suffix); + +/** + * Check if a string has a given prefix. + **/ +bool sc_startswith(const char *str, const char *prefix); + +/** + * Allocate and return a copy of a string. +**/ +char *sc_strdup(const char *str); + +/** + * Safer version of snprintf. + * + * This version dies on any error condition. + **/ +__attribute__((format(printf, 3, 4))) +int sc_must_snprintf(char *str, size_t size, const char *format, ...); + +/** + * Append a string to a buffer containing a string. + * + * This version is fully aware of the destination buffer and is extra careful + * not to overflow it. If any argument is NULL or a buffer overflow is detected + * then the function dies. + * + * The buffers cannot overlap. + **/ +size_t sc_string_append(char *dst, size_t dst_size, const char *str); + +/** + * Append a single character to a buffer containing a string. + * + * This version is fully aware of the destination buffer and is extra careful + * not to overflow it. If any argument is NULL or a buffer overflow is detected + * then the function dies. + * + * The character cannot be the string terminator. + * + * The return value is the new length of the string. + **/ +size_t sc_string_append_char(char *dst, size_t dst_size, char c); + +/** + * Append a pair of characters to a buffer containing a string. + * + * This version is fully aware of the destination buffer and is extra careful + * not to overflow it. If any argument is NULL or a buffer overflow is detected + * then the function dies. + * + * Neither character can be the string terminator. + * + * The return value is the new length of the string. + **/ +size_t sc_string_append_char_pair(char *dst, size_t dst_size, char c1, char c2); + +/** + * Initialize a string (make it empty). + * + * Initialize a string as empty, ensuring buf is non-NULL buf_size is > 0. + **/ +void sc_string_init(char *buf, size_t buf_size); + +/** + * Quote a string so it is safe for printing. + * + * This function is fully aware of the destination buffer and is extra careful + * not to overflow it. If any argument is NULL or a buffer overflow is detected + * then the function dies. + * + * The function "quotes" the content of the given string into the given buffer. + * The buffer must be of sufficient size. Apart from letters and digits and + * some punctuation all characters are escaped using their hexadecimal escape + * codes. + * + * As a practical consideration the buffer should be of the following capacity: + * strlen(str) * 4 + 2 + 1; This corresponds to the most pessimistic escape + * process (each character is escaped to a hexadecimal value like \x05, two + * double-quote characters (one front, one rear) and the final string + * terminator character. + **/ +void sc_string_quote(char *buf, size_t buf_size, const char *str); + +#endif diff --git a/cmd/libsnap-confine-private/test-utils-test.c b/cmd/libsnap-confine-private/test-utils-test.c new file mode 100644 index 00000000..6d0a2d0c --- /dev/null +++ b/cmd/libsnap-confine-private/test-utils-test.c @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "test-utils.h" + +#include +#include + +#include + +// Check that rm_rf_tmp doesn't remove things outside of /tmp +static void test_rm_rf_tmp(void) +{ + if (access("/nonexistent", F_OK) == 0) { + g_test_message + ("/nonexistent exists but this test doesn't want it to"); + g_test_fail(); + return; + } + if (g_test_subprocess()) { + rm_rf_tmp("/nonexistent"); + return; + } + g_test_trap_subprocess(NULL, 0, 0); + g_test_trap_assert_failed(); +} + +static void test_test_argc_argv(void) +{ + // Check that test_argc_argv() correctly stores data + int argc = 0; + char **argv = NULL; + + test_argc_argv(&argc, &argv, NULL); + g_assert_cmpint(argc, ==, 0); + g_assert_nonnull(argv); + g_assert_null(argv[0]); + + argc = 0; + argv = NULL; + + test_argc_argv(&argc, &argv, "zero", "one", "two", NULL); + g_assert_cmpint(argc, ==, 3); + g_assert_nonnull(argv); + g_assert_cmpstr(argv[0], ==, "zero"); + g_assert_cmpstr(argv[1], ==, "one"); + g_assert_cmpstr(argv[2], ==, "two"); + g_assert_null(argv[3]); +} + +static void __attribute__((constructor)) init(void) +{ + g_test_add_func("/test-utils/rm_rf_tmp", test_rm_rf_tmp); + g_test_add_func("/test-utils/test_argc_argv", test_test_argc_argv); +} diff --git a/cmd/libsnap-confine-private/test-utils.c b/cmd/libsnap-confine-private/test-utils.c new file mode 100644 index 00000000..5cf4eb53 --- /dev/null +++ b/cmd/libsnap-confine-private/test-utils.c @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "test-utils.h" +#include "string-utils.h" + +#include "error.h" +#include "utils.h" + +#include + +#if !GLIB_CHECK_VERSION(2, 69, 0) +// g_spawn_check_exit_status is considered deprecated since 2.69 +#define g_spawn_check_wait_status(x, y) (g_spawn_check_exit_status (x, y)) +#endif + +void rm_rf_tmp(const char *dir) +{ + // Sanity check, don't remove anything that's not in the temporary + // directory. This is here to prevent unintended data loss. + if (!g_str_has_prefix(dir, "/tmp/")) + die("refusing to remove: %s", dir); + const gchar *working_directory = NULL; + gchar **argv = NULL; + gchar **envp = NULL; + GSpawnFlags flags = G_SPAWN_SEARCH_PATH; + GSpawnChildSetupFunc child_setup = NULL; + gpointer user_data = NULL; + gchar **standard_output = NULL; + gchar **standard_error = NULL; + gint exit_status = 0; + GError *error = NULL; + + argv = calloc(5, sizeof *argv); + if (argv == NULL) + die("cannot allocate command argument array"); + argv[0] = g_strdup("rm"); + if (argv[0] == NULL) + die("cannot allocate memory"); + argv[1] = g_strdup("-rf"); + if (argv[1] == NULL) + die("cannot allocate memory"); + argv[2] = g_strdup("--"); + if (argv[2] == NULL) + die("cannot allocate memory"); + argv[3] = g_strdup(dir); + if (argv[3] == NULL) + die("cannot allocate memory"); + argv[4] = NULL; + g_assert_true(g_spawn_sync + (working_directory, argv, envp, flags, child_setup, + user_data, standard_output, standard_error, &exit_status, + &error)); + g_assert_true(g_spawn_check_wait_status(exit_status, NULL)); + if (error != NULL) { + g_test_message("cannot remove temporary directory: %s\n", + error->message); + g_error_free(error); + } + g_free(argv[0]); + g_free(argv[1]); + g_free(argv[2]); + g_free(argv[3]); + g_free(argv); +} + +void + __attribute__((sentinel)) test_argc_argv(int *argcp, char ***argvp, ...) +{ + int argc = 0; + char **argv = NULL; + va_list ap; + + /* find out how many elements there are */ + va_start(ap, argvp); + while (NULL != va_arg(ap, const char *)) { + argc += 1; + } + va_end(ap); + + /* argc + terminating NULL entry */ + argv = calloc(argc + 1, sizeof argv[0]); + g_assert_nonnull(argv); + + va_start(ap, argvp); + for (int i = 0; i < argc; i++) { + const char *arg = va_arg(ap, const char *); + char *arg_copy = sc_strdup(arg); + g_test_queue_free(arg_copy); + argv[i] = arg_copy; + } + va_end(ap); + + /* free argv last, so that entries do not leak */ + g_test_queue_free(argv); + + *argcp = argc; + *argvp = argv; +} diff --git a/cmd/libsnap-confine-private/test-utils.h b/cmd/libsnap-confine-private/test-utils.h new file mode 100644 index 00000000..2b41ed93 --- /dev/null +++ b/cmd/libsnap-confine-private/test-utils.h @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#ifndef SNAP_CONFINE_TEST_UTILS_H +#define SNAP_CONFINE_TEST_UTILS_H + +/** + * Shell-out to "rm -rf -- $dir" as long as $dir is in /tmp. + */ +void rm_rf_tmp(const char *dir); + +/** + * Create an argc + argv pair out of a NULL terminated argument list. + **/ +void + __attribute__((sentinel)) test_argc_argv(int *argcp, char ***argvp, ...); + +#endif diff --git a/cmd/libsnap-confine-private/tool.c b/cmd/libsnap-confine-private/tool.c new file mode 100644 index 00000000..35b973d2 --- /dev/null +++ b/cmd/libsnap-confine-private/tool.c @@ -0,0 +1,255 @@ +/* + * Copyright (C) 2018 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#ifdef HAVE_CONFIG_H +#include "config.h" +#endif + +#include "tool.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "../libsnap-confine-private/apparmor-support.h" +#include "../libsnap-confine-private/cleanup-funcs.h" +#include "../libsnap-confine-private/string-utils.h" +#include "../libsnap-confine-private/utils.h" + +/** + * sc_open_snapd_tool returns a file descriptor of the given internal executable. + * + * The executable is located based on the location of the currently executing process. + * The returning file descriptor can be used with fexecve function, like in sc_call_snapd_tool. +**/ +static int sc_open_snapd_tool(const char *tool_name); + +/** + * sc_call_snapd_tool calls a snapd tool by file descriptor. + * + * The idea with calling with an open file descriptor is to allow calling executables + * across mount namespaces, where the executable may not be visible in the new filesystem + * anymore. The caller establishes an open file descriptor in one namespace and later on + * performs the call in another mount namespace. + * + * The environment vector has special support for expanding the string "SNAPD_DEBUG=x". + * If such string is present, the "x" is replaced with either "0" or "1" depending on + * the result of is_sc_debug_enabled(). + **/ +static void sc_call_snapd_tool(int tool_fd, const char *tool_name, char **argv, + char **envp); + +/** + * sc_call_snapd_tool_with_apparmor calls a snapd tool by file descriptor, + * possibly confining the program with a specific apparmor profile. +**/ +static void sc_call_snapd_tool_with_apparmor(int tool_fd, const char *tool_name, + struct sc_apparmor *apparmor, + const char *aa_profile, + char **argv, char **envp); + +int sc_open_snap_update_ns(void) +{ + return sc_open_snapd_tool("snap-update-ns"); +} + +void sc_call_snap_update_ns(int snap_update_ns_fd, const char *snap_name, + struct sc_apparmor *apparmor) +{ + char *snap_name_copy SC_CLEANUP(sc_cleanup_string) = NULL; + snap_name_copy = sc_strdup(snap_name); + + char aa_profile[PATH_MAX] = { 0 }; + sc_must_snprintf(aa_profile, sizeof aa_profile, "snap-update-ns.%s", + snap_name); + + char *argv[] = { + "snap-update-ns", + /* This tells snap-update-ns we are calling from snap-confine and locking is in place */ + "--from-snap-confine", + snap_name_copy, NULL + }; + char *envp[] = { "SNAPD_DEBUG=x", NULL }; + + /* Switch the group to root so that directories, files and locks created by + * snap-update-ns are owned by the root group. */ + sc_identity old = sc_set_effective_identity(sc_root_group_identity()); + sc_call_snapd_tool_with_apparmor(snap_update_ns_fd, + "snap-update-ns", apparmor, + aa_profile, argv, envp); + (void)sc_set_effective_identity(old); +} + +void sc_call_snap_update_ns_as_user(int snap_update_ns_fd, + const char *snap_name, + struct sc_apparmor *apparmor) +{ + char *snap_name_copy SC_CLEANUP(sc_cleanup_string) = NULL; + snap_name_copy = sc_strdup(snap_name); + + char aa_profile[PATH_MAX] = { 0 }; + sc_must_snprintf(aa_profile, sizeof aa_profile, "snap-update-ns.%s", + snap_name); + + const char *xdg_runtime_dir = getenv("XDG_RUNTIME_DIR"); + char xdg_runtime_dir_env[PATH_MAX + sizeof("XDG_RUNTIME_DIR=")] = { 0 }; + if (xdg_runtime_dir != NULL) { + sc_must_snprintf(xdg_runtime_dir_env, + sizeof(xdg_runtime_dir_env), + "XDG_RUNTIME_DIR=%s", xdg_runtime_dir); + } + + const char *snap_real_home = getenv("SNAP_REAL_HOME"); + char snap_real_home_env[PATH_MAX + sizeof("SNAP_REAL_HOME=")] = { 0 }; + if (snap_real_home != NULL) { + sc_must_snprintf(snap_real_home_env, + sizeof(snap_real_home_env), + "SNAP_REAL_HOME=%s", snap_real_home); + } + + char *argv[] = { + "snap-update-ns", + /* This tells snap-update-ns we are calling from snap-confine and locking is in place */ + "--from-snap-confine", + /* This tells snap-update-ns that we want to process the per-user profile */ + "--user-mounts", snap_name_copy, NULL + }; + char *envp[] = { + /* SNAPD_DEBUG=x is replaced by sc_call_snapd_tool_with_apparmor + * with either SNAPD_DEBUG=0 or SNAPD_DEBUG=1, see that function + * for details. */ + "SNAPD_DEBUG=x", + xdg_runtime_dir_env, + snap_real_home_env, NULL + }; + sc_call_snapd_tool_with_apparmor(snap_update_ns_fd, + "snap-update-ns", apparmor, + aa_profile, argv, envp); +} + +int sc_open_snap_discard_ns(void) +{ + return sc_open_snapd_tool("snap-discard-ns"); +} + +void sc_call_snap_discard_ns(int snap_discard_ns_fd, const char *snap_name) +{ + char *snap_name_copy SC_CLEANUP(sc_cleanup_string) = NULL; + snap_name_copy = sc_strdup(snap_name); + char *argv[] = + { "snap-discard-ns", "--from-snap-confine", snap_name_copy, NULL }; + /* SNAPD_DEBUG=x is replaced by sc_call_snapd_tool_with_apparmor with + * either SNAPD_DEBUG=0 or SNAPD_DEBUG=1, see that function for details. */ + char *envp[] = { "SNAPD_DEBUG=x", NULL }; + /* Switch the group to root so that directories and locks created by + * snap-discard-ns are owned by the root group. */ + sc_identity old = sc_set_effective_identity(sc_root_group_identity()); + sc_call_snapd_tool(snap_discard_ns_fd, "snap-discard-ns", argv, envp); + (void)sc_set_effective_identity(old); +} + +static int sc_open_snapd_tool(const char *tool_name) +{ + // +1 is for the case where the link is exactly PATH_MAX long but we also + // want to store the terminating '\0'. The readlink system call doesn't add + // terminating null, but our initialization of buf handles this for us. + char buf[PATH_MAX + 1] = { 0 }; + if (readlink("/proc/self/exe", buf, sizeof(buf) - 1) < 0) { + die("cannot readlink /proc/self/exe"); + } + if (buf[0] != '/') { // this shouldn't happen, but make sure have absolute path + die("readlink /proc/self/exe returned relative path"); + } + // as we are looking up other tools relative to our own path, check + // we are located where we think we should be - otherwise we + // may have been hardlink'd elsewhere and then may execute the + // wrong tool as a result + if (!sc_is_expected_path(buf)) { + die("running from unexpected location: %s", buf); + } + char *dir_name = dirname(buf); + int dir_fd SC_CLEANUP(sc_cleanup_close) = -1; + dir_fd = open(dir_name, O_PATH | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (dir_fd < 0) { + die("cannot open path %s", dir_name); + } + int tool_fd = -1; + tool_fd = openat(dir_fd, tool_name, O_PATH | O_NOFOLLOW | O_CLOEXEC); + if (tool_fd < 0) { + die("cannot open path %s/%s", dir_name, tool_name); + } + debug("opened %s executable as file descriptor %d", tool_name, tool_fd); + return tool_fd; +} + +static void sc_call_snapd_tool(int tool_fd, const char *tool_name, char **argv, + char **envp) +{ + sc_call_snapd_tool_with_apparmor(tool_fd, tool_name, NULL, NULL, argv, + envp); +} + +static void sc_call_snapd_tool_with_apparmor(int tool_fd, const char *tool_name, + struct sc_apparmor *apparmor, + const char *aa_profile, + char **argv, char **envp) +{ + debug("calling snapd tool %s", tool_name); + pid_t child = fork(); + if (child < 0) { + die("cannot fork to run snapd tool %s", tool_name); + } + if (child == 0) { + /* If the caller provided template environment entry for SNAPD_DEBUG + * then expand it to the actual value. */ + for (char **env = envp; + /* Mama mia, that's a spicy meatball. */ + env != NULL && *env != NULL && **env != '\0'; env++) { + if (sc_streq(*env, "SNAPD_DEBUG=x")) { + /* NOTE: this is not released, on purpose. */ + char *entry = sc_strdup(*env); + entry[strlen("SNAPD_DEBUG=x") - 1] = + sc_is_debug_enabled()? '1' : '0'; + *env = entry; + } + } + /* Switch apparmor profile for the process after exec. */ + if (apparmor != NULL && aa_profile != NULL) { + sc_maybe_aa_change_onexec(apparmor, aa_profile); + } + fexecve(tool_fd, argv, envp); + die("cannot execute snapd tool %s", tool_name); + } else { + int status = 0; + debug("waiting for snapd tool %s to terminate", tool_name); + if (waitpid(child, &status, 0) < 0) { + die("cannot get snapd tool %s termination status via waitpid", tool_name); + } + if (WIFEXITED(status) && WEXITSTATUS(status) != 0) { + die("%s failed with code %i", tool_name, + WEXITSTATUS(status)); + } else if (WIFSIGNALED(status)) { + die("%s killed by signal %i", tool_name, + WTERMSIG(status)); + } + debug("%s finished successfully", tool_name); + } +} diff --git a/cmd/libsnap-confine-private/tool.h b/cmd/libsnap-confine-private/tool.h new file mode 100644 index 00000000..784b0a6e --- /dev/null +++ b/cmd/libsnap-confine-private/tool.h @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2018 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#ifndef SNAP_CONFINE_TOOL_H +#define SNAP_CONFINE_TOOL_H + +/* Forward declaration, for real see apparmor-support.h */ +struct sc_apparmor; + +/** + * sc_open_snap_update_ns returns a file descriptor for the snap-update-ns tool. +**/ +int sc_open_snap_update_ns(void); + +/** + * sc_call_snap_update_ns calls snap-update-ns from snap-confine + **/ +void sc_call_snap_update_ns(int snap_update_ns_fd, const char *snap_name, + struct sc_apparmor *apparmor); + +/** + * sc_call_snap_update_ns calls snap-update-ns --user-mounts from snap-confine + **/ +void sc_call_snap_update_ns_as_user(int snap_update_ns_fd, + const char *snap_name, + struct sc_apparmor *apparmor); + +/** + * sc_open_snap_update_ns returns a file descriptor for the snap-discard-ns tool. +**/ +int sc_open_snap_discard_ns(void); + +/** + * sc_call_snap_discard_ns calls the snap-discard-ns from snap confine. +**/ +void sc_call_snap_discard_ns(int snap_discard_ns_fd, const char *snap_name); + +#endif diff --git a/cmd/libsnap-confine-private/unit-tests-main.c b/cmd/libsnap-confine-private/unit-tests-main.c new file mode 100644 index 00000000..f2c0993d --- /dev/null +++ b/cmd/libsnap-confine-private/unit-tests-main.c @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2015 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +#include "unit-tests.h" + +int main(int argc, char **argv) +{ + return sc_run_unit_tests(&argc, &argv); +} diff --git a/cmd/libsnap-confine-private/unit-tests.c b/cmd/libsnap-confine-private/unit-tests.c new file mode 100644 index 00000000..c3a822e2 --- /dev/null +++ b/cmd/libsnap-confine-private/unit-tests.c @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2015 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +#ifdef HAVE_CONFIG_H +#include "config.h" +#endif + +#include "unit-tests.h" +#include + +int sc_run_unit_tests(int *argc, char ***argv) +{ + g_test_init(argc, argv, NULL); + g_test_set_nonfatal_assertions(); + return g_test_run(); +} diff --git a/cmd/libsnap-confine-private/unit-tests.h b/cmd/libsnap-confine-private/unit-tests.h new file mode 100644 index 00000000..31414ad5 --- /dev/null +++ b/cmd/libsnap-confine-private/unit-tests.h @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#ifndef SNAP_CONFINE_UNIT_TESTS_H +#define SNAP_CONFINE_UNIT_TESTS_H + +/** + * Run unit tests and exit. + * + * The function inspects and modifies command line arguments. + * Internally it is using glib-test functions. + */ +int sc_run_unit_tests(int *argc, char ***argv); + +#endif // SNAP_CONFINE_SANITY_H diff --git a/cmd/libsnap-confine-private/utils-test.c b/cmd/libsnap-confine-private/utils-test.c new file mode 100644 index 00000000..b22f80ba --- /dev/null +++ b/cmd/libsnap-confine-private/utils-test.c @@ -0,0 +1,281 @@ +/* + * Copyright (C) 2015 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "utils.h" +#include "utils.c" + +#include + +static void test_parse_bool(void) +{ + int err; + bool value; + + value = false; + err = parse_bool("yes", &value, false); + g_assert_cmpint(err, ==, 0); + g_assert_true(value); + + value = false; + err = parse_bool("1", &value, false); + g_assert_cmpint(err, ==, 0); + g_assert_true(value); + + value = true; + err = parse_bool("no", &value, false); + g_assert_cmpint(err, ==, 0); + g_assert_false(value); + + value = true; + err = parse_bool("0", &value, false); + g_assert_cmpint(err, ==, 0); + g_assert_false(value); + + value = true; + err = parse_bool("", &value, false); + g_assert_cmpint(err, ==, 0); + g_assert_false(value); + + value = true; + err = parse_bool(NULL, &value, false); + g_assert_cmpint(err, ==, 0); + g_assert_false(value); + + value = false; + err = parse_bool(NULL, &value, true); + g_assert_cmpint(err, ==, 0); + g_assert_true(value); + + value = true; + err = parse_bool("flower", &value, false); + g_assert_cmpint(err, ==, -1); + g_assert_cmpint(errno, ==, EINVAL); + g_assert_true(value); + + err = parse_bool("yes", NULL, false); + g_assert_cmpint(err, ==, -1); + g_assert_cmpint(errno, ==, EFAULT); +} + +static void test_sc_is_expected_path(void) +{ + struct { + const char *path; + bool expected; + } test_cases[] = { + {"/tmp/snap-confine", false}, + {"/tmp/foo", false}, + {"/home/ ", false}, + {"/usr/lib/snapd/snap-confine1", false}, + {"/usr/lib/snapd/snap—confine", false}, + {"/snap/core/usr/lib/snapd/snap-confine", false}, + {"/snap/core/x1x/usr/lib/snapd/snap-confine", false}, + {"/snap/core/z1/usr/lib/snapd/snap-confine", false}, + {"/snap/cꓳre/1/usr/lib/snapd/snap-confine", false}, + {"/snap/snapd1/1/usr/lib/snapd/snap-confine", false}, + {"/snap/core/current/usr/lib/snapd/snap-confine", false}, + {"/usr/lib/snapd/snap-confine", true}, + {"/usr/libexec/snapd/snap-confine", true}, + {"/snap/core/1/usr/lib/snapd/snap-confine", true}, + {"/snap/core/x1/usr/lib/snapd/snap-confine", true}, + {"/snap/snapd/1/usr/lib/snapd/snap-confine", true}, + {"/snap/snapd/1/usr/libexec/snapd/snap-confine", false}, + }; + size_t i; + for (i = 0; i < sizeof(test_cases) / sizeof(test_cases[0]); i++) { + bool result = sc_is_expected_path(test_cases[i].path); + g_assert_cmpint(result, ==, test_cases[i].expected); + } +} + +static void test_die(void) +{ + if (g_test_subprocess()) { + errno = 0; + die("death message"); + g_test_message("expected die not to return"); + g_test_fail(); + return; + } + g_test_trap_subprocess(NULL, 0, 0); + g_test_trap_assert_failed(); + g_test_trap_assert_stderr("death message\n"); +} + +static void test_die_with_errno(void) +{ + if (g_test_subprocess()) { + errno = EPERM; + die("death message"); + g_test_message("expected die not to return"); + g_test_fail(); + return; + } + g_test_trap_subprocess(NULL, 0, 0); + g_test_trap_assert_failed(); + g_test_trap_assert_stderr("death message: Operation not permitted\n"); +} + +// A variant of rmdir that is compatible with GDestroyNotify +static void my_rmdir(const char *path) +{ + if (rmdir(path) != 0) { + die("cannot rmdir %s", path); + } +} + +// A variant of chdir that is compatible with GDestroyNotify +static void my_chdir(const char *path) +{ + if (chdir(path) != 0) { + die("cannot change dir to %s", path); + } +} + +static void my_unlink(const char *path) +{ + if (unlink(path) != 0 && errno != ENOENT) { + die("cannot unlink: %s", path); + } +} + +/** + * Perform the rest of testing in a ephemeral directory. + * + * Create a temporary directory, move the current process there and undo those + * operations at the end of the test. If any additional directories or files + * are created in this directory they must be removed by the caller. + **/ +static void g_test_in_ephemeral_dir(void) +{ + gchar *temp_dir = g_dir_make_tmp(NULL, NULL); + gchar *orig_dir = g_get_current_dir(); + int err = chdir(temp_dir); + g_assert_cmpint(err, ==, 0); + + g_test_queue_free(temp_dir); + g_test_queue_destroy((GDestroyNotify) my_rmdir, temp_dir); + g_test_queue_free(orig_dir); + g_test_queue_destroy((GDestroyNotify) my_chdir, orig_dir); +} + +/** + * Test sc_nonfatal_mkpath() given two directories. + **/ +static void _test_sc_nonfatal_mkpath(const gchar *dirname, + const gchar *subdirname) +{ + // Check that directory does not exist. + g_assert_false(g_file_test(dirname, G_FILE_TEST_EXISTS | + G_FILE_TEST_IS_DIR)); + // Use sc_nonfatal_mkpath to create the directory and ensure that it worked + // as expected. + g_test_queue_destroy((GDestroyNotify) my_rmdir, (char *)dirname); + int err = sc_nonfatal_mkpath(dirname, 0755); + g_assert_cmpint(err, ==, 0); + g_assert_cmpint(errno, ==, 0); + g_assert_true(g_file_test(dirname, G_FILE_TEST_EXISTS | + G_FILE_TEST_IS_REGULAR)); + // Use same function again to try to create the same directory and ensure + // that it didn't fail and properly retained EEXIST in errno. + err = sc_nonfatal_mkpath(dirname, 0755); + g_assert_cmpint(err, ==, 0); + g_assert_cmpint(errno, ==, EEXIST); + // Now create a sub-directory of the original directory and observe the + // results. We should no longer see errno of EEXIST! + g_test_queue_destroy((GDestroyNotify) my_rmdir, (char *)subdirname); + err = sc_nonfatal_mkpath(subdirname, 0755); + g_assert_cmpint(err, ==, 0); + g_assert_cmpint(errno, ==, 0); +} + +/** + * Test that sc_nonfatal_mkpath behaves when using relative paths. + **/ +static void test_sc_nonfatal_mkpath__relative(void) +{ + g_test_in_ephemeral_dir(); + gchar *current_dir = g_get_current_dir(); + g_test_queue_free(current_dir); + gchar *dirname = g_build_path("/", current_dir, "foo", NULL); + g_test_queue_free(dirname); + gchar *subdirname = g_build_path("/", current_dir, "foo", "bar", NULL); + g_test_queue_free(subdirname); + _test_sc_nonfatal_mkpath(dirname, subdirname); +} + +/** + * Test that sc_nonfatal_mkpath behaves when using absolute paths. + **/ +static void test_sc_nonfatal_mkpath__absolute(void) +{ + g_test_in_ephemeral_dir(); + const char *dirname = "foo"; + const char *subdirname = "foo/bar"; + _test_sc_nonfatal_mkpath(dirname, subdirname); +} + +static void test_sc_is_container__empty(void) +{ + g_test_in_ephemeral_dir(); + g_test_queue_destroy((GDestroyNotify) my_unlink, "container"); + g_assert_true(g_file_set_contents("container", "", -1, NULL)); + g_assert_false(_sc_is_in_container("container")); +} + +static void test_sc_is_container__lxc(void) +{ + g_test_in_ephemeral_dir(); + g_test_queue_destroy((GDestroyNotify) my_unlink, "container"); + g_assert_true(g_file_set_contents("container", "lxc", -1, NULL)); + g_assert_true(_sc_is_in_container("container")); +} + +static void test_sc_is_container__lxc_with_newline(void) +{ + g_test_in_ephemeral_dir(); + g_test_queue_destroy((GDestroyNotify) my_unlink, "container"); + g_assert_true(g_file_set_contents("container", "lxc\n", -1, NULL)); + g_assert_true(_sc_is_in_container("container")); +} + +static void test_sc_is_container__no_file(void) +{ + g_test_in_ephemeral_dir(); + g_test_queue_destroy((GDestroyNotify) my_unlink, "container"); + g_assert_false(_sc_is_in_container("container")); +} + +static void __attribute__((constructor)) init(void) +{ + g_test_add_func("/utils/parse_bool", test_parse_bool); + g_test_add_func("/utils/sc_is_expected_path", test_sc_is_expected_path); + g_test_add_func("/utils/die", test_die); + g_test_add_func("/utils/die_with_errno", test_die_with_errno); + g_test_add_func("/utils/sc_nonfatal_mkpath/relative", + test_sc_nonfatal_mkpath__relative); + g_test_add_func("/utils/sc_nonfatal_mkpath/absolute", + test_sc_nonfatal_mkpath__absolute); + g_test_add_func("/utils/sc_is_in_container/empty", + test_sc_is_container__empty); + g_test_add_func("/utils/sc_is_in_container/no_file", + test_sc_is_container__no_file); + g_test_add_func("/utils/sc_is_in_container/lxc", + test_sc_is_container__lxc); + g_test_add_func("/utils/sc_is_in_container/lxc_newline", + test_sc_is_container__lxc_with_newline); +} diff --git a/cmd/libsnap-confine-private/utils.c b/cmd/libsnap-confine-private/utils.c new file mode 100644 index 00000000..aa7fbe24 --- /dev/null +++ b/cmd/libsnap-confine-private/utils.c @@ -0,0 +1,304 @@ +/* + * Copyright (C) 2015 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "cleanup-funcs.h" +#include "panic.h" +#include "utils.h" + +void die(const char *msg, ...) +{ + va_list ap; + va_start(ap, msg); + sc_panicv(msg, ap); + va_end(ap); +} + +struct sc_bool_name { + const char *text; + bool value; +}; + +static const struct sc_bool_name sc_bool_names[] = { + {"yes", true}, + {"no", false}, + {"1", true}, + {"0", false}, + {"", false}, +}; + +/** + * Convert string to a boolean value, with a default. + * + * The return value is 0 in case of success or -1 when the string cannot be + * converted correctly. In such case errno is set to indicate the problem and + * the value is not written back to the caller-supplied pointer. + * + * If the text cannot be recognized, the default value is used. + **/ +static int parse_bool(const char *text, bool *value, bool default_value) +{ + if (value == NULL) { + errno = EFAULT; + return -1; + } + if (text == NULL) { + *value = default_value; + return 0; + } + for (size_t i = 0; i < sizeof sc_bool_names / sizeof *sc_bool_names; + ++i) { + if (strcmp(text, sc_bool_names[i].text) == 0) { + *value = sc_bool_names[i].value; + return 0; + } + } + errno = EINVAL; + return -1; +} + +/** + * Get an environment variable and convert it to a boolean. + * + * Supported values are those of parse_bool(), namely "yes", "no" as well as "1" + * and "0". All other values are treated as false and a diagnostic message is + * printed to stderr. If the environment variable is unset, set value to the + * default_value as if the environment variable was set to default_value. + **/ +bool getenv_bool(const char *name, bool default_value) +{ + const char *str_value = getenv(name); + bool value = default_value; + if (parse_bool(str_value, &value, default_value) < 0) { + if (errno == EINVAL) { + fprintf(stderr, + "WARNING: unrecognized value of environment variable %s (expected yes/no or 1/0)\n", + name); + return false; + } else { + die("cannot convert value of environment variable %s to a boolean", name); + } + } + return value; +} + +bool sc_is_debug_enabled(void) +{ + return getenv_bool("SNAP_CONFINE_DEBUG", false) + || getenv_bool("SNAPD_DEBUG", false); +} + +bool sc_is_reexec_enabled(void) +{ + return getenv_bool("SNAP_REEXEC", true); +} + +void debug(const char *msg, ...) +{ + if (sc_is_debug_enabled()) { + va_list va; + va_start(va, msg); + fprintf(stderr, "DEBUG: "); + vfprintf(stderr, msg, va); + fprintf(stderr, "\n"); + va_end(va); + } +} + +void write_string_to_file(const char *filepath, const char *buf) +{ + debug("write_string_to_file %s %s", filepath, buf); + FILE *f = fopen(filepath, "w"); + if (f == NULL) + die("fopen %s failed", filepath); + if (fwrite(buf, strlen(buf), 1, f) != 1) + die("fwrite failed"); + if (fflush(f) != 0) + die("fflush failed"); + if (fclose(f) != 0) + die("fclose failed"); +} + +sc_identity sc_set_effective_identity(sc_identity identity) +{ + debug("set_effective_identity uid:%d (change: %s), gid:%d (change: %s)", + identity.uid, identity.change_uid ? "yes" : "no", + identity.gid, identity.change_gid ? "yes" : "no"); + /* We are being careful not to return a value instructing us to change GID + * or UID by accident. */ + sc_identity old = { + .change_gid = 0, + .change_uid = 0, + }; + + if (identity.change_gid) { + old.gid = getegid(); + old.change_gid = 1; + if (setegid(identity.gid) < 0) { + die("cannot set effective group to %d", identity.gid); + } + if (getegid() != identity.gid) { + die("effective group change from %d to %d has failed", + old.gid, identity.gid); + } + } + if (identity.change_uid) { + old.uid = geteuid(); + old.change_uid = 1; + if (seteuid(identity.uid) < 0) { + die("cannot set effective user to %d", identity.uid); + } + if (geteuid() != identity.uid) { + die("effective user change from %d to %d has failed", + old.uid, identity.uid); + } + } + return old; +} + +int sc_nonfatal_mkpath(const char *const path, mode_t mode) +{ + // If asked to create an empty path, return immediately. + if (strlen(path) == 0) { + return 0; + } + // We're going to use strtok_r, which needs to modify the path, so we'll + // make a copy of it. + char *path_copy SC_CLEANUP(sc_cleanup_string) = NULL; + path_copy = strdup(path); + if (path_copy == NULL) { + return -1; + } + // Open flags to use while we walk the user data path: + // - Don't follow symlinks + // - Don't allow child access to file descriptor + // - Only open a directory (fail otherwise) + const int open_flags = O_NOFOLLOW | O_CLOEXEC | O_DIRECTORY; + + // We're going to create each path segment via openat/mkdirat calls instead + // of mkdir calls, to avoid following symlinks and placing the user data + // directory somewhere we never intended for it to go. The first step is to + // get an initial file descriptor. + int fd SC_CLEANUP(sc_cleanup_close) = AT_FDCWD; + if (path_copy[0] == '/') { + fd = open("/", open_flags); + if (fd < 0) { + return -1; + } + } + // strtok_r needs a pointer to keep track of where it is in the string. + char *path_walker = NULL; + + // Initialize tokenizer and obtain first path segment. + char *path_segment = strtok_r(path_copy, "/", &path_walker); + while (path_segment) { + // Try to create the directory. It's okay if it already existed, but + // return with error on any other error. Reset errno before attempting + // this as it may stay stale (errno is not reset if mkdirat(2) returns + // successfully). + errno = 0; + if (mkdirat(fd, path_segment, mode) < 0 && errno != EEXIST) { + return -1; + } + // Open the parent directory we just made (and close the previous one + // (but not the special value AT_FDCWD) so we can continue down the + // path. + int previous_fd = fd; + fd = openat(fd, path_segment, open_flags); + if (previous_fd != AT_FDCWD && close(previous_fd) != 0) { + return -1; + } + if (fd < 0) { + return -1; + } + // Obtain the next path segment. + path_segment = strtok_r(NULL, "/", &path_walker); + } + return 0; +} + +bool sc_is_expected_path(const char *path) +{ + const char *expected_path_re = + "^(/snap/(snapd|core)/x?[0-9]+/usr/lib|/usr/lib(exec)?)/snapd/snap-confine$"; + regex_t re; + if (regcomp(&re, expected_path_re, REG_EXTENDED | REG_NOSUB) != 0) + die("can not compile regex %s", expected_path_re); + int status = regexec(&re, path, 0, NULL, 0); + regfree(&re); + return status == 0; +} + +bool sc_wait_for_file(const char *path, size_t timeout_sec) +{ + for (size_t i = 0; i < timeout_sec; ++i) { + if (access(path, F_OK) == 0) { + return true; + } + sleep(1); + } + return false; +} + +const char *run_systemd_container = "/run/systemd/container"; + +static bool _sc_is_in_container(const char *p) +{ + // see what systemd-detect-virt --container does in, see: + // https://github.com/systemd/systemd/blob/5dcd6b1d55a1cfe247621d70f0e25d020de6e0ed/src/basic/virt.c#L749-L755 + // https://systemd.io/CONTAINER_INTERFACE/ + FILE *in SC_CLEANUP(sc_cleanup_file) = fopen(p, "r"); + if (in == NULL) { + return false; + } + + char container[128] = { 0 }; + + if (fgets(container, sizeof(container), in) == NULL) { + /* nothing read or other error? */ + return false; + } + + size_t r = strnlen(container, sizeof container); + // TODO add sc_str_chomp()? + if (r > 0 && container[r - 1] == '\n') { + /* replace trailing newline */ + container[r - 1] = 0; + r--; + } + + if (r == 0) { + /* empty or just a newline */ + return false; + } + + debug("detected container environment: %s", container); + return true; +} + +bool sc_is_in_container(void) +{ + return _sc_is_in_container(run_systemd_container); +} diff --git a/cmd/libsnap-confine-private/utils.h b/cmd/libsnap-confine-private/utils.h new file mode 100644 index 00000000..460718c7 --- /dev/null +++ b/cmd/libsnap-confine-private/utils.h @@ -0,0 +1,132 @@ +/* + * Copyright (C) 2015 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +#ifndef CORE_LAUNCHER_UTILS_H +#define CORE_LAUNCHER_UTILS_H + +#include +#include + +__attribute__((noreturn)) + __attribute__((format(printf, 1, 2))) +void die(const char *fmt, ...); + +__attribute__((format(printf, 1, 2))) +void debug(const char *fmt, ...); + +/** + * Get an environment variable and convert it to a boolean. + * + * Supported values are those of parse_bool(), namely "yes", "no" as well as "1" + * and "0". All other values are treated as false and a diagnostic message is + * printed to stderr. If the environment variable is unset, set value to the + * default_value as if the environment variable was set to default_value. + **/ +bool getenv_bool(const char *name, bool default_value); + +/** + * Return true if debugging is enabled. + * + * This can used to avoid costly computation that is only useful for debugging. + **/ +bool sc_is_debug_enabled(void); + +/** + * Return true if re-execution is enabled. + **/ +bool sc_is_reexec_enabled(void); + +/** + * Return true if executing inside a container. + **/ +bool sc_is_in_container(void); + +/** + * sc_identity describes the user performing certain operation. + * + * UID and GID represent user and group accounts numbers and are controlled by + * change_uid and change_gid flags. +**/ +typedef struct sc_identity { + uid_t uid; + gid_t gid; + unsigned change_uid:1; + unsigned change_gid:1; +} sc_identity; + +/** + * Identity of the root group. + * + * The return value is suitable for passing to sc_set_effective_identity. It + * causes the effective group to change to the root group. No change is made to + * effective user identity. + **/ +static inline sc_identity sc_root_group_identity(void) +{ + sc_identity id = { + /* Explicitly set our intent of changing just the GID. + * Refactoring of this code must retain this property. */ + .change_uid = 0, + .change_gid = 1, + .gid = 0, + }; + return id; +} + +/** + * Set the effective user and group IDs to given values. + * + * Effective user and group identifiers are applied to the system. The + * current values are returned as another identity that can be restored via + * another call to sc_set_effective_identity. + * + * The fields change_uid and change_gid control if user and group ID is changed. + * The returned old identity has identical values of both use flags. +**/ +sc_identity sc_set_effective_identity(sc_identity identity); + +void write_string_to_file(const char *filepath, const char *buf); + +/** + * Safely create a given directory. + * + * NOTE: non-fatal functions don't die on errors. It is the responsibility of + * the caller to call die() or handle the error appropriately. + * + * This function behaves like "mkdir -p" (recursive mkdir) with the exception + * that each directory is carefully created in a way that avoids symlink + * attacks. The preceding directory is kept openat(2) (along with O_DIRECTORY) + * and the next directory is created using mkdirat(2), this sequence continues + * while there are more directories to process. + * + * The function returns -1 in case of any error. + **/ +__attribute__((warn_unused_result)) +int sc_nonfatal_mkpath(const char *const path, mode_t mode); + +/** + * Return true if path is a valid path for the snap-confine binary + **/ +__attribute__((warn_unused_result)) +bool sc_is_expected_path(const char *path); + +/** + * Wait for file to appear for timeout_sec seconds. Returns true once the file + * is present. + */ +bool sc_wait_for_file(const char *path, size_t timeout_sec); + +#endif diff --git a/cmd/snap-bootstrap/README.md b/cmd/snap-bootstrap/README.md new file mode 100644 index 00000000..8023638c --- /dev/null +++ b/cmd/snap-bootstrap/README.md @@ -0,0 +1,77 @@ +# _snap-bootstrap_ + +Welcome to the world of the initramfs of UC20! + +## Short intro + +_snap-bootstrap_ is the main executable that is run during the initramfs stage of UC20. It has several responsibilities: + +1. Mounting some partitions from the disk that UC20 is installed to. This includes ubuntu-data, ubuntu-boot, ubuntu-seed, and if present, ubuntu-save (ubuntu-save is optional on unencrypted devices). +1. As part of mounting those partitions, _snap-bootstrap_ may perform the necessary steps to unlock any encrypted partitions such as ubuntu-data and ubuntu-save. +1. After unlocking and mounting all such partitions, _snap-bootstrap_ then chooses which base snap file is to be used for the root filesystem of userspace (as the root filesystem of the initramfs is just a static set of files built into the initramfs and is not the final root filesystem), and mounts this base snap file. +1. _snap-bootstrap_ then chooses which kernel snap file is to be used to mount and find additional kernel modules that are not compiled into the kernel or shipped as modules inside the initramfs or otherwise loaded as DTBs, etc. +1. _snap-bootstrap_ then also will mount the ubuntu-data partition such that either the writable components of the root filesystem come from this actual partition, or if the mode the system is booting into is an ephemeral system such as install or recover, will mount a temporary filesystem for this. +1. _snap-bootstrap_ on kernel and base snap upgrades will also handle updating bootloader environment variables to implement A/B or try-boot functionality. +1. _snap-bootstrap_ then finally may do some additional setup of the root filesystem such as copying some default files for ephemeral system modes such as recover. + +## In depth walkthrough + +_snap-bootstrap_ operates differently depending on snapd_recovery_mode, so each mode is considered separately below. + +Note that while snap-bootstrap contains the largest chunk of the logic for the initramfs, there are additional steps that need to be considered. These take over after snap-bootstrap has exited successfully and they're required to fully complete the initramfs operations beyond snap-bootstrap. Ideally, these additional steps will be moved into snap-bootstrap at some point, where they can be more fully tested and documented. But for now, take a look at the unit files in the initrd for "the-modeenv" and "the-tool" to follow what happens after snap-bootstrap is done. + +Additionally, note that in all modes where there is a TPM available, we must lock access to the keys before exiting snap-bootstrap. This is implemented specifically with `secboot.LockSealedKeys`. This is regardless of whether or not the system is encrypted or not. + +### Install mode + +Install mode has the following steps: + +1. The first step of the initramfs-mounts command is always to measure the "epoch" of the secboot version that snap-bootstrap is compiled with to the TPM (if one exists). This is for maximum security and to prevent a newer epoch of secboot from being vulnerable to prior versions. +1. The next step is to pick the first partition to mount as securely as possible. With EFI systems, we query an EFI variable used to indicate the Partition UUID of the disk which the kernel was booted off. We then use that Partition UUID to identify the partition which should be mounted as ubuntu-seed (since on grub amd64 systems, the kernel is initially booted by mounting the squashfs in grub and then booting the kernel.efi inside the mounted squashfs). If there is no such EFI variable, we fall back to just using the label to choose which partition to mount. Although we do have snap-bootstrap ordered to run after udev has fully settled via `After=systemd-udev-settle.service` in the unit file, sometimes we still don't have that Partition UUID device node available in /dev/ by the time we are executing, so we wait in a loop for the device node to appear before giving up. +1. After having identified which partition is ubuntu-seed, we mount it at /run/mnt/ubuntu-seed. +1. Next, we will load the "recovery system seed", which is the set of snaps associated with this recovery system, this includes the base snap, the kernel snap, the snapd snap and the gadget snap. These snaps are verified to match their assertions via hashing. +1. Next we do another measurement to the TPM (if available) of the model assertion from the recovery system we loaded. +1. After having verified that the recovery system seed snaps are valid and that the model assertion is correct, we will then mount these snaps at /run/mnt/base, /run/mnt/kernel, and /run/mnt/snapd (the gadget is not mounted at this time). +1. Next, we create a tmpfs mount at /run/mnt/data, which will be the root filesystem we pivot_root into at the end of the initramfs. +1. Next, we will "configure" the target system root filesystem using the gadget snap itself, this will handle things like "early snap config" and cloud-init config, etc. that need to be applied before we fully boot to userspace. +1. Next, we will write out a modeenv file to the root filesystem based on the model assertion and the recovery system seed snaps that will be read by snapd in userspace when we get there. +1. Finally, the last step of all modes is to expose any boot flags. There is currently only one boot flag and this is used during install mode to allow factory-specific behavior in the install-device hook, stopping re-execution if the device is re-installed in the field and re-enters install mode again. A boot flag is set by a bootloader environment variable which is then put into a file in /run for userspace to measure. See https://ubuntu.com/core/docs/uc20/installation-process for full details of how this can be used from an image building standpoint, and see the implementation of `boot.InitramfsExposeBootFlagsForSystem` for how this works at a low-level for a snapd/Ubuntu Core developer. + +### Run mode + +1. The first step of the initramfs-mounts command is always to measure the "epoch" of the secboot version that snap-bootstrap is compiled with to the TPM (if one exists). This is for maximum security and to prevent a newer epoch of secboot from being vulnerable to prior versions. +1. The next step is to pick the first partition to mount as securely as possible. With EFI systems, we query an EFI variable used to indicate the Partition UUID of the disk which the kernel was booted off of. We then use that Partition UUID to identify the partition which should be mounted as ubuntu-boot. This is because in run mode (for amd64 grub systems at least), we will boot using the kernel.efi file from the ubuntu-boot partition, as opposed to recover and install modes which use the kernel snap from ubuntu-seed. If there is no such EFI variable, we fall back to just using the label instead to choose which partition to mount. Although we do have snap-bootstrap ordered to run after udev has fully settled via `After=systemd-udev-settle.service` in the unit file, sometimes we still don't have that Partition UUID device node available in /dev/ by the time we are executing, so we wait in a loop for the device node to appear before giving up. +1. After having identified which partition is ubuntu-boot, we mount it at /run/mnt/ubuntu-boot. +1. Using the disk we found ubuntu-boot on as a reference, we will pick the partition with label "ubuntu-seed" and mount this partition at /run/mnt/ubuntu-seed. +1. Next we will measure the model assertion to the TPM as well. +1. Next, we will try to unlock the ubuntu-data partition (if it is encrypted) using the sealed-key which exists on ubuntu-boot. After unlocking (or just finding the unencrypted version if encryption is not being used), we will mount it at /run/mnt/data. +1. If ubuntu-data was encrypted, then we will proceed to attempt to unlock an ubuntu-save partition from the same disk, and mount it at /run/mnt/ubuntu-save. If ubuntu-data was not encrypted, then we will try to mount an unencrypted ubuntu-save at /run/mnt/ubuntu-save, but in the unencrypted case we do not require ubuntu-save to be present so it is not a fatal error if we do not find ubuntu-save in the unencrypted case. +1. After having mounted all of the relevant partitions, we will perform a double check that the mount points /run/mnt/ubuntu-{save,data} come from the same disk. For extra paranoia, we will also validate that ubuntu-data and ubuntu-save, if they were encrypted, were unlocked with the same key pairing. +1. Next we read the modeenv from the data partition, and based on the modeenv, we decide what snaps to mount. On all boots into run mode the base and kernel snap must be identified and mounted. Note that for run mode, we find the snaps to mount for this purpose through `boot.InitramfsRunModeSelectSnapsToMount` which handles kernel / base snap updates and will return the "try" snap if there is a new snap being tried on this boot. +1. If this boot is the first ever boot into run mode, we will also mount the snapd snap by reading and validating the recovery system seed from ubuntu-seed and mounting the snapd snap at /run/mnt/snap. +1. Finally, the last step of all modes is to expose the boot flags that were put into the boot environment for userspace to measure. This is done via `boot.InitramfsExposeBootFlagsForSystem` + +### Recover mode + +The first 8 steps for recover mode are shared exactly with install mode, so they are not repeated here, but see the steps 1-8 for install mode, then we continue: + +9. The next thing we check is whether we are inside the recovery environment to actually do recover mode, or if we are simply validating that the recovery system we are booting into is valid. We do this by inspecting bootloader environment variables via `boot.InitramfsIsTryingRecoverySystem`. +10. In the case that we are trying a recovery system, we will ensure that the next reboot will transition us back to run mode. Additionally, if we are in an inconsistent state, such as there being no agreement on the state of the tried recover system, for example, we will reboot and attempt to go back to run mode and give up on recover mode. +11. If we are either not trying a recovery system, or we are in a consistent state and are trying a recovery system, then we enter the following magical state machine. This state diagram essentially allows recover mode to be extra robust against failure modes, such as having a partition disappear, keys not being able to unlock some partitions, etc. This is referred to as "degraded mode". Specifically, if we don't use all the _happy paths_ then we are in a "degraded" recover mode as opposed to being in a normal recover mode. For the case where we are trying a recovery system, none of the fallback paths are allowed to be taken and will immediately exit the state machine and the state machine is marked as being in "degraded mode". + + +![](/cmd/snap-bootstrap/degraded-recover-mode.svg) + + +The above state diagram was made with https://app.diagrams.net/ and can be imported by opening the SVG file in this directory there. + +12. After exiting the state machine (in all cases), we will again consider if we are trying a recovery system. If we are, we will inspect if the state machine degraded at all (meaning that the "happy path" for unlocking disks and mounting partitions was not fully executed and we had to use an alternative option at least one time). If the state machine outputs a degraded state, we mark the recovery system as a failure and go back to run mode. Once back in run mode, the tasks that requested the recovery system to be created will fail and be undone and the snap change will be in failed state. If it was successful, we mark it as successful and reboot to run mode. This is the last step for all situations related to trying a recovery system. +13. Next, we will write out a file called `degraded.json` that contains details on whether the state machine output was in a degraded state or not. This may affect some choices userspace snapd makes when we get there. +14. If the state machine exited in a state that was at least sufficiently usable, such that we can trust the data partition unlocked and mounted, we will then copy some files from the data partition to our tmpfs root filesystem. These could include authentication files, such as ssh keys, networking configuration, and other miscellaneous files like the clock sync file for systems without a battery powered RTC. If we didn't trust the data partition, then "safe" defaults will be used instead. This is to prevent a situation wherein we don't "trust" the data partition enough (but perhaps we did trust ubuntu-save when unlocking it) to copy authentication files over, but then we leave console-conf in such a state where it could allow an attacker to create their own new account and then exfiltrate secret data from the trusted ubuntu-save. +15. Next, we will write out a modeenv file to the root filesystem based on the model assertion and the recovery system seed snaps that will be read by snapd in userspace when we get there. +16. Penultimately, we will ensure that if the system is rebooted at all after this point, the system will be automatically transitioned back to run mode without further input. +17. Finally, the last step of all modes is to expose the boot flags that were put into the boot environment for userspace to measure. This is done via `boot.InitramfsExposeBootFlagsForSystem` + +### Classic mode + +This mode may eventually be developed to support using the same initramfs + kernel on Ubuntu Classic (i.e. Server or Desktop) as is currently used on Ubuntu Core 20+. This feature is still in the design stage. diff --git a/cmd/snap-bootstrap/cmd_initramfs_mounts.go b/cmd/snap-bootstrap/cmd_initramfs_mounts.go new file mode 100644 index 00000000..6fcd1b04 --- /dev/null +++ b/cmd/snap-bootstrap/cmd_initramfs_mounts.go @@ -0,0 +1,2152 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2019-2021 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "bytes" + "crypto/subtle" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/boot" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/gadget" + "github.com/snapcore/snapd/gadget/device" + gadgetInstall "github.com/snapcore/snapd/gadget/install" + "github.com/snapcore/snapd/kernel/fde" + "github.com/snapcore/snapd/logger" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/osutil/disks" + "github.com/snapcore/snapd/osutil/kcmdline" + "github.com/snapcore/snapd/snapdtool" + + // to set sysconfig.ApplyFilesystemOnlyDefaultsImpl + _ "github.com/snapcore/snapd/overlord/configstate/configcore" + "github.com/snapcore/snapd/overlord/install" + "github.com/snapcore/snapd/overlord/state" + "github.com/snapcore/snapd/secboot" + "github.com/snapcore/snapd/seed" + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/snap/squashfs" + "github.com/snapcore/snapd/sysconfig" + "github.com/snapcore/snapd/timings" +) + +func init() { + const ( + short = "Generate mounts for the initramfs" + long = "Generate and perform all mounts for the initramfs before transitioning to userspace" + ) + + addCommandBuilder(func(parser *flags.Parser) { + if _, err := parser.AddCommand("initramfs-mounts", short, long, &cmdInitramfsMounts{}); err != nil { + panic(err) + } + }) + + snap.SanitizePlugsSlots = func(*snap.Info) {} +} + +type cmdInitramfsMounts struct{} + +func (c *cmdInitramfsMounts) Execute([]string) error { + boot.HasFDESetupHook = hasFDESetupHook + boot.RunFDESetupHook = runFDESetupHook + + logger.Noticef("snap-bootstrap version %v starting", snapdtool.Version) + + return generateInitramfsMounts() +} + +var ( + osutilIsMounted = osutil.IsMounted + + snapTypeToMountDir = map[snap.Type]string{ + snap.TypeBase: "base", + snap.TypeGadget: "gadget", + snap.TypeKernel: "kernel", + snap.TypeSnapd: "snapd", + } + + secbootProvisionForCVM func(initramfsUbuntuSeedDir string) error + secbootMeasureSnapSystemEpochWhenPossible func() error + secbootMeasureSnapModelWhenPossible func(findModel func() (*asserts.Model, error)) error + secbootUnlockVolumeUsingSealedKeyIfEncrypted func(disk disks.Disk, name string, encryptionKeyFile string, opts *secboot.UnlockVolumeUsingSealedKeyOptions) (secboot.UnlockResult, error) + secbootUnlockEncryptedVolumeUsingKey func(disk disks.Disk, name string, key []byte) (secboot.UnlockResult, error) + + secbootLockSealedKeys func() error + + bootFindPartitionUUIDForBootedKernelDisk = boot.FindPartitionUUIDForBootedKernelDisk + + mountReadOnlyOptions = &systemdMountOptions{ + ReadOnly: true, + Private: true, + } + + gadgetInstallRun = gadgetInstall.Run + bootMakeRunnableStandaloneSystem = boot.MakeRunnableStandaloneSystemFromInitrd + installApplyPreseededData = install.ApplyPreseededData + bootEnsureNextBootToRunMode = boot.EnsureNextBootToRunMode +) + +func stampedAction(stamp string, action func() error) error { + stampFile := filepath.Join(dirs.SnapBootstrapRunDir, stamp) + if osutil.FileExists(stampFile) { + return nil + } + if err := os.MkdirAll(filepath.Dir(stampFile), 0755); err != nil { + return err + } + if err := action(); err != nil { + return err + } + return os.WriteFile(stampFile, nil, 0644) +} + +func generateInitramfsMounts() (err error) { + // ensure that the last thing we do is to lock access to sealed keys, + // regardless of mode or early failures. + defer func() { + if e := secbootLockSealedKeys(); e != nil { + e = fmt.Errorf("error locking access to sealed keys: %v", e) + if err == nil { + err = e + } else { + // preserve err but log + logger.Noticef("%v", e) + } + } + }() + + // Ensure there is a very early initial measurement + err = stampedAction("secboot-epoch-measured", func() error { + return secbootMeasureSnapSystemEpochWhenPossible() + }) + if err != nil { + return err + } + + mode, recoverySystem, err := boot.ModeAndRecoverySystemFromKernelCommandLine() + if err != nil { + return err + } + + mst := &initramfsMountsState{ + mode: mode, + recoverySystem: recoverySystem, + } + // generate mounts and set mst.validatedModel + switch mode { + case "recover": + err = generateMountsModeRecover(mst) + case "install": + err = generateMountsModeInstall(mst) + case "factory-reset": + err = generateMountsModeFactoryReset(mst) + case "run": + err = generateMountsModeRun(mst) + case "cloudimg-rootfs": + err = generateMountsModeRunCVM(mst) + default: + // this should never be reached, ModeAndRecoverySystemFromKernelCommandLine + // will have returned a non-nill error above if there was another mode + // specified on the kernel command line for some reason + return fmt.Errorf("internal error: mode in generateInitramfsMounts not handled") + } + if err != nil { + return err + } + model := mst.verifiedModel + if model == nil { + return fmt.Errorf("internal error: no verified model set") + } + + isRunMode := (mode == "run") + rootfsDir := boot.InitramfsWritableDir(model, isRunMode) + + // finally, the initramfs is responsible for reading the boot flags and + // copying them to /run, so that userspace has an unambiguous place to read + // the boot flags for the current boot from + flags, err := boot.InitramfsActiveBootFlags(mode, rootfsDir) + if err != nil { + // We don't die on failing to read boot flags, we just log the error and + // don't set any flags, this is because the boot flags in the case of + // install comes from untrusted input, the bootenv. In the case of run + // mode, boot flags are read from the modeenv, which should be valid and + // trusted, but if the modeenv becomes corrupted, we would block + // accessing the system (except through an initramfs shell), to recover + // the modeenv (though maybe we could enable some sort of fixing from + // recover mode instead?) + logger.Noticef("error accessing boot flags: %v", err) + } else { + // write the boot flags + if err := boot.InitramfsExposeBootFlagsForSystem(flags); err != nil { + // cannot write to /run, error here since arguably we have major + // problems if we can't write to /run + return err + } + } + + return nil +} + +func canInstallAndRunAtOnce(mst *initramfsMountsState) (bool, error) { + currentSeed, err := mst.LoadSeed(mst.recoverySystem) + if err != nil { + return false, err + } + preseedSeed, ok := currentSeed.(seed.PreseedCapable) + if !ok { + return false, nil + } + + // TODO: relax this condition when "install and run" well tested + if !preseedSeed.HasArtifact("preseed.tgz") { + return false, nil + } + + // If kernel has fde-setup hook, then we should also have fde-setup in initramfs + kernelPath := filepath.Join(boot.InitramfsRunMntDir, "kernel") + kernelHasFdeSetup := osutil.FileExists(filepath.Join(kernelPath, "meta", "hooks", "fde-setup")) + _, fdeSetupErr := exec.LookPath("fde-setup") + if kernelHasFdeSetup && fdeSetupErr != nil { + return false, nil + } + + gadgetPath := filepath.Join(boot.InitramfsRunMntDir, "gadget") + if osutil.FileExists(filepath.Join(gadgetPath, "meta", "hooks", "install-device")) { + return false, nil + } + + return true, nil +} + +func readSnapInfo(sysSnaps map[snap.Type]*seed.Snap, snapType snap.Type) (*snap.Info, error) { + seedSnap := sysSnaps[snapType] + mountPoint := filepath.Join(boot.InitramfsRunMntDir, snapTypeToMountDir[snapType]) + info, err := snap.ReadInfoFromMountPoint(seedSnap.SnapName(), mountPoint, seedSnap.Path, seedSnap.SideInfo) + if err != nil { + return nil, err + } + if info.Revision.Unset() { + info.Revision = snap.R(-1) + } + return info, nil + +} + +func runFDESetupHook(req *fde.SetupRequest) ([]byte, error) { + // TODO: use systemd-run + encoded, err := json.Marshal(req) + if err != nil { + return nil, err + } + cmd := exec.Command("fde-setup") + cmd.Stdin = bytes.NewBuffer(encoded) + output, err := cmd.Output() + if err != nil { + return nil, err + } + return output, nil +} +func hasFDESetupHook(kernelInfo *snap.Info) (bool, error) { + _, ok := kernelInfo.Hooks["fde-setup"] + return ok, nil +} + +func doInstall(mst *initramfsMountsState, model *asserts.Model, sysSnaps map[snap.Type]*seed.Snap) error { + kernelSnap, err := readSnapInfo(sysSnaps, snap.TypeKernel) + if err != nil { + return err + } + baseSnap, err := readSnapInfo(sysSnaps, snap.TypeBase) + if err != nil { + return err + } + gadgetSnap, err := readSnapInfo(sysSnaps, snap.TypeGadget) + if err != nil { + return err + } + kernelMountDir := filepath.Join(boot.InitramfsRunMntDir, snapTypeToMountDir[snap.TypeKernel]) + gadgetMountDir := filepath.Join(boot.InitramfsRunMntDir, snapTypeToMountDir[snap.TypeGadget]) + gadgetInfo, err := gadget.ReadInfo(gadgetMountDir, model) + if err != nil { + return err + } + encryptionSupport, err := install.CheckEncryptionSupport(model, secboot.TPMProvisionFull, kernelSnap, gadgetInfo, runFDESetupHook) + if err != nil { + return err + } + useEncryption := (encryptionSupport != secboot.EncryptionTypeNone) + + installObserver, trustedInstallObserver, err := install.BuildInstallObserver(model, gadgetMountDir, useEncryption) + if err != nil { + return err + } + + options := gadgetInstall.Options{ + Mount: true, + EncryptionType: encryptionSupport, + } + + validationConstraints := gadget.ValidationConstraints{ + EncryptedData: useEncryption, + } + + gadgetInfo, err = gadget.ReadInfoAndValidate(gadgetMountDir, model, &validationConstraints) + if err != nil { + return fmt.Errorf("cannot use gadget: %v", err) + } + if err := gadget.ValidateContent(gadgetInfo, gadgetMountDir, kernelMountDir); err != nil { + return fmt.Errorf("cannot use gadget: %v", err) + } + + bootDevice := "" + installedSystem, err := gadgetInstallRun(model, gadgetMountDir, kernelMountDir, bootDevice, options, installObserver, timings.New(nil)) + if err != nil { + return err + } + + if trustedInstallObserver != nil { + if err := install.PrepareEncryptedSystemData(model, installedSystem.KeyForRole, trustedInstallObserver); err != nil { + return err + } + } + + err = install.PrepareRunSystemData(model, gadgetMountDir, timings.New(nil)) + if err != nil { + return err + } + + bootWith := &boot.BootableSet{ + Base: baseSnap, + BasePath: sysSnaps[snap.TypeBase].Path, + Gadget: gadgetSnap, + GadgetPath: sysSnaps[snap.TypeGadget].Path, + Kernel: kernelSnap, + KernelPath: sysSnaps[snap.TypeKernel].Path, + UnpackedGadgetDir: gadgetMountDir, + RecoverySystemLabel: mst.recoverySystem, + } + + if err := bootMakeRunnableStandaloneSystem(model, bootWith, trustedInstallObserver); err != nil { + return err + } + + dataMountOpts := &systemdMountOptions{ + Bind: true, + } + if err := doSystemdMount(boot.InstallUbuntuDataDir, boot.InitramfsDataDir, dataMountOpts); err != nil { + return err + } + + currentSeed, err := mst.LoadSeed(mst.recoverySystem) + if err != nil { + return err + } + preseedSeed, ok := currentSeed.(seed.PreseedCapable) + if ok && preseedSeed.HasArtifact("preseed.tgz") { + runMode := false + if err := installApplyPreseededData(preseedSeed, boot.InitramfsWritableDir(model, runMode)); err != nil { + return err + } + } + + if err := bootEnsureNextBootToRunMode(mst.recoverySystem); err != nil { + return fmt.Errorf("failed to set system to run mode: %v\n", err) + } + + mst.mode = "run" + mst.recoverySystem = "" + + return nil +} + +// generateMountsMode* is called multiple times from initramfs until it +// no longer generates more mount points and just returns an empty output. +func generateMountsModeInstall(mst *initramfsMountsState) error { + // steps 1 and 2 are shared with recover mode + model, snaps, err := generateMountsCommonInstallRecoverStart(mst) + if err != nil { + return err + } + + installAndRun, err := canInstallAndRunAtOnce(mst) + if err != nil { + return err + } + + if installAndRun { + if err := doInstall(mst, model, snaps); err != nil { + return err + } + return nil + } else { + if err := generateMountsCommonInstallRecoverContinue(mst, model, snaps); err != nil { + return err + } + + // 3. final step: write modeenv to tmpfs data dir and disable cloud-init in + // install mode + modeEnv, err := mst.EphemeralModeenvForModel(model, snaps) + if err != nil { + return err + } + isRunMode := false + if err := modeEnv.WriteTo(boot.InitramfsWritableDir(model, isRunMode)); err != nil { + return err + } + + // done, no output, no error indicates to initramfs we are done with + // mounting stuff + return nil + } +} + +// copyNetworkConfig copies the network configuration to the target +// directory. This is used to copy the network configuration +// data from a real uc20 ubuntu-data partition into a ephemeral one. +func copyNetworkConfig(src, dst string) error { + for _, globEx := range []string{ + // for network configuration setup by console-conf, etc. + // TODO:UC20: we want some way to "try" or "verify" the network + // configuration or to only use known-to-be-good network + // configuration i.e. from ubuntu-save before installing it + // onto recover mode, because the network configuration could + // have been what was broken so we don't want to break + // network configuration for recover mode as well, but for + // now this is fine + "system-data/etc/netplan/*", + // etc/machine-id is part of what systemd-networkd uses to generate a + // DHCP clientid (the other part being the interface name), so to have + // the same IP addresses across run mode and recover mode, we need to + // also copy the machine-id across + "system-data/etc/machine-id", + } { + if err := copyFromGlobHelper(src, dst, globEx); err != nil { + return err + } + } + return nil +} + +// copyUbuntuDataMisc copies miscellaneous other files from the run mode system +// to the recover system such as: +// - timesync clock to keep the same time setting in recover as in run mode +func copyUbuntuDataMisc(src, dst string) error { + for _, globEx := range []string{ + // systemd's timesync clock file so that the time in recover mode moves + // forward to what it was in run mode + // NOTE: we don't sync back the time movement from recover mode to run + // mode currently, unclear how/when we could do this, but recover mode + // isn't meant to be long lasting and as such it's probably not a big + // problem to "lose" the time spent in recover mode + "system-data/var/lib/systemd/timesync/clock", + } { + if err := copyFromGlobHelper(src, dst, globEx); err != nil { + return err + } + } + + return nil +} + +// copyUbuntuDataAuth copies the authentication files like +// - extrausers passwd,shadow etc +// - sshd host configuration +// - user .ssh dir +// +// to the target directory. This is used to copy the authentication +// data from a real uc20 ubuntu-data partition into a ephemeral one. +func copyUbuntuDataAuth(src, dst string) error { + for _, globEx := range []string{ + "system-data/var/lib/extrausers/*", + "system-data/etc/ssh/*", + "user-data/*/.ssh/*", + // this ensures we get proper authentication to snapd from "snap" + // commands in recover mode + "user-data/*/.snap/auth.json", + // this ensures we also get non-ssh enabled accounts copied + "user-data/*/.profile", + // so that users have proper perms, i.e. console-conf added users are + // sudoers + "system-data/etc/sudoers.d/*", + } { + if err := copyFromGlobHelper(src, dst, globEx); err != nil { + return err + } + } + + // ensure the user state is transferred as well + srcState := filepath.Join(src, "system-data/var/lib/snapd/state.json") + dstState := filepath.Join(dst, "system-data/var/lib/snapd/state.json") + err := state.CopyState(srcState, dstState, []string{"auth.users", "auth.macaroon-key", "auth.last-id"}) + if err != nil && !errors.Is(err, state.ErrNoState) { + return fmt.Errorf("cannot copy user state: %v", err) + } + + return nil +} + +// drop a marker file that disables console-conf +func disableConsoleConf(dst string) error { + consoleConfCompleteFile := filepath.Join(dst, "system-data/var/lib/console-conf/complete") + if err := os.MkdirAll(filepath.Dir(consoleConfCompleteFile), 0755); err != nil { + return err + } + return os.WriteFile(consoleConfCompleteFile, nil, 0644) +} + +// copySafeDefaultData will copy to the destination a "safe" set of data for +// a blank recover mode, i.e. one where we cannot copy authentication, etc. from +// the actual host ubuntu-data. Currently this is just a file to disable +// console-conf from running. +func copySafeDefaultData(dst string) error { + return disableConsoleConf(dst) +} + +func copyFromGlobHelper(src, dst, globEx string) error { + matches, err := filepath.Glob(filepath.Join(src, globEx)) + if err != nil { + return err + } + for _, p := range matches { + comps := strings.Split(strings.TrimPrefix(p, src), "/") + for i := range comps { + part := filepath.Join(comps[0 : i+1]...) + fi, err := os.Stat(filepath.Join(src, part)) + if err != nil { + return err + } + if fi.IsDir() { + if err := os.Mkdir(filepath.Join(dst, part), fi.Mode()); err != nil && !os.IsExist(err) { + return err + } + st, ok := fi.Sys().(*syscall.Stat_t) + if !ok { + return fmt.Errorf("cannot get stat data: %v", err) + } + if err := os.Chown(filepath.Join(dst, part), int(st.Uid), int(st.Gid)); err != nil { + return err + } + } else { + if err := osutil.CopyFile(p, filepath.Join(dst, part), osutil.CopyFlagPreserveAll); err != nil { + return err + } + } + } + } + + return nil +} + +// states for partition state +const ( + // states for LocateState + partitionFound = "found" + partitionNotFound = "not-found" + partitionErrFinding = "error-finding" + // states for MountState + partitionMounted = "mounted" + partitionErrMounting = "error-mounting" + partitionAbsentOptional = "absent-but-optional" + partitionMountedUntrusted = "mounted-untrusted" + // states for UnlockState + partitionUnlocked = "unlocked" + partitionErrUnlocking = "error-unlocking" + // keys used to unlock for UnlockKey + keyRun = "run" + keyFallback = "fallback" + keyRecovery = "recovery" +) + +// partitionState is the state of a partition after recover mode has completed +// for degraded mode. +type partitionState struct { + // MountState is whether the partition was mounted successfully or not. + MountState string `json:"mount-state,omitempty"` + // MountLocation is where the partition was mounted. + MountLocation string `json:"mount-location,omitempty"` + // Device is what device the partition corresponds to. It can be the + // physical block device if the partition is unencrypted or if it was not + // successfully unlocked, or it can be a decrypted mapper device if the + // partition was encrypted and successfully decrypted, or it can be the + // empty string (or missing) if the partition was not found at all. + Device string `json:"device,omitempty"` + // FindState indicates whether the partition was found on the disk or not. + FindState string `json:"find-state,omitempty"` + // UnlockState was whether the partition was unlocked successfully or not. + UnlockState string `json:"unlock-state,omitempty"` + // UnlockKey was what key the partition was unlocked with, either "run", + // "fallback" or "recovery". + UnlockKey string `json:"unlock-key,omitempty"` + + // unexported internal fields for tracking the device, these are used during + // state machine execution, and then combined into Device during finalize() + // for simple representation to the consumer of degraded.json + + // fsDevice is what decrypted mapper device corresponds to the + // partition, it can have the following states + // - successfully decrypted => the decrypted mapper device + // - unencrypted => the block device of the partition + // - identified as decrypted, but failed to decrypt => empty string + fsDevice string + // partDevice is always the physical block device of the partition, in the + // encrypted case this is the physical encrypted partition. + partDevice string +} + +type recoverDegradedState struct { + // UbuntuData is the state of the ubuntu-data (or ubuntu-data-enc) + // partition. + UbuntuData partitionState `json:"ubuntu-data,omitempty"` + // UbuntuBoot is the state of the ubuntu-boot partition. + UbuntuBoot partitionState `json:"ubuntu-boot,omitempty"` + // UbuntuSave is the state of the ubuntu-save (or ubuntu-save-enc) + // partition. + UbuntuSave partitionState `json:"ubuntu-save,omitempty"` + // ErrorLog is the log of error messages encountered during recover mode + // setting up degraded mode. + ErrorLog []string `json:"error-log"` +} + +func (r *recoverDegradedState) partition(part string) *partitionState { + switch part { + case "ubuntu-data": + return &r.UbuntuData + case "ubuntu-boot": + return &r.UbuntuBoot + case "ubuntu-save": + return &r.UbuntuSave + } + panic(fmt.Sprintf("unknown partition %s", part)) +} + +func (r *recoverDegradedState) LogErrorf(format string, v ...interface{}) { + msg := fmt.Sprintf(format, v...) + r.ErrorLog = append(r.ErrorLog, msg) + logger.Noticef(msg) +} + +func (r *recoverDegradedState) serializeTo(name string) error { + b, err := json.Marshal(r) + if err != nil { + return err + } + + if err := os.MkdirAll(dirs.SnapBootstrapRunDir, 0755); err != nil { + return err + } + + // leave the information about degraded state at an ephemeral location + return os.WriteFile(filepath.Join(dirs.SnapBootstrapRunDir, name), b, 0644) +} + +// stateFunc is a function which executes a state action, returns the next +// function (for the next) state or nil if it is the final state. +type stateFunc func() (stateFunc, error) + +// recoverModeStateMachine is a state machine implementing the logic for +// degraded recover mode. +// A full state diagram for the state machine can be found in +// /cmd/snap-bootstrap/degraded-recover-mode.svg in this repo. +type recoverModeStateMachine struct { + // the current state is the one that is about to be executed + current stateFunc + + // device model + model *asserts.Model + + // the disk we have all our partitions on + disk disks.Disk + + // when true, the fallback unlock paths will not be tried + noFallback bool + + // TODO:UC20: for clarity turn this into into tristate: + // unknown|encrypted|unencrypted + isEncryptedDev bool + + // state for tracking what happens as we progress through degraded mode of + // recovery + degradedState *recoverDegradedState +} + +func (m *recoverModeStateMachine) whichModel() (*asserts.Model, error) { + return m.model, nil +} + +// degraded returns whether a degraded recover mode state has fallen back from +// the typical operation to some sort of degraded mode. +func (m *recoverModeStateMachine) degraded() bool { + r := m.degradedState + + if m.isEncryptedDev { + // for encrypted devices, we need to have ubuntu-save mounted + if r.UbuntuSave.MountState != partitionMounted { + return true + } + + // we also should have all the unlock keys as run keys + if r.UbuntuData.UnlockKey != keyRun { + return true + } + + if r.UbuntuSave.UnlockKey != keyRun { + return true + } + } else { + // for unencrypted devices, ubuntu-save must either be mounted or + // absent-but-optional + if r.UbuntuSave.MountState != partitionMounted { + if r.UbuntuSave.MountState != partitionAbsentOptional { + return true + } + } + } + + // ubuntu-boot and ubuntu-data should both be mounted + if r.UbuntuBoot.MountState != partitionMounted { + return true + } + if r.UbuntuData.MountState != partitionMounted { + return true + } + + // TODO: should we also check MountLocation too? + + // we should have nothing in the error log + if len(r.ErrorLog) != 0 { + return true + } + + return false +} + +func (m *recoverModeStateMachine) diskOpts() *disks.Options { + if m.isEncryptedDev { + return &disks.Options{ + IsDecryptedDevice: true, + } + } + return nil +} + +func (m *recoverModeStateMachine) verifyMountPoint(dir, name string) error { + matches, err := m.disk.MountPointIsFromDisk(dir, m.diskOpts()) + if err != nil { + return err + } + if !matches { + return fmt.Errorf("cannot validate mount: %s mountpoint target %s is expected to be from disk %s but is not", name, dir, m.disk.Dev()) + } + return nil +} + +func (m *recoverModeStateMachine) setFindState(partName, partUUID string, err error, optionalPartition bool) error { + part := m.degradedState.partition(partName) + if err != nil { + if _, ok := err.(disks.PartitionNotFoundError); ok { + // explicit error that the device was not found + part.FindState = partitionNotFound + if !optionalPartition { + // partition is not optional, thus the error is relevant + m.degradedState.LogErrorf("cannot find %v partition on disk %s", partName, m.disk.Dev()) + } + return nil + } + // the error is not "not-found", so we have a real error + part.FindState = partitionErrFinding + m.degradedState.LogErrorf("error finding %v partition on disk %s: %v", partName, m.disk.Dev(), err) + return nil + } + + // device was found + part.FindState = partitionFound + dev := fmt.Sprintf("/dev/disk/by-partuuid/%s", partUUID) + part.partDevice = dev + part.fsDevice = dev + return nil +} + +func (m *recoverModeStateMachine) setMountState(part, where string, err error) error { + if err != nil { + m.degradedState.LogErrorf("cannot mount %v: %v", part, err) + m.degradedState.partition(part).MountState = partitionErrMounting + return nil + } + + m.degradedState.partition(part).MountState = partitionMounted + m.degradedState.partition(part).MountLocation = where + + if err := m.verifyMountPoint(where, part); err != nil { + m.degradedState.LogErrorf("cannot verify %s mount point at %v: %v", part, where, err) + return err + } + return nil +} + +func (m *recoverModeStateMachine) setUnlockStateWithRunKey(partName string, unlockRes secboot.UnlockResult, err error) error { + part := m.degradedState.partition(partName) + // save the device if we found it from secboot + if unlockRes.PartDevice != "" { + part.FindState = partitionFound + part.partDevice = unlockRes.PartDevice + part.fsDevice = unlockRes.FsDevice + } else { + part.FindState = partitionNotFound + } + if unlockRes.IsEncrypted { + m.isEncryptedDev = true + } + + if err != nil { + // create different error message for encrypted vs unencrypted + if unlockRes.IsEncrypted { + // if we know the device is decrypted we must also always know at + // least the partDevice (which is the encrypted block device) + m.degradedState.LogErrorf("cannot unlock encrypted %s (device %s) with sealed run key: %v", partName, part.partDevice, err) + part.UnlockState = partitionErrUnlocking + } else { + // TODO: we don't know if this is a plain not found or a different error + m.degradedState.LogErrorf("cannot locate %s partition for mounting host data: %v", partName, err) + } + + return nil + } + + if unlockRes.IsEncrypted { + // unlocked successfully + part.UnlockState = partitionUnlocked + part.UnlockKey = keyRun + } + + return nil +} + +func (m *recoverModeStateMachine) setUnlockStateWithFallbackKey(partName string, unlockRes secboot.UnlockResult, err error) error { + // first check the result and error for consistency; since we are using udev + // there could be inconsistent results at different points in time + + // TODO: consider refactoring UnlockVolumeUsingSealedKeyIfEncrypted to not + // also find the partition on the disk, that should eliminate this + // consistency checking as we can code it such that we don't get these + // possible inconsistencies + + // do basic consistency checking on unlockRes to make sure the + // result makes sense. + if unlockRes.FsDevice != "" && err != nil { + // This case should be impossible to enter, we can't + // have a filesystem device but an error set + return fmt.Errorf("internal error: inconsistent return values from UnlockVolumeUsingSealedKeyIfEncrypted for partition %s: %v", partName, err) + } + + part := m.degradedState.partition(partName) + // Also make sure that if we previously saw a partition device that we see + // the same device again. + if unlockRes.PartDevice != "" && part.partDevice != "" && unlockRes.PartDevice != part.partDevice { + return fmt.Errorf("inconsistent partitions found for %s: previously found %s but now found %s", partName, part.partDevice, unlockRes.PartDevice) + } + + // ensure consistency between encrypted state of the device/disk and what we + // may have seen previously + if m.isEncryptedDev && !unlockRes.IsEncrypted { + // then we previously were able to positively identify an + // ubuntu-data-enc but can't anymore, so we have inconsistent results + // from inspecting the disk which is suspicious and we should fail + return fmt.Errorf("inconsistent disk encryption status: previous access resulted in encrypted, but now is unencrypted from partition %s", partName) + } + + // now actually process the result into the state + if unlockRes.PartDevice != "" { + part.FindState = partitionFound + // Note that in some case this may be redundantly assigning the same + // value to partDevice again. + part.partDevice = unlockRes.PartDevice + part.fsDevice = unlockRes.FsDevice + } + + // There are a few cases where this could be the first time that we found a + // decrypted device in the UnlockResult, but m.isEncryptedDev is still + // false. + // - The first case is if we couldn't find ubuntu-boot at all, in which case + // we can't use the run object keys from there and instead need to directly + // fallback to trying the fallback object keys from ubuntu-seed + // - The second case is if we couldn't identify an ubuntu-data-enc or an + // ubuntu-data partition at all, we still could have an ubuntu-save-enc + // partition in which case we maybe could still have an encrypted disk that + // needs unlocking with the fallback object keys from ubuntu-seed + // + // As such, if m.isEncryptedDev is false, but unlockRes.IsEncrypted is + // true, then it is safe to assign m.isEncryptedDev to true. + if !m.isEncryptedDev && unlockRes.IsEncrypted { + m.isEncryptedDev = true + } + + if err != nil { + // create different error message for encrypted vs unencrypted + if m.isEncryptedDev { + m.degradedState.LogErrorf("cannot unlock encrypted %s partition with sealed fallback key: %v", partName, err) + part.UnlockState = partitionErrUnlocking + } else { + // if we don't have an encrypted device and err != nil, then the + // device must be not-found, see above checks + + // log an error the partition is mandatory + m.degradedState.LogErrorf("cannot locate %s partition: %v", partName, err) + } + + return nil + } + + if m.isEncryptedDev { + // unlocked successfully + part.UnlockState = partitionUnlocked + + // figure out which key/method we used to unlock the partition + switch unlockRes.UnlockMethod { + case secboot.UnlockedWithSealedKey: + part.UnlockKey = keyFallback + case secboot.UnlockedWithRecoveryKey: + part.UnlockKey = keyRecovery + + // TODO: should we fail with internal error for default case here? + } + } + + return nil +} + +func newRecoverModeStateMachine(model *asserts.Model, disk disks.Disk, allowFallback bool) *recoverModeStateMachine { + m := &recoverModeStateMachine{ + model: model, + disk: disk, + degradedState: &recoverDegradedState{ + ErrorLog: []string{}, + }, + noFallback: !allowFallback, + } + // first step is to mount ubuntu-boot to check for run mode keys to unlock + // ubuntu-data + m.current = m.mountBoot + return m +} + +func (m *recoverModeStateMachine) execute() (finished bool, err error) { + next, err := m.current() + m.current = next + finished = next == nil + if finished && err == nil { + if err := m.finalize(); err != nil { + return true, err + } + } + return finished, err +} + +func (m *recoverModeStateMachine) finalize() error { + // check soundness + // the grade check makes sure that if data was mounted unencrypted + // but the model is secured it will end up marked as untrusted + isEncrypted := m.isEncryptedDev || m.model.StorageSafety() == asserts.StorageSafetyEncrypted + part := m.degradedState.partition("ubuntu-data") + if part.MountState == partitionMounted && isEncrypted { + // check that save and data match + // We want to avoid a chosen ubuntu-data + // (e.g. activated with a recovery key) to get access + // via its logins to the secrets in ubuntu-save (in + // particular the policy update auth key) + // TODO:UC20: we should try to be a bit more specific here in checking that + // data and save match, and not mark data as untrusted if we + // know that the real save is locked/protected (or doesn't exist + // in the case of bad corruption) because currently this code will + // mark data as untrusted, even if it was unlocked with the run + // object key and we failed to unlock ubuntu-save at all, which is + // undesirable. This effectively means that you need to have both + // ubuntu-data and ubuntu-save unlockable and have matching marker + // files in order to use the files from ubuntu-data to log-in, + // etc. + trustData, _ := checkDataAndSavePairing(boot.InitramfsHostWritableDir(m.model)) + if !trustData { + part.MountState = partitionMountedUntrusted + m.degradedState.LogErrorf("cannot trust ubuntu-data, ubuntu-save and ubuntu-data are not marked as from the same install") + } + } + + // finally, combine the states of partDevice and fsDevice into the + // exported Device field for marshalling + // ubuntu-boot is easy - it will always be unencrypted so we just set + // Device to partDevice + m.degradedState.partition("ubuntu-boot").Device = m.degradedState.partition("ubuntu-boot").partDevice + + // for ubuntu-data and save, we need to actually look at the states + for _, partName := range []string{"ubuntu-data", "ubuntu-save"} { + part := m.degradedState.partition(partName) + if part.fsDevice == "" { + // then the device is encrypted, but we failed to decrypt it, so + // set Device to the encrypted block device + part.Device = part.partDevice + } else { + // all other cases, fsDevice is set to what we want to + // export, either it is set to the decrypted mapper device in the + // case it was successfully decrypted, or it is set to the encrypted + // block device if we failed to decrypt it, or it was set to the + // unencrypted block device if it was unencrypted + part.Device = part.fsDevice + } + } + + return nil +} + +func (m *recoverModeStateMachine) trustData() bool { + return m.degradedState.partition("ubuntu-data").MountState == partitionMounted +} + +// mountBoot is the first state to execute in the state machine, it can +// transition to the following states: +// - if ubuntu-boot is mounted successfully, execute unlockDataRunKey +// - if ubuntu-boot can't be mounted, execute unlockDataFallbackKey +// - if we mounted the wrong ubuntu-boot (or otherwise can't verify which one we +// mounted), return fatal error +func (m *recoverModeStateMachine) mountBoot() (stateFunc, error) { + part := m.degradedState.partition("ubuntu-boot") + // use the disk we mounted ubuntu-seed from as a reference to find + // ubuntu-seed and mount it + partUUID, findErr := m.disk.FindMatchingPartitionUUIDWithFsLabel("ubuntu-boot") + const partitionMandatory = false + if err := m.setFindState("ubuntu-boot", partUUID, findErr, partitionMandatory); err != nil { + return nil, err + } + if part.FindState != partitionFound { + // if we didn't find ubuntu-boot, we can't try to unlock data with the + // run key, and should instead just jump straight to attempting to + // unlock with the fallback key + return m.unlockDataFallbackKey, nil + } + + // should we fsck ubuntu-boot? probably yes because on some platforms + // (u-boot for example) ubuntu-boot is vfat and it could have been unmounted + // dirtily, and we need to fsck it to ensure it is mounted safely before + // reading keys from it + systemdOpts := &systemdMountOptions{ + NeedsFsck: true, + Private: true, + } + mountErr := doSystemdMount(part.fsDevice, boot.InitramfsUbuntuBootDir, systemdOpts) + if err := m.setMountState("ubuntu-boot", boot.InitramfsUbuntuBootDir, mountErr); err != nil { + return nil, err + } + if part.MountState == partitionErrMounting { + // if we didn't mount data, then try to unlock data with the + // fallback key + return m.unlockDataFallbackKey, nil + } + + // next step try to unlock data with run object + return m.unlockDataRunKey, nil +} + +// stateUnlockDataRunKey will try to unlock ubuntu-data with the normal run-mode +// key, and if it fails, progresses to the next state, which is either: +// - failed to unlock data, but we know it's an encrypted device -> try to unlock with fallback key +// - failed to find data at all -> try to unlock save +// - unlocked data with run key -> mount data +func (m *recoverModeStateMachine) unlockDataRunKey() (stateFunc, error) { + runModeKey := device.DataSealedKeyUnder(boot.InitramfsBootEncryptionKeyDir) + unlockOpts := &secboot.UnlockVolumeUsingSealedKeyOptions{ + // don't allow using the recovery key to unlock, we only try using the + // recovery key after we first try the fallback object + AllowRecoveryKey: false, + WhichModel: m.whichModel, + } + unlockRes, unlockErr := secbootUnlockVolumeUsingSealedKeyIfEncrypted(m.disk, "ubuntu-data", runModeKey, unlockOpts) + if err := m.setUnlockStateWithRunKey("ubuntu-data", unlockRes, unlockErr); err != nil { + return nil, err + } + if unlockErr != nil { + // we couldn't unlock ubuntu-data with the primary key, or we didn't + // find it in the unencrypted case + if unlockRes.IsEncrypted { + // we know the device is encrypted, so the next state is to try + // unlocking with the fallback key + return m.unlockDataFallbackKey, nil + } + + // if we didn't even find the device to the point where it would have + // been identified as decrypted or unencrypted device, we could have + // just entirely lost ubuntu-data-enc, and we could still have an + // encrypted device, so instead try to unlock ubuntu-save with the + // fallback key, the logic there can also handle an unencrypted ubuntu-save + return m.unlockMaybeEncryptedAloneSaveFallbackKey, nil + } + + // otherwise successfully unlocked it (or just found it if it was unencrypted) + // so just mount it + return m.mountData, nil +} + +func (m *recoverModeStateMachine) unlockDataFallbackKey() (stateFunc, error) { + if m.noFallback { + return nil, fmt.Errorf("cannot unlock ubuntu-data (fallback disabled)") + } + + // try to unlock data with the fallback key on ubuntu-seed, which must have + // been mounted at this point + unlockOpts := &secboot.UnlockVolumeUsingSealedKeyOptions{ + // we want to allow using the recovery key if the fallback key fails as + // using the fallback object is the last chance before we give up trying + // to unlock data + AllowRecoveryKey: true, + WhichModel: m.whichModel, + } + // TODO: this prompts for a recovery key + // TODO: we should somehow customize the prompt to mention what key we need + // the user to enter, and what we are unlocking (as currently the prompt + // says "recovery key" and the partition UUID for what is being unlocked) + dataFallbackKey := device.FallbackDataSealedKeyUnder(boot.InitramfsSeedEncryptionKeyDir) + unlockRes, unlockErr := secbootUnlockVolumeUsingSealedKeyIfEncrypted(m.disk, "ubuntu-data", dataFallbackKey, unlockOpts) + if err := m.setUnlockStateWithFallbackKey("ubuntu-data", unlockRes, unlockErr); err != nil { + return nil, err + } + if unlockErr != nil { + // skip trying to mount data, since we did not unlock data we cannot + // open save with with the run key, so try the fallback one + return m.unlockEncryptedSaveFallbackKey, nil + } + + // unlocked it, now go mount it + return m.mountData, nil +} + +func (m *recoverModeStateMachine) mountData() (stateFunc, error) { + data := m.degradedState.partition("ubuntu-data") + // don't do fsck on the data partition, it could be corrupted + // however, data should always be mounted nosuid to prevent snaps from + // extracting suid executables there and trying to circumvent the sandbox + mountOpts := &systemdMountOptions{ + NoSuid: true, + Private: true, + } + mountErr := doSystemdMount(data.fsDevice, boot.InitramfsHostUbuntuDataDir, mountOpts) + if err := m.setMountState("ubuntu-data", boot.InitramfsHostUbuntuDataDir, mountErr); err != nil { + return nil, err + } + if m.isEncryptedDev { + if mountErr == nil { + // if we succeeded in mounting data and we are encrypted, the next step + // is to unlock save with the run key from ubuntu-data + return m.unlockEncryptedSaveRunKey, nil + } else { + // we are encrypted and we failed to mount data successfully, meaning we + // don't have the bare key from ubuntu-data to use, and need to fall back + // to the sealed key from ubuntu-seed + return m.unlockEncryptedSaveFallbackKey, nil + } + } + + // the data is not encrypted, in which case the ubuntu-save, if it + // exists, will be plain too + return m.openUnencryptedSave, nil +} + +func (m *recoverModeStateMachine) unlockEncryptedSaveRunKey() (stateFunc, error) { + // to get to this state, we needed to have mounted ubuntu-data on host, so + // if encrypted, we can try to read the run key from host ubuntu-data + saveKey := device.SaveKeyUnder(dirs.SnapFDEDirUnder(boot.InitramfsHostWritableDir(m.model))) + key, err := os.ReadFile(saveKey) + if err != nil { + // log the error and skip to trying the fallback key + m.degradedState.LogErrorf("cannot access run ubuntu-save key: %v", err) + return m.unlockEncryptedSaveFallbackKey, nil + } + + unlockRes, unlockErr := secbootUnlockEncryptedVolumeUsingKey(m.disk, "ubuntu-save", key) + if err := m.setUnlockStateWithRunKey("ubuntu-save", unlockRes, unlockErr); err != nil { + return nil, err + } + if unlockErr != nil { + // failed to unlock with run key, try fallback key + return m.unlockEncryptedSaveFallbackKey, nil + } + + // unlocked it properly, go mount it + return m.mountSave, nil +} + +func (m *recoverModeStateMachine) unlockMaybeEncryptedAloneSaveFallbackKey() (stateFunc, error) { + // we can only get here by not finding ubuntu-data at all, meaning the + // system can still be encrypted and have an encrypted ubuntu-save, + // which we will determine now + + // first check whether there is an encrypted save + _, findErr := m.disk.FindMatchingPartitionUUIDWithFsLabel(secboot.EncryptedPartitionName("ubuntu-save")) + if findErr == nil { + // well there is one, go try and unlock it + return m.unlockEncryptedSaveFallbackKey, nil + } + // encrypted ubuntu-save does not exist, there may still be an + // unencrypted one + return m.openUnencryptedSave, nil +} + +func (m *recoverModeStateMachine) openUnencryptedSave() (stateFunc, error) { + // do we have ubuntu-save at all? + partSave := m.degradedState.partition("ubuntu-save") + const partitionOptional = true + partUUID, findErr := m.disk.FindMatchingPartitionUUIDWithFsLabel("ubuntu-save") + if err := m.setFindState("ubuntu-save", partUUID, findErr, partitionOptional); err != nil { + return nil, err + } + if partSave.FindState == partitionFound { + // we have ubuntu-save, go mount it + return m.mountSave, nil + } + + // unencrypted ubuntu-save was not found, try to log something in case + // the early boot output can be collected for debugging purposes + if uuid, err := m.disk.FindMatchingPartitionUUIDWithFsLabel(secboot.EncryptedPartitionName("ubuntu-save")); err == nil { + // highly unlikely that encrypted save exists + logger.Noticef("ignoring unexpected encrypted ubuntu-save with UUID %q", uuid) + } else { + logger.Noticef("ubuntu-save was not found") + } + + // save is optional in an unencrypted system + partSave.MountState = partitionAbsentOptional + + // we're done, nothing more to try + return nil, nil +} + +func (m *recoverModeStateMachine) unlockEncryptedSaveFallbackKey() (stateFunc, error) { + // try to unlock save with the fallback key on ubuntu-seed, which must have + // been mounted at this point + + if m.noFallback { + return nil, fmt.Errorf("cannot unlock ubuntu-save (fallback disabled)") + } + + unlockOpts := &secboot.UnlockVolumeUsingSealedKeyOptions{ + // we want to allow using the recovery key if the fallback key fails as + // using the fallback object is the last chance before we give up trying + // to unlock save + AllowRecoveryKey: true, + WhichModel: m.whichModel, + } + saveFallbackKey := device.FallbackSaveSealedKeyUnder(boot.InitramfsSeedEncryptionKeyDir) + // TODO: this prompts again for a recover key, but really this is the + // reinstall key we will prompt for + // TODO: we should somehow customize the prompt to mention what key we need + // the user to enter, and what we are unlocking (as currently the prompt + // says "recovery key" and the partition UUID for what is being unlocked) + unlockRes, unlockErr := secbootUnlockVolumeUsingSealedKeyIfEncrypted(m.disk, "ubuntu-save", saveFallbackKey, unlockOpts) + if err := m.setUnlockStateWithFallbackKey("ubuntu-save", unlockRes, unlockErr); err != nil { + return nil, err + } + if unlockErr != nil { + // all done, nothing left to try and mount, mounting ubuntu-save is the + // last step but we couldn't find or unlock it + return nil, nil + } + // otherwise we unlocked it, so go mount it + return m.mountSave, nil +} + +func (m *recoverModeStateMachine) mountSave() (stateFunc, error) { + save := m.degradedState.partition("ubuntu-save") + // TODO: should we fsck ubuntu-save ? + mountOpts := &systemdMountOptions{ + Private: true, + } + mountErr := doSystemdMount(save.fsDevice, boot.InitramfsUbuntuSaveDir, mountOpts) + if err := m.setMountState("ubuntu-save", boot.InitramfsUbuntuSaveDir, mountErr); err != nil { + return nil, err + } + // all done, nothing left to try and mount + return nil, nil +} + +func generateMountsModeRecover(mst *initramfsMountsState) error { + // steps 1 and 2 are shared with install mode + model, snaps, err := generateMountsCommonInstallRecover(mst) + if err != nil { + return err + } + + // get the disk that we mounted the ubuntu-seed partition from as a + // reference point for future mounts + disk, err := disks.DiskFromMountPoint(boot.InitramfsUbuntuSeedDir, nil) + if err != nil { + return err + } + + // for most cases we allow the use of fallback to unlock/mount things + allowFallback := true + + tryingCurrentSystem, err := boot.InitramfsIsTryingRecoverySystem(mst.recoverySystem) + if err != nil { + if boot.IsInconsistentRecoverySystemState(err) { + // there is some try recovery system state in bootenv + // but it is inconsistent, make sure we clear it and + // return back to run mode + + // finalize reboots or panics + logger.Noticef("try recovery system state is inconsistent: %v", err) + finalizeTryRecoverySystemAndReboot(model, boot.TryRecoverySystemOutcomeInconsistent) + } + return err + } + if tryingCurrentSystem { + // but in this case, use only the run keys + allowFallback = false + + // make sure that if rebooted, the next boot goes into run mode + if err := boot.EnsureNextBootToRunMode(""); err != nil { + return err + } + } + + // 3. run the state machine logic for mounting partitions, this involves + // trying to unlock then mount ubuntu-data, and then unlocking and + // mounting ubuntu-save + // see the state* functions for details of what each step does and + // possible transition points + + machine, err := func() (machine *recoverModeStateMachine, err error) { + // first state to execute is to unlock ubuntu-data with the run key + machine = newRecoverModeStateMachine(model, disk, allowFallback) + for { + finished, err := machine.execute() + // TODO: consider whether certain errors are fatal or not + if err != nil { + return nil, err + } + if finished { + break + } + } + + return machine, nil + }() + if tryingCurrentSystem { + // end of the line for a recovery system we are only trying out, + // this branch always ends with a reboot (or a panic) + var outcome boot.TryRecoverySystemOutcome + if err == nil && !machine.degraded() { + outcome = boot.TryRecoverySystemOutcomeSuccess + } else { + outcome = boot.TryRecoverySystemOutcomeFailure + if err == nil { + err = fmt.Errorf("in degraded state") + } + logger.Noticef("try recovery system %q failed: %v", mst.recoverySystem, err) + } + // finalize reboots or panics + finalizeTryRecoverySystemAndReboot(model, outcome) + } + + if err != nil { + return err + } + + // 3.1 write out degraded.json if we ended up falling back somewhere + if machine.degraded() { + if err := machine.degradedState.serializeTo("degraded.json"); err != nil { + return err + } + } + + // 4. final step: copy the auth data and network config from + // the real ubuntu-data dir to the ephemeral ubuntu-data + // dir, write the modeenv to the tmpfs data, and disable + // cloud-init in recover mode + + // if we have the host location, then we were able to successfully mount + // ubuntu-data, and as such we can proceed with copying files from there + // onto the tmpfs + // Proceed only if we trust ubuntu-data to be paired with ubuntu-save + if machine.trustData() { + // TODO: erroring here should fallback to copySafeDefaultData and + // proceed on with degraded mode anyways + if err := copyUbuntuDataAuth(boot.InitramfsHostUbuntuDataDir, boot.InitramfsDataDir); err != nil { + return err + } + if err := copyNetworkConfig(boot.InitramfsHostUbuntuDataDir, boot.InitramfsDataDir); err != nil { + return err + } + if err := copyUbuntuDataMisc(boot.InitramfsHostUbuntuDataDir, boot.InitramfsDataDir); err != nil { + return err + } + } else { + // we don't have ubuntu-data host mountpoint, so we should setup safe + // defaults for i.e. console-conf in the running image to block + // attackers from accessing the system - just because we can't access + // ubuntu-data doesn't mean that attackers wouldn't be able to if they + // could login + + if err := copySafeDefaultData(boot.InitramfsDataDir); err != nil { + return err + } + } + + modeEnv, err := mst.EphemeralModeenvForModel(model, snaps) + if err != nil { + return err + } + isRunMode := false + if err := modeEnv.WriteTo(boot.InitramfsWritableDir(model, isRunMode)); err != nil { + return err + } + + // finally we need to modify the bootenv to mark the system as successful, + // this ensures that when you reboot from recover mode without doing + // anything else, you are auto-transitioned back to run mode + // TODO:UC20: as discussed unclear we need to pass the recovery system here + if err := boot.EnsureNextBootToRunMode(mst.recoverySystem); err != nil { + return err + } + + // done, no output, no error indicates to initramfs we are done with + // mounting stuff + return nil +} + +func generateMountsModeFactoryReset(mst *initramfsMountsState) error { + // steps 1 and 2 are shared with install mode + model, snaps, err := generateMountsCommonInstallRecover(mst) + if err != nil { + return err + } + + // get the disk that we mounted the ubuntu-seed partition from as a + // reference point for future mounts + disk, err := disks.DiskFromMountPoint(boot.InitramfsUbuntuSeedDir, nil) + if err != nil { + return err + } + // step 3: find ubuntu-save, unlock and mount, note that factory-reset + // mode only cares about ubuntu-save, as ubuntu-data and ubuntu-boot + // will be wiped anyway so we do not even bother looking up those + // partitions (which may be corrupted too, hence factory-reset was + // invoked) + machine, err := func() (machine *recoverModeStateMachine, err error) { + allowFallback := true + machine = newRecoverModeStateMachine(model, disk, allowFallback) + // start from looking up encrypted ubuntu-save and unlocking with the fallback key + machine.current = machine.unlockMaybeEncryptedAloneSaveFallbackKey + for { + finished, err := machine.execute() + // TODO: consider whether certain errors are fatal or not + if err != nil { + return nil, err + } + if finished { + break + } + } + return machine, nil + }() + + if err != nil { + return err + } + + if err := machine.degradedState.serializeTo("factory-reset-bootstrap.json"); err != nil { + return err + } + + // disable console-conf as it won't be needed + if err := disableConsoleConf(boot.InitramfsDataDir); err != nil { + return err + } + + modeEnv, err := mst.EphemeralModeenvForModel(model, snaps) + if err != nil { + return err + } + isRunMode := false + if err := modeEnv.WriteTo(boot.InitramfsWritableDir(model, isRunMode)); err != nil { + return err + } + + // done, no output, no error indicates to initramfs we are done with + // mounting stuff + return nil +} + +// checkDataAndSavePairing make sure that ubuntu-data and ubuntu-save +// come from the same install by comparing secret markers in them +func checkDataAndSavePairing(rootdir string) (bool, error) { + marker1, marker2, err := device.ReadEncryptionMarkers(dirs.SnapFDEDirUnder(rootdir), dirs.SnapFDEDirUnderSave(boot.InitramfsUbuntuSaveDir)) + if err != nil { + return false, err + } + return subtle.ConstantTimeCompare(marker1, marker2) == 1, nil +} + +// waitFile waits for the given file/device-node/directory to appear. +var waitFile = func(path string, wait time.Duration, n int) error { + for i := 0; i < n; i++ { + if osutil.FileExists(path) { + return nil + } + time.Sleep(wait) + } + + return fmt.Errorf("no %v after waiting for %v", path, time.Duration(n)*wait) +} + +// TODO: those have to be waited by udev instead +func waitForDevice(path string) error { + if !osutil.FileExists(filepath.Join(dirs.GlobalRootDir, path)) { + pollWait := 50 * time.Millisecond + pollIterations := 1200 + logger.Noticef("waiting up to %v for %v to appear", time.Duration(pollIterations)*pollWait, path) + if err := waitFile(filepath.Join(dirs.GlobalRootDir, path), pollWait, pollIterations); err != nil { + return fmt.Errorf("cannot find device: %v", err) + } + } + return nil +} + +// Defined externally for faster unit tests +var pollWaitForLabel = 50 * time.Millisecond +var pollWaitForLabelIters = 1200 + +// TODO: those have to be waited by udev instead +func waitForCandidateByLabelPath(label string) (string, error) { + logger.Noticef("waiting up to %v for label %v to appear", + time.Duration(pollWaitForLabelIters)*pollWaitForLabel, label) + var err error + for i := 0; i < pollWaitForLabelIters; i++ { + var candidate string + // Ideally depending on the type of error we would return + // immediately or try again, but that would complicate code more + // than necessary and the extra wait will happen only when we + // will fail to boot anyway. Note also that this code is + // actually racy as we could get a not-best-possible-label (say, + // we get "Ubuntu-boot" while actually an exact "ubuntu-boot" + // label exists but the link has not been created yet): this is + // not a fully solvable problem although waiting by udev will + // help if the disk is present on boot. + if candidate, err = disks.CandidateByLabelPath(label); err == nil { + logger.Noticef("label %q found", candidate) + return candidate, nil + } + time.Sleep(pollWaitForLabel) + } + + // This is the last error from CandidateByLabelPath + return "", err +} + +func getNonUEFISystemDisk(fallbacklabel string) (string, error) { + values, err := kcmdline.KeyValues("snapd_system_disk") + if err != nil { + return "", err + } + if value, ok := values["snapd_system_disk"]; ok { + if err := waitForDevice(value); err != nil { + return "", err + } + systemdDisk, err := disks.DiskFromDeviceName(value) + if err != nil { + systemdDiskDevicePath, errDevicePath := disks.DiskFromDevicePath(value) + if errDevicePath != nil { + return "", fmt.Errorf("%q can neither be used as a device nor as a block: %v; %v", value, errDevicePath, err) + } + systemdDisk = systemdDiskDevicePath + } + partition, err := systemdDisk.FindMatchingPartitionWithFsLabel(fallbacklabel) + if err != nil { + return "", err + } + return partition.KernelDeviceNode, nil + } + + candidate, err := waitForCandidateByLabelPath(fallbacklabel) + if err != nil { + return "", err + } + + return candidate, nil +} + +// mountNonDataPartitionMatchingKernelDisk will select the partition to mount at +// dir, using the boot package function FindPartitionUUIDForBootedKernelDisk to +// determine what partition the booted kernel came from. If which disk the +// kernel came from cannot be determined, then it will fallback to mounting via +// the specified disk label. +func mountNonDataPartitionMatchingKernelDisk(dir, fallbacklabel string) error { + partuuid, err := bootFindPartitionUUIDForBootedKernelDisk() + var partSrc string + if err == nil { + // TODO: the by-partuuid is only available on gpt disks, on mbr we need + // to use by-uuid or by-id + partSrc = filepath.Join("/dev/disk/by-partuuid", partuuid) + } else { + partSrc, err = getNonUEFISystemDisk(fallbacklabel) + if err != nil { + return err + } + } + + // The partition uuid is read from the EFI variables. At this point + // the kernel may not have initialized the storage HW yet so poll + // here. + if err := waitForDevice(partSrc); err != nil { + return err + } + + opts := &systemdMountOptions{ + // always fsck the partition when we are mounting it, as this is the + // first partition we will be mounting, we can't know if anything is + // corrupted yet + NeedsFsck: true, + // don't need nosuid option here, since this function is only used + // for ubuntu-boot and ubuntu-seed, never ubuntu-data + Private: true, + } + return doSystemdMount(partSrc, dir, opts) +} + +func generateMountsCommonInstallRecoverStart(mst *initramfsMountsState) (model *asserts.Model, sysSnaps map[snap.Type]*seed.Snap, err error) { + // 1. always ensure seed partition is mounted first before the others, + // since the seed partition is needed to mount the snap files there + if err := mountNonDataPartitionMatchingKernelDisk(boot.InitramfsUbuntuSeedDir, "ubuntu-seed"); err != nil { + return nil, nil, err + } + + // load model and verified essential snaps metadata + typs := []snap.Type{snap.TypeBase, snap.TypeKernel, snap.TypeSnapd, snap.TypeGadget} + + theSeed, err := mst.LoadSeed("") + if err != nil { + return nil, nil, fmt.Errorf("cannot load seed: %v", err) + } + + perf := timings.New(nil) + if err := theSeed.LoadEssentialMeta(typs, perf); err != nil { + return nil, nil, fmt.Errorf("cannot load metadata and verify essential bootstrap snaps %v: %v", typs, err) + } + + model = theSeed.Model() + essSnaps := theSeed.EssentialSnaps() + + // 2.1. measure model + err = stampedAction(fmt.Sprintf("%s-model-measured", mst.recoverySystem), func() error { + return secbootMeasureSnapModelWhenPossible(func() (*asserts.Model, error) { + return model, nil + }) + }) + if err != nil { + return nil, nil, err + } + // verified model from the seed is now measured + mst.SetVerifiedBootModel(model) + + // at this point on a system with TPM-based encryption + // data can be open only if the measured model matches the actual + // expected recovery model we sealed against. + // TODO:UC20: on ARM systems and no TPM with encryption + // we need other ways to make sure that the disk is opened + // and we continue booting only for expected recovery models + + // 2.2. (auto) select recovery system and mount seed snaps + // TODO:UC20: do we need more cross checks here? + + systemSnaps := make(map[snap.Type]*seed.Snap) + + for _, essentialSnap := range essSnaps { + systemSnaps[essentialSnap.EssentialType] = essentialSnap + dir := snapTypeToMountDir[essentialSnap.EssentialType] + // TODO:UC20: we need to cross-check the kernel path with snapd_recovery_kernel used by grub + if err := doSystemdMount(essentialSnap.Path, filepath.Join(boot.InitramfsRunMntDir, dir), mountReadOnlyOptions); err != nil { + return nil, nil, err + } + } + + return model, systemSnaps, nil +} + +func generateMountsCommonInstallRecoverContinue(mst *initramfsMountsState, model *asserts.Model, sysSnaps map[snap.Type]*seed.Snap) (err error) { + // TODO:UC20: after we have the kernel and base snaps mounted, we should do + // the bind mounts from the kernel modules on top of the base + // mount and delete the corresponding systemd units from the + // initramfs layout + + // TODO:UC20: after the kernel and base snaps are mounted, we should setup + // writable here as well to take over from "the-modeenv" script + // in the initrd too + + // TODO:UC20: after the kernel and base snaps are mounted and writable is + // mounted, we should also implement writable-paths here too as + // writing it in Go instead of shellscript is desirable + + // 2.3. mount "ubuntu-data" on a tmpfs, and also mount with nosuid to prevent + // snaps from being able to bypass the sandbox by creating suid root files + // there and try to escape the sandbox + mntOpts := &systemdMountOptions{ + Tmpfs: true, + NoSuid: true, + Private: true, + } + err = doSystemdMount("tmpfs", boot.InitramfsDataDir, mntOpts) + if err != nil { + return err + } + + // finally get the gadget snap from the essential snaps and use it to + // configure the ephemeral system + // should only be one seed snap + gadgetSnap := squashfs.New(sysSnaps[snap.TypeGadget].Path) + + isRunMode := false + // we need to configure the ephemeral system with defaults and such using + // from the seed gadget + configOpts := &sysconfig.Options{ + // never allow cloud-init to run inside the ephemeral system, in the + // install case we don't want it to ever run, and in the recover case + // cloud-init will already have run in run mode, so things like network + // config and users should already be setup and we will copy those + // further down in the setup for recover mode + AllowCloudInit: false, + TargetRootDir: boot.InitramfsWritableDir(model, isRunMode), + GadgetSnap: gadgetSnap, + } + if err := sysconfig.ConfigureTargetSystem(model, configOpts); err != nil { + return err + } + + return nil +} + +func generateMountsCommonInstallRecover(mst *initramfsMountsState) (model *asserts.Model, sysSnaps map[snap.Type]*seed.Snap, err error) { + model, snaps, err := generateMountsCommonInstallRecoverStart(mst) + if err != nil { + return nil, nil, err + } + + if err := generateMountsCommonInstallRecoverContinue(mst, model, snaps); err != nil { + return nil, nil, err + } + + return model, snaps, nil +} + +func maybeMountSave(disk disks.Disk, rootdir string, encrypted bool, mountOpts *systemdMountOptions) (haveSave bool, err error) { + var saveDevice string + if encrypted { + saveKey := device.SaveKeyUnder(dirs.SnapFDEDirUnder(rootdir)) + // if ubuntu-save exists and is encrypted, the key has been created during install + if !osutil.FileExists(saveKey) { + // ubuntu-data is encrypted, but we appear to be missing + // a key to open ubuntu-save + return false, fmt.Errorf("cannot find ubuntu-save encryption key at %v", saveKey) + } + // we have save.key, volume exists and is encrypted + key, err := os.ReadFile(saveKey) + if err != nil { + return true, err + } + unlockRes, err := secbootUnlockEncryptedVolumeUsingKey(disk, "ubuntu-save", key) + if err != nil { + return true, fmt.Errorf("cannot unlock ubuntu-save volume: %v", err) + } + saveDevice = unlockRes.FsDevice + } else { + partUUID, err := disk.FindMatchingPartitionUUIDWithFsLabel("ubuntu-save") + if err != nil { + if _, ok := err.(disks.PartitionNotFoundError); ok { + // this is ok, ubuntu-save may not exist for + // non-encrypted device + return false, nil + } + return false, err + } + saveDevice = filepath.Join("/dev/disk/by-partuuid", partUUID) + } + if err := doSystemdMount(saveDevice, boot.InitramfsUbuntuSaveDir, mountOpts); err != nil { + return true, err + } + return true, nil +} + +// XXX: workaround for the lack of model in CVM systems +type genericCVMModel struct{} + +func (*genericCVMModel) Classic() bool { + return true +} + +func (*genericCVMModel) Grade() asserts.ModelGrade { + return "signed" +} + +func generateMountsModeRunCVM(mst *initramfsMountsState) error { + // Mount ESP as UbuntuSeedDir which has UEFI label + if err := mountNonDataPartitionMatchingKernelDisk(boot.InitramfsUbuntuSeedDir, "UEFI"); err != nil { + return err + } + + // get the disk that we mounted the ESP from as a reference + // point for future mounts + disk, err := disks.DiskFromMountPoint(boot.InitramfsUbuntuSeedDir, nil) + if err != nil { + return err + } + + // Mount rootfs + if err := secbootProvisionForCVM(boot.InitramfsUbuntuSeedDir); err != nil { + return err + } + runModeCVMKey := filepath.Join(boot.InitramfsSeedEncryptionKeyDir, "cloudimg-rootfs.sealed-key") + opts := &secboot.UnlockVolumeUsingSealedKeyOptions{ + AllowRecoveryKey: true, + } + unlockRes, err := secbootUnlockVolumeUsingSealedKeyIfEncrypted(disk, "cloudimg-rootfs", runModeCVMKey, opts) + if err != nil { + return err + } + fsckSystemdOpts := &systemdMountOptions{ + NeedsFsck: true, + Ephemeral: true, + } + if err := doSystemdMount(unlockRes.FsDevice, boot.InitramfsDataDir, fsckSystemdOpts); err != nil { + return err + } + + // Verify that cloudimg-rootfs comes from where we expect it to + diskOpts := &disks.Options{} + if unlockRes.IsEncrypted { + // then we need to specify that the data mountpoint is + // expected to be a decrypted device + diskOpts.IsDecryptedDevice = true + } + + matches, err := disk.MountPointIsFromDisk(boot.InitramfsDataDir, diskOpts) + if err != nil { + return err + } + if !matches { + // failed to verify that cloudimg-rootfs mountpoint + // comes from the same disk as ESP + return fmt.Errorf("cannot validate boot: cloudimg-rootfs mountpoint is expected to be from disk %s but is not", disk.Dev()) + } + + // Unmount ESP because otherwise unmounting is racy and results in booted systems without ESP + if err := doSystemdMount("", boot.InitramfsUbuntuSeedDir, &systemdMountOptions{Umount: true, Ephemeral: true}); err != nil { + return err + } + + // There is no real model on a CVM device but minimal model + // information is required by the later code + mst.SetVerifiedBootModel(&genericCVMModel{}) + + return nil +} + +func generateMountsModeRun(mst *initramfsMountsState) error { + // 1. mount ubuntu-boot + if err := mountNonDataPartitionMatchingKernelDisk(boot.InitramfsUbuntuBootDir, "ubuntu-boot"); err != nil { + return err + } + + // get the disk that we mounted the ubuntu-boot partition from as a + // reference point for future mounts + disk, err := disks.DiskFromMountPoint(boot.InitramfsUbuntuBootDir, nil) + if err != nil { + return err + } + + // 1.1. measure model + err = stampedAction("run-model-measured", func() error { + return secbootMeasureSnapModelWhenPossible(mst.UnverifiedBootModel) + }) + if err != nil { + return err + } + // XXX: I wonder if secbootMeasureSnapModelWhenPossible() + // should return the model so that we don't need to run + // mst.UnverifiedBootModel() again + model, err := mst.UnverifiedBootModel() + if err != nil { + return err + } + isClassic := model.Classic() + if model.Classic() { + logger.Noticef("generating mounts for classic system, run mode") + } else { + logger.Noticef("generating mounts for Ubuntu Core system, run mode") + } + isRunMode := true + + // 2. mount ubuntu-seed (optional for classic) + systemdOpts := &systemdMountOptions{ + NeedsFsck: true, + Private: true, + } + // use the disk we mounted ubuntu-boot from as a reference to find + // ubuntu-seed and mount it + hasSeedPart := true + partUUID, err := disk.FindMatchingPartitionUUIDWithFsLabel("ubuntu-seed") + if err != nil { + if isClassic { + // If there is no ubuntu-seed on classic, that's fine + if _, ok := err.(disks.PartitionNotFoundError); !ok { + return err + } + hasSeedPart = false + } else { + return err + } + } + // fsck is safe to run on ubuntu-seed as per the manpage, it should not + // meaningfully contribute to corruption if we fsck it every time we boot, + // and it is important to fsck it because it is vfat and mounted writable + // TODO:UC20: mount it as read-only here and remount as writable when we + // need it to be writable for i.e. transitioning to recover mode + if partUUID != "" { + if err := doSystemdMount(fmt.Sprintf("/dev/disk/by-partuuid/%s", partUUID), + boot.InitramfsUbuntuSeedDir, systemdOpts); err != nil { + return err + } + } + + // 2.1 Update bootloader variables now that boot/seed are mounted + if err := boot.InitramfsRunModeUpdateBootloaderVars(); err != nil { + return err + } + + // at this point on a system with TPM-based encryption + // data can be open only if the measured model matches the actual + // run model. + // TODO:UC20: on ARM systems and no TPM with encryption + // we need other ways to make sure that the disk is opened + // and we continue booting only for expected models + + // 3.1. mount Data + runModeKey := device.DataSealedKeyUnder(boot.InitramfsBootEncryptionKeyDir) + opts := &secboot.UnlockVolumeUsingSealedKeyOptions{ + AllowRecoveryKey: true, + WhichModel: mst.UnverifiedBootModel, + } + unlockRes, err := secbootUnlockVolumeUsingSealedKeyIfEncrypted(disk, "ubuntu-data", runModeKey, opts) + if err != nil { + return err + } + + // TODO: do we actually need fsck if we are mounting a mapper device? + // probably not? + dataMountOpts := &systemdMountOptions{ + NeedsFsck: true, + } + if !isClassic { + // fsck and mount with nosuid to prevent snaps from being able to bypass + // the sandbox by creating suid root files there and trying to escape the + // sandbox + dataMountOpts.NoSuid = true + // Note that on classic the default is to allow mount propagation + dataMountOpts.Private = true + } + if err := doSystemdMount(unlockRes.FsDevice, boot.InitramfsDataDir, dataMountOpts); err != nil { + return err + } + isEncryptedDev := unlockRes.IsEncrypted + + // at this point data was opened so we can consider the model okay + mst.SetVerifiedBootModel(model) + rootfsDir := boot.InitramfsWritableDir(model, isRunMode) + + // 3.2. mount ubuntu-save (if present) + haveSave, err := maybeMountSave(disk, rootfsDir, isEncryptedDev, systemdOpts) + if err != nil { + return err + } + + // 4.1 verify that ubuntu-data comes from where we expect it to + diskOpts := &disks.Options{} + if unlockRes.IsEncrypted { + // then we need to specify that the data mountpoint is expected to be a + // decrypted device, applies to both ubuntu-data and ubuntu-save + diskOpts.IsDecryptedDevice = true + } + + matches, err := disk.MountPointIsFromDisk(boot.InitramfsDataDir, diskOpts) + if err != nil { + return err + } + if !matches { + // failed to verify that ubuntu-data mountpoint comes from the same disk + // as ubuntu-boot + return fmt.Errorf("cannot validate boot: ubuntu-data mountpoint is expected to be from disk %s but is not", disk.Dev()) + } + if haveSave { + // 4.1a we have ubuntu-save, verify it as well + matches, err = disk.MountPointIsFromDisk(boot.InitramfsUbuntuSaveDir, diskOpts) + if err != nil { + return err + } + if !matches { + return fmt.Errorf("cannot validate boot: ubuntu-save mountpoint is expected to be from disk %s but is not", disk.Dev()) + } + + if isEncryptedDev { + // in run mode the path to open an encrypted save is for + // data to be encrypted and the save key in it + // to be successfully used. This already should stop + // allowing to chose ubuntu-data to try to access + // save. as safety boot also stops if the keys cannot + // be locked. + // for symmetry with recover code and extra paranoia + // though also check that the markers match. + paired, err := checkDataAndSavePairing(rootfsDir) + if err != nil { + return err + } + if !paired { + return fmt.Errorf("cannot validate boot: ubuntu-save and ubuntu-data are not marked as from the same install") + } + } + } + + // 4.2. read modeenv + modeEnv, err := boot.ReadModeenv(rootfsDir) + if err != nil { + return err + } + + // order in the list must not change as it determines the mount order + typs := []snap.Type{snap.TypeGadget, snap.TypeKernel} + if !isClassic { + typs = append([]snap.Type{snap.TypeBase}, typs...) + } + + // 4.2 choose base, gadget and kernel snaps (this includes updating + // modeenv if needed to try the base snap) + mounts, err := boot.InitramfsRunModeSelectSnapsToMount(typs, modeEnv, rootfsDir) + if err != nil { + return err + } + + // TODO:UC20: with grade > dangerous, verify the kernel snap hash against + // what we booted using the tpm log, this may need to be passed + // to the function above to make decisions there, or perhaps this + // code actually belongs in the bootloader implementation itself + + // 4.3 mount base (if UC), gadget and kernel snaps + for _, typ := range typs { + if sn, ok := mounts[typ]; ok { + dir := snapTypeToMountDir[typ] + snapPath := filepath.Join(dirs.SnapBlobDirUnder(rootfsDir), sn.Filename()) + if err := doSystemdMount(snapPath, filepath.Join(boot.InitramfsRunMntDir, dir), mountReadOnlyOptions); err != nil { + return err + } + } + } + + // 4.4 check if we expected a ubuntu-seed partition from the gadget data + if isClassic { + gadgetDir := filepath.Join(boot.InitramfsRunMntDir, snapTypeToMountDir[snap.TypeGadget]) + foundRole, err := gadget.HasRole(gadgetDir, []string{gadget.SystemSeed, gadget.SystemSeedNull}) + if err != nil { + return err + } + seedDefinedInGadget := foundRole != "" + if hasSeedPart && !seedDefinedInGadget { + return fmt.Errorf("ubuntu-seed partition found but not defined in the gadget") + } + if !hasSeedPart && seedDefinedInGadget { + return fmt.Errorf("ubuntu-seed partition not found but defined in the gadget (%s)", foundRole) + } + } + + // 4.5 mount snapd snap only on first boot + if modeEnv.RecoverySystem != "" && !isClassic { + // load the recovery system and generate mount for snapd + theSeed, err := mst.LoadSeed(modeEnv.RecoverySystem) + if err != nil { + return fmt.Errorf("cannot load metadata and verify snapd snap: %v", err) + } + perf := timings.New(nil) + if err := theSeed.LoadEssentialMeta([]snap.Type{snap.TypeSnapd}, perf); err != nil { + return fmt.Errorf("cannot load metadata and verify snapd snap: %v", err) + } + essSnaps := theSeed.EssentialSnaps() + if err := doSystemdMount(essSnaps[0].Path, filepath.Join(boot.InitramfsRunMntDir, "snapd"), mountReadOnlyOptions); err != nil { + return fmt.Errorf("cannot mount snapd snap: %v", err) + } + } + + return nil +} + +var tryRecoverySystemHealthCheck = func(model gadget.Model) error { + // check that writable is accessible by checking whether the + // state file exists + if !osutil.FileExists(dirs.SnapStateFileUnder(boot.InitramfsHostWritableDir(model))) { + return fmt.Errorf("host state file is not accessible") + } + return nil +} + +func finalizeTryRecoverySystemAndReboot(model gadget.Model, outcome boot.TryRecoverySystemOutcome) (err error) { + // from this point on, we must finish with a system reboot + defer func() { + if rebootErr := boot.InitramfsReboot(); rebootErr != nil { + if err != nil { + err = fmt.Errorf("%v (cannot reboot to run system: %v)", err, rebootErr) + } else { + err = fmt.Errorf("cannot reboot to run system: %v", rebootErr) + } + } + // not reached, unless in tests + panic(fmt.Errorf("finalize try recovery system did not reboot, last error: %v", err)) + }() + + if outcome == boot.TryRecoverySystemOutcomeSuccess { + if err := tryRecoverySystemHealthCheck(model); err != nil { + // health checks failed, the recovery system is considered + // unsuccessful + outcome = boot.TryRecoverySystemOutcomeFailure + logger.Noticef("try recovery system health check failed: %v", err) + } + } + + // that's it, we've tried booting a new recovery system to this point, + // whether things are looking good or bad we will reboot back to run + // mode and update the boot variables accordingly + if err := boot.EnsureNextBootToRunModeWithTryRecoverySystemOutcome(outcome); err != nil { + logger.Noticef("cannot update the try recovery system state: %v", err) + return fmt.Errorf("cannot mark recovery system successful: %v", err) + } + return nil +} diff --git a/cmd/snap-bootstrap/cmd_initramfs_mounts_nosecboot.go b/cmd/snap-bootstrap/cmd_initramfs_mounts_nosecboot.go new file mode 100644 index 00000000..640063d1 --- /dev/null +++ b/cmd/snap-bootstrap/cmd_initramfs_mounts_nosecboot.go @@ -0,0 +1,55 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- +//go:build nosecboot + +/* + * Copyright (C) 2019-2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "errors" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/osutil/disks" + "github.com/snapcore/snapd/secboot" +) + +var ( + errNotImplemented = errors.New("not implemented") +) + +func init() { + secbootProvisionForCVM = func(_ string) error { + return errNotImplemented + } + secbootMeasureSnapSystemEpochWhenPossible = func() error { + return errNotImplemented + } + secbootMeasureSnapModelWhenPossible = func(_ func() (*asserts.Model, error)) error { + return errNotImplemented + } + secbootUnlockVolumeUsingSealedKeyIfEncrypted = func(disk disks.Disk, name string, sealedEncryptionKeyFile string, opts *secboot.UnlockVolumeUsingSealedKeyOptions) (secboot.UnlockResult, error) { + return secboot.UnlockResult{}, errNotImplemented + } + secbootUnlockEncryptedVolumeUsingKey = func(disk disks.Disk, name string, key []byte) (secboot.UnlockResult, error) { + return secboot.UnlockResult{}, errNotImplemented + } + + secbootLockSealedKeys = func() error { + return errNotImplemented + } +} diff --git a/cmd/snap-bootstrap/cmd_initramfs_mounts_recover_degraded_test.go b/cmd/snap-bootstrap/cmd_initramfs_mounts_recover_degraded_test.go new file mode 100644 index 00000000..59767ef5 --- /dev/null +++ b/cmd/snap-bootstrap/cmd_initramfs_mounts_recover_degraded_test.go @@ -0,0 +1,292 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + . "gopkg.in/check.v1" + + main "github.com/snapcore/snapd/cmd/snap-bootstrap" +) + +func (s *initramfsMountsSuite) TestInitramfsDegradedState(c *C) { + tt := []struct { + r main.RecoverDegradedState + encrypted bool + degraded bool + comment string + }{ + // unencrypted happy + { + r: main.RecoverDegradedState{ + UbuntuBoot: main.PartitionState{ + MountState: "mounted", + }, + UbuntuData: main.PartitionState{ + MountState: "mounted", + }, + UbuntuSave: main.PartitionState{ + MountState: "absent-but-optional", + }, + }, + degraded: false, + comment: "happy unencrypted no save", + }, + { + r: main.RecoverDegradedState{ + UbuntuBoot: main.PartitionState{ + MountState: "mounted", + }, + UbuntuData: main.PartitionState{ + MountState: "mounted", + }, + UbuntuSave: main.PartitionState{ + MountState: "mounted", + }, + }, + degraded: false, + comment: "happy unencrypted save", + }, + // unencrypted unhappy + { + r: main.RecoverDegradedState{ + UbuntuBoot: main.PartitionState{ + MountState: "error-mounting", + }, + UbuntuData: main.PartitionState{ + MountState: "mounted", + }, + UbuntuSave: main.PartitionState{ + MountState: "absent-but-optional", + }, + ErrorLog: []string{ + "cannot find ubuntu-boot partition on disk 259:0", + }, + }, + degraded: true, + comment: "unencrypted, error mounting boot", + }, + { + r: main.RecoverDegradedState{ + UbuntuBoot: main.PartitionState{ + MountState: "mounted", + }, + UbuntuData: main.PartitionState{ + MountState: "error-mounting", + }, + UbuntuSave: main.PartitionState{ + MountState: "absent-but-optional", + }, + ErrorLog: []string{ + "cannot find ubuntu-data partition on disk 259:0", + }, + }, + degraded: true, + comment: "unencrypted, error mounting data", + }, + { + r: main.RecoverDegradedState{ + UbuntuBoot: main.PartitionState{ + MountState: "mounted", + }, + UbuntuData: main.PartitionState{ + MountState: "mounted", + }, + UbuntuSave: main.PartitionState{ + MountState: "error-mounting", + }, + ErrorLog: []string{ + "cannot find ubuntu-save partition on disk 259:0", + }, + }, + degraded: true, + comment: "unencrypted, error mounting save", + }, + + // encrypted happy + { + r: main.RecoverDegradedState{ + UbuntuBoot: main.PartitionState{ + MountState: "mounted", + }, + UbuntuData: main.PartitionState{ + MountState: "mounted", + UnlockState: "unlocked", + UnlockKey: "run", + }, + UbuntuSave: main.PartitionState{ + MountState: "mounted", + UnlockState: "unlocked", + UnlockKey: "run", + }, + }, + encrypted: true, + degraded: false, + comment: "happy encrypted", + }, + // encrypted unhappy + { + r: main.RecoverDegradedState{ + UbuntuBoot: main.PartitionState{ + MountState: "error-mounting", + }, + UbuntuData: main.PartitionState{ + MountState: "mounted", + UnlockState: "unlocked", + UnlockKey: "fallback", + }, + UbuntuSave: main.PartitionState{ + MountState: "mounted", + UnlockState: "unlocked", + UnlockKey: "run", + }, + ErrorLog: []string{ + "cannot find ubuntu-boot partition on disk 259:0", + }, + }, + encrypted: true, + degraded: true, + comment: "encrypted, no boot, fallback data", + }, + { + r: main.RecoverDegradedState{ + UbuntuBoot: main.PartitionState{ + MountState: "mounted", + }, + UbuntuData: main.PartitionState{ + MountState: "mounted", + UnlockState: "unlocked", + UnlockKey: "fallback", + }, + UbuntuSave: main.PartitionState{ + MountState: "mounted", + UnlockState: "unlocked", + UnlockKey: "run", + }, + ErrorLog: []string{ + "cannot unlock encrypted ubuntu-data with sealed run key: failed to unlock ubuntu-data", + }, + }, + encrypted: true, + degraded: true, + comment: "encrypted, fallback data", + }, + { + r: main.RecoverDegradedState{ + UbuntuBoot: main.PartitionState{ + MountState: "mounted", + }, + UbuntuData: main.PartitionState{ + MountState: "mounted", + UnlockState: "unlocked", + UnlockKey: "run", + }, + UbuntuSave: main.PartitionState{ + MountState: "mounted", + UnlockState: "unlocked", + UnlockKey: "fallback", + }, + ErrorLog: []string{ + "cannot unlock encrypted ubuntu-save with sealed run key: failed to unlock ubuntu-save", + }, + }, + encrypted: true, + degraded: true, + comment: "encrypted, fallback save", + }, + { + r: main.RecoverDegradedState{ + UbuntuBoot: main.PartitionState{ + MountState: "mounted", + }, + UbuntuData: main.PartitionState{ + MountState: "mounted", + UnlockState: "unlocked", + UnlockKey: "run", + }, + UbuntuSave: main.PartitionState{ + MountState: "mounted", + UnlockState: "unlocked", + UnlockKey: "recovery", + }, + ErrorLog: []string{ + "cannot unlock encrypted ubuntu-save with sealed run key: failed to unlock ubuntu-save", + }, + }, + encrypted: true, + degraded: true, + comment: "encrypted, recovery save", + }, + { + r: main.RecoverDegradedState{ + UbuntuBoot: main.PartitionState{ + MountState: "mounted", + }, + UbuntuData: main.PartitionState{ + MountState: "mounted", + UnlockState: "unlocked", + UnlockKey: "fallback", + }, + UbuntuSave: main.PartitionState{ + MountState: "mounted", + UnlockState: "unlocked", + UnlockKey: "fallback", + }, + ErrorLog: []string{ + "cannot unlock encrypted ubuntu-data with sealed run key: failed to unlock ubuntu-data", + }, + }, + encrypted: true, + degraded: true, + comment: "encrypted, fallback data, fallback save", + }, + { + r: main.RecoverDegradedState{ + UbuntuBoot: main.PartitionState{ + MountState: "mounted", + }, + UbuntuData: main.PartitionState{ + MountState: "mounted", + UnlockState: "unlocked", + UnlockKey: "fallback", + }, + UbuntuSave: main.PartitionState{ + MountState: "not-mounted", + UnlockState: "not-unlocked", + }, + ErrorLog: []string{ + "cannot unlock encrypted ubuntu-save with sealed run key: failed to unlock ubuntu-save", + "cannot unlock encrypted ubuntu-save with sealed fallback key: failed to unlock ubuntu-save", + }, + }, + encrypted: true, + degraded: true, + comment: "encrypted, fallback data, no save", + }, + } + + for _, t := range tt { + var comment CommentInterface + if t.comment != "" { + comment = Commentf(t.comment) + } + + c.Assert(t.r.Degraded(t.encrypted), Equals, t.degraded, comment) + } +} diff --git a/cmd/snap-bootstrap/cmd_initramfs_mounts_secboot.go b/cmd/snap-bootstrap/cmd_initramfs_mounts_secboot.go new file mode 100644 index 00000000..1a30006b --- /dev/null +++ b/cmd/snap-bootstrap/cmd_initramfs_mounts_secboot.go @@ -0,0 +1,34 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- +//go:build !nosecboot + +/* + * Copyright (C) 2019-2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "github.com/snapcore/snapd/secboot" +) + +func init() { + secbootProvisionForCVM = secboot.ProvisionForCVM + secbootMeasureSnapSystemEpochWhenPossible = secboot.MeasureSnapSystemEpochWhenPossible + secbootMeasureSnapModelWhenPossible = secboot.MeasureSnapModelWhenPossible + secbootUnlockVolumeUsingSealedKeyIfEncrypted = secboot.UnlockVolumeUsingSealedKeyIfEncrypted + secbootUnlockEncryptedVolumeUsingKey = secboot.UnlockEncryptedVolumeUsingKey + secbootLockSealedKeys = secboot.LockSealedKeys +} diff --git a/cmd/snap-bootstrap/cmd_initramfs_mounts_test.go b/cmd/snap-bootstrap/cmd_initramfs_mounts_test.go new file mode 100644 index 00000000..8ddac856 --- /dev/null +++ b/cmd/snap-bootstrap/cmd_initramfs_mounts_test.go @@ -0,0 +1,8405 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2019-2021 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/asserts/assertstest" + "github.com/snapcore/snapd/boot" + "github.com/snapcore/snapd/boot/boottest" + "github.com/snapcore/snapd/bootloader" + "github.com/snapcore/snapd/bootloader/bootloadertest" + main "github.com/snapcore/snapd/cmd/snap-bootstrap" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/gadget" + gadgetInstall "github.com/snapcore/snapd/gadget/install" + "github.com/snapcore/snapd/logger" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/osutil/disks" + "github.com/snapcore/snapd/osutil/kcmdline" + "github.com/snapcore/snapd/secboot" + "github.com/snapcore/snapd/secboot/keys" + "github.com/snapcore/snapd/seed" + "github.com/snapcore/snapd/seed/seedtest" + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/snapdtool" + "github.com/snapcore/snapd/systemd" + "github.com/snapcore/snapd/testutil" + "github.com/snapcore/snapd/timings" +) + +var brandPrivKey, _ = assertstest.GenerateKey(752) + +type baseInitramfsMountsSuite struct { + testutil.BaseTest + + isClassic bool + + // makes available a bunch of helper (like MakeAssertedSnap) + *seedtest.TestingSeed20 + + Stdout *bytes.Buffer + logs *bytes.Buffer + + seedDir string + byLabelDir string + sysLabel string + model *asserts.Model + tmpDir string + + snapDeclAssertsTime time.Time + + kernel snap.PlaceInfo + kernelr2 snap.PlaceInfo + core20 snap.PlaceInfo + core20r2 snap.PlaceInfo + gadget snap.PlaceInfo + snapd snap.PlaceInfo +} + +type initramfsMountsSuite struct { + baseInitramfsMountsSuite +} + +var _ = Suite(&initramfsMountsSuite{}) + +var ( + tmpfsMountOpts = &main.SystemdMountOptions{ + Tmpfs: true, + NoSuid: true, + Private: true, + } + needsFsckNoPrivateDiskMountOpts = &main.SystemdMountOptions{ + NeedsFsck: true, + } + needsFsckDiskMountOpts = &main.SystemdMountOptions{ + NeedsFsck: true, + Private: true, + } + needsFsckAndNoSuidDiskMountOpts = &main.SystemdMountOptions{ + NeedsFsck: true, + NoSuid: true, + Private: true, + } + needsNoSuidDiskMountOpts = &main.SystemdMountOptions{ + NoSuid: true, + Private: true, + } + snapMountOpts = &main.SystemdMountOptions{ + ReadOnly: true, + Private: true, + } + mountOpts = &main.SystemdMountOptions{ + Private: true, + } + bindOpts = &main.SystemdMountOptions{ + Bind: true, + } + + seedPart = disks.Partition{ + FilesystemLabel: "ubuntu-seed", + PartitionUUID: "ubuntu-seed-partuuid", + KernelDeviceNode: "/dev/sda2", + } + + seedPartCapitalFsLabel = disks.Partition{ + FilesystemLabel: "UBUNTU-SEED", + FilesystemType: "vfat", + PartitionUUID: "ubuntu-seed-partuuid", + KernelDeviceNode: "/dev/sda2", + } + + bootPart = disks.Partition{ + FilesystemLabel: "ubuntu-boot", + PartitionUUID: "ubuntu-boot-partuuid", + KernelDeviceNode: "/dev/sda3", + } + + savePart = disks.Partition{ + FilesystemLabel: "ubuntu-save", + PartitionUUID: "ubuntu-save-partuuid", + KernelDeviceNode: "/dev/sda4", + } + + dataPart = disks.Partition{ + FilesystemLabel: "ubuntu-data", + PartitionUUID: "ubuntu-data-partuuid", + KernelDeviceNode: "/dev/sda5", + } + + saveEncPart = disks.Partition{ + FilesystemLabel: "ubuntu-save-enc", + PartitionUUID: "ubuntu-save-enc-partuuid", + KernelDeviceNode: "/dev/sda4", + } + + dataEncPart = disks.Partition{ + FilesystemLabel: "ubuntu-data-enc", + PartitionUUID: "ubuntu-data-enc-partuuid", + KernelDeviceNode: "/dev/sda5", + } + + cvmEncPart = disks.Partition{ + FilesystemLabel: "cloudimg-rootfs-enc", + PartitionUUID: "cloudimg-rootfs-enc-partuuid", + KernelDeviceNode: "/dev/sda1", + } + + // a boot disk without ubuntu-save + defaultBootDisk = &disks.MockDiskMapping{ + Structure: []disks.Partition{ + seedPart, + bootPart, + dataPart, + }, + DiskHasPartitions: true, + DevNum: "default", + } + + defaultBootWithSaveDisk = &disks.MockDiskMapping{ + Structure: []disks.Partition{ + seedPart, + bootPart, + dataPart, + savePart, + }, + DiskHasPartitions: true, + DevNum: "default-with-save", + } + + defaultBootWithSeedPartCapitalFsLabel = &disks.MockDiskMapping{ + Structure: []disks.Partition{ + seedPartCapitalFsLabel, + bootPart, + dataPart, + savePart, + }, + DiskHasPartitions: true, + DevNum: "default-with-save", + } + + defaultEncBootDisk = &disks.MockDiskMapping{ + Structure: []disks.Partition{ + bootPart, + seedPart, + dataEncPart, + saveEncPart, + }, + DiskHasPartitions: true, + DevNum: "defaultEncDev", + } + + defaultCVMDisk = &disks.MockDiskMapping{ + Structure: []disks.Partition{ + seedPart, + cvmEncPart, + }, + DiskHasPartitions: true, + DevNum: "defaultCVMDev", + } + + // a boot disk without ubuntu-seed, which can happen for classic + defaultNoSeedWithSaveDisk = &disks.MockDiskMapping{ + Structure: []disks.Partition{ + bootPart, + dataPart, + savePart, + }, + DiskHasPartitions: true, + DevNum: "default-no-seed-with-save", + } + + mockStateContent = `{"data":{"auth":{"users":[{"id":1,"name":"mvo"}],"macaroon-key":"not-a-cookie","last-id":1}},"some":{"other":"stuff"}}` +) + +func (s *baseInitramfsMountsSuite) setupSeed(c *C, modelAssertTime time.Time, gadgetSnapFiles [][]string) { + + // pretend /run/mnt/ubuntu-seed has a valid seed + s.seedDir = boot.InitramfsUbuntuSeedDir + + // now create a minimal uc20 seed dir with snaps/assertions + seed20 := &seedtest.TestingSeed20{SeedDir: s.seedDir} + seed20.SetupAssertSigning("canonical") + restore := seed.MockTrusted(seed20.StoreSigning.Trusted) + s.AddCleanup(restore) + + // XXX: we don't really use this but seedtest always expects my-brand + seed20.Brands.Register("my-brand", brandPrivKey, map[string]interface{}{ + "verification": "verified", + }) + + // make sure all the assertions use the same time + seed20.SetSnapAssertionNow(s.snapDeclAssertsTime) + + // add a bunch of snaps + seed20.MakeAssertedSnap(c, "name: snapd\nversion: 1\ntype: snapd", nil, snap.R(1), "canonical", seed20.StoreSigning.Database) + seed20.MakeAssertedSnap(c, "name: pc\nversion: 1\ntype: gadget\nbase: core20", gadgetSnapFiles, snap.R(1), "canonical", seed20.StoreSigning.Database) + seed20.MakeAssertedSnap(c, "name: pc-kernel\nversion: 1\ntype: kernel", nil, snap.R(1), "canonical", seed20.StoreSigning.Database) + seed20.MakeAssertedSnap(c, "name: core20\nversion: 1\ntype: base", nil, snap.R(1), "canonical", seed20.StoreSigning.Database) + + // pretend that by default, the model uses an older timestamp than the + // snap assertions + if modelAssertTime.IsZero() { + modelAssertTime = s.snapDeclAssertsTime.Add(-30 * time.Minute) + } + + s.sysLabel = "20191118" + model := map[string]interface{}{ + "display-name": "my model", + "architecture": "amd64", + "base": "core20", + "timestamp": modelAssertTime.Format(time.RFC3339), + "snaps": []interface{}{ + map[string]interface{}{ + "name": "pc-kernel", + "id": seed20.AssertedSnapID("pc-kernel"), + "type": "kernel", + "default-channel": "20", + }, + map[string]interface{}{ + "name": "pc", + "id": seed20.AssertedSnapID("pc"), + "type": "gadget", + "default-channel": "20", + }}, + } + if s.isClassic { + model["classic"] = "true" + model["distribution"] = "ubuntu" + } + s.model = seed20.MakeSeed(c, s.sysLabel, "my-brand", "my-model", model, nil) +} + +func (s *baseInitramfsMountsSuite) SetUpTest(c *C) { + s.BaseTest.SetUpTest(c) + + s.AddCleanup(osutil.MockMountInfo("")) + + s.Stdout = bytes.NewBuffer(nil) + + buf, restore := logger.MockLogger() + s.AddCleanup(restore) + s.logs = buf + + s.tmpDir = c.MkDir() + + // mock /run/mnt + dirs.SetRootDir(s.tmpDir) + restore = func() { dirs.SetRootDir("") } + s.AddCleanup(restore) + + restore = main.MockWaitFile(func(string, time.Duration, int) error { + return nil + }) + s.AddCleanup(restore) + + // use a specific time for all the assertions, in the future so that we can + // set the timestamp of the model assertion to something newer than now, but + // still older than the snap declarations by default + s.snapDeclAssertsTime = time.Now().Add(60 * time.Minute) + + // setup the seed + s.setupSeed(c, time.Time{}, nil) + + // Make sure we have a model assertion in the ubuntu-boot partition + var err error + err = os.MkdirAll(filepath.Join(boot.InitramfsUbuntuBootDir, "device"), 0755) + c.Assert(err, IsNil) + mf, err := os.Create(filepath.Join(boot.InitramfsUbuntuBootDir, "device/model")) + c.Assert(err, IsNil) + defer mf.Close() + err = asserts.NewEncoder(mf).Encode(s.model) + c.Assert(err, IsNil) + + s.byLabelDir = filepath.Join(s.tmpDir, "dev/disk/by-label") + err = os.MkdirAll(s.byLabelDir, 0755) + c.Assert(err, IsNil) + err = os.WriteFile(filepath.Join(s.tmpDir, "dev/sda1"), nil, 0644) + c.Assert(err, IsNil) + err = os.Symlink("../../sda1", filepath.Join(s.byLabelDir, "ubuntu-seed")) + c.Assert(err, IsNil) + err = os.WriteFile(filepath.Join(s.tmpDir, "dev/sda2"), nil, 0644) + c.Assert(err, IsNil) + err = os.Symlink("../../sda2", filepath.Join(s.byLabelDir, "ubuntu-boot")) + c.Assert(err, IsNil) + err = os.WriteFile(filepath.Join(s.byLabelDir, "ubuntu-boot"), nil, 0644) + c.Assert(err, IsNil) + err = os.WriteFile(filepath.Join(s.tmpDir, "dev/sda"), nil, 0644) + c.Assert(err, IsNil) + + // make test snap PlaceInfo's for various boot functionality + s.kernel, err = snap.ParsePlaceInfoFromSnapFileName("pc-kernel_1.snap") + c.Assert(err, IsNil) + + s.core20, err = snap.ParsePlaceInfoFromSnapFileName("core20_1.snap") + c.Assert(err, IsNil) + + s.kernelr2, err = snap.ParsePlaceInfoFromSnapFileName("pc-kernel_2.snap") + c.Assert(err, IsNil) + + s.core20r2, err = snap.ParsePlaceInfoFromSnapFileName("core20_2.snap") + c.Assert(err, IsNil) + + s.gadget, err = snap.ParsePlaceInfoFromSnapFileName("pc_1.snap") + c.Assert(err, IsNil) + + s.snapd, err = snap.ParsePlaceInfoFromSnapFileName("snapd_1.snap") + c.Assert(err, IsNil) + + // by default mock that we don't have UEFI vars, etc. to get the booted + // kernel partition partition uuid + s.AddCleanup(main.MockPartitionUUIDForBootedKernelDisk("")) + s.AddCleanup(main.MockSecbootProvisionForCVM(func(_ string) error { + return nil + })) + s.AddCleanup(main.MockSecbootMeasureSnapSystemEpochWhenPossible(func() error { + return nil + })) + s.AddCleanup(main.MockSecbootMeasureSnapModelWhenPossible(func(f func() (*asserts.Model, error)) error { + c.Check(f, NotNil) + return nil + })) + s.AddCleanup(main.MockSecbootUnlockVolumeUsingSealedKeyIfEncrypted(func(disk disks.Disk, name string, sealedEncryptionKeyFile string, opts *secboot.UnlockVolumeUsingSealedKeyOptions) (secboot.UnlockResult, error) { + return foundUnencrypted(name), nil + })) + s.AddCleanup(main.MockSecbootLockSealedKeys(func() error { + return nil + })) + + s.AddCleanup(main.MockOsutilSetTime(func(time.Time) error { + return nil + })) + + s.AddCleanup(main.MockPollWaitForLabel(0)) + s.AddCleanup(main.MockPollWaitForLabelIters(1)) +} + +// static test cases for time test variants shared across the different modes + +type timeTestCase struct { + now time.Time + modelTime time.Time + expT time.Time + setTimeCalls int + comment string +} + +func (s *initramfsMountsSuite) timeTestCases() []timeTestCase { + // epoch time + epoch := time.Time{} + + // t1 is the kernel initrd build time + t1 := s.snapDeclAssertsTime.Add(-30 * 24 * time.Hour) + // technically there is another time here between t1 and t2, that is the + // default model sign time, but since it's older than the snap assertion + // sign time (t2) it's not actually used in the test + + // t2 is the time that snap-revision / snap-declaration assertions will be + // signed with + t2 := s.snapDeclAssertsTime + + // t3 is a time after the snap-declarations are signed + t3 := s.snapDeclAssertsTime.Add(30 * 24 * time.Hour) + + // t4 and t5 are both times after the the snap declarations are signed + t4 := s.snapDeclAssertsTime.Add(60 * 24 * time.Hour) + t5 := s.snapDeclAssertsTime.Add(120 * 24 * time.Hour) + + return []timeTestCase{ + { + now: epoch, + expT: t2, + setTimeCalls: 1, + comment: "now() is epoch", + }, + { + now: t1, + expT: t2, + setTimeCalls: 1, + comment: "now() is kernel initrd sign time", + }, + { + now: t3, + expT: t3, + setTimeCalls: 0, + comment: "now() is newer than snap assertion", + }, + { + now: t3, + modelTime: t4, + expT: t4, + setTimeCalls: 1, + comment: "model time is newer than now(), which is newer than snap asserts", + }, + { + now: t5, + modelTime: t4, + expT: t5, + setTimeCalls: 0, + comment: "model time is newest, but older than now()", + }, + } +} + +// helpers to create consistent UnlockResult values + +func foundUnencrypted(name string) secboot.UnlockResult { + dev := filepath.Join("/dev/disk/by-partuuid", name+"-partuuid") + return secboot.UnlockResult{ + PartDevice: dev, + FsDevice: dev, + } +} + +func happyUnlocked(name string, method secboot.UnlockMethod) secboot.UnlockResult { + return secboot.UnlockResult{ + PartDevice: filepath.Join("/dev/disk/by-partuuid", name+"-enc-partuuid"), + FsDevice: filepath.Join("/dev/mapper", name+"-random"), + IsEncrypted: true, + UnlockMethod: method, + } +} + +func foundEncrypted(name string) secboot.UnlockResult { + return secboot.UnlockResult{ + PartDevice: filepath.Join("/dev/disk/by-partuuid", name+"-enc-partuuid"), + // FsDevice is empty if we didn't unlock anything + FsDevice: "", + IsEncrypted: true, + } +} + +func notFoundPart() secboot.UnlockResult { + return secboot.UnlockResult{} +} + +// makeSnapFilesOnEarlyBootUbuntuData creates the snap files on ubuntu-data as +// we +func (s *baseInitramfsMountsSuite) makeSnapFilesOnEarlyBootUbuntuData(c *C, snaps ...snap.PlaceInfo) { + snapDir := dirs.SnapBlobDirUnder(filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data")) + if s.isClassic { + snapDir = dirs.SnapBlobDirUnder(filepath.Join(dirs.GlobalRootDir, "/run/mnt/data")) + } + err := os.MkdirAll(snapDir, 0755) + c.Assert(err, IsNil) + for _, sn := range snaps { + snFilename := sn.Filename() + err = os.WriteFile(filepath.Join(snapDir, snFilename), nil, 0644) + c.Assert(err, IsNil) + } +} + +func (s *baseInitramfsMountsSuite) mockProcCmdlineContent(c *C, newContent string) { + mockProcCmdline := filepath.Join(c.MkDir(), "proc-cmdline") + err := os.WriteFile(mockProcCmdline, []byte(newContent), 0644) + c.Assert(err, IsNil) + restore := kcmdline.MockProcCmdline(mockProcCmdline) + s.AddCleanup(restore) +} + +func (s *baseInitramfsMountsSuite) mockUbuntuSaveKeyAndMarker(c *C, rootDir, key, marker string) { + keyPath := filepath.Join(dirs.SnapFDEDirUnder(rootDir), "ubuntu-save.key") + c.Assert(os.MkdirAll(filepath.Dir(keyPath), 0700), IsNil) + c.Assert(os.WriteFile(keyPath, []byte(key), 0600), IsNil) + + if marker != "" { + markerPath := filepath.Join(dirs.SnapFDEDirUnder(rootDir), "marker") + c.Assert(os.WriteFile(markerPath, []byte(marker), 0600), IsNil) + } +} + +func (s *baseInitramfsMountsSuite) mockUbuntuSaveMarker(c *C, rootDir, marker string) { + markerPath := filepath.Join(rootDir, "device/fde", "marker") + c.Assert(os.MkdirAll(filepath.Dir(markerPath), 0700), IsNil) + c.Assert(os.WriteFile(markerPath, []byte(marker), 0600), IsNil) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsNoModeError(c *C) { + s.mockProcCmdlineContent(c, "nothing-to-see") + + _, err := main.Parser().ParseArgs([]string{"initramfs-mounts"}) + c.Assert(err, ErrorMatches, "cannot detect mode nor recovery system to use") +} + +func (s *initramfsMountsSuite) TestInitramfsMountsUnknownMode(c *C) { + s.mockProcCmdlineContent(c, "snapd_recovery_mode=install-foo") + + _, err := main.Parser().ParseArgs([]string{"initramfs-mounts"}) + c.Assert(err, ErrorMatches, `cannot use unknown mode "install-foo"`) +} + +type systemdMount struct { + what string + where string + opts *main.SystemdMountOptions + err error +} + +// this is a function so we evaluate InitramfsUbuntuBootDir, etc at the time of +// the test to pick up test-specific dirs.GlobalRootDir +func (s *baseInitramfsMountsSuite) ubuntuLabelMount(label string, mode string) systemdMount { + mnt := systemdMount{ + opts: needsFsckDiskMountOpts, + } + switch label { + case "ubuntu-boot": + mnt.what = filepath.Join(s.byLabelDir, "ubuntu-boot") + mnt.where = boot.InitramfsUbuntuBootDir + case "ubuntu-seed": + mnt.what = filepath.Join(s.byLabelDir, "ubuntu-seed") + mnt.where = boot.InitramfsUbuntuSeedDir + // don't fsck in run mode + if mode == "run" { + mnt.opts = nil + } + case "ubuntu-data": + mnt.what = filepath.Join(s.byLabelDir, "ubuntu-data") + mnt.where = boot.InitramfsDataDir + if s.isClassic { + mnt.opts = needsFsckNoPrivateDiskMountOpts + } else { + mnt.opts = needsFsckAndNoSuidDiskMountOpts + } + } + + return mnt +} + +// ubuntuPartUUIDMount returns a systemdMount for the partuuid disk, expecting +// that the partuuid contains in it the expected label for easier coding +func (s *baseInitramfsMountsSuite) ubuntuPartUUIDMount(partuuid string, mode string) systemdMount { + // all partitions are expected to be mounted with fsck on + mnt := systemdMount{ + opts: needsFsckDiskMountOpts, + } + mnt.what = filepath.Join("/dev/disk/by-partuuid", partuuid) + switch { + case strings.Contains(partuuid, "ubuntu-boot"): + mnt.where = boot.InitramfsUbuntuBootDir + case strings.Contains(partuuid, "ubuntu-seed"): + mnt.where = boot.InitramfsUbuntuSeedDir + case strings.Contains(partuuid, "ubuntu-data"): + mnt.where = boot.InitramfsDataDir + if s.isClassic { + mnt.opts = needsFsckNoPrivateDiskMountOpts + } else { + mnt.opts = needsFsckAndNoSuidDiskMountOpts + } + case strings.Contains(partuuid, "ubuntu-save"): + mnt.where = boot.InitramfsUbuntuSaveDir + } + + return mnt +} + +func (s *baseInitramfsMountsSuite) makeSeedSnapSystemdMount(typ snap.Type) systemdMount { + mnt := systemdMount{} + var name, dir string + switch typ { + case snap.TypeSnapd: + name = "snapd" + dir = "snapd" + case snap.TypeBase: + name = "core20" + dir = "base" + case snap.TypeGadget: + name = "pc" + dir = "gadget" + case snap.TypeKernel: + name = "pc-kernel" + dir = "kernel" + } + mnt.what = filepath.Join(s.seedDir, "snaps", name+"_1.snap") + mnt.where = filepath.Join(boot.InitramfsRunMntDir, dir) + mnt.opts = snapMountOpts + + return mnt +} + +func (s *baseInitramfsMountsSuite) makeRunSnapSystemdMount(typ snap.Type, sn snap.PlaceInfo) systemdMount { + mnt := systemdMount{} + var dir string + switch typ { + case snap.TypeSnapd: + dir = "snapd" + case snap.TypeBase: + dir = "base" + case snap.TypeGadget: + dir = "gadget" + case snap.TypeKernel: + dir = "kernel" + } + + snapDir := filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data") + if s.isClassic { + snapDir = filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/") + } + mnt.what = filepath.Join(dirs.SnapBlobDirUnder(snapDir), sn.Filename()) + mnt.where = filepath.Join(boot.InitramfsRunMntDir, dir) + mnt.opts = snapMountOpts + + return mnt +} + +func (s *baseInitramfsMountsSuite) mockSystemdMountSequence(c *C, mounts []systemdMount, comment CommentInterface) (restore func()) { + n := 0 + if comment == nil { + comment = Commentf("") + } + s.AddCleanup(func() { + // make sure that after the test is done, we had as many mount calls as + // mocked mounts + c.Check(n, Equals, len(mounts), comment) + }) + return main.MockSystemdMount(func(what, where string, opts *main.SystemdMountOptions) error { + n++ + c.Assert(n <= len(mounts), Equals, true) + if n > len(mounts) { + return fmt.Errorf("unexpected systemd-mount call: %s, %s, %+v", what, where, opts) + } + mnt := mounts[n-1] + c.Assert(what, Equals, mnt.what, comment) + c.Assert(where, Equals, mnt.where, comment) + c.Assert(opts, DeepEquals, mnt.opts, Commentf("what is %s, where is %s, comment is %s", what, where, comment)) + return mnt.err + }) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsInstallModeHappy(c *C) { + logbuf, restore := logger.MockLogger() + defer restore() + + restore = snapdtool.MockVersion("1.2.3") + defer restore() + + s.mockProcCmdlineContent(c, "snapd_recovery_mode=install snapd_recovery_system="+s.sysLabel) + + // ensure that we check that access to sealed keys were locked + sealedKeysLocked := false + defer main.MockSecbootLockSealedKeys(func() error { + sealedKeysLocked = true + return nil + })() + + restore = s.mockSystemdMountSequence(c, []systemdMount{ + s.ubuntuLabelMount("ubuntu-seed", "install"), + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + s.makeSeedSnapSystemdMount(snap.TypeGadget), + { + "tmpfs", + boot.InitramfsDataDir, + tmpfsMountOpts, + nil, + }, + }, nil) + defer restore() + + _, err := main.Parser().ParseArgs([]string{"initramfs-mounts"}) + c.Assert(err, IsNil) + + modeEnv := dirs.SnapModeenvFileUnder(filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data")) + c.Check(modeEnv, testutil.FileEquals, `mode=install +recovery_system=20191118 +base=core20_1.snap +gadget=pc_1.snap +model=my-brand/my-model +grade=signed +`) + cloudInitDisable := filepath.Join(filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data"), "_writable_defaults/etc/cloud/cloud-init.disabled") + c.Check(cloudInitDisable, testutil.FilePresent) + + c.Check(sealedKeysLocked, Equals, true) + + c.Check(logbuf.String(), testutil.Contains, "snap-bootstrap version 1.2.3 starting\n") +} + +func (s *initramfsMountsSuite) TestInitramfsMountsInstallModeBootFlagsSet(c *C) { + s.mockProcCmdlineContent(c, "snapd_recovery_mode=install snapd_recovery_system="+s.sysLabel) + + tt := []struct { + bootFlags string + expBootFlagsFile string + }{ + { + "factory", + "factory", + }, + { + "factory,,,,", + "factory", + }, + { + "factory,,,,unknown-new-flag", + "factory,unknown-new-flag", + }, + { + "", + "", + }, + } + + for _, t := range tt { + restore := s.mockSystemdMountSequence(c, []systemdMount{ + s.ubuntuLabelMount("ubuntu-seed", "install"), + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + s.makeSeedSnapSystemdMount(snap.TypeGadget), + { + "tmpfs", + boot.InitramfsDataDir, + tmpfsMountOpts, + nil, + }, + }, nil) + defer restore() + + // mock a bootloader + bl := bootloadertest.Mock("bootloader", c.MkDir()) + err := bl.SetBootVars(map[string]string{ + "snapd_boot_flags": t.bootFlags, + }) + c.Assert(err, IsNil) + bootloader.Force(bl) + defer bootloader.Force(nil) + + _, err = main.Parser().ParseArgs([]string{"initramfs-mounts"}) + c.Assert(err, IsNil) + + // check that we wrote the /run file with the boot flags in it + c.Assert(filepath.Join(dirs.SnapRunDir, "boot-flags"), testutil.FileEquals, t.expBootFlagsFile) + } +} + +func (s *initramfsMountsSuite) TestInitramfsMountsRunModeBootFlagsSet(c *C) { + s.mockProcCmdlineContent(c, "snapd_recovery_mode=run") + + tt := []struct { + bootFlags []string + expBootFlagsFile string + }{ + { + []string{"factory"}, + "factory", + }, + { + []string{"factory", ""}, + "factory", + }, + { + []string{"factory", "unknown-new-flag"}, + "factory,unknown-new-flag", + }, + { + []string{}, + "", + }, + } + + for _, t := range tt { + restore := disks.MockMountPointDisksToPartitionMapping( + map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuBootDir}: defaultBootWithSaveDisk, + {Mountpoint: boot.InitramfsDataDir}: defaultBootWithSaveDisk, + {Mountpoint: boot.InitramfsUbuntuSaveDir}: defaultBootWithSaveDisk, + }, + ) + defer restore() + + restore = s.mockSystemdMountSequence(c, []systemdMount{ + s.ubuntuLabelMount("ubuntu-boot", "run"), + s.ubuntuPartUUIDMount("ubuntu-seed-partuuid", "run"), + s.ubuntuPartUUIDMount("ubuntu-data-partuuid", "run"), + s.ubuntuPartUUIDMount("ubuntu-save-partuuid", "run"), + s.makeRunSnapSystemdMount(snap.TypeBase, s.core20), + s.makeRunSnapSystemdMount(snap.TypeGadget, s.gadget), + s.makeRunSnapSystemdMount(snap.TypeKernel, s.kernel), + }, nil) + defer restore() + + // mock a bootloader + bloader := boottest.MockUC20RunBootenv(bootloadertest.Mock("mock", c.MkDir())) + bootloader.Force(bloader) + defer bootloader.Force(nil) + + // set the current kernel + restore = bloader.SetEnabledKernel(s.kernel) + defer restore() + + s.makeSnapFilesOnEarlyBootUbuntuData(c, s.kernel, s.core20, s.gadget) + + // write modeenv with boot flags + modeEnv := boot.Modeenv{ + Mode: "run", + Base: s.core20.Filename(), + Gadget: s.gadget.Filename(), + CurrentKernels: []string{s.kernel.Filename()}, + BootFlags: t.bootFlags, + } + err := modeEnv.WriteTo(filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data")) + c.Assert(err, IsNil) + + _, err = main.Parser().ParseArgs([]string{"initramfs-mounts"}) + c.Assert(err, IsNil) + + // check that we wrote the /run file with the boot flags in it + c.Assert(filepath.Join(dirs.SnapRunDir, "boot-flags"), testutil.FileEquals, t.expBootFlagsFile) + } +} + +func (s *initramfsMountsSuite) TestInitramfsMountsInstallModeTimeMovesForwardHappy(c *C) { + s.mockProcCmdlineContent(c, "snapd_recovery_mode=install snapd_recovery_system="+s.sysLabel) + + for _, tc := range s.timeTestCases() { + comment := Commentf(tc.comment) + cleanups := []func(){} + + // always remove the ubuntu-seed dir, otherwise setupSeed complains the + // model file already exists and can't setup the seed + err := os.RemoveAll(filepath.Join(boot.InitramfsUbuntuSeedDir)) + c.Assert(err, IsNil, comment) + s.setupSeed(c, tc.modelTime, nil) + + restore := main.MockTimeNow(func() time.Time { + return tc.now + }) + cleanups = append(cleanups, restore) + osutilSetTimeCalls := 0 + + // check what time we try to move forward to + restore = main.MockOsutilSetTime(func(t time.Time) error { + osutilSetTimeCalls++ + // make sure the timestamps are within 1 second of each other, they + // won't be equal since the timestamp is serialized to an assertion and + // read back + tTrunc := t.Truncate(2 * time.Second) + expTTrunc := tc.expT.Truncate(2 * time.Second) + c.Assert(tTrunc.Equal(expTTrunc), Equals, true, Commentf("%s, exp %s, got %s", tc.comment, t, s.snapDeclAssertsTime)) + return nil + }) + cleanups = append(cleanups, restore) + + restore = s.mockSystemdMountSequence(c, []systemdMount{ + s.ubuntuLabelMount("ubuntu-seed", "install"), + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + s.makeSeedSnapSystemdMount(snap.TypeGadget), + { + "tmpfs", + boot.InitramfsDataDir, + tmpfsMountOpts, + nil, + }, + }, nil) + cleanups = append(cleanups, restore) + + _, err = main.Parser().ParseArgs([]string{"initramfs-mounts"}) + c.Assert(err, IsNil, comment) + + c.Assert(osutilSetTimeCalls, Equals, tc.setTimeCalls) + + for _, r := range cleanups { + r() + } + } +} + +func (s *initramfsMountsSuite) TestInitramfsMountsInstallModeGadgetDefaultsHappy(c *C) { + // setup a seed with default gadget yaml + const gadgetYamlDefaults = ` +defaults: + system: + service: + rsyslog.disable: true + ssh.disable: true + console-conf.disable: true + journal.persistent: true +` + c.Assert(os.RemoveAll(s.seedDir), IsNil) + + s.setupSeed(c, time.Time{}, + [][]string{{"meta/gadget.yaml", gadgetYamlDefaults}}) + + s.mockProcCmdlineContent(c, "snapd_recovery_mode=install snapd_recovery_system="+s.sysLabel) + + restore := s.mockSystemdMountSequence(c, []systemdMount{ + s.ubuntuLabelMount("ubuntu-seed", "install"), + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + s.makeSeedSnapSystemdMount(snap.TypeGadget), + { + "tmpfs", + boot.InitramfsDataDir, + tmpfsMountOpts, + nil, + }, + }, nil) + defer restore() + + // we will call out to systemctl in the initramfs, but only using --root + // which doesn't talk to systemd, just manipulates files around + var sysctlArgs [][]string + systemctlRestorer := systemd.MockSystemctl(func(args ...string) (buf []byte, err error) { + sysctlArgs = append(sysctlArgs, args) + return nil, nil + }) + defer systemctlRestorer() + + _, err := main.Parser().ParseArgs([]string{"initramfs-mounts"}) + c.Assert(err, IsNil) + + modeEnv := dirs.SnapModeenvFileUnder(filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data")) + c.Check(modeEnv, testutil.FileEquals, `mode=install +recovery_system=20191118 +base=core20_1.snap +gadget=pc_1.snap +model=my-brand/my-model +grade=signed +`) + + cloudInitDisable := filepath.Join(filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data"), "_writable_defaults/etc/cloud/cloud-init.disabled") + c.Check(cloudInitDisable, testutil.FilePresent) + + // check that everything from the gadget defaults was setup + c.Assert(osutil.FileExists(filepath.Join(filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data"), "_writable_defaults/etc/ssh/sshd_not_to_be_run")), Equals, true) + c.Assert(osutil.FileExists(filepath.Join(filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data"), "_writable_defaults/var/lib/console-conf/complete")), Equals, true) + exists, _, _ := osutil.DirExists(filepath.Join(filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data"), "_writable_defaults/var/log/journal")) + c.Assert(exists, Equals, true) + + // systemctl was called the way we expect + c.Assert(sysctlArgs, DeepEquals, [][]string{{"--root", filepath.Join(filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data"), "_writable_defaults"), "mask", "rsyslog.service"}}) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsInstallModeBootedKernelPartitionUUIDHappy(c *C) { + s.mockProcCmdlineContent(c, "snapd_recovery_mode=install snapd_recovery_system="+s.sysLabel) + + restore := main.MockPartitionUUIDForBootedKernelDisk("specific-ubuntu-seed-partuuid") + defer restore() + + restore = s.mockSystemdMountSequence(c, []systemdMount{ + { + "/dev/disk/by-partuuid/specific-ubuntu-seed-partuuid", + boot.InitramfsUbuntuSeedDir, + needsFsckDiskMountOpts, + nil, + }, + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + s.makeSeedSnapSystemdMount(snap.TypeGadget), + { + "tmpfs", + boot.InitramfsDataDir, + tmpfsMountOpts, + nil, + }, + }, nil) + defer restore() + + _, err := main.Parser().ParseArgs([]string{"initramfs-mounts"}) + c.Assert(err, IsNil) + + modeEnv := dirs.SnapModeenvFileUnder(filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data")) + c.Check(modeEnv, testutil.FileEquals, `mode=install +recovery_system=20191118 +base=core20_1.snap +gadget=pc_1.snap +model=my-brand/my-model +grade=signed +`) + cloudInitDisable := filepath.Join(filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data"), "_writable_defaults/etc/cloud/cloud-init.disabled") + c.Check(cloudInitDisable, testutil.FilePresent) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsRunModeUnencryptedWithSaveHappy(c *C) { + s.mockProcCmdlineContent(c, "snapd_recovery_mode=run") + + restore := disks.MockMountPointDisksToPartitionMapping( + map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuBootDir}: defaultBootWithSaveDisk, + {Mountpoint: boot.InitramfsDataDir}: defaultBootWithSaveDisk, + {Mountpoint: boot.InitramfsUbuntuSaveDir}: defaultBootWithSaveDisk, + }, + ) + defer restore() + + restore = s.mockSystemdMountSequence(c, []systemdMount{ + s.ubuntuLabelMount("ubuntu-boot", "run"), + s.ubuntuPartUUIDMount("ubuntu-seed-partuuid", "run"), + s.ubuntuPartUUIDMount("ubuntu-data-partuuid", "run"), + s.ubuntuPartUUIDMount("ubuntu-save-partuuid", "run"), + s.makeRunSnapSystemdMount(snap.TypeBase, s.core20), + s.makeRunSnapSystemdMount(snap.TypeGadget, s.gadget), + s.makeRunSnapSystemdMount(snap.TypeKernel, s.kernel), + }, nil) + defer restore() + + // mock a bootloader + bloader := boottest.MockUC20RunBootenv(bootloadertest.Mock("mock", c.MkDir())) + bootloader.Force(bloader) + defer bootloader.Force(nil) + + // set the current kernel + restore = bloader.SetEnabledKernel(s.kernel) + defer restore() + + s.makeSnapFilesOnEarlyBootUbuntuData(c, s.kernel, s.core20, s.gadget) + + // write modeenv + modeEnv := boot.Modeenv{ + Mode: "run", + Base: s.core20.Filename(), + Gadget: s.gadget.Filename(), + CurrentKernels: []string{s.kernel.Filename()}, + } + err := modeEnv.WriteTo(filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data")) + c.Assert(err, IsNil) + + _, err = main.Parser().ParseArgs([]string{"initramfs-mounts"}) + c.Assert(err, IsNil) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsRunModeHappyNoGadgetMount(c *C) { + // M + s.mockProcCmdlineContent(c, "snapd_recovery_mode=run") + + restore := disks.MockMountPointDisksToPartitionMapping( + map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuBootDir}: defaultBootWithSaveDisk, + {Mountpoint: boot.InitramfsDataDir}: defaultBootWithSaveDisk, + {Mountpoint: boot.InitramfsUbuntuSaveDir}: defaultBootWithSaveDisk, + }, + ) + defer restore() + + restore = s.mockSystemdMountSequence(c, []systemdMount{ + s.ubuntuLabelMount("ubuntu-boot", "run"), + s.ubuntuPartUUIDMount("ubuntu-seed-partuuid", "run"), + s.ubuntuPartUUIDMount("ubuntu-data-partuuid", "run"), + s.ubuntuPartUUIDMount("ubuntu-save-partuuid", "run"), + s.makeRunSnapSystemdMount(snap.TypeBase, s.core20), + s.makeRunSnapSystemdMount(snap.TypeKernel, s.kernel), + }, nil) + defer restore() + + // mock a bootloader + bloader := boottest.MockUC20RunBootenv(bootloadertest.Mock("mock", c.MkDir())) + bootloader.Force(bloader) + defer bootloader.Force(nil) + + // set the current kernel + restore = bloader.SetEnabledKernel(s.kernel) + defer restore() + + s.makeSnapFilesOnEarlyBootUbuntuData(c, s.kernel, s.core20, s.gadget) + + // write modeenv, with no gadget field so the gadget is not mounted + modeEnv := boot.Modeenv{ + Mode: "run", + Base: s.core20.Filename(), + CurrentKernels: []string{s.kernel.Filename()}, + } + err := modeEnv.WriteTo(filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data")) + c.Assert(err, IsNil) + + _, err = main.Parser().ParseArgs([]string{"initramfs-mounts"}) + c.Assert(err, IsNil) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsRunModeTimeMovesForwardHappy(c *C) { + s.mockProcCmdlineContent(c, "snapd_recovery_mode=run") + + for _, isFirstBoot := range []bool{true, false} { + for _, tc := range s.timeTestCases() { + comment := Commentf(tc.comment) + cleanups := []func(){} + + // always remove the ubuntu-seed dir, otherwise setupSeed complains the + // model file already exists and can't setup the seed + err := os.RemoveAll(filepath.Join(boot.InitramfsUbuntuSeedDir)) + c.Assert(err, IsNil, comment) + s.setupSeed(c, tc.modelTime, nil) + + restore := main.MockTimeNow(func() time.Time { + return tc.now + }) + cleanups = append(cleanups, restore) + + restore = disks.MockMountPointDisksToPartitionMapping( + map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuBootDir}: defaultBootDisk, + {Mountpoint: boot.InitramfsDataDir}: defaultBootDisk, + }, + ) + cleanups = append(cleanups, restore) + + osutilSetTimeCalls := 0 + + // check what time we try to move forward to + restore = main.MockOsutilSetTime(func(t time.Time) error { + osutilSetTimeCalls++ + // make sure the timestamps are within 1 second of each other, they + // won't be equal since the timestamp is serialized to an assertion and + // read back + tTrunc := t.Truncate(2 * time.Second) + expTTrunc := tc.expT.Truncate(2 * time.Second) + c.Assert(tTrunc.Equal(expTTrunc), Equals, true, Commentf("%s, exp %s, got %s", tc.comment, t, s.snapDeclAssertsTime)) + return nil + }) + cleanups = append(cleanups, restore) + + mnts := []systemdMount{ + s.ubuntuLabelMount("ubuntu-boot", "run"), + s.ubuntuPartUUIDMount("ubuntu-seed-partuuid", "run"), + s.ubuntuPartUUIDMount("ubuntu-data-partuuid", "run"), + s.makeRunSnapSystemdMount(snap.TypeBase, s.core20), + s.makeRunSnapSystemdMount(snap.TypeGadget, s.gadget), + s.makeRunSnapSystemdMount(snap.TypeKernel, s.kernel), + } + + if isFirstBoot { + mnts = append(mnts, s.makeSeedSnapSystemdMount(snap.TypeSnapd)) + } + + restore = s.mockSystemdMountSequence(c, mnts, nil) + cleanups = append(cleanups, restore) + + // mock a bootloader + bloader := boottest.MockUC20RunBootenv(bootloadertest.Mock("mock", c.MkDir())) + bootloader.Force(bloader) + cleanups = append(cleanups, func() { bootloader.Force(nil) }) + + // set the current kernel + restore = bloader.SetEnabledKernel(s.kernel) + cleanups = append(cleanups, restore) + + s.makeSnapFilesOnEarlyBootUbuntuData(c, s.kernel, s.core20, s.gadget) + + // write modeenv + modeEnv := boot.Modeenv{ + Mode: "run", + Base: s.core20.Filename(), + Gadget: s.gadget.Filename(), + CurrentKernels: []string{s.kernel.Filename()}, + } + + if isFirstBoot { + // set RecoverySystem so that the system operates in first boot + // of run mode, and still reads the system essential snaps to + // mount the snapd snap + modeEnv.RecoverySystem = "20191118" + } + + err = modeEnv.WriteTo(filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data")) + c.Assert(err, IsNil, comment) + + _, err = main.Parser().ParseArgs([]string{"initramfs-mounts"}) + c.Assert(err, IsNil, comment) + + if isFirstBoot { + c.Assert(osutilSetTimeCalls, Equals, tc.setTimeCalls, comment) + } else { + // non-first boot should not have moved the time at all since it + // doesn't read assertions + c.Assert(osutilSetTimeCalls, Equals, 0, comment) + } + + for _, r := range cleanups { + r() + } + } + + } +} + +func (s *initramfsMountsSuite) testInitramfsMountsRunModeNoSaveUnencrypted(c *C) error { + s.mockProcCmdlineContent(c, "snapd_recovery_mode=run") + + restore := disks.MockMountPointDisksToPartitionMapping( + map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuBootDir}: defaultBootDisk, + {Mountpoint: boot.InitramfsDataDir}: defaultBootDisk, + }, + ) + defer restore() + + restore = s.mockSystemdMountSequence(c, []systemdMount{ + s.ubuntuLabelMount("ubuntu-boot", "run"), + s.ubuntuPartUUIDMount("ubuntu-seed-partuuid", "run"), + s.ubuntuPartUUIDMount("ubuntu-data-partuuid", "run"), + s.makeRunSnapSystemdMount(snap.TypeBase, s.core20), + s.makeRunSnapSystemdMount(snap.TypeGadget, s.gadget), + s.makeRunSnapSystemdMount(snap.TypeKernel, s.kernel), + }, nil) + defer restore() + + // mock a bootloader + bloader := boottest.MockUC20RunBootenv(bootloadertest.Mock("mock", c.MkDir())) + bootloader.Force(bloader) + defer bootloader.Force(nil) + + // set the current kernel + restore = bloader.SetEnabledKernel(s.kernel) + defer restore() + + s.makeSnapFilesOnEarlyBootUbuntuData(c, s.kernel, s.core20, s.gadget) + + // write modeenv + modeEnv := boot.Modeenv{ + Mode: "run", + Base: s.core20.Filename(), + Gadget: s.gadget.Filename(), + CurrentKernels: []string{s.kernel.Filename()}, + } + err := modeEnv.WriteTo(filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data")) + c.Assert(err, IsNil) + + _, err = main.Parser().ParseArgs([]string{"initramfs-mounts"}) + return err +} + +func (s *initramfsMountsSuite) TestInitramfsMountsRunModeNoSaveUnencryptedHappy(c *C) { + // ensure that we check that access to sealed keys were locked + sealedKeysLocked := false + defer main.MockSecbootLockSealedKeys(func() error { + sealedKeysLocked = true + return nil + })() + + err := s.testInitramfsMountsRunModeNoSaveUnencrypted(c) + c.Assert(err, IsNil) + + c.Check(sealedKeysLocked, Equals, true) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsRunModeNoSaveUnencryptedKeyLockingUnhappy(c *C) { + // have blocking sealed keys fail + defer main.MockSecbootLockSealedKeys(func() error { + return fmt.Errorf("blocking keys failed") + })() + + err := s.testInitramfsMountsRunModeNoSaveUnencrypted(c) + c.Assert(err, ErrorMatches, "error locking access to sealed keys: blocking keys failed") +} + +func (s *initramfsMountsSuite) TestInitramfsMountsInstallModeRealSystemdMountTimesOutNoMount(c *C) { + s.mockProcCmdlineContent(c, "snapd_recovery_mode=install snapd_recovery_system="+s.sysLabel) + + testStart := time.Now() + timeCalls := 0 + restore := main.MockTimeNow(func() time.Time { + timeCalls++ + switch timeCalls { + case 1, 2: + return testStart + case 3: + // 1:31 later, we should time out + return testStart.Add(1*time.Minute + 31*time.Second) + default: + c.Errorf("unexpected time.Now() call (%d)", timeCalls) + // we want the test to fail at some point and not run forever, so + // move time way forward to make it for sure time out + return testStart.Add(10000 * time.Hour) + } + }) + defer restore() + + cmd := testutil.MockCommand(c, "systemd-mount", ``) + defer cmd.Restore() + + isMountedCalls := 0 + restore = main.MockOsutilIsMounted(func(where string) (bool, error) { + isMountedCalls++ + switch isMountedCalls { + // always return false for the mount + case 1, 2: + c.Assert(where, Equals, boot.InitramfsUbuntuSeedDir) + return false, nil + default: + // shouldn't be called more than twice due to the time.Now() mocking + c.Errorf("test broken, IsMounted called too many (%d) times", isMountedCalls) + return false, fmt.Errorf("test broken, IsMounted called too many (%d) times", isMountedCalls) + } + }) + defer restore() + + _, err := main.Parser().ParseArgs([]string{"initramfs-mounts"}) + c.Assert(err, ErrorMatches, fmt.Sprintf("timed out after 1m30s waiting for mount %s on %s", filepath.Join(s.tmpDir, "/dev/disk/by-label/ubuntu-seed"), boot.InitramfsUbuntuSeedDir)) + c.Check(s.Stdout.String(), Equals, "") + +} + +func (s *initramfsMountsSuite) TestInitramfsMountsInstallModeHappyRealSystemdMount(c *C) { + s.mockProcCmdlineContent(c, "snapd_recovery_mode=install snapd_recovery_system="+s.sysLabel) + + baseMnt := filepath.Join(boot.InitramfsRunMntDir, "base") + gadgetMnt := filepath.Join(boot.InitramfsRunMntDir, "gadget") + kernelMnt := filepath.Join(boot.InitramfsRunMntDir, "kernel") + snapdMnt := filepath.Join(boot.InitramfsRunMntDir, "snapd") + + // don't do anything from systemd-mount, we verify the arguments passed at + // the end with cmd.Calls + cmd := testutil.MockCommand(c, "systemd-mount", ``) + defer cmd.Restore() + + // mock that in turn, /run/mnt/ubuntu-boot, /run/mnt/ubuntu-seed, etc. are + // mounted + n := 0 + restore := main.MockOsutilIsMounted(func(where string) (bool, error) { + n++ + switch n { + // first call for each mount returns false, then returns true, this + // tests in the case where systemd is racy / inconsistent and things + // aren't mounted by the time systemd-mount returns + case 1, 2: + c.Assert(where, Equals, boot.InitramfsUbuntuSeedDir) + case 3, 4: + c.Assert(where, Equals, snapdMnt) + case 5, 6: + c.Assert(where, Equals, kernelMnt) + case 7, 8: + c.Assert(where, Equals, baseMnt) + case 9, 10: + c.Assert(where, Equals, gadgetMnt) + case 11, 12: + c.Assert(where, Equals, boot.InitramfsDataDir) + default: + c.Errorf("unexpected IsMounted check on %s", where) + return false, fmt.Errorf("unexpected IsMounted check on %s", where) + } + return n%2 == 0, nil + }) + defer restore() + + // mock a bootloader + bloader := boottest.MockUC20RunBootenv(bootloadertest.Mock("mock", c.MkDir())) + bootloader.Force(bloader) + defer bootloader.Force(nil) + + // set the current kernel + restore = bloader.SetEnabledKernel(s.kernel) + defer restore() + + s.makeSnapFilesOnEarlyBootUbuntuData(c, s.kernel, s.core20) + + // write modeenv + modeEnv := boot.Modeenv{ + Mode: "run", + Base: s.core20.Filename(), + CurrentKernels: []string{s.kernel.Filename()}, + } + err := modeEnv.WriteTo(filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data")) + c.Assert(err, IsNil) + + _, err = main.Parser().ParseArgs([]string{"initramfs-mounts"}) + c.Assert(err, IsNil) + c.Check(s.Stdout.String(), Equals, "") + + // check that all of the override files are present + for _, initrdUnit := range []string{ + "initrd-fs.target", + "local-fs.target", + } { + for _, mountUnit := range []string{ + systemd.EscapeUnitNamePath(boot.InitramfsUbuntuSeedDir), + systemd.EscapeUnitNamePath(snapdMnt), + systemd.EscapeUnitNamePath(kernelMnt), + systemd.EscapeUnitNamePath(baseMnt), + systemd.EscapeUnitNamePath(gadgetMnt), + systemd.EscapeUnitNamePath(boot.InitramfsDataDir), + } { + fname := fmt.Sprintf("snap_bootstrap_%s.conf", mountUnit) + unitFile := filepath.Join(dirs.GlobalRootDir, "/run/systemd/system", initrdUnit+".d", fname) + c.Assert(unitFile, testutil.FileEquals, fmt.Sprintf(`[Unit] +Wants=%[1]s +`, mountUnit+".mount")) + } + } + + // 2 IsMounted calls per mount point, so 10 total IsMounted calls + c.Assert(n, Equals, 12) + + c.Assert(cmd.Calls(), DeepEquals, [][]string{ + { + "systemd-mount", + filepath.Join(s.tmpDir, "/dev/disk/by-label/ubuntu-seed"), + boot.InitramfsUbuntuSeedDir, + "--no-pager", + "--no-ask-password", + "--fsck=yes", + "--options=private", + "--property=Before=initrd-fs.target", + }, { + "systemd-mount", + filepath.Join(s.seedDir, "snaps", s.snapd.Filename()), + snapdMnt, + "--no-pager", + "--no-ask-password", + "--fsck=no", + "--options=ro,private", + "--property=Before=initrd-fs.target", + }, { + "systemd-mount", + filepath.Join(s.seedDir, "snaps", s.kernel.Filename()), + kernelMnt, + "--no-pager", + "--no-ask-password", + "--fsck=no", + "--options=ro,private", + "--property=Before=initrd-fs.target", + }, { + "systemd-mount", + filepath.Join(s.seedDir, "snaps", s.core20.Filename()), + baseMnt, + "--no-pager", + "--no-ask-password", + "--fsck=no", + "--options=ro,private", + "--property=Before=initrd-fs.target", + }, { + "systemd-mount", + filepath.Join(s.seedDir, "snaps", s.gadget.Filename()), + gadgetMnt, + "--no-pager", + "--no-ask-password", + "--fsck=no", + "--options=ro,private", + "--property=Before=initrd-fs.target", + }, { + "systemd-mount", + "tmpfs", + boot.InitramfsDataDir, + "--no-pager", + "--no-ask-password", + "--type=tmpfs", + "--fsck=no", + "--options=nosuid,private", + "--property=Before=initrd-fs.target", + }, + }) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsRecoverModeNoSaveHappyRealSystemdMount(c *C) { + s.mockProcCmdlineContent(c, "snapd_recovery_mode=recover snapd_recovery_system="+s.sysLabel) + + baseMnt := filepath.Join(boot.InitramfsRunMntDir, "base") + gadgetMnt := filepath.Join(boot.InitramfsRunMntDir, "gadget") + kernelMnt := filepath.Join(boot.InitramfsRunMntDir, "kernel") + snapdMnt := filepath.Join(boot.InitramfsRunMntDir, "snapd") + + restore := disks.MockMountPointDisksToPartitionMapping( + map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuSeedDir}: defaultBootDisk, + {Mountpoint: boot.InitramfsUbuntuBootDir}: defaultBootDisk, + {Mountpoint: boot.InitramfsHostUbuntuDataDir}: defaultBootDisk, + }, + ) + defer restore() + + // don't do anything from systemd-mount, we verify the arguments passed at + // the end with cmd.Calls + cmd := testutil.MockCommand(c, "systemd-mount", ``) + defer cmd.Restore() + + // mock that in turn, /run/mnt/ubuntu-boot, /run/mnt/ubuntu-seed, etc. are + // mounted + n := 0 + restore = main.MockOsutilIsMounted(func(where string) (bool, error) { + n++ + switch n { + // first call for each mount returns false, then returns true, this + // tests in the case where systemd is racy / inconsistent and things + // aren't mounted by the time systemd-mount returns + case 1, 2: + c.Assert(where, Equals, boot.InitramfsUbuntuSeedDir) + case 3, 4: + c.Assert(where, Equals, snapdMnt) + case 5, 6: + c.Assert(where, Equals, kernelMnt) + case 7, 8: + c.Assert(where, Equals, baseMnt) + case 9, 10: + c.Assert(where, Equals, gadgetMnt) + case 11, 12: + c.Assert(where, Equals, boot.InitramfsDataDir) + case 13, 14: + c.Assert(where, Equals, boot.InitramfsUbuntuBootDir) + case 15, 16: + c.Assert(where, Equals, boot.InitramfsHostUbuntuDataDir) + default: + c.Errorf("unexpected IsMounted check on %s", where) + return false, fmt.Errorf("unexpected IsMounted check on %s", where) + } + return n%2 == 0, nil + }) + defer restore() + + unlockVolumeWithSealedKeyCalls := 0 + restore = main.MockSecbootUnlockVolumeUsingSealedKeyIfEncrypted(func(disk disks.Disk, name string, sealedEncryptionKeyFile string, opts *secboot.UnlockVolumeUsingSealedKeyOptions) (secboot.UnlockResult, error) { + // this test doesn't use ubuntu-save, so we need to return an + // unencrypted ubuntu-data the first time, but not found the second time + unlockVolumeWithSealedKeyCalls++ + switch unlockVolumeWithSealedKeyCalls { + case 1: + return foundUnencrypted(name), nil + case 2: + return notFoundPart(), fmt.Errorf("error enumerating to find ubuntu-save") + default: + c.Errorf("unexpected call (number %d) to UnlockVolumeUsingSealedKeyIfEncrypted", unlockVolumeWithSealedKeyCalls) + return secboot.UnlockResult{}, fmt.Errorf("unexpected call (%d) to UnlockVolumeUsingSealedKeyIfEncrypted", unlockVolumeWithSealedKeyCalls) + } + }) + defer restore() + + // mock a bootloader + bloader := boottest.MockUC20RunBootenv(bootloadertest.Mock("mock", c.MkDir())) + bootloader.Force(bloader) + defer bootloader.Force(nil) + + // set the current kernel + restore = bloader.SetEnabledKernel(s.kernel) + defer restore() + + s.makeSnapFilesOnEarlyBootUbuntuData(c, s.kernel, s.core20) + + // write modeenv + modeEnv := boot.Modeenv{ + Mode: "run", + Base: s.core20.Filename(), + CurrentKernels: []string{s.kernel.Filename()}, + } + err := modeEnv.WriteTo(filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data")) + c.Assert(err, IsNil) + + s.testRecoverModeHappy(c) + + c.Check(s.Stdout.String(), Equals, "") + + // check that all of the override files are present + for _, initrdUnit := range []string{ + "initrd-fs.target", + "local-fs.target", + } { + for _, mountUnit := range []string{ + systemd.EscapeUnitNamePath(boot.InitramfsUbuntuSeedDir), + systemd.EscapeUnitNamePath(snapdMnt), + systemd.EscapeUnitNamePath(kernelMnt), + systemd.EscapeUnitNamePath(baseMnt), + systemd.EscapeUnitNamePath(gadgetMnt), + systemd.EscapeUnitNamePath(boot.InitramfsDataDir), + systemd.EscapeUnitNamePath(boot.InitramfsHostUbuntuDataDir), + } { + fname := fmt.Sprintf("snap_bootstrap_%s.conf", mountUnit) + unitFile := filepath.Join(dirs.GlobalRootDir, "/run/systemd/system", initrdUnit+".d", fname) + c.Assert(unitFile, testutil.FileEquals, fmt.Sprintf(`[Unit] +Wants=%[1]s +`, mountUnit+".mount")) + } + } + + // 2 IsMounted calls per mount point, so 14 total IsMounted calls + c.Assert(n, Equals, 16) + + c.Assert(cmd.Calls(), DeepEquals, [][]string{ + { + "systemd-mount", + filepath.Join(s.tmpDir, "/dev/disk/by-label/ubuntu-seed"), + boot.InitramfsUbuntuSeedDir, + "--no-pager", + "--no-ask-password", + "--fsck=yes", + "--options=private", + "--property=Before=initrd-fs.target", + }, { + "systemd-mount", + filepath.Join(s.seedDir, "snaps", s.snapd.Filename()), + snapdMnt, + "--no-pager", + "--no-ask-password", + "--fsck=no", + "--options=ro,private", + "--property=Before=initrd-fs.target", + }, { + "systemd-mount", + filepath.Join(s.seedDir, "snaps", s.kernel.Filename()), + kernelMnt, + "--no-pager", + "--no-ask-password", + "--fsck=no", + "--options=ro,private", + "--property=Before=initrd-fs.target", + }, { + "systemd-mount", + filepath.Join(s.seedDir, "snaps", s.core20.Filename()), + baseMnt, + "--no-pager", + "--no-ask-password", + "--fsck=no", + "--options=ro,private", + "--property=Before=initrd-fs.target", + }, { + "systemd-mount", + filepath.Join(s.seedDir, "snaps", s.gadget.Filename()), + gadgetMnt, + "--no-pager", + "--no-ask-password", + "--fsck=no", + "--options=ro,private", + "--property=Before=initrd-fs.target", + }, { + "systemd-mount", + "tmpfs", + boot.InitramfsDataDir, + "--no-pager", + "--no-ask-password", + "--type=tmpfs", + "--fsck=no", + "--options=nosuid,private", + "--property=Before=initrd-fs.target", + }, { + "systemd-mount", + "/dev/disk/by-partuuid/ubuntu-boot-partuuid", + boot.InitramfsUbuntuBootDir, + "--no-pager", + "--no-ask-password", + "--fsck=yes", + "--options=private", + "--property=Before=initrd-fs.target", + }, { + "systemd-mount", + "/dev/disk/by-partuuid/ubuntu-data-partuuid", + boot.InitramfsHostUbuntuDataDir, + "--no-pager", + "--no-ask-password", + "--fsck=no", + "--options=nosuid,private", + "--property=Before=initrd-fs.target", + }, + }) + + // we should not have written a degraded.json + c.Assert(filepath.Join(dirs.SnapBootstrapRunDir, "degraded.json"), testutil.FileAbsent) + + // we should have only tried to unseal things only once, when unlocking ubuntu-data + c.Assert(unlockVolumeWithSealedKeyCalls, Equals, 1) + + // save is optional and not found in this test + c.Check(s.logs.String(), testutil.Contains, "ubuntu-save was not found") +} + +func (s *initramfsMountsSuite) TestInitramfsMountsRecoverModeWithSaveHappyRealSystemdMount(c *C) { + s.mockProcCmdlineContent(c, "snapd_recovery_mode=recover snapd_recovery_system="+s.sysLabel) + + restore := disks.MockMountPointDisksToPartitionMapping( + map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuSeedDir}: defaultBootWithSaveDisk, + {Mountpoint: boot.InitramfsUbuntuBootDir}: defaultBootWithSaveDisk, + {Mountpoint: boot.InitramfsHostUbuntuDataDir}: defaultBootWithSaveDisk, + {Mountpoint: boot.InitramfsUbuntuSaveDir}: defaultBootWithSaveDisk, + }, + ) + defer restore() + + baseMnt := filepath.Join(boot.InitramfsRunMntDir, "base") + gadgetMnt := filepath.Join(boot.InitramfsRunMntDir, "gadget") + kernelMnt := filepath.Join(boot.InitramfsRunMntDir, "kernel") + snapdMnt := filepath.Join(boot.InitramfsRunMntDir, "snapd") + + // don't do anything from systemd-mount, we verify the arguments passed at + // the end with cmd.Calls + cmd := testutil.MockCommand(c, "systemd-mount", ``) + defer cmd.Restore() + + isMountedChecks := []string{} + restore = main.MockOsutilIsMounted(func(where string) (bool, error) { + isMountedChecks = append(isMountedChecks, where) + return true, nil + }) + defer restore() + + // mock a bootloader + bloader := boottest.MockUC20RunBootenv(bootloadertest.Mock("mock", c.MkDir())) + bootloader.Force(bloader) + defer bootloader.Force(nil) + + // set the current kernel + restore = bloader.SetEnabledKernel(s.kernel) + defer restore() + + s.makeSnapFilesOnEarlyBootUbuntuData(c, s.kernel, s.core20) + + // write modeenv + modeEnv := boot.Modeenv{ + Mode: "run", + Base: s.core20.Filename(), + CurrentKernels: []string{s.kernel.Filename()}, + } + err := modeEnv.WriteTo(filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data")) + c.Assert(err, IsNil) + + s.testRecoverModeHappy(c) + + c.Check(s.Stdout.String(), Equals, "") + + // check that all of the override files are present + for _, initrdUnit := range []string{ + "initrd-fs.target", + "local-fs.target", + } { + + mountUnit := systemd.EscapeUnitNamePath(boot.InitramfsUbuntuSaveDir) + fname := fmt.Sprintf("snap_bootstrap_%s.conf", mountUnit) + unitFile := filepath.Join(dirs.GlobalRootDir, "/run/systemd/system", initrdUnit+".d", fname) + c.Assert(unitFile, testutil.FileEquals, fmt.Sprintf(`[Unit] +Wants=%[1]s +`, mountUnit+".mount")) + } + + c.Check(isMountedChecks, DeepEquals, []string{ + boot.InitramfsUbuntuSeedDir, + snapdMnt, + kernelMnt, + baseMnt, + gadgetMnt, + boot.InitramfsDataDir, + boot.InitramfsUbuntuBootDir, + boot.InitramfsHostUbuntuDataDir, + boot.InitramfsUbuntuSaveDir, + }) + c.Check(cmd.Calls(), DeepEquals, [][]string{ + { + "systemd-mount", + filepath.Join(s.tmpDir, "/dev/disk/by-label/ubuntu-seed"), + boot.InitramfsUbuntuSeedDir, + "--no-pager", + "--no-ask-password", + "--fsck=yes", + "--options=private", + "--property=Before=initrd-fs.target", + }, { + "systemd-mount", + filepath.Join(s.seedDir, "snaps", s.snapd.Filename()), + snapdMnt, + "--no-pager", + "--no-ask-password", + "--fsck=no", + "--options=ro,private", + "--property=Before=initrd-fs.target", + }, { + "systemd-mount", + filepath.Join(s.seedDir, "snaps", s.kernel.Filename()), + kernelMnt, + "--no-pager", + "--no-ask-password", + "--fsck=no", + "--options=ro,private", + "--property=Before=initrd-fs.target", + }, { + "systemd-mount", + filepath.Join(s.seedDir, "snaps", s.core20.Filename()), + baseMnt, + "--no-pager", + "--no-ask-password", + "--fsck=no", + "--options=ro,private", + "--property=Before=initrd-fs.target", + }, { + "systemd-mount", + filepath.Join(s.seedDir, "snaps", s.gadget.Filename()), + gadgetMnt, + "--no-pager", + "--no-ask-password", + "--fsck=no", + "--options=ro,private", + "--property=Before=initrd-fs.target", + }, { + "systemd-mount", + "tmpfs", + boot.InitramfsDataDir, + "--no-pager", + "--no-ask-password", + "--type=tmpfs", + "--fsck=no", + "--options=nosuid,private", + "--property=Before=initrd-fs.target", + }, { + "systemd-mount", + "/dev/disk/by-partuuid/ubuntu-boot-partuuid", + boot.InitramfsUbuntuBootDir, + "--no-pager", + "--no-ask-password", + "--fsck=yes", + "--options=private", + "--property=Before=initrd-fs.target", + }, { + "systemd-mount", + "/dev/disk/by-partuuid/ubuntu-data-partuuid", + boot.InitramfsHostUbuntuDataDir, + "--no-pager", + "--no-ask-password", + "--fsck=no", + "--options=nosuid,private", + "--property=Before=initrd-fs.target", + }, { + "systemd-mount", + "/dev/disk/by-partuuid/ubuntu-save-partuuid", + boot.InitramfsUbuntuSaveDir, + "--no-pager", + "--no-ask-password", + "--fsck=no", + "--options=private", + "--property=Before=initrd-fs.target", + }, + }) + + // we should not have written a degraded.json + c.Assert(filepath.Join(dirs.SnapBootstrapRunDir, "degraded.json"), testutil.FileAbsent) + + // save is optional and found in this test + c.Check(s.logs.String(), Not(testutil.Contains), "ubuntu-save was not found") +} + +func (s *initramfsMountsSuite) TestInitramfsMountsRunModeHappyNoSaveRealSystemdMount(c *C) { + s.mockProcCmdlineContent(c, "snapd_recovery_mode=run") + + restore := disks.MockMountPointDisksToPartitionMapping( + map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuBootDir}: defaultBootDisk, + {Mountpoint: boot.InitramfsDataDir}: defaultBootDisk, + }, + ) + defer restore() + + baseMnt := filepath.Join(boot.InitramfsRunMntDir, "base") + gadgetMnt := filepath.Join(boot.InitramfsRunMntDir, "gadget") + kernelMnt := filepath.Join(boot.InitramfsRunMntDir, "kernel") + + // don't do anything from systemd-mount, we verify the arguments passed at + // the end with cmd.Calls + cmd := testutil.MockCommand(c, "systemd-mount", ``) + defer cmd.Restore() + + // mock that in turn, /run/mnt/ubuntu-boot, /run/mnt/ubuntu-seed, etc. are + // mounted + n := 0 + restore = main.MockOsutilIsMounted(func(where string) (bool, error) { + n++ + switch n { + // first call for each mount returns false, then returns true, this + // tests in the case where systemd is racy / inconsistent and things + // aren't mounted by the time systemd-mount returns + case 1, 2: + c.Assert(where, Equals, boot.InitramfsUbuntuBootDir) + case 3, 4: + c.Assert(where, Equals, boot.InitramfsUbuntuSeedDir) + case 5, 6: + c.Assert(where, Equals, boot.InitramfsDataDir) + case 7, 8: + c.Assert(where, Equals, baseMnt) + case 9, 10: + c.Assert(where, Equals, gadgetMnt) + case 11, 12: + c.Assert(where, Equals, kernelMnt) + default: + c.Errorf("unexpected IsMounted check on %s", where) + return false, fmt.Errorf("unexpected IsMounted check on %s", where) + } + return n%2 == 0, nil + }) + defer restore() + + // mock a bootloader + bloader := boottest.MockUC20RunBootenv(bootloadertest.Mock("mock", c.MkDir())) + bootloader.Force(bloader) + defer bootloader.Force(nil) + + // set the current kernel + restore = bloader.SetEnabledKernel(s.kernel) + defer restore() + + s.makeSnapFilesOnEarlyBootUbuntuData(c, s.kernel, s.core20, s.gadget) + + // write modeenv + modeEnv := boot.Modeenv{ + Mode: "run", + Base: s.core20.Filename(), + Gadget: s.gadget.Filename(), + CurrentKernels: []string{s.kernel.Filename()}, + } + err := modeEnv.WriteTo(filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data")) + c.Assert(err, IsNil) + + _, err = main.Parser().ParseArgs([]string{"initramfs-mounts"}) + c.Assert(err, IsNil) + c.Check(s.Stdout.String(), Equals, "") + + // check that all of the override files are present + for _, initrdUnit := range []string{ + "initrd-fs.target", + "local-fs.target", + } { + for _, mountUnit := range []string{ + systemd.EscapeUnitNamePath(boot.InitramfsUbuntuBootDir), + systemd.EscapeUnitNamePath(boot.InitramfsUbuntuSeedDir), + systemd.EscapeUnitNamePath(boot.InitramfsDataDir), + systemd.EscapeUnitNamePath(baseMnt), + systemd.EscapeUnitNamePath(gadgetMnt), + systemd.EscapeUnitNamePath(kernelMnt), + } { + fname := fmt.Sprintf("snap_bootstrap_%s.conf", mountUnit) + unitFile := filepath.Join(dirs.GlobalRootDir, "/run/systemd/system", initrdUnit+".d", fname) + c.Assert(unitFile, testutil.FileEquals, fmt.Sprintf(`[Unit] +Wants=%[1]s +`, mountUnit+".mount")) + } + } + + // 2 IsMounted calls per mount point, so 10 total IsMounted calls + c.Assert(n, Equals, 12) + + c.Assert(cmd.Calls(), DeepEquals, [][]string{ + { + "systemd-mount", + filepath.Join(s.tmpDir, "/dev/disk/by-label/ubuntu-boot"), + boot.InitramfsUbuntuBootDir, + "--no-pager", + "--no-ask-password", + "--fsck=yes", + "--options=private", + "--property=Before=initrd-fs.target", + }, { + "systemd-mount", + "/dev/disk/by-partuuid/ubuntu-seed-partuuid", + boot.InitramfsUbuntuSeedDir, + "--no-pager", + "--no-ask-password", + "--fsck=yes", + "--options=private", + "--property=Before=initrd-fs.target", + }, { + "systemd-mount", + "/dev/disk/by-partuuid/ubuntu-data-partuuid", + boot.InitramfsDataDir, + "--no-pager", + "--no-ask-password", + "--fsck=yes", + "--options=nosuid,private", + "--property=Before=initrd-fs.target", + }, { + "systemd-mount", + filepath.Join(dirs.SnapBlobDirUnder(filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data")), s.core20.Filename()), + baseMnt, + "--no-pager", + "--no-ask-password", + "--fsck=no", + "--options=ro,private", + "--property=Before=initrd-fs.target", + }, { + "systemd-mount", + filepath.Join(dirs.SnapBlobDirUnder(filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data")), s.gadget.Filename()), + gadgetMnt, + "--no-pager", + "--no-ask-password", + "--fsck=no", + "--options=ro,private", + "--property=Before=initrd-fs.target", + }, { + "systemd-mount", + filepath.Join(dirs.SnapBlobDirUnder(filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data")), s.kernel.Filename()), + kernelMnt, + "--no-pager", + "--no-ask-password", + "--fsck=no", + "--options=ro,private", + "--property=Before=initrd-fs.target", + }, + }) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsRunModeWithSaveHappyRealSystemdMount(c *C) { + s.mockProcCmdlineContent(c, "snapd_recovery_mode=run") + + restore := disks.MockMountPointDisksToPartitionMapping( + map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuBootDir}: defaultBootWithSaveDisk, + {Mountpoint: boot.InitramfsDataDir}: defaultBootWithSaveDisk, + {Mountpoint: boot.InitramfsUbuntuSaveDir}: defaultBootWithSaveDisk, + }, + ) + defer restore() + + baseMnt := filepath.Join(boot.InitramfsRunMntDir, "base") + gadgetMnt := filepath.Join(boot.InitramfsRunMntDir, "gadget") + kernelMnt := filepath.Join(boot.InitramfsRunMntDir, "kernel") + + // don't do anything from systemd-mount, we verify the arguments passed at + // the end with cmd.Calls + cmd := testutil.MockCommand(c, "systemd-mount", ``) + defer cmd.Restore() + + isMountedChecks := []string{} + restore = main.MockOsutilIsMounted(func(where string) (bool, error) { + isMountedChecks = append(isMountedChecks, where) + return true, nil + }) + defer restore() + + // mock a bootloader + bloader := boottest.MockUC20RunBootenv(bootloadertest.Mock("mock", c.MkDir())) + bootloader.Force(bloader) + defer bootloader.Force(nil) + + // set the current kernel + restore = bloader.SetEnabledKernel(s.kernel) + defer restore() + + s.makeSnapFilesOnEarlyBootUbuntuData(c, s.kernel, s.core20, s.gadget) + + // write modeenv + modeEnv := boot.Modeenv{ + Mode: "run", + Base: s.core20.Filename(), + Gadget: s.gadget.Filename(), + CurrentKernels: []string{s.kernel.Filename()}, + } + err := modeEnv.WriteTo(filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data")) + c.Assert(err, IsNil) + + _, err = main.Parser().ParseArgs([]string{"initramfs-mounts"}) + c.Assert(err, IsNil) + c.Check(s.Stdout.String(), Equals, "") + + // check that all of the override files are present + for _, initrdUnit := range []string{ + "initrd-fs.target", + "local-fs.target", + } { + + mountUnit := systemd.EscapeUnitNamePath(boot.InitramfsUbuntuSaveDir) + fname := fmt.Sprintf("snap_bootstrap_%s.conf", mountUnit) + unitFile := filepath.Join(dirs.GlobalRootDir, "/run/systemd/system", initrdUnit+".d", fname) + c.Assert(unitFile, testutil.FileEquals, fmt.Sprintf(`[Unit] +Wants=%[1]s +`, mountUnit+".mount")) + } + + c.Check(isMountedChecks, DeepEquals, []string{ + boot.InitramfsUbuntuBootDir, + boot.InitramfsUbuntuSeedDir, + boot.InitramfsDataDir, + boot.InitramfsUbuntuSaveDir, + baseMnt, + gadgetMnt, + kernelMnt, + }) + c.Check(cmd.Calls(), DeepEquals, [][]string{ + { + "systemd-mount", + filepath.Join(s.tmpDir, "/dev/disk/by-label/ubuntu-boot"), + boot.InitramfsUbuntuBootDir, + "--no-pager", + "--no-ask-password", + "--fsck=yes", + "--options=private", + "--property=Before=initrd-fs.target", + }, { + "systemd-mount", + "/dev/disk/by-partuuid/ubuntu-seed-partuuid", + boot.InitramfsUbuntuSeedDir, + "--no-pager", + "--no-ask-password", + "--fsck=yes", + "--options=private", + "--property=Before=initrd-fs.target", + }, { + "systemd-mount", + "/dev/disk/by-partuuid/ubuntu-data-partuuid", + boot.InitramfsDataDir, + "--no-pager", + "--no-ask-password", + "--fsck=yes", + "--options=nosuid,private", + "--property=Before=initrd-fs.target", + }, { + "systemd-mount", + "/dev/disk/by-partuuid/ubuntu-save-partuuid", + boot.InitramfsUbuntuSaveDir, + "--no-pager", + "--no-ask-password", + "--fsck=yes", + "--options=private", + "--property=Before=initrd-fs.target", + }, { + "systemd-mount", + filepath.Join(dirs.SnapBlobDirUnder(filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data")), s.core20.Filename()), + baseMnt, + "--no-pager", + "--no-ask-password", + "--fsck=no", + "--options=ro,private", + "--property=Before=initrd-fs.target", + }, { + "systemd-mount", + filepath.Join(dirs.SnapBlobDirUnder(filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data")), s.gadget.Filename()), + gadgetMnt, + "--no-pager", + "--no-ask-password", + "--fsck=no", + "--options=ro,private", + "--property=Before=initrd-fs.target", + }, { + "systemd-mount", + filepath.Join(dirs.SnapBlobDirUnder(filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data")), s.kernel.Filename()), + kernelMnt, + "--no-pager", + "--no-ask-password", + "--fsck=no", + "--options=ro,private", + "--property=Before=initrd-fs.target", + }, + }) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsRunModeFirstBootRecoverySystemSetHappy(c *C) { + s.mockProcCmdlineContent(c, "snapd_recovery_mode=run") + + restore := disks.MockMountPointDisksToPartitionMapping( + map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuBootDir}: defaultBootWithSaveDisk, + {Mountpoint: boot.InitramfsDataDir}: defaultBootWithSaveDisk, + {Mountpoint: boot.InitramfsUbuntuSaveDir}: defaultBootWithSaveDisk, + }, + ) + defer restore() + + restore = s.mockSystemdMountSequence(c, []systemdMount{ + s.ubuntuLabelMount("ubuntu-boot", "run"), + s.ubuntuPartUUIDMount("ubuntu-seed-partuuid", "run"), + s.ubuntuPartUUIDMount("ubuntu-data-partuuid", "run"), + s.ubuntuPartUUIDMount("ubuntu-save-partuuid", "run"), + s.makeRunSnapSystemdMount(snap.TypeBase, s.core20), + s.makeRunSnapSystemdMount(snap.TypeGadget, s.gadget), + s.makeRunSnapSystemdMount(snap.TypeKernel, s.kernel), + // RecoverySystem set makes us mount the snapd snap here + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + }, nil) + defer restore() + + // mock a bootloader + bloader := boottest.MockUC20RunBootenv(bootloadertest.Mock("mock", c.MkDir())) + bootloader.Force(bloader) + defer bootloader.Force(nil) + + // set the current kernel + restore = bloader.SetEnabledKernel(s.kernel) + defer restore() + + s.makeSnapFilesOnEarlyBootUbuntuData(c, s.kernel, s.core20, s.gadget) + + // write modeenv + modeEnv := boot.Modeenv{ + Mode: "run", + RecoverySystem: "20191118", + Base: s.core20.Filename(), + Gadget: s.gadget.Filename(), + CurrentKernels: []string{s.kernel.Filename()}, + } + err := modeEnv.WriteTo(filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data")) + c.Assert(err, IsNil) + + _, err = main.Parser().ParseArgs([]string{"initramfs-mounts"}) + c.Assert(err, IsNil) + + // we should not have written a degraded.json + c.Assert(filepath.Join(dirs.SnapBootstrapRunDir, "degraded.json"), testutil.FileAbsent) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsRunModeWithBootedKernelPartUUIDHappy(c *C) { + s.mockProcCmdlineContent(c, "snapd_recovery_mode=run") + + restore := main.MockPartitionUUIDForBootedKernelDisk("ubuntu-boot-partuuid") + defer restore() + + restore = disks.MockMountPointDisksToPartitionMapping( + map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuBootDir}: defaultBootDisk, + {Mountpoint: boot.InitramfsDataDir}: defaultBootDisk, + }, + ) + defer restore() + + restore = s.mockSystemdMountSequence(c, []systemdMount{ + { + "/dev/disk/by-partuuid/ubuntu-boot-partuuid", + boot.InitramfsUbuntuBootDir, + needsFsckDiskMountOpts, + nil, + }, + s.ubuntuPartUUIDMount("ubuntu-seed-partuuid", "run"), + s.ubuntuPartUUIDMount("ubuntu-data-partuuid", "run"), + s.makeRunSnapSystemdMount(snap.TypeBase, s.core20), + s.makeRunSnapSystemdMount(snap.TypeGadget, s.gadget), + s.makeRunSnapSystemdMount(snap.TypeKernel, s.kernel), + }, nil) + defer restore() + + // mock a bootloader + bloader := boottest.MockUC20RunBootenv(bootloadertest.Mock("mock", c.MkDir())) + bootloader.Force(bloader) + defer bootloader.Force(nil) + + // set the current kernel + restore = bloader.SetEnabledKernel(s.kernel) + defer restore() + + s.makeSnapFilesOnEarlyBootUbuntuData(c, s.kernel, s.core20, s.gadget) + + // write modeenv + modeEnv := boot.Modeenv{ + Mode: "run", + Base: s.core20.Filename(), + Gadget: s.gadget.Filename(), + CurrentKernels: []string{s.kernel.Filename()}, + } + err := modeEnv.WriteTo(filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data")) + c.Assert(err, IsNil) + + _, err = main.Parser().ParseArgs([]string{"initramfs-mounts"}) + c.Assert(err, IsNil) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsRunModeEncryptedDataHappy(c *C) { + s.mockProcCmdlineContent(c, "snapd_recovery_mode=run") + + // ensure that we check that access to sealed keys were locked + sealedKeysLocked := false + defer main.MockSecbootLockSealedKeys(func() error { + sealedKeysLocked = true + return nil + })() + + restore := disks.MockMountPointDisksToPartitionMapping( + map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuBootDir}: defaultEncBootDisk, + {Mountpoint: boot.InitramfsDataDir, IsDecryptedDevice: true}: defaultEncBootDisk, + {Mountpoint: boot.InitramfsUbuntuSaveDir, IsDecryptedDevice: true}: defaultEncBootDisk, + }, + ) + defer restore() + + restore = s.mockSystemdMountSequence(c, []systemdMount{ + s.ubuntuLabelMount("ubuntu-boot", "run"), + s.ubuntuPartUUIDMount("ubuntu-seed-partuuid", "run"), + { + "/dev/mapper/ubuntu-data-random", + boot.InitramfsDataDir, + needsFsckAndNoSuidDiskMountOpts, + nil, + }, + { + "/dev/mapper/ubuntu-save-random", + boot.InitramfsUbuntuSaveDir, + needsFsckDiskMountOpts, + nil, + }, + s.makeRunSnapSystemdMount(snap.TypeBase, s.core20), + s.makeRunSnapSystemdMount(snap.TypeGadget, s.gadget), + s.makeRunSnapSystemdMount(snap.TypeKernel, s.kernel), + }, nil) + defer restore() + + // write the installed model like makebootable does it + err := os.MkdirAll(filepath.Join(boot.InitramfsUbuntuBootDir, "device"), 0755) + c.Assert(err, IsNil) + mf, err := os.Create(filepath.Join(boot.InitramfsUbuntuBootDir, "device/model")) + c.Assert(err, IsNil) + defer mf.Close() + err = asserts.NewEncoder(mf).Encode(s.model) + c.Assert(err, IsNil) + + dataActivated := false + restore = main.MockSecbootUnlockVolumeUsingSealedKeyIfEncrypted(func(disk disks.Disk, name string, sealedEncryptionKeyFile string, opts *secboot.UnlockVolumeUsingSealedKeyOptions) (secboot.UnlockResult, error) { + c.Assert(name, Equals, "ubuntu-data") + c.Assert(sealedEncryptionKeyFile, Equals, filepath.Join(s.tmpDir, "run/mnt/ubuntu-boot/device/fde/ubuntu-data.sealed-key")) + c.Assert(opts.AllowRecoveryKey, Equals, true) + c.Assert(opts.WhichModel, NotNil) + mod, err := opts.WhichModel() + c.Assert(err, IsNil) + c.Check(mod.Model(), Equals, "my-model") + + dataActivated = true + // return true because we are using an encrypted device + return happyUnlocked("ubuntu-data", secboot.UnlockedWithSealedKey), nil + }) + defer restore() + + s.mockUbuntuSaveKeyAndMarker(c, filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data"), "foo", "marker") + s.mockUbuntuSaveMarker(c, boot.InitramfsUbuntuSaveDir, "marker") + + saveActivated := false + restore = main.MockSecbootUnlockEncryptedVolumeUsingKey(func(disk disks.Disk, name string, key []byte) (secboot.UnlockResult, error) { + c.Check(dataActivated, Equals, true, Commentf("ubuntu-data not activated yet")) + saveActivated = true + c.Assert(name, Equals, "ubuntu-save") + c.Assert(key, DeepEquals, []byte("foo")) + return happyUnlocked("ubuntu-save", secboot.UnlockedWithKey), nil + }) + defer restore() + + measureEpochCalls := 0 + measureModelCalls := 0 + restore = main.MockSecbootMeasureSnapSystemEpochWhenPossible(func() error { + measureEpochCalls++ + return nil + }) + defer restore() + + var measuredModel *asserts.Model + restore = main.MockSecbootMeasureSnapModelWhenPossible(func(findModel func() (*asserts.Model, error)) error { + measureModelCalls++ + var err error + measuredModel, err = findModel() + if err != nil { + return err + } + return nil + }) + defer restore() + + // mock a bootloader + bloader := boottest.MockUC20RunBootenv(bootloadertest.Mock("mock", c.MkDir())) + bootloader.Force(bloader) + defer bootloader.Force(nil) + + // set the current kernel + restore = bloader.SetEnabledKernel(s.kernel) + defer restore() + + s.makeSnapFilesOnEarlyBootUbuntuData(c, s.kernel, s.core20, s.gadget) + + // write modeenv + modeEnv := boot.Modeenv{ + Mode: "run", + Base: s.core20.Filename(), + Gadget: s.gadget.Filename(), + CurrentKernels: []string{s.kernel.Filename()}, + } + err = modeEnv.WriteTo(filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data")) + c.Assert(err, IsNil) + + _, err = main.Parser().ParseArgs([]string{"initramfs-mounts"}) + c.Assert(err, IsNil) + c.Check(dataActivated, Equals, true) + c.Check(saveActivated, Equals, true) + c.Check(measureEpochCalls, Equals, 1) + c.Check(measureModelCalls, Equals, 1) + c.Check(measuredModel, DeepEquals, s.model) + c.Check(sealedKeysLocked, Equals, true) + + c.Assert(filepath.Join(dirs.SnapBootstrapRunDir, "secboot-epoch-measured"), testutil.FilePresent) + c.Assert(filepath.Join(dirs.SnapBootstrapRunDir, "run-model-measured"), testutil.FilePresent) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsRunCVMModeHappy(c *C) { + s.mockProcCmdlineContent(c, "snapd_recovery_mode=cloudimg-rootfs") + + restore := main.MockPartitionUUIDForBootedKernelDisk("specific-ubuntu-seed-partuuid") + defer restore() + + restore = disks.MockMountPointDisksToPartitionMapping( + map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuSeedDir}: defaultCVMDisk, + {Mountpoint: boot.InitramfsDataDir, IsDecryptedDevice: true}: defaultCVMDisk, + }, + ) + defer restore() + + // don't do anything from systemd-mount, we verify the arguments passed at + // the end with cmd.Calls + cmd := testutil.MockCommand(c, "systemd-mount", ``) + defer cmd.Restore() + + // mock that in turn, /run/mnt/ubuntu-boot, /run/mnt/ubuntu-seed, etc. are + // mounted + n := 0 + restore = main.MockOsutilIsMounted(func(where string) (bool, error) { + n++ + switch n { + // first call for each mount returns false, then returns true, this + // tests in the case where systemd is racy / inconsistent and things + // aren't mounted by the time systemd-mount returns + case 1, 2: + c.Assert(where, Equals, boot.InitramfsUbuntuSeedDir) + case 3, 4: + c.Assert(where, Equals, boot.InitramfsDataDir) + case 5, 6: + c.Assert(where, Equals, boot.InitramfsUbuntuSeedDir) + default: + c.Errorf("unexpected IsMounted check on %s", where) + return false, fmt.Errorf("unexpected IsMounted check on %s", where) + } + return n%2 == 0, nil + }) + defer restore() + + // Mock the call to TPMCVM, to ensure that TPM provisioning is + // done before unlock attempt + provisionTPMCVMCalled := false + restore = main.MockSecbootProvisionForCVM(func(_ string) error { + // Ensure this function is only called once + c.Assert(provisionTPMCVMCalled, Equals, false) + provisionTPMCVMCalled = true + return nil + }) + defer restore() + + cloudimgActivated := false + restore = main.MockSecbootUnlockVolumeUsingSealedKeyIfEncrypted(func(disk disks.Disk, name string, sealedEncryptionKeyFile string, opts *secboot.UnlockVolumeUsingSealedKeyOptions) (secboot.UnlockResult, error) { + c.Assert(provisionTPMCVMCalled, Equals, true) + c.Assert(name, Equals, "cloudimg-rootfs") + c.Assert(sealedEncryptionKeyFile, Equals, filepath.Join(s.tmpDir, "run/mnt/ubuntu-seed/device/fde/cloudimg-rootfs.sealed-key")) + c.Assert(opts.AllowRecoveryKey, Equals, true) + c.Assert(opts.WhichModel, IsNil) + + cloudimgActivated = true + // return true because we are using an encrypted device + return happyUnlocked("cloudimg-rootfs", secboot.UnlockedWithSealedKey), nil + }) + defer restore() + + _, err := main.Parser().ParseArgs([]string{"initramfs-mounts"}) + c.Assert(err, IsNil) + c.Check(s.Stdout.String(), Equals, "") + + // 2 per mountpoint + 1 more for cross check + c.Assert(n, Equals, 5) + + // failed to use mockSystemdMountSequence way of asserting this + // note that other test cases also mix & match using + // mockSystemdMountSequence & DeepEquals + c.Assert(cmd.Calls(), DeepEquals, [][]string{ + { + "systemd-mount", + "/dev/disk/by-partuuid/specific-ubuntu-seed-partuuid", + boot.InitramfsUbuntuSeedDir, + "--no-pager", + "--no-ask-password", + "--fsck=yes", + "--options=private", + "--property=Before=initrd-fs.target", + }, + { + "systemd-mount", + "/dev/mapper/cloudimg-rootfs-random", + boot.InitramfsDataDir, + "--no-pager", + "--no-ask-password", + "--fsck=yes", + }, + { + "systemd-mount", + boot.InitramfsUbuntuSeedDir, + "--umount", + "--no-pager", + "--no-ask-password", + "--fsck=no", + }, + }) + + c.Check(provisionTPMCVMCalled, Equals, true) + c.Check(cloudimgActivated, Equals, true) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsRunModeEncryptedDataUnhappyNoSave(c *C) { + s.mockProcCmdlineContent(c, "snapd_recovery_mode=run") + + defaultEncNoSaveBootDisk := &disks.MockDiskMapping{ + Structure: []disks.Partition{ + seedPart, + bootPart, + dataEncPart, + // missing ubuntu-save + }, + DiskHasPartitions: true, + DevNum: "defaultEncDev", + } + + restore := disks.MockMountPointDisksToPartitionMapping( + map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuBootDir}: defaultEncNoSaveBootDisk, + {Mountpoint: boot.InitramfsDataDir, IsDecryptedDevice: true}: defaultEncNoSaveBootDisk, + }, + ) + defer restore() + + restore = s.mockSystemdMountSequence(c, []systemdMount{ + s.ubuntuLabelMount("ubuntu-boot", "run"), + s.ubuntuPartUUIDMount("ubuntu-seed-partuuid", "run"), + { + "/dev/mapper/ubuntu-data-random", + boot.InitramfsDataDir, + needsFsckAndNoSuidDiskMountOpts, + nil, + }, + }, nil) + defer restore() + + dataActivated := false + restore = main.MockSecbootUnlockVolumeUsingSealedKeyIfEncrypted(func(disk disks.Disk, name string, sealedEncryptionKeyFile string, opts *secboot.UnlockVolumeUsingSealedKeyOptions) (secboot.UnlockResult, error) { + c.Assert(name, Equals, "ubuntu-data") + dataActivated = true + // return true because we are using an encrypted device + return happyUnlocked("ubuntu-data", secboot.UnlockedWithSealedKey), nil + }) + defer restore() + + // the test does not mock ubuntu-save.key, the secboot helper for + // opening a volume using the key should not be called + restore = main.MockSecbootUnlockEncryptedVolumeUsingKey(func(disk disks.Disk, name string, key []byte) (secboot.UnlockResult, error) { + c.Fatal("unexpected call") + return secboot.UnlockResult{}, fmt.Errorf("unexpected call") + }) + defer restore() + + restore = main.MockSecbootMeasureSnapSystemEpochWhenPossible(func() error { return nil }) + defer restore() + restore = main.MockSecbootMeasureSnapModelWhenPossible(func(findModel func() (*asserts.Model, error)) error { + return nil + }) + defer restore() + + // mock a bootloader + bloader := boottest.MockUC20RunBootenv(bootloadertest.Mock("mock", c.MkDir())) + bootloader.Force(bloader) + defer bootloader.Force(nil) + + // set the current kernel + restore = bloader.SetEnabledKernel(s.kernel) + defer restore() + + s.makeSnapFilesOnEarlyBootUbuntuData(c, s.kernel, s.core20) + + // write modeenv + modeEnv := boot.Modeenv{ + Mode: "run", + Base: s.core20.Filename(), + CurrentKernels: []string{s.kernel.Filename()}, + } + err := modeEnv.WriteTo(filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data")) + c.Assert(err, IsNil) + + _, err = main.Parser().ParseArgs([]string{"initramfs-mounts"}) + c.Assert(err, ErrorMatches, "cannot find ubuntu-save encryption key at .*/run/mnt/data/system-data/var/lib/snapd/device/fde/ubuntu-save.key") + c.Check(dataActivated, Equals, true) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsRunModeEncryptedDataUnhappyUnlockSaveFail(c *C) { + // ensure that we check that access to sealed keys were locked + sealedKeysLocked := false + defer main.MockSecbootLockSealedKeys(func() error { + sealedKeysLocked = true + return fmt.Errorf("blocking keys failed") + })() + + s.mockProcCmdlineContent(c, "snapd_recovery_mode=run") + restore := disks.MockMountPointDisksToPartitionMapping( + map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuBootDir}: defaultEncBootDisk, + {Mountpoint: boot.InitramfsDataDir, IsDecryptedDevice: true}: defaultEncBootDisk, + {Mountpoint: boot.InitramfsUbuntuSaveDir, IsDecryptedDevice: true}: defaultEncBootDisk, + }, + ) + defer restore() + + restore = s.mockSystemdMountSequence(c, []systemdMount{ + s.ubuntuLabelMount("ubuntu-boot", "run"), + s.ubuntuPartUUIDMount("ubuntu-seed-partuuid", "run"), + { + "/dev/mapper/ubuntu-data-random", + boot.InitramfsDataDir, + needsFsckAndNoSuidDiskMountOpts, + nil, + }, + }, nil) + defer restore() + + dataActivated := false + restore = main.MockSecbootUnlockVolumeUsingSealedKeyIfEncrypted(func(disk disks.Disk, name string, sealedEncryptionKeyFile string, opts *secboot.UnlockVolumeUsingSealedKeyOptions) (secboot.UnlockResult, error) { + c.Assert(name, Equals, "ubuntu-data") + dataActivated = true + // return true because we are using an encrypted device + return happyUnlocked("ubuntu-data", secboot.UnlockedWithSealedKey), nil + }) + defer restore() + + s.mockUbuntuSaveKeyAndMarker(c, filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data"), "foo", "") + restore = main.MockSecbootUnlockEncryptedVolumeUsingKey(func(disk disks.Disk, name string, key []byte) (secboot.UnlockResult, error) { + c.Check(dataActivated, Equals, true, Commentf("ubuntu-data not yet activated")) + return foundEncrypted("ubuntu-save"), fmt.Errorf("ubuntu-save unlock fail") + }) + defer restore() + + restore = main.MockSecbootMeasureSnapSystemEpochWhenPossible(func() error { return nil }) + defer restore() + restore = main.MockSecbootMeasureSnapModelWhenPossible(func(findModel func() (*asserts.Model, error)) error { + return nil + }) + defer restore() + + // mock a bootloader + bloader := boottest.MockUC20RunBootenv(bootloadertest.Mock("mock", c.MkDir())) + bootloader.Force(bloader) + defer bootloader.Force(nil) + + // set the current kernel + restore = bloader.SetEnabledKernel(s.kernel) + defer restore() + + s.makeSnapFilesOnEarlyBootUbuntuData(c, s.kernel, s.core20) + + // write modeenv + modeEnv := boot.Modeenv{ + Mode: "run", + Base: s.core20.Filename(), + CurrentKernels: []string{s.kernel.Filename()}, + } + err := modeEnv.WriteTo(filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data")) + c.Assert(err, IsNil) + + _, err = main.Parser().ParseArgs([]string{"initramfs-mounts"}) + c.Assert(err, ErrorMatches, "cannot unlock ubuntu-save volume: ubuntu-save unlock fail") + c.Check(dataActivated, Equals, true) + // locking sealing keys was attempted, error was only logged + c.Check(sealedKeysLocked, Equals, true) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsRunModeEncryptedNoModel(c *C) { + s.testInitramfsMountsEncryptedNoModel(c, "run", "", 1) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsInstallModeEncryptedNoModel(c *C) { + s.testInitramfsMountsEncryptedNoModel(c, "install", s.sysLabel, 0) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsRecoverModeEncryptedNoModel(c *C) { + s.testInitramfsMountsEncryptedNoModel(c, "recover", s.sysLabel, 0) +} + +func (s *initramfsMountsSuite) testInitramfsMountsEncryptedNoModel(c *C, mode, label string, expectedMeasureModelCalls int) { + s.mockProcCmdlineContent(c, fmt.Sprintf("snapd_recovery_mode=%s", mode)) + + // Make sure there is no model for this test + err := os.Remove(filepath.Join(boot.InitramfsUbuntuBootDir, "device/model")) + c.Assert(err, IsNil) + + // ensure that we check that access to sealed keys were locked + sealedKeysLocked := false + defer main.MockSecbootLockSealedKeys(func() error { + sealedKeysLocked = true + return fmt.Errorf("blocking keys failed") + })() + + var restore func() + if mode == "run" { + // run mode will mount ubuntu-boot only before failing + restore = s.mockSystemdMountSequence(c, []systemdMount{ + s.ubuntuLabelMount("ubuntu-boot", mode), + }, nil) + restore2 := disks.MockMountPointDisksToPartitionMapping( + map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuBootDir}: defaultEncBootDisk, + }, + ) + defer restore2() + } else { + // install and recover mounts are just ubuntu-seed before we fail + restore = s.mockSystemdMountSequence(c, []systemdMount{ + s.ubuntuLabelMount("ubuntu-seed", mode), + }, nil) + + // in install / recover mode the code doesn't make it far enough to do + // any disk cross checking + } + defer restore() + + if label != "" { + s.mockProcCmdlineContent(c, + fmt.Sprintf("snapd_recovery_mode=%s snapd_recovery_system=%s", mode, label)) + // break the seed + err := os.Remove(filepath.Join(s.seedDir, "systems", label, "model")) + c.Assert(err, IsNil) + } + + measureEpochCalls := 0 + restore = main.MockSecbootMeasureSnapSystemEpochWhenPossible(func() error { + measureEpochCalls++ + return nil + }) + defer restore() + + measureModelCalls := 0 + restore = main.MockSecbootMeasureSnapModelWhenPossible(func(findModel func() (*asserts.Model, error)) error { + measureModelCalls++ + _, err := findModel() + if err != nil { + return err + } + return fmt.Errorf("unexpected call") + }) + defer restore() + + _, err = main.Parser().ParseArgs([]string{"initramfs-mounts"}) + where := "/run/mnt/ubuntu-boot/device/model" + if mode != "run" { + where = fmt.Sprintf("/run/mnt/ubuntu-seed/systems/%s/model", label) + } + c.Assert(err, ErrorMatches, fmt.Sprintf(".*cannot read model assertion: open .*%s: no such file or directory", where)) + c.Assert(measureEpochCalls, Equals, 1) + c.Assert(measureModelCalls, Equals, expectedMeasureModelCalls) + c.Assert(filepath.Join(dirs.SnapBootstrapRunDir, "secboot-epoch-measured"), testutil.FilePresent) + gl, err := filepath.Glob(filepath.Join(dirs.SnapBootstrapRunDir, "*-model-measured")) + c.Assert(err, IsNil) + c.Assert(gl, HasLen, 0) + c.Check(sealedKeysLocked, Equals, true) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsRunModeUpgradeScenarios(c *C) { + tt := []struct { + modeenv *boot.Modeenv + // this is a function so we can have delayed execution, typical values + // depend on the root dir which changes for each test case + additionalMountsFunc func() []systemdMount + enableKernel snap.PlaceInfo + enableTryKernel snap.PlaceInfo + snapFiles []snap.PlaceInfo + kernelStatus string + + expRebootPanic string + expLog string + expError string + expModeenv *boot.Modeenv + comment string + }{ + // default case no upgrades + { + modeenv: &boot.Modeenv{ + Mode: "run", + Base: s.core20.Filename(), + Gadget: s.gadget.Filename(), + CurrentKernels: []string{s.kernel.Filename()}, + }, + additionalMountsFunc: func() []systemdMount { + return []systemdMount{ + s.makeRunSnapSystemdMount(snap.TypeBase, s.core20), + s.makeRunSnapSystemdMount(snap.TypeGadget, s.gadget), + s.makeRunSnapSystemdMount(snap.TypeKernel, s.kernel), + } + }, + enableKernel: s.kernel, + snapFiles: []snap.PlaceInfo{s.core20, s.gadget, s.kernel}, + comment: "happy default no upgrades", + }, + + // happy upgrade cases + { + modeenv: &boot.Modeenv{ + Mode: "run", + Base: s.core20.Filename(), + Gadget: s.gadget.Filename(), + CurrentKernels: []string{s.kernel.Filename(), s.kernelr2.Filename()}, + }, + additionalMountsFunc: func() []systemdMount { + return []systemdMount{ + s.makeRunSnapSystemdMount(snap.TypeBase, s.core20), + s.makeRunSnapSystemdMount(snap.TypeGadget, s.gadget), + s.makeRunSnapSystemdMount(snap.TypeKernel, s.kernelr2), + } + }, + kernelStatus: boot.TryingStatus, + enableKernel: s.kernel, + enableTryKernel: s.kernelr2, + snapFiles: []snap.PlaceInfo{s.core20, s.gadget, s.kernel, s.kernelr2}, + comment: "happy kernel snap upgrade", + }, + { + modeenv: &boot.Modeenv{ + Mode: "run", + Base: s.core20.Filename(), + TryBase: s.core20r2.Filename(), + BaseStatus: boot.TryStatus, + Gadget: s.gadget.Filename(), + CurrentKernels: []string{s.kernel.Filename()}, + }, + additionalMountsFunc: func() []systemdMount { + return []systemdMount{ + s.makeRunSnapSystemdMount(snap.TypeBase, s.core20r2), + s.makeRunSnapSystemdMount(snap.TypeGadget, s.gadget), + s.makeRunSnapSystemdMount(snap.TypeKernel, s.kernel), + } + }, + enableKernel: s.kernel, + snapFiles: []snap.PlaceInfo{s.kernel, s.gadget, s.core20, s.core20r2}, + expModeenv: &boot.Modeenv{ + Mode: "run", + Base: s.core20.Filename(), + TryBase: s.core20r2.Filename(), + BaseStatus: boot.TryingStatus, + Gadget: s.gadget.Filename(), + CurrentKernels: []string{s.kernel.Filename()}, + }, + comment: "happy base snap upgrade", + }, + { + modeenv: &boot.Modeenv{ + Mode: "run", + Base: s.core20.Filename(), + TryBase: s.core20r2.Filename(), + BaseStatus: boot.TryStatus, + Gadget: s.gadget.Filename(), + CurrentKernels: []string{s.kernel.Filename(), s.kernelr2.Filename()}, + }, + additionalMountsFunc: func() []systemdMount { + return []systemdMount{ + s.makeRunSnapSystemdMount(snap.TypeBase, s.core20r2), + s.makeRunSnapSystemdMount(snap.TypeGadget, s.gadget), + s.makeRunSnapSystemdMount(snap.TypeKernel, s.kernelr2), + } + }, + enableKernel: s.kernel, + enableTryKernel: s.kernelr2, + snapFiles: []snap.PlaceInfo{s.kernel, s.kernelr2, s.core20, s.core20r2, s.gadget}, + kernelStatus: boot.TryingStatus, + expModeenv: &boot.Modeenv{ + Mode: "run", + Base: s.core20.Filename(), + TryBase: s.core20r2.Filename(), + BaseStatus: boot.TryingStatus, + Gadget: s.gadget.Filename(), + CurrentKernels: []string{s.kernel.Filename(), s.kernelr2.Filename()}, + }, + comment: "happy simultaneous base snap and kernel snap upgrade", + }, + + // fallback cases + { + modeenv: &boot.Modeenv{ + Mode: "run", + Base: s.core20.Filename(), + TryBase: s.core20r2.Filename(), + Gadget: s.gadget.Filename(), + BaseStatus: boot.TryStatus, + CurrentKernels: []string{s.kernel.Filename()}, + }, + additionalMountsFunc: func() []systemdMount { + return []systemdMount{ + s.makeRunSnapSystemdMount(snap.TypeBase, s.core20), + s.makeRunSnapSystemdMount(snap.TypeGadget, s.gadget), + s.makeRunSnapSystemdMount(snap.TypeKernel, s.kernel), + } + }, + enableKernel: s.kernel, + snapFiles: []snap.PlaceInfo{s.kernel, s.core20, s.gadget}, + comment: "happy fallback try base not existing", + }, + { + modeenv: &boot.Modeenv{ + Mode: "run", + Base: s.core20.Filename(), + BaseStatus: boot.TryStatus, + TryBase: "", + Gadget: s.gadget.Filename(), + CurrentKernels: []string{s.kernel.Filename()}, + }, + additionalMountsFunc: func() []systemdMount { + return []systemdMount{ + s.makeRunSnapSystemdMount(snap.TypeBase, s.core20), + s.makeRunSnapSystemdMount(snap.TypeGadget, s.gadget), + s.makeRunSnapSystemdMount(snap.TypeKernel, s.kernel), + } + }, + enableKernel: s.kernel, + snapFiles: []snap.PlaceInfo{s.kernel, s.core20, s.gadget}, + comment: "happy fallback base_status try, empty try_base", + }, + { + modeenv: &boot.Modeenv{ + Mode: "run", + Base: s.core20.Filename(), + TryBase: s.core20r2.Filename(), + BaseStatus: boot.TryingStatus, + Gadget: s.gadget.Filename(), + CurrentKernels: []string{s.kernel.Filename()}, + }, + additionalMountsFunc: func() []systemdMount { + return []systemdMount{ + s.makeRunSnapSystemdMount(snap.TypeBase, s.core20), + s.makeRunSnapSystemdMount(snap.TypeGadget, s.gadget), + s.makeRunSnapSystemdMount(snap.TypeKernel, s.kernel), + } + }, + enableKernel: s.kernel, + snapFiles: []snap.PlaceInfo{s.kernel, s.core20, s.core20r2, s.gadget}, + expModeenv: &boot.Modeenv{ + Mode: "run", + Base: s.core20.Filename(), + TryBase: s.core20r2.Filename(), + BaseStatus: boot.DefaultStatus, + Gadget: s.gadget.Filename(), + CurrentKernels: []string{s.kernel.Filename()}, + }, + comment: "happy fallback failed boot with try snap", + }, + { + modeenv: &boot.Modeenv{ + Mode: "run", + Base: s.core20.Filename(), + Gadget: s.gadget.Filename(), + CurrentKernels: []string{s.kernel.Filename()}, + }, + enableKernel: s.kernel, + enableTryKernel: s.kernelr2, + snapFiles: []snap.PlaceInfo{s.core20, s.gadget, s.kernel, s.kernelr2}, + kernelStatus: boot.TryingStatus, + expRebootPanic: "reboot due to untrusted try kernel snap", + comment: "happy fallback untrusted try kernel snap", + }, + // TODO:UC20: if we ever have a way to compare what kernel was booted, + // and we compute that the booted kernel was the try kernel, + // but the try kernel is not enabled on the bootloader + // (somehow??), then this should become a reboot case rather + // than mount the old kernel snap + { + modeenv: &boot.Modeenv{ + Mode: "run", + Base: s.core20.Filename(), + Gadget: s.gadget.Filename(), + CurrentKernels: []string{s.kernel.Filename()}, + }, + kernelStatus: boot.TryingStatus, + enableKernel: s.kernel, + snapFiles: []snap.PlaceInfo{s.core20, s.kernel, s.gadget}, + expRebootPanic: "reboot due to no try kernel snap", + comment: "happy fallback kernel_status trying no try kernel", + }, + + // unhappy cases + { + modeenv: &boot.Modeenv{ + Mode: "run", + }, + expError: "no currently usable base snaps: cannot get snap revision: modeenv base boot variable is empty", + comment: "unhappy empty modeenv", + }, + { + modeenv: &boot.Modeenv{ + Mode: "run", + Base: s.core20.Filename(), + Gadget: s.gadget.Filename(), + CurrentKernels: []string{s.kernel.Filename()}, + }, + enableKernel: s.kernelr2, + snapFiles: []snap.PlaceInfo{s.core20, s.kernelr2, s.gadget}, + expError: fmt.Sprintf("fallback kernel snap %q is not trusted in the modeenv", s.kernelr2.Filename()), + comment: "unhappy untrusted main kernel snap", + }, + } + + s.mockProcCmdlineContent(c, "snapd_recovery_mode=run") + + for _, t := range tt { + comment := Commentf(t.comment) + c.Log(comment) + + var cleanups []func() + + if t.expRebootPanic != "" { + r := boot.MockInitramfsReboot(func() error { + panic(t.expRebootPanic) + }) + cleanups = append(cleanups, r) + } + + // setup unique root dir per test + rootDir := c.MkDir() + cleanups = append(cleanups, func() { dirs.SetRootDir(dirs.GlobalRootDir) }) + dirs.SetRootDir(rootDir) + // we need to recreate by-label files in the new root dir + s.byLabelDir = filepath.Join(rootDir, "dev/disk/by-label") + var err error + err = os.MkdirAll(s.byLabelDir, 0755) + c.Assert(err, IsNil) + err = os.WriteFile(filepath.Join(s.byLabelDir, "ubuntu-seed"), nil, 0644) + c.Assert(err, IsNil) + err = os.WriteFile(filepath.Join(s.byLabelDir, "ubuntu-boot"), nil, 0644) + c.Assert(err, IsNil) + + restore := disks.MockMountPointDisksToPartitionMapping( + map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuBootDir}: defaultBootDisk, + {Mountpoint: boot.InitramfsDataDir}: defaultBootDisk, + }, + ) + cleanups = append(cleanups, restore) + + // Make sure we have a model + err = os.MkdirAll(filepath.Join(boot.InitramfsUbuntuBootDir, "device"), 0755) + c.Assert(err, IsNil) + mf, err := os.Create(filepath.Join(boot.InitramfsUbuntuBootDir, "device/model")) + c.Assert(err, IsNil) + defer mf.Close() + err = asserts.NewEncoder(mf).Encode(s.model) + c.Assert(err, IsNil) + + // setup expected systemd-mount calls - every test case has ubuntu-boot, + // ubuntu-seed and ubuntu-data mounts because all those mounts happen + // before any boot logic + mnts := []systemdMount{ + s.ubuntuLabelMount("ubuntu-boot", "run"), + s.ubuntuPartUUIDMount("ubuntu-seed-partuuid", "run"), + s.ubuntuPartUUIDMount("ubuntu-data-partuuid", "run"), + } + if t.additionalMountsFunc != nil { + mnts = append(mnts, t.additionalMountsFunc()...) + } + cleanups = append(cleanups, s.mockSystemdMountSequence(c, mnts, comment)) + + // mock a bootloader + bloader := boottest.MockUC20RunBootenv(bootloadertest.Mock("mock", c.MkDir())) + bootloader.Force(bloader) + cleanups = append(cleanups, func() { bootloader.Force(nil) }) + + if t.enableKernel != nil { + // don't need to restore since each test case has a unique bloader + bloader.SetEnabledKernel(t.enableKernel) + } + + if t.enableTryKernel != nil { + bloader.SetEnabledTryKernel(t.enableTryKernel) + } + + // set the kernel_status boot var + err = bloader.SetBootVars(map[string]string{"kernel_status": t.kernelStatus}) + c.Assert(err, IsNil, comment) + + // write the initial modeenv + err = t.modeenv.WriteTo(filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data")) + c.Assert(err, IsNil, comment) + + // make the snap files - no restore needed because we use a unique root + // dir for each test case + s.makeSnapFilesOnEarlyBootUbuntuData(c, t.snapFiles...) + + if t.expRebootPanic != "" { + f := func() { main.Parser().ParseArgs([]string{"initramfs-mounts"}) } + c.Assert(f, PanicMatches, t.expRebootPanic, comment) + } else { + _, err = main.Parser().ParseArgs([]string{"initramfs-mounts"}) + if t.expError != "" { + c.Assert(err, ErrorMatches, t.expError, comment) + } else { + c.Assert(err, IsNil, comment) + + // check the resultant modeenv + // if the expModeenv is nil, we just compare to the start + newModeenv, err := boot.ReadModeenv(filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data")) + c.Assert(err, IsNil, comment) + m := t.modeenv + if t.expModeenv != nil { + m = t.expModeenv + } + c.Assert(newModeenv.BaseStatus, DeepEquals, m.BaseStatus, comment) + c.Assert(newModeenv.TryBase, DeepEquals, m.TryBase, comment) + c.Assert(newModeenv.Base, DeepEquals, m.Base, comment) + } + } + + for _, r := range cleanups { + r() + } + } +} + +func (s *initramfsMountsSuite) testInitramfsMountsRunModeUpdateBootloaderVars( + c *C, cmdLine string, finalKernel *snap.PlaceInfo, finalStatus string) { + s.mockProcCmdlineContent(c, cmdLine) + + restore := disks.MockMountPointDisksToPartitionMapping( + map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuBootDir}: defaultBootWithSaveDisk, + {Mountpoint: boot.InitramfsDataDir}: defaultBootWithSaveDisk, + {Mountpoint: boot.InitramfsUbuntuSaveDir}: defaultBootWithSaveDisk, + }, + ) + defer restore() + + restore = s.mockSystemdMountSequence(c, []systemdMount{ + s.ubuntuLabelMount("ubuntu-boot", "run"), + s.ubuntuPartUUIDMount("ubuntu-seed-partuuid", "run"), + s.ubuntuPartUUIDMount("ubuntu-data-partuuid", "run"), + s.ubuntuPartUUIDMount("ubuntu-save-partuuid", "run"), + s.makeRunSnapSystemdMount(snap.TypeBase, s.core20), + s.makeRunSnapSystemdMount(snap.TypeGadget, s.gadget), + s.makeRunSnapSystemdMount(snap.TypeKernel, *finalKernel), + }, nil) + defer restore() + + // mock a bootloader + bloader := boottest.MockUC20RunBootenvNotScript(bootloadertest.Mock("mock", c.MkDir())) + bloader.SetBootVars(map[string]string{"kernel_status": boot.TryStatus}) + bootloader.Force(bloader) + defer bootloader.Force(nil) + + // set the current kernel + restore = bloader.SetEnabledKernel(s.kernel) + defer restore() + restore = bloader.SetEnabledTryKernel(s.kernelr2) + defer restore() + + s.makeSnapFilesOnEarlyBootUbuntuData(c, s.core20, s.gadget, s.kernel, s.kernelr2) + + // write modeenv + modeEnv := boot.Modeenv{ + Mode: "run", + Base: s.core20.Filename(), + Gadget: s.gadget.Filename(), + CurrentKernels: []string{s.kernel.Filename(), s.kernelr2.Filename()}, + } + err := modeEnv.WriteTo(filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data")) + c.Assert(err, IsNil) + + _, err = main.Parser().ParseArgs([]string{"initramfs-mounts"}) + c.Assert(err, IsNil) + vars, err := bloader.GetBootVars("kernel_status") + c.Assert(err, IsNil) + c.Assert(vars, DeepEquals, map[string]string{"kernel_status": finalStatus}) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsRunModeUpdateBootloaderVars(c *C) { + s.testInitramfsMountsRunModeUpdateBootloaderVars(c, + "snapd_recovery_mode=run kernel_status=trying", + &s.kernelr2, boot.TryingStatus) + s.testInitramfsMountsRunModeUpdateBootloaderVars(c, + "snapd_recovery_mode=run", + &s.kernel, boot.DefaultStatus) +} + +func (s *initramfsMountsSuite) testRecoverModeHappy(c *C) { + // ensure that we check that access to sealed keys were locked + sealedKeysLocked := false + restore := main.MockSecbootLockSealedKeys(func() error { + sealedKeysLocked = true + return nil + }) + defer restore() + + // mock various files that are copied around during recover mode (and files + // that shouldn't be copied around) + ephemeralUbuntuData := filepath.Join(boot.InitramfsRunMntDir, "data/") + err := os.MkdirAll(ephemeralUbuntuData, 0755) + c.Assert(err, IsNil) + // mock a auth data in the host's ubuntu-data + hostUbuntuData := filepath.Join(boot.InitramfsRunMntDir, "host/ubuntu-data/") + err = os.MkdirAll(hostUbuntuData, 0755) + c.Assert(err, IsNil) + mockCopiedFiles := []string{ + // extrausers + "system-data/var/lib/extrausers/passwd", + "system-data/var/lib/extrausers/shadow", + "system-data/var/lib/extrausers/group", + "system-data/var/lib/extrausers/gshadow", + // sshd + "system-data/etc/ssh/ssh_host_rsa.key", + "system-data/etc/ssh/ssh_host_rsa.key.pub", + // user ssh + "user-data/user1/.ssh/authorized_keys", + "user-data/user2/.ssh/authorized_keys", + // user snap authentication + "user-data/user1/.snap/auth.json", + // sudoers + "system-data/etc/sudoers.d/create-user-test", + // netplan networking + "system-data/etc/netplan/00-snapd-config.yaml", // example console-conf filename + "system-data/etc/netplan/50-cloud-init.yaml", // example cloud-init filename + // systemd clock file + "system-data/var/lib/systemd/timesync/clock", + "system-data/etc/machine-id", // machine-id for systemd-networkd + } + mockUnrelatedFiles := []string{ + "system-data/var/lib/foo", + "system-data/etc/passwd", + "user-data/user1/some-random-data", + "user-data/user2/other-random-data", + "user-data/user2/.snap/sneaky-not-auth.json", + "system-data/etc/not-networking/netplan", + "system-data/var/lib/systemd/timesync/clock-not-the-clock", + "system-data/etc/machine-id-except-not", + } + for _, mockFile := range append(mockCopiedFiles, mockUnrelatedFiles...) { + p := filepath.Join(hostUbuntuData, mockFile) + err = os.MkdirAll(filepath.Dir(p), 0750) + c.Assert(err, IsNil) + mockContent := fmt.Sprintf("content of %s", filepath.Base(mockFile)) + err = os.WriteFile(p, []byte(mockContent), 0640) + c.Assert(err, IsNil) + } + // create a mock state + mockedState := filepath.Join(hostUbuntuData, "system-data/var/lib/snapd/state.json") + err = os.MkdirAll(filepath.Dir(mockedState), 0750) + c.Assert(err, IsNil) + err = os.WriteFile(mockedState, []byte(mockStateContent), 0640) + c.Assert(err, IsNil) + + _, err = main.Parser().ParseArgs([]string{"initramfs-mounts"}) + c.Assert(err, IsNil) + + // we always need to lock access to sealed keys + c.Check(sealedKeysLocked, Equals, true) + + modeEnv := filepath.Join(ephemeralUbuntuData, "/system-data/var/lib/snapd/modeenv") + c.Check(modeEnv, testutil.FileEquals, `mode=recover +recovery_system=20191118 +base=core20_1.snap +gadget=pc_1.snap +model=my-brand/my-model +grade=signed +`) + for _, p := range mockUnrelatedFiles { + c.Check(filepath.Join(ephemeralUbuntuData, p), testutil.FileAbsent) + } + for _, p := range mockCopiedFiles { + c.Check(filepath.Join(ephemeralUbuntuData, p), testutil.FilePresent) + fi, err := os.Stat(filepath.Join(ephemeralUbuntuData, p)) + // check file mode is set + c.Assert(err, IsNil) + c.Check(fi.Mode(), Equals, os.FileMode(0640)) + // check dir mode is set in parent dir + fiParent, err := os.Stat(filepath.Dir(filepath.Join(ephemeralUbuntuData, p))) + c.Assert(err, IsNil) + c.Check(fiParent.Mode(), Equals, os.FileMode(os.ModeDir|0750)) + } + + c.Check(filepath.Join(ephemeralUbuntuData, "system-data/var/lib/snapd/state.json"), testutil.FileEquals, `{"data":{"auth":{"last-id":1,"macaroon-key":"not-a-cookie","users":[{"id":1,"name":"mvo"}]}},"changes":{},"tasks":{},"last-change-id":0,"last-task-id":0,"last-lane-id":0,"last-notice-id":0}`) + + // finally check that the recovery system bootenv was updated to be in run + // mode + bloader, err := bootloader.Find("", nil) + c.Assert(err, IsNil) + m, err := bloader.GetBootVars("snapd_recovery_system", "snapd_recovery_mode") + c.Assert(err, IsNil) + c.Assert(m, DeepEquals, map[string]string{ + "snapd_recovery_system": "20191118", + "snapd_recovery_mode": "run", + }) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsRecoverModeHappy(c *C) { + s.mockProcCmdlineContent(c, "snapd_recovery_mode=recover snapd_recovery_system="+s.sysLabel) + + // setup a bootloader for setting the bootenv after we are done + bloader := bootloadertest.Mock("mock", c.MkDir()) + bootloader.Force(bloader) + defer bootloader.Force(nil) + + // mock that we don't know which partition uuid the kernel was booted from + restore := main.MockPartitionUUIDForBootedKernelDisk("") + defer restore() + + restore = disks.MockMountPointDisksToPartitionMapping( + map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuSeedDir}: defaultBootWithSaveDisk, + {Mountpoint: boot.InitramfsUbuntuBootDir}: defaultBootWithSaveDisk, + {Mountpoint: boot.InitramfsHostUbuntuDataDir}: defaultBootWithSaveDisk, + {Mountpoint: boot.InitramfsUbuntuSaveDir}: defaultBootWithSaveDisk, + }, + ) + defer restore() + + restore = s.mockSystemdMountSequence(c, []systemdMount{ + s.ubuntuLabelMount("ubuntu-seed", "recover"), + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + s.makeSeedSnapSystemdMount(snap.TypeGadget), + { + "tmpfs", + boot.InitramfsDataDir, + tmpfsMountOpts, + nil, + }, + { + "/dev/disk/by-partuuid/ubuntu-boot-partuuid", + boot.InitramfsUbuntuBootDir, + needsFsckDiskMountOpts, + nil, + }, + { + "/dev/disk/by-partuuid/ubuntu-data-partuuid", + boot.InitramfsHostUbuntuDataDir, + needsNoSuidDiskMountOpts, + nil, + }, + { + "/dev/disk/by-partuuid/ubuntu-save-partuuid", + boot.InitramfsUbuntuSaveDir, + mountOpts, + nil, + }, + }, nil) + defer restore() + + s.testRecoverModeHappy(c) + + // we should not have written a degraded.json + c.Assert(filepath.Join(dirs.SnapBootstrapRunDir, "degraded.json"), testutil.FileAbsent) + + // we also should have written an empty boot-flags file + c.Assert(filepath.Join(dirs.SnapRunDir, "boot-flags"), testutil.FileEquals, "") +} + +func (s *initramfsMountsSuite) TestInitramfsMountsRecoverModeTimeMovesForwardHappy(c *C) { + s.mockProcCmdlineContent(c, "snapd_recovery_mode=recover snapd_recovery_system="+s.sysLabel) + + for _, tc := range s.timeTestCases() { + comment := Commentf(tc.comment) + cleanups := []func(){} + + // always remove the ubuntu-seed dir, otherwise setupSeed complains the + // model file already exists and can't setup the seed + err := os.RemoveAll(filepath.Join(boot.InitramfsUbuntuSeedDir)) + c.Assert(err, IsNil, comment) + + // also always remove the data dir, since we need to copy state.json + // there, so if the file already exists the initramfs code dies + err = os.RemoveAll(filepath.Join(boot.InitramfsDataDir)) + c.Assert(err, IsNil, comment) + + s.setupSeed(c, tc.modelTime, nil) + + restore := main.MockTimeNow(func() time.Time { + return tc.now + }) + cleanups = append(cleanups, restore) + + restore = disks.MockMountPointDisksToPartitionMapping( + map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuSeedDir}: defaultBootWithSaveDisk, + {Mountpoint: boot.InitramfsUbuntuBootDir}: defaultBootWithSaveDisk, + {Mountpoint: boot.InitramfsHostUbuntuDataDir}: defaultBootWithSaveDisk, + {Mountpoint: boot.InitramfsUbuntuSaveDir}: defaultBootWithSaveDisk, + }, + ) + cleanups = append(cleanups, restore) + osutilSetTimeCalls := 0 + // check what time we try to move forward to + restore = main.MockOsutilSetTime(func(t time.Time) error { + osutilSetTimeCalls++ + // make sure the timestamps are within 1 second of each other, they + // won't be equal since the timestamp is serialized to an assertion and + // read back + tTrunc := t.Truncate(2 * time.Second) + expTTrunc := tc.expT.Truncate(2 * time.Second) + c.Assert(tTrunc.Equal(expTTrunc), Equals, true, Commentf("%s, exp %s, got %s", tc.comment, t, s.snapDeclAssertsTime)) + return nil + }) + cleanups = append(cleanups, restore) + + restore = s.mockSystemdMountSequence(c, []systemdMount{ + s.ubuntuLabelMount("ubuntu-seed", "recover"), + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + s.makeSeedSnapSystemdMount(snap.TypeGadget), + { + "tmpfs", + boot.InitramfsDataDir, + tmpfsMountOpts, + nil, + }, + { + "/dev/disk/by-partuuid/ubuntu-boot-partuuid", + boot.InitramfsUbuntuBootDir, + needsFsckDiskMountOpts, + nil, + }, + { + "/dev/disk/by-partuuid/ubuntu-data-partuuid", + boot.InitramfsHostUbuntuDataDir, + needsNoSuidDiskMountOpts, + nil, + }, + { + "/dev/disk/by-partuuid/ubuntu-save-partuuid", + boot.InitramfsUbuntuSaveDir, + mountOpts, + nil, + }, + }, nil) + cleanups = append(cleanups, restore) + + bloader := bootloadertest.Mock("mock", c.MkDir()) + bootloader.Force(bloader) + cleanups = append(cleanups, func() { bootloader.Force(nil) }) + + s.testRecoverModeHappy(c) + c.Assert(osutilSetTimeCalls, Equals, tc.setTimeCalls) + + for _, r := range cleanups { + r() + } + } +} + +func (s *initramfsMountsSuite) TestInitramfsMountsRecoverModeGadgetDefaultsHappy(c *C) { + // setup a seed with default gadget yaml + const gadgetYamlDefaults = ` +defaults: + system: + service: + rsyslog.disable: true + ssh.disable: true + console-conf.disable: true + journal.persistent: true +` + c.Assert(os.RemoveAll(s.seedDir), IsNil) + + s.setupSeed(c, time.Time{}, [][]string{ + {"meta/gadget.yaml", gadgetYamlDefaults}, + }) + + s.mockProcCmdlineContent(c, "snapd_recovery_mode=recover snapd_recovery_system="+s.sysLabel) + + // setup a bootloader for setting the bootenv after we are done + bloader := bootloadertest.Mock("mock", c.MkDir()) + bootloader.Force(bloader) + defer bootloader.Force(nil) + + // mock that we don't know which partition uuid the kernel was booted from + restore := main.MockPartitionUUIDForBootedKernelDisk("") + defer restore() + + restore = disks.MockMountPointDisksToPartitionMapping( + map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuSeedDir}: defaultBootWithSaveDisk, + {Mountpoint: boot.InitramfsUbuntuBootDir}: defaultBootWithSaveDisk, + {Mountpoint: boot.InitramfsHostUbuntuDataDir}: defaultBootWithSaveDisk, + {Mountpoint: boot.InitramfsUbuntuSaveDir}: defaultBootWithSaveDisk, + }, + ) + defer restore() + + restore = s.mockSystemdMountSequence(c, []systemdMount{ + s.ubuntuLabelMount("ubuntu-seed", "recover"), + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + s.makeSeedSnapSystemdMount(snap.TypeGadget), + { + "tmpfs", + boot.InitramfsDataDir, + tmpfsMountOpts, + nil, + }, + { + "/dev/disk/by-partuuid/ubuntu-boot-partuuid", + boot.InitramfsUbuntuBootDir, + needsFsckDiskMountOpts, + nil, + }, + { + "/dev/disk/by-partuuid/ubuntu-data-partuuid", + boot.InitramfsHostUbuntuDataDir, + needsNoSuidDiskMountOpts, + nil, + }, + { + "/dev/disk/by-partuuid/ubuntu-save-partuuid", + boot.InitramfsUbuntuSaveDir, + mountOpts, + nil, + }, + }, nil) + defer restore() + + // we will call out to systemctl in the initramfs, but only using --root + // which doesn't talk to systemd, just manipulates files around + var sysctlArgs [][]string + systemctlRestorer := systemd.MockSystemctl(func(args ...string) (buf []byte, err error) { + sysctlArgs = append(sysctlArgs, args) + return nil, nil + }) + defer systemctlRestorer() + + s.testRecoverModeHappy(c) + + // we should not have written a degraded.json + c.Assert(filepath.Join(dirs.SnapBootstrapRunDir, "degraded.json"), testutil.FileAbsent) + + c.Assert(osutil.FileExists(filepath.Join(filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data"), "_writable_defaults/etc/cloud/cloud-init.disabled")), Equals, true) + + // check that everything from the gadget defaults was setup + c.Assert(osutil.FileExists(filepath.Join(filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data"), "_writable_defaults/etc/ssh/sshd_not_to_be_run")), Equals, true) + c.Assert(osutil.FileExists(filepath.Join(filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data"), "_writable_defaults/var/lib/console-conf/complete")), Equals, true) + exists, _, _ := osutil.DirExists(filepath.Join(filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data"), "_writable_defaults/var/log/journal")) + c.Assert(exists, Equals, true) + + // systemctl was called the way we expect + c.Assert(sysctlArgs, DeepEquals, [][]string{{"--root", filepath.Join(filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data"), "_writable_defaults"), "mask", "rsyslog.service"}}) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsRecoverModeHappyBootedKernelPartitionUUID(c *C) { + s.mockProcCmdlineContent(c, "snapd_recovery_mode=recover snapd_recovery_system="+s.sysLabel) + + restore := main.MockPartitionUUIDForBootedKernelDisk("specific-ubuntu-seed-partuuid") + defer restore() + + // setup a bootloader for setting the bootenv after we are done + bloader := bootloadertest.Mock("mock", c.MkDir()) + bootloader.Force(bloader) + defer bootloader.Force(nil) + + restore = disks.MockMountPointDisksToPartitionMapping( + map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuSeedDir}: defaultBootWithSaveDisk, + {Mountpoint: boot.InitramfsUbuntuBootDir}: defaultBootWithSaveDisk, + {Mountpoint: boot.InitramfsHostUbuntuDataDir}: defaultBootWithSaveDisk, + {Mountpoint: boot.InitramfsUbuntuSaveDir}: defaultBootWithSaveDisk, + }, + ) + defer restore() + + restore = s.mockSystemdMountSequence(c, []systemdMount{ + { + "/dev/disk/by-partuuid/specific-ubuntu-seed-partuuid", + boot.InitramfsUbuntuSeedDir, + needsFsckDiskMountOpts, + nil, + }, + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + s.makeSeedSnapSystemdMount(snap.TypeGadget), + { + "tmpfs", + boot.InitramfsDataDir, + tmpfsMountOpts, + nil, + }, + { + "/dev/disk/by-partuuid/ubuntu-boot-partuuid", + boot.InitramfsUbuntuBootDir, + needsFsckDiskMountOpts, + nil, + }, + { + "/dev/disk/by-partuuid/ubuntu-data-partuuid", + boot.InitramfsHostUbuntuDataDir, + needsNoSuidDiskMountOpts, + nil, + }, + { + "/dev/disk/by-partuuid/ubuntu-save-partuuid", + boot.InitramfsUbuntuSaveDir, + mountOpts, + nil, + }, + }, nil) + defer restore() + + s.testRecoverModeHappy(c) + + // we should not have written a degraded.json + c.Assert(filepath.Join(dirs.SnapBootstrapRunDir, "degraded.json"), testutil.FileAbsent) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsRecoverModeHappyEncrypted(c *C) { + s.mockProcCmdlineContent(c, "snapd_recovery_mode=recover snapd_recovery_system="+s.sysLabel) + + restore := main.MockPartitionUUIDForBootedKernelDisk("") + defer restore() + + // setup a bootloader for setting the bootenv after we are done + bloader := bootloadertest.Mock("mock", c.MkDir()) + bootloader.Force(bloader) + defer bootloader.Force(nil) + + restore = disks.MockMountPointDisksToPartitionMapping( + map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuSeedDir}: defaultEncBootDisk, + {Mountpoint: boot.InitramfsUbuntuBootDir}: defaultEncBootDisk, + { + Mountpoint: boot.InitramfsHostUbuntuDataDir, + IsDecryptedDevice: true, + }: defaultEncBootDisk, + { + Mountpoint: boot.InitramfsUbuntuSaveDir, + IsDecryptedDevice: true, + }: defaultEncBootDisk, + }, + ) + defer restore() + + dataActivated := false + restore = main.MockSecbootUnlockVolumeUsingSealedKeyIfEncrypted(func(disk disks.Disk, name string, sealedEncryptionKeyFile string, opts *secboot.UnlockVolumeUsingSealedKeyOptions) (secboot.UnlockResult, error) { + c.Assert(name, Equals, "ubuntu-data") + c.Assert(sealedEncryptionKeyFile, Equals, filepath.Join(s.tmpDir, "run/mnt/ubuntu-boot/device/fde/ubuntu-data.sealed-key")) + + encDevPartUUID, err := disk.FindMatchingPartitionUUIDWithFsLabel(name + "-enc") + c.Assert(err, IsNil) + c.Assert(encDevPartUUID, Equals, "ubuntu-data-enc-partuuid") + c.Assert(opts.AllowRecoveryKey, Equals, false) + c.Assert(opts.WhichModel, NotNil) + mod, err := opts.WhichModel() + c.Assert(err, IsNil) + c.Check(mod.Model(), Equals, "my-model") + + dataActivated = true + return happyUnlocked("ubuntu-data", secboot.UnlockedWithSealedKey), nil + }) + defer restore() + + s.mockUbuntuSaveKeyAndMarker(c, filepath.Join(dirs.GlobalRootDir, "/run/mnt/host/ubuntu-data/system-data"), "foo", "marker") + s.mockUbuntuSaveMarker(c, boot.InitramfsUbuntuSaveDir, "marker") + + saveActivated := false + restore = main.MockSecbootUnlockEncryptedVolumeUsingKey(func(disk disks.Disk, name string, key []byte) (secboot.UnlockResult, error) { + c.Check(dataActivated, Equals, true, Commentf("ubuntu-data not activated yet")) + encDevPartUUID, err := disk.FindMatchingPartitionUUIDWithFsLabel(name + "-enc") + c.Assert(err, IsNil) + c.Assert(encDevPartUUID, Equals, "ubuntu-save-enc-partuuid") + c.Assert(key, DeepEquals, []byte("foo")) + saveActivated = true + return happyUnlocked("ubuntu-save", secboot.UnlockedWithKey), nil + }) + defer restore() + + measureEpochCalls := 0 + measureModelCalls := 0 + restore = main.MockSecbootMeasureSnapSystemEpochWhenPossible(func() error { + measureEpochCalls++ + return nil + }) + defer restore() + + var measuredModel *asserts.Model + restore = main.MockSecbootMeasureSnapModelWhenPossible(func(findModel func() (*asserts.Model, error)) error { + measureModelCalls++ + var err error + measuredModel, err = findModel() + if err != nil { + return err + } + return nil + }) + defer restore() + + restore = s.mockSystemdMountSequence(c, []systemdMount{ + s.ubuntuLabelMount("ubuntu-seed", "recover"), + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + s.makeSeedSnapSystemdMount(snap.TypeGadget), + { + "tmpfs", + boot.InitramfsDataDir, + tmpfsMountOpts, + nil, + }, + { + "/dev/disk/by-partuuid/ubuntu-boot-partuuid", + boot.InitramfsUbuntuBootDir, + needsFsckDiskMountOpts, + nil, + }, + { + "/dev/mapper/ubuntu-data-random", + boot.InitramfsHostUbuntuDataDir, + needsNoSuidDiskMountOpts, + nil, + }, + { + "/dev/mapper/ubuntu-save-random", + boot.InitramfsUbuntuSaveDir, + mountOpts, + nil, + }, + }, nil) + defer restore() + + s.testRecoverModeHappy(c) + + // we should not have written a degraded.json + c.Assert(filepath.Join(dirs.SnapBootstrapRunDir, "degraded.json"), testutil.FileAbsent) + + c.Check(dataActivated, Equals, true) + c.Check(saveActivated, Equals, true) + c.Check(measureEpochCalls, Equals, 1) + c.Check(measureModelCalls, Equals, 1) + c.Check(measuredModel, DeepEquals, s.model) + + c.Assert(filepath.Join(dirs.SnapBootstrapRunDir, "secboot-epoch-measured"), testutil.FilePresent) + c.Assert(filepath.Join(dirs.SnapBootstrapRunDir, fmt.Sprintf("%s-model-measured", s.sysLabel)), testutil.FilePresent) +} + +func checkDegradedJSON(c *C, name string, exp map[string]interface{}) { + b, err := os.ReadFile(filepath.Join(dirs.SnapBootstrapRunDir, name)) + c.Assert(err, IsNil) + degradedJSONObj := make(map[string]interface{}) + err = json.Unmarshal(b, °radedJSONObj) + c.Assert(err, IsNil) + + c.Assert(degradedJSONObj, DeepEquals, exp) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsRecoverModeEncryptedDegradedDataUnlockFallbackHappy(c *C) { + s.mockProcCmdlineContent(c, "snapd_recovery_mode=recover snapd_recovery_system="+s.sysLabel) + + restore := main.MockPartitionUUIDForBootedKernelDisk("") + defer restore() + + // setup a bootloader for setting the bootenv after we are done + bloader := bootloadertest.Mock("mock", c.MkDir()) + bootloader.Force(bloader) + defer bootloader.Force(nil) + + restore = disks.MockMountPointDisksToPartitionMapping( + map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuSeedDir}: defaultEncBootDisk, + {Mountpoint: boot.InitramfsUbuntuBootDir}: defaultEncBootDisk, + { + Mountpoint: boot.InitramfsHostUbuntuDataDir, + IsDecryptedDevice: true, + }: defaultEncBootDisk, + { + Mountpoint: boot.InitramfsUbuntuSaveDir, + IsDecryptedDevice: true, + }: defaultEncBootDisk, + }, + ) + defer restore() + + dataActivated := false + saveActivated := false + unlockVolumeWithSealedKeyCalls := 0 + restore = main.MockSecbootUnlockVolumeUsingSealedKeyIfEncrypted(func(disk disks.Disk, name string, sealedEncryptionKeyFile string, opts *secboot.UnlockVolumeUsingSealedKeyOptions) (secboot.UnlockResult, error) { + unlockVolumeWithSealedKeyCalls++ + switch unlockVolumeWithSealedKeyCalls { + + case 1: + // pretend we can't unlock ubuntu-data with the main run key + c.Assert(name, Equals, "ubuntu-data") + c.Assert(sealedEncryptionKeyFile, Equals, filepath.Join(s.tmpDir, "run/mnt/ubuntu-boot/device/fde/ubuntu-data.sealed-key")) + encDevPartUUID, err := disk.FindMatchingPartitionUUIDWithFsLabel(name + "-enc") + c.Assert(err, IsNil) + c.Assert(encDevPartUUID, Equals, "ubuntu-data-enc-partuuid") + c.Assert(opts.AllowRecoveryKey, Equals, false) + c.Assert(opts.WhichModel, NotNil) + return foundEncrypted("ubuntu-data"), fmt.Errorf("failed to unlock ubuntu-data") + + case 2: + // now we can unlock ubuntu-data with the fallback key + c.Assert(name, Equals, "ubuntu-data") + c.Assert(sealedEncryptionKeyFile, Equals, filepath.Join(s.tmpDir, "run/mnt/ubuntu-seed/device/fde/ubuntu-data.recovery.sealed-key")) + encDevPartUUID, err := disk.FindMatchingPartitionUUIDWithFsLabel(name + "-enc") + c.Assert(err, IsNil) + c.Assert(encDevPartUUID, Equals, "ubuntu-data-enc-partuuid") + c.Assert(opts.AllowRecoveryKey, Equals, true) + c.Assert(opts.WhichModel, NotNil) + mod, err := opts.WhichModel() + c.Assert(err, IsNil) + c.Check(mod.Model(), Equals, "my-model") + + dataActivated = true + return happyUnlocked("ubuntu-data", secboot.UnlockedWithSealedKey), nil + default: + c.Errorf("unexpected call to UnlockVolumeUsingSealedKeyIfEncrypted (num %d)", unlockVolumeWithSealedKeyCalls) + return secboot.UnlockResult{}, fmt.Errorf("broken test") + } + }) + defer restore() + + s.mockUbuntuSaveKeyAndMarker(c, filepath.Join(dirs.GlobalRootDir, "/run/mnt/host/ubuntu-data/system-data"), "foo", "marker") + s.mockUbuntuSaveMarker(c, boot.InitramfsUbuntuSaveDir, "marker") + + restore = main.MockSecbootUnlockEncryptedVolumeUsingKey(func(disk disks.Disk, name string, key []byte) (secboot.UnlockResult, error) { + c.Check(dataActivated, Equals, true, Commentf("ubuntu-data not activated yet")) + encDevPartUUID, err := disk.FindMatchingPartitionUUIDWithFsLabel(name + "-enc") + c.Assert(err, IsNil) + c.Assert(encDevPartUUID, Equals, "ubuntu-save-enc-partuuid") + c.Assert(key, DeepEquals, []byte("foo")) + saveActivated = true + return happyUnlocked("ubuntu-save", secboot.UnlockedWithKey), nil + }) + defer restore() + + measureEpochCalls := 0 + measureModelCalls := 0 + restore = main.MockSecbootMeasureSnapSystemEpochWhenPossible(func() error { + measureEpochCalls++ + return nil + }) + defer restore() + + var measuredModel *asserts.Model + restore = main.MockSecbootMeasureSnapModelWhenPossible(func(findModel func() (*asserts.Model, error)) error { + measureModelCalls++ + var err error + measuredModel, err = findModel() + if err != nil { + return err + } + return nil + }) + defer restore() + + restore = s.mockSystemdMountSequence(c, []systemdMount{ + s.ubuntuLabelMount("ubuntu-seed", "recover"), + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + s.makeSeedSnapSystemdMount(snap.TypeGadget), + { + "tmpfs", + boot.InitramfsDataDir, + tmpfsMountOpts, + nil, + }, + { + "/dev/disk/by-partuuid/ubuntu-boot-partuuid", + boot.InitramfsUbuntuBootDir, + needsFsckDiskMountOpts, + nil, + }, + { + "/dev/mapper/ubuntu-data-random", + boot.InitramfsHostUbuntuDataDir, + needsNoSuidDiskMountOpts, + nil, + }, + { + "/dev/mapper/ubuntu-save-random", + boot.InitramfsUbuntuSaveDir, + mountOpts, + nil, + }, + }, nil) + defer restore() + + s.testRecoverModeHappy(c) + + checkDegradedJSON(c, "degraded.json", map[string]interface{}{ + "ubuntu-boot": map[string]interface{}{ + "find-state": "found", + "mount-state": "mounted", + "device": "/dev/disk/by-partuuid/ubuntu-boot-partuuid", + "mount-location": boot.InitramfsUbuntuBootDir, + }, + "ubuntu-data": map[string]interface{}{ + "device": "/dev/mapper/ubuntu-data-random", + "unlock-state": "unlocked", + "find-state": "found", + "mount-state": "mounted", + "unlock-key": "fallback", + "mount-location": boot.InitramfsHostUbuntuDataDir, + }, + "ubuntu-save": map[string]interface{}{ + "device": "/dev/mapper/ubuntu-save-random", + "unlock-key": "run", + "unlock-state": "unlocked", + "mount-state": "mounted", + "find-state": "found", + "mount-location": boot.InitramfsUbuntuSaveDir, + }, + "error-log": []interface{}{ + "cannot unlock encrypted ubuntu-data (device /dev/disk/by-partuuid/ubuntu-data-enc-partuuid) with sealed run key: failed to unlock ubuntu-data", + }, + }) + + c.Check(dataActivated, Equals, true) + c.Check(unlockVolumeWithSealedKeyCalls, Equals, 2) + c.Check(saveActivated, Equals, true) + c.Check(measureEpochCalls, Equals, 1) + c.Check(measureModelCalls, Equals, 1) + c.Check(measuredModel, DeepEquals, s.model) + + c.Assert(filepath.Join(dirs.SnapBootstrapRunDir, "secboot-epoch-measured"), testutil.FilePresent) + c.Assert(filepath.Join(dirs.SnapBootstrapRunDir, fmt.Sprintf("%s-model-measured", s.sysLabel)), testutil.FilePresent) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsRecoverModeEncryptedDegradedSaveUnlockFallbackHappy(c *C) { + s.mockProcCmdlineContent(c, "snapd_recovery_mode=recover snapd_recovery_system="+s.sysLabel) + + restore := main.MockPartitionUUIDForBootedKernelDisk("") + defer restore() + + // setup a bootloader for setting the bootenv after we are done + bloader := bootloadertest.Mock("mock", c.MkDir()) + bootloader.Force(bloader) + defer bootloader.Force(nil) + + restore = disks.MockMountPointDisksToPartitionMapping( + map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuSeedDir}: defaultEncBootDisk, + {Mountpoint: boot.InitramfsUbuntuBootDir}: defaultEncBootDisk, + { + Mountpoint: boot.InitramfsHostUbuntuDataDir, + IsDecryptedDevice: true, + }: defaultEncBootDisk, + { + Mountpoint: boot.InitramfsUbuntuSaveDir, + IsDecryptedDevice: true, + }: defaultEncBootDisk, + }, + ) + defer restore() + + dataActivated := false + saveActivationAttempted := false + unlockVolumeWithSealedKeyCalls := 0 + restore = main.MockSecbootUnlockVolumeUsingSealedKeyIfEncrypted(func(disk disks.Disk, name string, sealedEncryptionKeyFile string, opts *secboot.UnlockVolumeUsingSealedKeyOptions) (secboot.UnlockResult, error) { + unlockVolumeWithSealedKeyCalls++ + switch unlockVolumeWithSealedKeyCalls { + + case 1: + // ubuntu data can be unlocked fine + c.Assert(name, Equals, "ubuntu-data") + c.Assert(sealedEncryptionKeyFile, Equals, filepath.Join(s.tmpDir, "run/mnt/ubuntu-boot/device/fde/ubuntu-data.sealed-key")) + encDevPartUUID, err := disk.FindMatchingPartitionUUIDWithFsLabel(name + "-enc") + c.Assert(err, IsNil) + c.Assert(encDevPartUUID, Equals, "ubuntu-data-enc-partuuid") + c.Assert(opts.AllowRecoveryKey, Equals, false) + c.Assert(opts.WhichModel, NotNil) + dataActivated = true + return happyUnlocked("ubuntu-data", secboot.UnlockedWithSealedKey), nil + + case 2: + // then after ubuntu-save is attempted to be unlocked with the + // unsealed run object on the encrypted data partition, we fall back + // to using the sealed object on ubuntu-seed for save + c.Assert(saveActivationAttempted, Equals, true) + c.Assert(name, Equals, "ubuntu-save") + c.Assert(sealedEncryptionKeyFile, Equals, filepath.Join(s.tmpDir, "run/mnt/ubuntu-seed/device/fde/ubuntu-save.recovery.sealed-key")) + encDevPartUUID, err := disk.FindMatchingPartitionUUIDWithFsLabel(name + "-enc") + c.Assert(err, IsNil) + c.Assert(encDevPartUUID, Equals, "ubuntu-save-enc-partuuid") + c.Assert(opts.AllowRecoveryKey, Equals, true) + c.Assert(opts.WhichModel, NotNil) + mod, err := opts.WhichModel() + c.Assert(err, IsNil) + c.Check(mod.Model(), Equals, "my-model") + dataActivated = true + return happyUnlocked("ubuntu-save", secboot.UnlockedWithSealedKey), nil + default: + c.Errorf("unexpected call to UnlockVolumeUsingSealedKeyIfEncrypted (num %d)", unlockVolumeWithSealedKeyCalls) + return secboot.UnlockResult{}, fmt.Errorf("broken test") + } + }) + defer restore() + + s.mockUbuntuSaveKeyAndMarker(c, filepath.Join(dirs.GlobalRootDir, "/run/mnt/host/ubuntu-data/system-data"), "foo", "marker") + s.mockUbuntuSaveMarker(c, boot.InitramfsUbuntuSaveDir, "marker") + + restore = main.MockSecbootUnlockEncryptedVolumeUsingKey(func(disk disks.Disk, name string, key []byte) (secboot.UnlockResult, error) { + c.Check(dataActivated, Equals, true, Commentf("ubuntu-data not activated yet")) + encDevPartUUID, err := disk.FindMatchingPartitionUUIDWithFsLabel(name + "-enc") + c.Assert(err, IsNil) + c.Assert(encDevPartUUID, Equals, "ubuntu-save-enc-partuuid") + c.Assert(key, DeepEquals, []byte("foo")) + saveActivationAttempted = true + return foundEncrypted("ubuntu-save"), fmt.Errorf("failed to unlock ubuntu-save with run object") + }) + defer restore() + + measureEpochCalls := 0 + measureModelCalls := 0 + restore = main.MockSecbootMeasureSnapSystemEpochWhenPossible(func() error { + measureEpochCalls++ + return nil + }) + defer restore() + + var measuredModel *asserts.Model + restore = main.MockSecbootMeasureSnapModelWhenPossible(func(findModel func() (*asserts.Model, error)) error { + measureModelCalls++ + var err error + measuredModel, err = findModel() + if err != nil { + return err + } + return nil + }) + defer restore() + + restore = s.mockSystemdMountSequence(c, []systemdMount{ + s.ubuntuLabelMount("ubuntu-seed", "recover"), + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + s.makeSeedSnapSystemdMount(snap.TypeGadget), + { + "tmpfs", + boot.InitramfsDataDir, + tmpfsMountOpts, + nil, + }, + { + "/dev/disk/by-partuuid/ubuntu-boot-partuuid", + boot.InitramfsUbuntuBootDir, + needsFsckDiskMountOpts, + nil, + }, + { + "/dev/mapper/ubuntu-data-random", + boot.InitramfsHostUbuntuDataDir, + needsNoSuidDiskMountOpts, + nil, + }, + { + "/dev/mapper/ubuntu-save-random", + boot.InitramfsUbuntuSaveDir, + mountOpts, + nil, + }, + }, nil) + defer restore() + + s.testRecoverModeHappy(c) + + checkDegradedJSON(c, "degraded.json", map[string]interface{}{ + "ubuntu-boot": map[string]interface{}{ + "find-state": "found", + "mount-state": "mounted", + "device": "/dev/disk/by-partuuid/ubuntu-boot-partuuid", + "mount-location": boot.InitramfsUbuntuBootDir, + }, + "ubuntu-data": map[string]interface{}{ + "device": "/dev/mapper/ubuntu-data-random", + "unlock-state": "unlocked", + "find-state": "found", + "mount-state": "mounted", + "unlock-key": "run", + "mount-location": boot.InitramfsHostUbuntuDataDir, + }, + "ubuntu-save": map[string]interface{}{ + "device": "/dev/mapper/ubuntu-save-random", + "unlock-key": "fallback", + "unlock-state": "unlocked", + "mount-state": "mounted", + "find-state": "found", + "mount-location": boot.InitramfsUbuntuSaveDir, + }, + "error-log": []interface{}{ + "cannot unlock encrypted ubuntu-save (device /dev/disk/by-partuuid/ubuntu-save-enc-partuuid) with sealed run key: failed to unlock ubuntu-save with run object", + }, + }) + + c.Check(dataActivated, Equals, true) + c.Check(unlockVolumeWithSealedKeyCalls, Equals, 2) + c.Check(saveActivationAttempted, Equals, true) + c.Check(measureEpochCalls, Equals, 1) + c.Check(measureModelCalls, Equals, 1) + c.Check(measuredModel, DeepEquals, s.model) + + c.Assert(filepath.Join(dirs.SnapBootstrapRunDir, "secboot-epoch-measured"), testutil.FilePresent) + c.Assert(filepath.Join(dirs.SnapBootstrapRunDir, fmt.Sprintf("%s-model-measured", s.sysLabel)), testutil.FilePresent) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsRecoverModeEncryptedDegradedAbsentBootDataUnlockFallbackHappy(c *C) { + s.mockProcCmdlineContent(c, "snapd_recovery_mode=recover snapd_recovery_system="+s.sysLabel) + + restore := main.MockPartitionUUIDForBootedKernelDisk("") + defer restore() + + // setup a bootloader for setting the bootenv after we are done + bloader := bootloadertest.Mock("mock", c.MkDir()) + bootloader.Force(bloader) + defer bootloader.Force(nil) + + defaultEncDiskNoBoot := &disks.MockDiskMapping{ + Structure: []disks.Partition{ + seedPart, + // missing ubuntu-boot + dataEncPart, + saveEncPart, + }, + DiskHasPartitions: true, + DevNum: "defaultEncDevNoBoot", + } + + restore = disks.MockMountPointDisksToPartitionMapping( + map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuSeedDir}: defaultEncDiskNoBoot, + // no ubuntu-boot so we fall back to unlocking data with fallback + // key right away + { + Mountpoint: boot.InitramfsHostUbuntuDataDir, + IsDecryptedDevice: true, + }: defaultEncDiskNoBoot, + { + Mountpoint: boot.InitramfsUbuntuSaveDir, + IsDecryptedDevice: true, + }: defaultEncDiskNoBoot, + }, + ) + defer restore() + + dataActivated := false + unlockVolumeWithSealedKeyCalls := 0 + restore = main.MockSecbootUnlockVolumeUsingSealedKeyIfEncrypted(func(disk disks.Disk, name string, sealedEncryptionKeyFile string, opts *secboot.UnlockVolumeUsingSealedKeyOptions) (secboot.UnlockResult, error) { + unlockVolumeWithSealedKeyCalls++ + switch unlockVolumeWithSealedKeyCalls { + case 1: + // we skip trying to unlock with run key on ubuntu-boot and go + // directly to using the fallback key on ubuntu-seed + c.Assert(name, Equals, "ubuntu-data") + c.Assert(sealedEncryptionKeyFile, Equals, filepath.Join(s.tmpDir, "run/mnt/ubuntu-seed/device/fde/ubuntu-data.recovery.sealed-key")) + encDevPartUUID, err := disk.FindMatchingPartitionUUIDWithFsLabel(name + "-enc") + c.Assert(err, IsNil) + c.Assert(encDevPartUUID, Equals, "ubuntu-data-enc-partuuid") + c.Assert(opts.AllowRecoveryKey, Equals, true) + c.Assert(opts.WhichModel, NotNil) + dataActivated = true + return happyUnlocked("ubuntu-data", secboot.UnlockedWithSealedKey), nil + default: + c.Errorf("unexpected call to UnlockVolumeUsingSealedKeyIfEncrypted (num %d)", unlockVolumeWithSealedKeyCalls) + return secboot.UnlockResult{}, fmt.Errorf("broken test") + } + }) + defer restore() + + s.mockUbuntuSaveKeyAndMarker(c, filepath.Join(dirs.GlobalRootDir, "/run/mnt/host/ubuntu-data/system-data"), "foo", "marker") + s.mockUbuntuSaveMarker(c, boot.InitramfsUbuntuSaveDir, "marker") + + restore = main.MockSecbootUnlockEncryptedVolumeUsingKey(func(disk disks.Disk, name string, key []byte) (secboot.UnlockResult, error) { + c.Check(dataActivated, Equals, true, Commentf("ubuntu-data not activated yet")) + encDevPartUUID, err := disk.FindMatchingPartitionUUIDWithFsLabel(name + "-enc") + c.Assert(err, IsNil) + c.Assert(encDevPartUUID, Equals, "ubuntu-save-enc-partuuid") + c.Assert(key, DeepEquals, []byte("foo")) + return happyUnlocked("ubuntu-save", secboot.UnlockedWithKey), nil + }) + defer restore() + + measureEpochCalls := 0 + measureModelCalls := 0 + restore = main.MockSecbootMeasureSnapSystemEpochWhenPossible(func() error { + measureEpochCalls++ + return nil + }) + defer restore() + + var measuredModel *asserts.Model + restore = main.MockSecbootMeasureSnapModelWhenPossible(func(findModel func() (*asserts.Model, error)) error { + measureModelCalls++ + var err error + measuredModel, err = findModel() + if err != nil { + return err + } + return nil + }) + defer restore() + + restore = s.mockSystemdMountSequence(c, []systemdMount{ + s.ubuntuLabelMount("ubuntu-seed", "recover"), + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + s.makeSeedSnapSystemdMount(snap.TypeGadget), + { + "tmpfs", + boot.InitramfsDataDir, + tmpfsMountOpts, + nil, + }, + // no ubuntu-boot + { + "/dev/mapper/ubuntu-data-random", + boot.InitramfsHostUbuntuDataDir, + needsNoSuidDiskMountOpts, + nil, + }, + { + "/dev/mapper/ubuntu-save-random", + boot.InitramfsUbuntuSaveDir, + mountOpts, + nil, + }, + }, nil) + defer restore() + + s.testRecoverModeHappy(c) + + checkDegradedJSON(c, "degraded.json", map[string]interface{}{ + "ubuntu-boot": map[string]interface{}{ + "find-state": "not-found", + }, + "ubuntu-data": map[string]interface{}{ + "device": "/dev/mapper/ubuntu-data-random", + "unlock-state": "unlocked", + "find-state": "found", + "mount-state": "mounted", + "unlock-key": "fallback", + "mount-location": boot.InitramfsHostUbuntuDataDir, + }, + "ubuntu-save": map[string]interface{}{ + "device": "/dev/mapper/ubuntu-save-random", + "unlock-key": "run", + "unlock-state": "unlocked", + "mount-state": "mounted", + "find-state": "found", + "mount-location": boot.InitramfsUbuntuSaveDir, + }, + "error-log": []interface{}{ + "cannot find ubuntu-boot partition on disk defaultEncDevNoBoot", + }, + }) + + c.Check(dataActivated, Equals, true) + c.Check(unlockVolumeWithSealedKeyCalls, Equals, 1) + c.Check(measureEpochCalls, Equals, 1) + c.Check(measureModelCalls, Equals, 1) + c.Check(measuredModel, DeepEquals, s.model) + + c.Assert(filepath.Join(dirs.SnapBootstrapRunDir, "secboot-epoch-measured"), testutil.FilePresent) + c.Assert(filepath.Join(dirs.SnapBootstrapRunDir, fmt.Sprintf("%s-model-measured", s.sysLabel)), testutil.FilePresent) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsRecoverModeEncryptedDegradedAbsentBootDataUnlockRecoveryKeyHappy(c *C) { + s.mockProcCmdlineContent(c, "snapd_recovery_mode=recover snapd_recovery_system="+s.sysLabel) + + restore := main.MockPartitionUUIDForBootedKernelDisk("") + defer restore() + + // setup a bootloader for setting the bootenv after we are done + bloader := bootloadertest.Mock("mock", c.MkDir()) + bootloader.Force(bloader) + defer bootloader.Force(nil) + + defaultEncDiskNoBoot := &disks.MockDiskMapping{ + Structure: []disks.Partition{ + seedPart, + // missing ubuntu-boot + dataEncPart, + saveEncPart, + }, + DiskHasPartitions: true, + DevNum: "defaultEncDevNoBoot", + } + + restore = disks.MockMountPointDisksToPartitionMapping( + map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuSeedDir}: defaultEncDiskNoBoot, + // no ubuntu-boot so we fall back to unlocking data with fallback + // key right away + { + Mountpoint: boot.InitramfsHostUbuntuDataDir, + IsDecryptedDevice: true, + }: defaultEncDiskNoBoot, + { + Mountpoint: boot.InitramfsUbuntuSaveDir, + IsDecryptedDevice: true, + }: defaultEncDiskNoBoot, + }, + ) + defer restore() + + dataActivated := false + unlockVolumeWithSealedKeyCalls := 0 + restore = main.MockSecbootUnlockVolumeUsingSealedKeyIfEncrypted(func(disk disks.Disk, name string, sealedEncryptionKeyFile string, opts *secboot.UnlockVolumeUsingSealedKeyOptions) (secboot.UnlockResult, error) { + unlockVolumeWithSealedKeyCalls++ + switch unlockVolumeWithSealedKeyCalls { + case 1: + // we skip trying to unlock with run key on ubuntu-boot and go + // directly to using the fallback key on ubuntu-seed + c.Assert(name, Equals, "ubuntu-data") + c.Assert(sealedEncryptionKeyFile, Equals, filepath.Join(s.tmpDir, "run/mnt/ubuntu-seed/device/fde/ubuntu-data.recovery.sealed-key")) + encDevPartUUID, err := disk.FindMatchingPartitionUUIDWithFsLabel(name + "-enc") + c.Assert(err, IsNil) + c.Assert(encDevPartUUID, Equals, "ubuntu-data-enc-partuuid") + c.Assert(opts.AllowRecoveryKey, Equals, true) + c.Assert(opts.WhichModel, NotNil) + dataActivated = true + // it was unlocked with a recovery key + + return happyUnlocked("ubuntu-data", secboot.UnlockedWithRecoveryKey), nil + default: + c.Errorf("unexpected call to UnlockVolumeUsingSealedKeyIfEncrypted (num %d)", unlockVolumeWithSealedKeyCalls) + return secboot.UnlockResult{}, fmt.Errorf("broken test") + } + }) + defer restore() + + s.mockUbuntuSaveKeyAndMarker(c, filepath.Join(dirs.GlobalRootDir, "/run/mnt/host/ubuntu-data/system-data"), "foo", "marker") + s.mockUbuntuSaveMarker(c, boot.InitramfsUbuntuSaveDir, "marker") + + restore = main.MockSecbootUnlockEncryptedVolumeUsingKey(func(disk disks.Disk, name string, key []byte) (secboot.UnlockResult, error) { + c.Check(dataActivated, Equals, true, Commentf("ubuntu-data not activated yet")) + encDevPartUUID, err := disk.FindMatchingPartitionUUIDWithFsLabel(name + "-enc") + c.Assert(err, IsNil) + c.Assert(encDevPartUUID, Equals, "ubuntu-save-enc-partuuid") + c.Assert(key, DeepEquals, []byte("foo")) + return happyUnlocked("ubuntu-save", secboot.UnlockedWithKey), nil + }) + defer restore() + + measureEpochCalls := 0 + measureModelCalls := 0 + restore = main.MockSecbootMeasureSnapSystemEpochWhenPossible(func() error { + measureEpochCalls++ + return nil + }) + defer restore() + + var measuredModel *asserts.Model + restore = main.MockSecbootMeasureSnapModelWhenPossible(func(findModel func() (*asserts.Model, error)) error { + measureModelCalls++ + var err error + measuredModel, err = findModel() + if err != nil { + return err + } + return nil + }) + defer restore() + + restore = s.mockSystemdMountSequence(c, []systemdMount{ + s.ubuntuLabelMount("ubuntu-seed", "recover"), + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + s.makeSeedSnapSystemdMount(snap.TypeGadget), + { + "tmpfs", + boot.InitramfsDataDir, + tmpfsMountOpts, + nil, + }, + // no ubuntu-boot + { + "/dev/mapper/ubuntu-data-random", + boot.InitramfsHostUbuntuDataDir, + needsNoSuidDiskMountOpts, + nil, + }, + { + "/dev/mapper/ubuntu-save-random", + boot.InitramfsUbuntuSaveDir, + mountOpts, + nil, + }, + }, nil) + defer restore() + + s.testRecoverModeHappy(c) + + checkDegradedJSON(c, "degraded.json", map[string]interface{}{ + "ubuntu-boot": map[string]interface{}{ + "find-state": "not-found", + }, + "ubuntu-data": map[string]interface{}{ + "device": "/dev/mapper/ubuntu-data-random", + "unlock-state": "unlocked", + "find-state": "found", + "mount-state": "mounted", + "unlock-key": "recovery", + "mount-location": boot.InitramfsHostUbuntuDataDir, + }, + "ubuntu-save": map[string]interface{}{ + "device": "/dev/mapper/ubuntu-save-random", + "unlock-key": "run", + "unlock-state": "unlocked", + "mount-state": "mounted", + "find-state": "found", + "mount-location": boot.InitramfsUbuntuSaveDir, + }, + "error-log": []interface{}{ + "cannot find ubuntu-boot partition on disk defaultEncDevNoBoot", + }, + }) + + c.Check(dataActivated, Equals, true) + c.Check(unlockVolumeWithSealedKeyCalls, Equals, 1) + c.Check(measureEpochCalls, Equals, 1) + c.Check(measureModelCalls, Equals, 1) + c.Check(measuredModel, DeepEquals, s.model) + + c.Assert(filepath.Join(dirs.SnapBootstrapRunDir, "secboot-epoch-measured"), testutil.FilePresent) + c.Assert(filepath.Join(dirs.SnapBootstrapRunDir, fmt.Sprintf("%s-model-measured", s.sysLabel)), testutil.FilePresent) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsRecoverModeEncryptedDegradedDataUnlockFailSaveUnlockFallbackHappy(c *C) { + // test a scenario when unsealing of data fails with both the run key + // and fallback key, but save can be unlocked using the fallback key + + s.mockProcCmdlineContent(c, "snapd_recovery_mode=recover snapd_recovery_system="+s.sysLabel) + + restore := main.MockPartitionUUIDForBootedKernelDisk("") + defer restore() + + // setup a bootloader for setting the bootenv after we are done + bloader := bootloadertest.Mock("mock", c.MkDir()) + bootloader.Force(bloader) + defer bootloader.Force(nil) + + restore = disks.MockMountPointDisksToPartitionMapping( + map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuSeedDir}: defaultEncBootDisk, + {Mountpoint: boot.InitramfsUbuntuBootDir}: defaultEncBootDisk, + { + Mountpoint: boot.InitramfsUbuntuSaveDir, + IsDecryptedDevice: true, + }: defaultEncBootDisk, + }, + ) + defer restore() + + dataActivationAttempts := 0 + saveActivated := false + unlockVolumeWithSealedKeyCalls := 0 + restore = main.MockSecbootUnlockVolumeUsingSealedKeyIfEncrypted(func(disk disks.Disk, name string, sealedEncryptionKeyFile string, opts *secboot.UnlockVolumeUsingSealedKeyOptions) (secboot.UnlockResult, error) { + unlockVolumeWithSealedKeyCalls++ + switch unlockVolumeWithSealedKeyCalls { + + case 1: + // ubuntu data can't be unlocked with run key + c.Assert(name, Equals, "ubuntu-data") + c.Assert(sealedEncryptionKeyFile, Equals, filepath.Join(s.tmpDir, "run/mnt/ubuntu-boot/device/fde/ubuntu-data.sealed-key")) + encDevPartUUID, err := disk.FindMatchingPartitionUUIDWithFsLabel(name + "-enc") + c.Assert(err, IsNil) + c.Assert(encDevPartUUID, Equals, "ubuntu-data-enc-partuuid") + c.Assert(opts.AllowRecoveryKey, Equals, false) + c.Assert(opts.WhichModel, NotNil) + dataActivationAttempts++ + return foundEncrypted("ubuntu-data"), fmt.Errorf("failed to unlock ubuntu-data with run object") + + case 2: + // nor can it be unlocked with fallback key + c.Assert(name, Equals, "ubuntu-data") + c.Assert(sealedEncryptionKeyFile, Equals, filepath.Join(s.tmpDir, "run/mnt/ubuntu-seed/device/fde/ubuntu-data.recovery.sealed-key")) + encDevPartUUID, err := disk.FindMatchingPartitionUUIDWithFsLabel(name + "-enc") + c.Assert(err, IsNil) + c.Assert(encDevPartUUID, Equals, "ubuntu-data-enc-partuuid") + c.Assert(opts.AllowRecoveryKey, Equals, true) + c.Assert(opts.WhichModel, NotNil) + dataActivationAttempts++ + return foundEncrypted("ubuntu-data"), fmt.Errorf("failed to unlock ubuntu-data with fallback object") + + case 3: + // we can however still unlock ubuntu-save (somehow?) + c.Assert(name, Equals, "ubuntu-save") + c.Assert(sealedEncryptionKeyFile, Equals, filepath.Join(s.tmpDir, "run/mnt/ubuntu-seed/device/fde/ubuntu-save.recovery.sealed-key")) + encDevPartUUID, err := disk.FindMatchingPartitionUUIDWithFsLabel(name + "-enc") + c.Assert(err, IsNil) + c.Assert(encDevPartUUID, Equals, "ubuntu-save-enc-partuuid") + c.Assert(opts.AllowRecoveryKey, Equals, true) + c.Assert(opts.WhichModel, NotNil) + saveActivated = true + return happyUnlocked("ubuntu-save", secboot.UnlockedWithSealedKey), nil + default: + c.Errorf("unexpected call to UnlockVolumeUsingSealedKeyIfEncrypted (num %d)", unlockVolumeWithSealedKeyCalls) + return secboot.UnlockResult{}, fmt.Errorf("broken test") + } + }) + defer restore() + + s.mockUbuntuSaveKeyAndMarker(c, filepath.Join(dirs.GlobalRootDir, "/run/mnt/host/ubuntu-data/system-data"), "foo", "") + + restore = main.MockSecbootUnlockEncryptedVolumeUsingKey(func(disk disks.Disk, name string, key []byte) (secboot.UnlockResult, error) { + // nothing can call this function in the tested scenario + c.Fatalf("unexpected call") + return secboot.UnlockResult{}, fmt.Errorf("unexpected call") + }) + defer restore() + + measureEpochCalls := 0 + measureModelCalls := 0 + restore = main.MockSecbootMeasureSnapSystemEpochWhenPossible(func() error { + measureEpochCalls++ + return nil + }) + defer restore() + + var measuredModel *asserts.Model + restore = main.MockSecbootMeasureSnapModelWhenPossible(func(findModel func() (*asserts.Model, error)) error { + measureModelCalls++ + var err error + measuredModel, err = findModel() + if err != nil { + return err + } + return nil + }) + defer restore() + + restore = s.mockSystemdMountSequence(c, []systemdMount{ + s.ubuntuLabelMount("ubuntu-seed", "recover"), + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + s.makeSeedSnapSystemdMount(snap.TypeGadget), + { + "tmpfs", + boot.InitramfsDataDir, + tmpfsMountOpts, + nil, + }, + { + "/dev/disk/by-partuuid/ubuntu-boot-partuuid", + boot.InitramfsUbuntuBootDir, + needsFsckDiskMountOpts, + nil, + }, + { + "/dev/mapper/ubuntu-save-random", + boot.InitramfsUbuntuSaveDir, + mountOpts, + nil, + }, + }, nil) + defer restore() + + // ensure that we check that access to sealed keys were locked + sealedKeysLocked := false + restore = main.MockSecbootLockSealedKeys(func() error { + sealedKeysLocked = true + return nil + }) + defer restore() + + _, err := main.Parser().ParseArgs([]string{"initramfs-mounts"}) + c.Assert(err, IsNil) + + // we always need to lock access to sealed keys + c.Check(sealedKeysLocked, Equals, true) + + modeEnv := filepath.Join(filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data"), "var/lib/snapd/modeenv") + c.Check(modeEnv, testutil.FileEquals, `mode=recover +recovery_system=20191118 +base=core20_1.snap +gadget=pc_1.snap +model=my-brand/my-model +grade=signed +`) + + checkDegradedJSON(c, "degraded.json", map[string]interface{}{ + "ubuntu-boot": map[string]interface{}{ + "device": "/dev/disk/by-partuuid/ubuntu-boot-partuuid", + "mount-state": "mounted", + "find-state": "found", + "mount-location": boot.InitramfsUbuntuBootDir, + }, + "ubuntu-data": map[string]interface{}{ + "find-state": "found", + "device": "/dev/disk/by-partuuid/ubuntu-data-enc-partuuid", + "unlock-state": "error-unlocking", + }, + "ubuntu-save": map[string]interface{}{ + "device": "/dev/mapper/ubuntu-save-random", + "unlock-key": "fallback", + "unlock-state": "unlocked", + "mount-state": "mounted", + "find-state": "found", + "mount-location": boot.InitramfsUbuntuSaveDir, + }, + "error-log": []interface{}{ + "cannot unlock encrypted ubuntu-data (device /dev/disk/by-partuuid/ubuntu-data-enc-partuuid) with sealed run key: failed to unlock ubuntu-data with run object", + "cannot unlock encrypted ubuntu-data partition with sealed fallback key: failed to unlock ubuntu-data with fallback object", + }, + }) + + bloader2, err := bootloader.Find("", nil) + c.Assert(err, IsNil) + m, err := bloader2.GetBootVars("snapd_recovery_system", "snapd_recovery_mode") + c.Assert(err, IsNil) + c.Assert(m, DeepEquals, map[string]string{ + "snapd_recovery_system": "20191118", + "snapd_recovery_mode": "run", + }) + + // since we didn't mount data at all, we won't have copied in files from + // there and instead will copy safe defaults to the ephemeral data + c.Assert(filepath.Join(boot.InitramfsRunMntDir, "/data/system-data/var/lib/console-conf/complete"), testutil.FilePresent) + + c.Check(dataActivationAttempts, Equals, 2) + c.Check(saveActivated, Equals, true) + c.Check(unlockVolumeWithSealedKeyCalls, Equals, 3) + c.Check(measureEpochCalls, Equals, 1) + c.Check(measureModelCalls, Equals, 1) + c.Check(measuredModel, DeepEquals, s.model) + + c.Assert(filepath.Join(dirs.SnapBootstrapRunDir, "secboot-epoch-measured"), testutil.FilePresent) + c.Assert(filepath.Join(dirs.SnapBootstrapRunDir, fmt.Sprintf("%s-model-measured", s.sysLabel)), testutil.FilePresent) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsRecoverModeDegradedAbsentDataUnencryptedSaveHappy(c *C) { + // test a scenario when data cannot be found but unencrypted save can be + // mounted + + s.mockProcCmdlineContent(c, "snapd_recovery_mode=recover snapd_recovery_system="+s.sysLabel) + + restore := main.MockPartitionUUIDForBootedKernelDisk("") + defer restore() + + // setup a bootloader for setting the bootenv after we are done + bloader := bootloadertest.Mock("mock", c.MkDir()) + bootloader.Force(bloader) + defer bootloader.Force(nil) + + // no ubuntu-data on the disk at all + mockDiskNoData := &disks.MockDiskMapping{ + Structure: []disks.Partition{ + seedPart, + bootPart, + savePart, + }, + DiskHasPartitions: true, + DevNum: "noDataUnenc", + } + + restore = disks.MockMountPointDisksToPartitionMapping( + map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuSeedDir}: mockDiskNoData, + {Mountpoint: boot.InitramfsUbuntuBootDir}: mockDiskNoData, + {Mountpoint: boot.InitramfsUbuntuSaveDir}: mockDiskNoData, + }, + ) + defer restore() + + dataActivated := false + unlockVolumeWithSealedKeyCalls := 0 + restore = main.MockSecbootUnlockVolumeUsingSealedKeyIfEncrypted(func(disk disks.Disk, name string, sealedEncryptionKeyFile string, opts *secboot.UnlockVolumeUsingSealedKeyOptions) (secboot.UnlockResult, error) { + unlockVolumeWithSealedKeyCalls++ + switch unlockVolumeWithSealedKeyCalls { + + case 1: + // ubuntu data can't be found at all + c.Assert(name, Equals, "ubuntu-data") + c.Assert(sealedEncryptionKeyFile, Equals, filepath.Join(s.tmpDir, "run/mnt/ubuntu-boot/device/fde/ubuntu-data.sealed-key")) + _, err := disk.FindMatchingPartitionUUIDWithFsLabel(name + "-enc") + c.Assert(err, FitsTypeOf, disks.PartitionNotFoundError{}) + c.Assert(opts.AllowRecoveryKey, Equals, false) + c.Assert(opts.WhichModel, NotNil) + // validity check that we can't find a normal ubuntu-data either + _, err = disk.FindMatchingPartitionUUIDWithFsLabel(name) + c.Assert(err, FitsTypeOf, disks.PartitionNotFoundError{}) + dataActivated = true + // data not found at all + return notFoundPart(), fmt.Errorf("error enumerating to find ubuntu-data") + default: + c.Errorf("unexpected call to UnlockVolumeUsingSealedKeyIfEncrypted (num %d)", unlockVolumeWithSealedKeyCalls) + return secboot.UnlockResult{}, fmt.Errorf("broken test") + } + }) + defer restore() + + s.mockUbuntuSaveKeyAndMarker(c, filepath.Join(dirs.GlobalRootDir, "/run/mnt/host/ubuntu-data/system-data"), "foo", "") + + restore = main.MockSecbootUnlockEncryptedVolumeUsingKey(func(disk disks.Disk, name string, key []byte) (secboot.UnlockResult, error) { + // nothing can call this function in the tested scenario + c.Fatalf("unexpected call") + return secboot.UnlockResult{}, fmt.Errorf("unexpected call") + }) + defer restore() + + measureEpochCalls := 0 + measureModelCalls := 0 + restore = main.MockSecbootMeasureSnapSystemEpochWhenPossible(func() error { + measureEpochCalls++ + return nil + }) + defer restore() + + var measuredModel *asserts.Model + restore = main.MockSecbootMeasureSnapModelWhenPossible(func(findModel func() (*asserts.Model, error)) error { + measureModelCalls++ + var err error + measuredModel, err = findModel() + if err != nil { + return err + } + return nil + }) + defer restore() + + restore = s.mockSystemdMountSequence(c, []systemdMount{ + s.ubuntuLabelMount("ubuntu-seed", "recover"), + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + s.makeSeedSnapSystemdMount(snap.TypeGadget), + { + "tmpfs", + boot.InitramfsDataDir, + tmpfsMountOpts, + nil, + }, + { + "/dev/disk/by-partuuid/ubuntu-boot-partuuid", + boot.InitramfsUbuntuBootDir, + needsFsckDiskMountOpts, + nil, + }, + { + "/dev/disk/by-partuuid/ubuntu-save-partuuid", + boot.InitramfsUbuntuSaveDir, + mountOpts, + nil, + }, + }, nil) + defer restore() + + // ensure that we check that access to sealed keys were locked + sealedKeysLocked := false + restore = main.MockSecbootLockSealedKeys(func() error { + sealedKeysLocked = true + return nil + }) + defer restore() + + _, err := main.Parser().ParseArgs([]string{"initramfs-mounts"}) + c.Assert(err, IsNil) + + // we always need to lock access to sealed keys + c.Check(sealedKeysLocked, Equals, true) + + modeEnv := filepath.Join(filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data"), "var/lib/snapd/modeenv") + c.Check(modeEnv, testutil.FileEquals, `mode=recover +recovery_system=20191118 +base=core20_1.snap +gadget=pc_1.snap +model=my-brand/my-model +grade=signed +`) + + checkDegradedJSON(c, "degraded.json", map[string]interface{}{ + "ubuntu-boot": map[string]interface{}{ + "device": "/dev/disk/by-partuuid/ubuntu-boot-partuuid", + "mount-state": "mounted", + "find-state": "found", + "mount-location": boot.InitramfsUbuntuBootDir, + }, + "ubuntu-data": map[string]interface{}{ + "find-state": "not-found", + }, + "ubuntu-save": map[string]interface{}{ + "device": "/dev/disk/by-partuuid/ubuntu-save-partuuid", + "mount-state": "mounted", + "find-state": "found", + "mount-location": boot.InitramfsUbuntuSaveDir, + }, + "error-log": []interface{}{ + "cannot locate ubuntu-data partition for mounting host data: error enumerating to find ubuntu-data", + }, + }) + + bloader2, err := bootloader.Find("", nil) + c.Assert(err, IsNil) + m, err := bloader2.GetBootVars("snapd_recovery_system", "snapd_recovery_mode") + c.Assert(err, IsNil) + c.Assert(m, DeepEquals, map[string]string{ + "snapd_recovery_system": "20191118", + "snapd_recovery_mode": "run", + }) + + // since we didn't mount data at all, we won't have copied in files from + // there and instead will copy safe defaults to the ephemeral data + c.Assert(filepath.Join(boot.InitramfsRunMntDir, "/data/system-data/var/lib/console-conf/complete"), testutil.FilePresent) + + c.Check(dataActivated, Equals, true) + // unlocked tried only once, when attempting to set up ubuntu-data + c.Check(unlockVolumeWithSealedKeyCalls, Equals, 1) + c.Check(measureEpochCalls, Equals, 1) + c.Check(measureModelCalls, Equals, 1) + c.Check(measuredModel, DeepEquals, s.model) + + c.Assert(filepath.Join(dirs.SnapBootstrapRunDir, "secboot-epoch-measured"), testutil.FilePresent) + c.Assert(filepath.Join(dirs.SnapBootstrapRunDir, fmt.Sprintf("%s-model-measured", s.sysLabel)), testutil.FilePresent) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsRecoverModeDegradedUnencryptedDataSaveEncryptedHappy(c *C) { + // test a rather impossible scenario when data is unencrypted, but save + // is encrypted and thus gets completely ignored, because plain data + // implies plain save + s.mockProcCmdlineContent(c, "snapd_recovery_mode=recover snapd_recovery_system="+s.sysLabel) + + restore := main.MockPartitionUUIDForBootedKernelDisk("") + defer restore() + + // setup a bootloader for setting the bootenv after we are done + bloader := bootloadertest.Mock("mock", c.MkDir()) + bootloader.Force(bloader) + defer bootloader.Force(nil) + + // no ubuntu-data on the disk at all + mockDiskDataUnencSaveEnc := &disks.MockDiskMapping{ + Structure: []disks.Partition{ + seedPart, + bootPart, + // ubuntu-data is unencrypted but ubuntu-save is encrypted + dataPart, + saveEncPart, + }, + DiskHasPartitions: true, + DevNum: "dataUnencSaveEnc", + } + + restore = disks.MockMountPointDisksToPartitionMapping( + map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuSeedDir}: mockDiskDataUnencSaveEnc, + {Mountpoint: boot.InitramfsUbuntuBootDir}: mockDiskDataUnencSaveEnc, + {Mountpoint: boot.InitramfsHostUbuntuDataDir}: mockDiskDataUnencSaveEnc, + // we don't include the mountpoint for ubuntu-save, since it should + // never be mounted + }, + ) + defer restore() + + dataActivated := false + unlockVolumeWithSealedKeyCalls := 0 + restore = main.MockSecbootUnlockVolumeUsingSealedKeyIfEncrypted(func(disk disks.Disk, name string, sealedEncryptionKeyFile string, opts *secboot.UnlockVolumeUsingSealedKeyOptions) (secboot.UnlockResult, error) { + unlockVolumeWithSealedKeyCalls++ + switch unlockVolumeWithSealedKeyCalls { + + case 1: + // ubuntu data is a plain old unencrypted partition + c.Assert(name, Equals, "ubuntu-data") + c.Assert(sealedEncryptionKeyFile, Equals, filepath.Join(s.tmpDir, "run/mnt/ubuntu-boot/device/fde/ubuntu-data.sealed-key")) + _, err := disk.FindMatchingPartitionUUIDWithFsLabel(name + "-enc") + c.Assert(err, FitsTypeOf, disks.PartitionNotFoundError{}) + c.Assert(opts.AllowRecoveryKey, Equals, false) + c.Assert(opts.WhichModel, NotNil) + // validity check that we can't find a normal ubuntu-data either + partUUID, err := disk.FindMatchingPartitionUUIDWithFsLabel(name) + c.Assert(err, IsNil) + c.Assert(partUUID, Equals, "ubuntu-data-partuuid") + dataActivated = true + + return foundUnencrypted("ubuntu-data"), nil + default: + // no other partition is activated via secboot calls + c.Errorf("unexpected call to UnlockVolumeUsingSealedKeyIfEncrypted (num %d)", unlockVolumeWithSealedKeyCalls) + return secboot.UnlockResult{}, fmt.Errorf("broken test") + } + }) + defer restore() + + s.mockUbuntuSaveKeyAndMarker(c, filepath.Join(dirs.GlobalRootDir, "/run/mnt/host/ubuntu-data/system-data"), "foo", "") + + restore = main.MockSecbootUnlockEncryptedVolumeUsingKey(func(disk disks.Disk, name string, key []byte) (secboot.UnlockResult, error) { + // nothing can call this function in the tested scenario + c.Fatalf("unexpected call") + return secboot.UnlockResult{}, fmt.Errorf("unexpected call") + }) + defer restore() + + measureEpochCalls := 0 + measureModelCalls := 0 + restore = main.MockSecbootMeasureSnapSystemEpochWhenPossible(func() error { + measureEpochCalls++ + return nil + }) + defer restore() + + var measuredModel *asserts.Model + restore = main.MockSecbootMeasureSnapModelWhenPossible(func(findModel func() (*asserts.Model, error)) error { + measureModelCalls++ + var err error + measuredModel, err = findModel() + if err != nil { + return err + } + return nil + }) + defer restore() + + restore = s.mockSystemdMountSequence(c, []systemdMount{ + s.ubuntuLabelMount("ubuntu-seed", "recover"), + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + s.makeSeedSnapSystemdMount(snap.TypeGadget), + { + "tmpfs", + boot.InitramfsDataDir, + tmpfsMountOpts, + nil, + }, + { + "/dev/disk/by-partuuid/ubuntu-boot-partuuid", + boot.InitramfsUbuntuBootDir, + needsFsckDiskMountOpts, + nil, + }, + { + "/dev/disk/by-partuuid/ubuntu-data-partuuid", + boot.InitramfsHostUbuntuDataDir, + needsNoSuidDiskMountOpts, + nil, + }, + }, nil) + defer restore() + + s.testRecoverModeHappy(c) + + c.Check(dataActivated, Equals, true) + c.Check(unlockVolumeWithSealedKeyCalls, Equals, 1) + c.Check(measureEpochCalls, Equals, 1) + c.Check(measureModelCalls, Equals, 1) + c.Check(measuredModel, DeepEquals, s.model) + + c.Assert(filepath.Join(dirs.SnapBootstrapRunDir, "secboot-epoch-measured"), testutil.FilePresent) + c.Assert(filepath.Join(dirs.SnapBootstrapRunDir, fmt.Sprintf("%s-model-measured", s.sysLabel)), testutil.FilePresent) + + // the system is not encrypted, even if encrypted save exists it gets + // ignored + c.Check(s.logs.String(), testutil.Contains, "ignoring unexpected encrypted ubuntu-save") +} + +func (s *initramfsMountsSuite) TestInitramfsMountsRecoverModeDegradedEncryptedDataUnencryptedSaveHappy(c *C) { + // test a scenario when data is encrypted, thus implying an encrypted + // ubuntu save, but save found on the disk is unencrypted + s.mockProcCmdlineContent(c, "snapd_recovery_mode=recover snapd_recovery_system="+s.sysLabel) + + restore := main.MockPartitionUUIDForBootedKernelDisk("") + defer restore() + + // setup a bootloader for setting the bootenv after we are done + bloader := bootloadertest.Mock("mock", c.MkDir()) + bootloader.Force(bloader) + defer bootloader.Force(nil) + + mockDiskDataUnencSaveEnc := &disks.MockDiskMapping{ + Structure: []disks.Partition{ + seedPart, + bootPart, + // ubuntu-data is encrypted but ubuntu-save is not + savePart, + dataEncPart, + }, + DiskHasPartitions: true, + DevNum: "dataUnencSaveEnc", + } + + restore = disks.MockMountPointDisksToPartitionMapping( + map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuSeedDir}: mockDiskDataUnencSaveEnc, + {Mountpoint: boot.InitramfsUbuntuBootDir}: mockDiskDataUnencSaveEnc, + {Mountpoint: boot.InitramfsHostUbuntuDataDir}: mockDiskDataUnencSaveEnc, + // we don't include the mountpoint for ubuntu-save, since it should + // never be mounted - we fail as soon as we find the encrypted save + // and unlock it, but before we mount it + }, + ) + defer restore() + + unlockVolumeWithSealedKeyCalls := 0 + restore = main.MockSecbootUnlockVolumeUsingSealedKeyIfEncrypted(func(disk disks.Disk, name string, sealedEncryptionKeyFile string, opts *secboot.UnlockVolumeUsingSealedKeyOptions) (secboot.UnlockResult, error) { + unlockVolumeWithSealedKeyCalls++ + switch unlockVolumeWithSealedKeyCalls { + + case 1: + // ubuntu-data is encrypted partition + c.Assert(name, Equals, "ubuntu-data") + c.Assert(sealedEncryptionKeyFile, Equals, filepath.Join(s.tmpDir, "run/mnt/ubuntu-boot/device/fde/ubuntu-data.sealed-key")) + _, err := disk.FindMatchingPartitionUUIDWithFsLabel(name + "-enc") + c.Assert(err, IsNil) + // validity check that we can't find a normal ubuntu-data either + _, err = disk.FindMatchingPartitionUUIDWithFsLabel(name) + c.Assert(err, FitsTypeOf, disks.PartitionNotFoundError{}) + return foundEncrypted("ubuntu-data"), fmt.Errorf("failed to unlock ubuntu-data with run object") + case 2: + c.Assert(name, Equals, "ubuntu-data") + c.Assert(sealedEncryptionKeyFile, Equals, filepath.Join(s.tmpDir, "run/mnt/ubuntu-seed/device/fde/ubuntu-data.recovery.sealed-key")) + return foundEncrypted("ubuntu-data"), fmt.Errorf("failed to unlock ubuntu-data with recovery object") + case 3: + // we are asked to unlock encrypted ubuntu-save with the recovery key + c.Assert(name, Equals, "ubuntu-save") + c.Assert(sealedEncryptionKeyFile, Equals, filepath.Join(s.tmpDir, "run/mnt/ubuntu-seed/device/fde/ubuntu-save.recovery.sealed-key")) + _, err := disk.FindMatchingPartitionUUIDWithFsLabel(name) + c.Assert(err, IsNil) + _, err = disk.FindMatchingPartitionUUIDWithFsLabel(name + "-enc") + // validity + c.Assert(err, FitsTypeOf, disks.PartitionNotFoundError{}) + // but we find an unencrypted one instead + return foundUnencrypted("ubuntu-save"), nil + default: + c.Errorf("unexpected call to UnlockVolumeUsingSealedKeyIfEncrypted (num %d)", unlockVolumeWithSealedKeyCalls) + return secboot.UnlockResult{}, fmt.Errorf("broken test") + } + }) + defer restore() + + s.mockUbuntuSaveKeyAndMarker(c, filepath.Join(dirs.GlobalRootDir, "/run/mnt/host/ubuntu-data/system-data"), "foo", "") + + restore = main.MockSecbootUnlockEncryptedVolumeUsingKey(func(disk disks.Disk, name string, key []byte) (secboot.UnlockResult, error) { + // nothing can call this function in the tested scenario + c.Fatalf("unexpected call") + return secboot.UnlockResult{}, fmt.Errorf("unexpected call") + }) + defer restore() + + measureEpochCalls := 0 + measureModelCalls := 0 + restore = main.MockSecbootMeasureSnapSystemEpochWhenPossible(func() error { + measureEpochCalls++ + return nil + }) + defer restore() + + var measuredModel *asserts.Model + restore = main.MockSecbootMeasureSnapModelWhenPossible(func(findModel func() (*asserts.Model, error)) error { + measureModelCalls++ + var err error + measuredModel, err = findModel() + if err != nil { + return err + } + return nil + }) + defer restore() + + restore = s.mockSystemdMountSequence(c, []systemdMount{ + s.ubuntuLabelMount("ubuntu-seed", "recover"), + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + s.makeSeedSnapSystemdMount(snap.TypeGadget), + { + "tmpfs", + boot.InitramfsDataDir, + tmpfsMountOpts, + nil, + }, + { + "/dev/disk/by-partuuid/ubuntu-boot-partuuid", + boot.InitramfsUbuntuBootDir, + needsFsckDiskMountOpts, + nil, + }, + }, nil) + defer restore() + + // ensure that we check that access to sealed keys were locked + sealedKeysLocked := false + restore = main.MockSecbootLockSealedKeys(func() error { + sealedKeysLocked = true + return nil + }) + defer restore() + + _, err := main.Parser().ParseArgs([]string{"initramfs-mounts"}) + c.Assert(err, ErrorMatches, `inconsistent disk encryption status: previous access resulted in encrypted, but now is unencrypted from partition ubuntu-save`) + + // we always need to lock access to sealed keys + c.Check(sealedKeysLocked, Equals, true) + + // unlocking tried 3 times, first attempt tries to unlock ubuntu-data + // with run key, then the recovery key, and lastly we tried to unlock + // ubuntu-save with the recovery key + c.Check(unlockVolumeWithSealedKeyCalls, Equals, 3) + c.Check(measureEpochCalls, Equals, 1) + c.Check(measureModelCalls, Equals, 1) + c.Check(measuredModel, DeepEquals, s.model) + + c.Assert(filepath.Join(dirs.SnapBootstrapRunDir, "secboot-epoch-measured"), testutil.FilePresent) + c.Assert(filepath.Join(dirs.SnapBootstrapRunDir, fmt.Sprintf("%s-model-measured", s.sysLabel)), testutil.FilePresent) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsRecoverModeUnencryptedDataUnencryptedSaveHappy(c *C) { + // test a scenario when data is unencrypted, same goes for save and the + // test observes calls to secboot unlock helper + s.mockProcCmdlineContent(c, "snapd_recovery_mode=recover snapd_recovery_system="+s.sysLabel) + + restore := main.MockPartitionUUIDForBootedKernelDisk("") + defer restore() + + // setup a bootloader for setting the bootenv after we are done + bloader := bootloadertest.Mock("mock", c.MkDir()) + bootloader.Force(bloader) + defer bootloader.Force(nil) + + restore = disks.MockMountPointDisksToPartitionMapping( + map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuSeedDir}: defaultBootWithSaveDisk, + {Mountpoint: boot.InitramfsUbuntuBootDir}: defaultBootWithSaveDisk, + {Mountpoint: boot.InitramfsHostUbuntuDataDir}: defaultBootWithSaveDisk, + {Mountpoint: boot.InitramfsUbuntuSaveDir}: defaultBootWithSaveDisk, + }, + ) + defer restore() + + unlockVolumeWithSealedKeyCalls := 0 + restore = main.MockSecbootUnlockVolumeUsingSealedKeyIfEncrypted(func(disk disks.Disk, name string, sealedEncryptionKeyFile string, opts *secboot.UnlockVolumeUsingSealedKeyOptions) (secboot.UnlockResult, error) { + unlockVolumeWithSealedKeyCalls++ + switch unlockVolumeWithSealedKeyCalls { + + case 1: + // ubuntu data is an unencrypted partition + c.Assert(name, Equals, "ubuntu-data") + c.Assert(sealedEncryptionKeyFile, Equals, filepath.Join(s.tmpDir, "run/mnt/ubuntu-boot/device/fde/ubuntu-data.sealed-key")) + _, err := disk.FindMatchingPartitionUUIDWithFsLabel(name) + c.Assert(err, IsNil) + // validity check that we can't find encrypted ubuntu-data + _, err = disk.FindMatchingPartitionUUIDWithFsLabel(name + "-enc") + c.Assert(err, FitsTypeOf, disks.PartitionNotFoundError{}) + return foundUnencrypted("ubuntu-data"), nil + default: + // we do not expect any more calls here, since + // ubuntu-data was found unencrypted unlocking will not + // be tried again + c.Errorf("unexpected call to UnlockVolumeUsingSealedKeyIfEncrypted (num %d)", unlockVolumeWithSealedKeyCalls) + return secboot.UnlockResult{}, fmt.Errorf("broken test") + } + }) + defer restore() + + s.mockUbuntuSaveKeyAndMarker(c, filepath.Join(dirs.GlobalRootDir, "/run/mnt/host/ubuntu-data/system-data"), "foo", "") + + restore = main.MockSecbootUnlockEncryptedVolumeUsingKey(func(disk disks.Disk, name string, key []byte) (secboot.UnlockResult, error) { + // nothing can call this function in the tested scenario + c.Fatalf("unexpected call") + return secboot.UnlockResult{}, fmt.Errorf("unexpected call") + }) + defer restore() + + measureEpochCalls := 0 + measureModelCalls := 0 + restore = main.MockSecbootMeasureSnapSystemEpochWhenPossible(func() error { + measureEpochCalls++ + return nil + }) + defer restore() + restore = main.MockSecbootMeasureSnapModelWhenPossible(func(findModel func() (*asserts.Model, error)) error { + measureModelCalls++ + return nil + }) + defer restore() + + restore = s.mockSystemdMountSequence(c, []systemdMount{ + s.ubuntuLabelMount("ubuntu-seed", "recover"), + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + s.makeSeedSnapSystemdMount(snap.TypeGadget), + { + "tmpfs", + boot.InitramfsDataDir, + tmpfsMountOpts, + nil, + }, + { + "/dev/disk/by-partuuid/ubuntu-boot-partuuid", + boot.InitramfsUbuntuBootDir, + needsFsckDiskMountOpts, + nil, + }, + { + "/dev/disk/by-partuuid/ubuntu-data-partuuid", + boot.InitramfsHostUbuntuDataDir, + needsNoSuidDiskMountOpts, + nil, + }, + { + "/dev/disk/by-partuuid/ubuntu-save-partuuid", + boot.InitramfsUbuntuSaveDir, + mountOpts, + nil, + }, + }, nil) + defer restore() + + s.testRecoverModeHappy(c) + + c.Check(unlockVolumeWithSealedKeyCalls, Equals, 1) + c.Check(measureEpochCalls, Equals, 1) + c.Check(measureModelCalls, Equals, 1) + + c.Assert(filepath.Join(dirs.SnapBootstrapRunDir, "secboot-epoch-measured"), testutil.FilePresent) + c.Assert(filepath.Join(dirs.SnapBootstrapRunDir, fmt.Sprintf("%s-model-measured", s.sysLabel)), testutil.FilePresent) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsRecoverModeEncryptedDegradedAbsentDataSaveUnlockFallbackHappy(c *C) { + // test a scenario when data cannot be found but save can be + // unlocked using the fallback key + + s.mockProcCmdlineContent(c, "snapd_recovery_mode=recover snapd_recovery_system="+s.sysLabel) + + restore := main.MockPartitionUUIDForBootedKernelDisk("") + defer restore() + + // setup a bootloader for setting the bootenv after we are done + bloader := bootloadertest.Mock("mock", c.MkDir()) + bootloader.Force(bloader) + defer bootloader.Force(nil) + + mockDiskNoData := &disks.MockDiskMapping{ + Structure: []disks.Partition{ + seedPart, + bootPart, + // no ubuntu-data on the disk at all + saveEncPart, + }, + DiskHasPartitions: true, + DevNum: "defaultEncDev", + } + + restore = disks.MockMountPointDisksToPartitionMapping( + map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuSeedDir}: mockDiskNoData, + {Mountpoint: boot.InitramfsUbuntuBootDir}: mockDiskNoData, + { + Mountpoint: boot.InitramfsUbuntuSaveDir, + IsDecryptedDevice: true, + }: mockDiskNoData, + }, + ) + defer restore() + + dataActivated := false + saveActivated := false + unlockVolumeWithSealedKeyCalls := 0 + restore = main.MockSecbootUnlockVolumeUsingSealedKeyIfEncrypted(func(disk disks.Disk, name string, sealedEncryptionKeyFile string, opts *secboot.UnlockVolumeUsingSealedKeyOptions) (secboot.UnlockResult, error) { + unlockVolumeWithSealedKeyCalls++ + switch unlockVolumeWithSealedKeyCalls { + + case 1: + // ubuntu data can't be found at all + c.Assert(name, Equals, "ubuntu-data") + c.Assert(sealedEncryptionKeyFile, Equals, filepath.Join(s.tmpDir, "run/mnt/ubuntu-boot/device/fde/ubuntu-data.sealed-key")) + _, err := disk.FindMatchingPartitionUUIDWithFsLabel(name + "-enc") + c.Assert(err, FitsTypeOf, disks.PartitionNotFoundError{}) + c.Assert(opts.AllowRecoveryKey, Equals, false) + c.Assert(opts.WhichModel, NotNil) + dataActivated = true + // data not found at all + return notFoundPart(), fmt.Errorf("error enumerating to find ubuntu-data") + + case 2: + // we can however still unlock ubuntu-save with the fallback key + c.Assert(name, Equals, "ubuntu-save") + c.Assert(sealedEncryptionKeyFile, Equals, filepath.Join(s.tmpDir, "run/mnt/ubuntu-seed/device/fde/ubuntu-save.recovery.sealed-key")) + encDevPartUUID, err := disk.FindMatchingPartitionUUIDWithFsLabel(name + "-enc") + c.Assert(err, IsNil) + c.Assert(encDevPartUUID, Equals, "ubuntu-save-enc-partuuid") + c.Assert(opts.AllowRecoveryKey, Equals, true) + c.Assert(opts.WhichModel, NotNil) + saveActivated = true + return happyUnlocked("ubuntu-save", secboot.UnlockedWithSealedKey), nil + default: + c.Errorf("unexpected call to UnlockVolumeUsingSealedKeyIfEncrypted (num %d)", unlockVolumeWithSealedKeyCalls) + return secboot.UnlockResult{}, fmt.Errorf("broken test") + } + }) + defer restore() + + s.mockUbuntuSaveKeyAndMarker(c, filepath.Join(dirs.GlobalRootDir, "/run/mnt/host/ubuntu-data/system-data"), "foo", "") + + restore = main.MockSecbootUnlockEncryptedVolumeUsingKey(func(disk disks.Disk, name string, key []byte) (secboot.UnlockResult, error) { + // nothing can call this function in the tested scenario + c.Fatalf("unexpected call") + return secboot.UnlockResult{}, fmt.Errorf("unexpected call") + }) + defer restore() + + measureEpochCalls := 0 + measureModelCalls := 0 + restore = main.MockSecbootMeasureSnapSystemEpochWhenPossible(func() error { + measureEpochCalls++ + return nil + }) + defer restore() + + var measuredModel *asserts.Model + restore = main.MockSecbootMeasureSnapModelWhenPossible(func(findModel func() (*asserts.Model, error)) error { + measureModelCalls++ + var err error + measuredModel, err = findModel() + if err != nil { + return err + } + return nil + }) + defer restore() + + restore = s.mockSystemdMountSequence(c, []systemdMount{ + s.ubuntuLabelMount("ubuntu-seed", "recover"), + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + s.makeSeedSnapSystemdMount(snap.TypeGadget), + { + "tmpfs", + boot.InitramfsDataDir, + tmpfsMountOpts, + nil, + }, + { + "/dev/disk/by-partuuid/ubuntu-boot-partuuid", + boot.InitramfsUbuntuBootDir, + needsFsckDiskMountOpts, + nil, + }, + { + "/dev/mapper/ubuntu-save-random", + boot.InitramfsUbuntuSaveDir, + mountOpts, + nil, + }, + }, nil) + defer restore() + + // ensure that we check that access to sealed keys were locked + sealedKeysLocked := false + restore = main.MockSecbootLockSealedKeys(func() error { + sealedKeysLocked = true + return nil + }) + defer restore() + + _, err := main.Parser().ParseArgs([]string{"initramfs-mounts"}) + c.Assert(err, IsNil) + + // we always need to lock access to sealed keys + c.Check(sealedKeysLocked, Equals, true) + + modeEnv := filepath.Join(filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data"), "var/lib/snapd/modeenv") + c.Check(modeEnv, testutil.FileEquals, `mode=recover +recovery_system=20191118 +base=core20_1.snap +gadget=pc_1.snap +model=my-brand/my-model +grade=signed +`) + + checkDegradedJSON(c, "degraded.json", map[string]interface{}{ + "ubuntu-boot": map[string]interface{}{ + "device": "/dev/disk/by-partuuid/ubuntu-boot-partuuid", + "mount-state": "mounted", + "find-state": "found", + "mount-location": boot.InitramfsUbuntuBootDir, + }, + "ubuntu-data": map[string]interface{}{ + "find-state": "not-found", + }, + "ubuntu-save": map[string]interface{}{ + "device": "/dev/mapper/ubuntu-save-random", + "unlock-key": "fallback", + "unlock-state": "unlocked", + "mount-state": "mounted", + "find-state": "found", + "mount-location": boot.InitramfsUbuntuSaveDir, + }, + "error-log": []interface{}{ + "cannot locate ubuntu-data partition for mounting host data: error enumerating to find ubuntu-data", + }, + }) + + bloader2, err := bootloader.Find("", nil) + c.Assert(err, IsNil) + m, err := bloader2.GetBootVars("snapd_recovery_system", "snapd_recovery_mode") + c.Assert(err, IsNil) + c.Assert(m, DeepEquals, map[string]string{ + "snapd_recovery_system": "20191118", + "snapd_recovery_mode": "run", + }) + + // since we didn't mount data at all, we won't have copied in files from + // there and instead will copy safe defaults to the ephemeral data + c.Assert(filepath.Join(boot.InitramfsRunMntDir, "/data/system-data/var/lib/console-conf/complete"), testutil.FilePresent) + + c.Check(dataActivated, Equals, true) + c.Check(saveActivated, Equals, true) + c.Check(unlockVolumeWithSealedKeyCalls, Equals, 2) + c.Check(measureEpochCalls, Equals, 1) + c.Check(measureModelCalls, Equals, 1) + c.Check(measuredModel, DeepEquals, s.model) + + c.Assert(filepath.Join(dirs.SnapBootstrapRunDir, "secboot-epoch-measured"), testutil.FilePresent) + c.Assert(filepath.Join(dirs.SnapBootstrapRunDir, fmt.Sprintf("%s-model-measured", s.sysLabel)), testutil.FilePresent) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsRecoverModeEncryptedDegradedDataUnlockFailSaveUnlockFailHappy(c *C) { + // test a scenario when unlocking data with both run and fallback keys + // fails, followed by a failure to unlock save with the fallback key + + s.mockProcCmdlineContent(c, "snapd_recovery_mode=recover snapd_recovery_system="+s.sysLabel) + + restore := main.MockPartitionUUIDForBootedKernelDisk("") + defer restore() + + // setup a bootloader for setting the bootenv after we are done + bloader := bootloadertest.Mock("mock", c.MkDir()) + bootloader.Force(bloader) + defer bootloader.Force(nil) + + restore = disks.MockMountPointDisksToPartitionMapping( + // no ubuntu-data mountpoint is mocked, but there is an + // ubuntu-data-enc partition in the disk we find + map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuSeedDir}: defaultEncBootDisk, + {Mountpoint: boot.InitramfsUbuntuBootDir}: defaultEncBootDisk, + { + Mountpoint: boot.InitramfsUbuntuSaveDir, + IsDecryptedDevice: true, + }: defaultEncBootDisk, + }, + ) + defer restore() + + dataActivationAttempts := 0 + saveUnsealActivationAttempted := false + unlockVolumeWithSealedKeyCalls := 0 + restore = main.MockSecbootUnlockVolumeUsingSealedKeyIfEncrypted(func(disk disks.Disk, name string, sealedEncryptionKeyFile string, opts *secboot.UnlockVolumeUsingSealedKeyOptions) (secboot.UnlockResult, error) { + unlockVolumeWithSealedKeyCalls++ + switch unlockVolumeWithSealedKeyCalls { + + case 1: + // ubuntu data can't be unlocked with run key + c.Assert(name, Equals, "ubuntu-data") + c.Assert(sealedEncryptionKeyFile, Equals, filepath.Join(s.tmpDir, "run/mnt/ubuntu-boot/device/fde/ubuntu-data.sealed-key")) + encDevPartUUID, err := disk.FindMatchingPartitionUUIDWithFsLabel(name + "-enc") + c.Assert(err, IsNil) + c.Assert(encDevPartUUID, Equals, "ubuntu-data-enc-partuuid") + c.Assert(opts.AllowRecoveryKey, Equals, false) + c.Assert(opts.WhichModel, NotNil) + dataActivationAttempts++ + return foundEncrypted("ubuntu-data"), fmt.Errorf("failed to unlock ubuntu-data with run object") + + case 2: + // nor can it be unlocked with fallback key + c.Assert(name, Equals, "ubuntu-data") + c.Assert(sealedEncryptionKeyFile, Equals, filepath.Join(s.tmpDir, "run/mnt/ubuntu-seed/device/fde/ubuntu-data.recovery.sealed-key")) + encDevPartUUID, err := disk.FindMatchingPartitionUUIDWithFsLabel(name + "-enc") + c.Assert(err, IsNil) + c.Assert(encDevPartUUID, Equals, "ubuntu-data-enc-partuuid") + c.Assert(opts.AllowRecoveryKey, Equals, true) + c.Assert(opts.WhichModel, NotNil) + dataActivationAttempts++ + return foundEncrypted("ubuntu-data"), fmt.Errorf("failed to unlock ubuntu-data with fallback object") + + case 3: + // we also fail to unlock save + + // no attempts to activate ubuntu-save yet + c.Assert(name, Equals, "ubuntu-save") + c.Assert(sealedEncryptionKeyFile, Equals, filepath.Join(s.tmpDir, "run/mnt/ubuntu-seed/device/fde/ubuntu-save.recovery.sealed-key")) + encDevPartUUID, err := disk.FindMatchingPartitionUUIDWithFsLabel(name + "-enc") + c.Assert(err, IsNil) + c.Assert(encDevPartUUID, Equals, "ubuntu-save-enc-partuuid") + c.Assert(opts.AllowRecoveryKey, Equals, true) + c.Assert(opts.WhichModel, NotNil) + saveUnsealActivationAttempted = true + return foundEncrypted("ubuntu-save"), fmt.Errorf("failed to unlock ubuntu-save with fallback object") + + default: + c.Errorf("unexpected call to UnlockVolumeUsingSealedKeyIfEncrypted (num %d)", unlockVolumeWithSealedKeyCalls) + return secboot.UnlockResult{}, fmt.Errorf("broken test") + } + }) + defer restore() + + s.mockUbuntuSaveKeyAndMarker(c, filepath.Join(dirs.GlobalRootDir, "/run/mnt/host/ubuntu-data/system-data"), "foo", "") + + restore = main.MockSecbootUnlockEncryptedVolumeUsingKey(func(disk disks.Disk, name string, key []byte) (secboot.UnlockResult, error) { + // nothing can call this function in the tested scenario + c.Fatalf("unexpected call") + return secboot.UnlockResult{}, fmt.Errorf("unexpected call") + }) + defer restore() + + measureEpochCalls := 0 + measureModelCalls := 0 + restore = main.MockSecbootMeasureSnapSystemEpochWhenPossible(func() error { + measureEpochCalls++ + return nil + }) + defer restore() + + var measuredModel *asserts.Model + restore = main.MockSecbootMeasureSnapModelWhenPossible(func(findModel func() (*asserts.Model, error)) error { + measureModelCalls++ + var err error + measuredModel, err = findModel() + if err != nil { + return err + } + return nil + }) + defer restore() + + restore = s.mockSystemdMountSequence(c, []systemdMount{ + s.ubuntuLabelMount("ubuntu-seed", "recover"), + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + s.makeSeedSnapSystemdMount(snap.TypeGadget), + { + "tmpfs", + boot.InitramfsDataDir, + tmpfsMountOpts, + nil, + }, + { + "/dev/disk/by-partuuid/ubuntu-boot-partuuid", + boot.InitramfsUbuntuBootDir, + needsFsckDiskMountOpts, + nil, + }, + }, nil) + defer restore() + + // ensure that we check that access to sealed keys were locked + sealedKeysLocked := false + restore = main.MockSecbootLockSealedKeys(func() error { + sealedKeysLocked = true + return nil + }) + defer restore() + + _, err := main.Parser().ParseArgs([]string{"initramfs-mounts"}) + c.Assert(err, IsNil) + + // we always need to lock access to sealed keys + c.Check(sealedKeysLocked, Equals, true) + + modeEnv := filepath.Join(boot.InitramfsRunMntDir, "data/system-data/var/lib/snapd/modeenv") + c.Check(modeEnv, testutil.FileEquals, `mode=recover +recovery_system=20191118 +base=core20_1.snap +gadget=pc_1.snap +model=my-brand/my-model +grade=signed +`) + + checkDegradedJSON(c, "degraded.json", map[string]interface{}{ + "ubuntu-boot": map[string]interface{}{ + "device": "/dev/disk/by-partuuid/ubuntu-boot-partuuid", + "mount-state": "mounted", + "find-state": "found", + "mount-location": boot.InitramfsUbuntuBootDir, + }, + "ubuntu-data": map[string]interface{}{ + "find-state": "found", + "device": "/dev/disk/by-partuuid/ubuntu-data-enc-partuuid", + "unlock-state": "error-unlocking", + }, + "ubuntu-save": map[string]interface{}{ + "find-state": "found", + "device": "/dev/disk/by-partuuid/ubuntu-save-enc-partuuid", + "unlock-state": "error-unlocking", + }, + "error-log": []interface{}{ + "cannot unlock encrypted ubuntu-data (device /dev/disk/by-partuuid/ubuntu-data-enc-partuuid) with sealed run key: failed to unlock ubuntu-data with run object", + "cannot unlock encrypted ubuntu-data partition with sealed fallback key: failed to unlock ubuntu-data with fallback object", + "cannot unlock encrypted ubuntu-save partition with sealed fallback key: failed to unlock ubuntu-save with fallback object", + }, + }) + + bloader2, err := bootloader.Find("", nil) + c.Assert(err, IsNil) + m, err := bloader2.GetBootVars("snapd_recovery_system", "snapd_recovery_mode") + c.Assert(err, IsNil) + c.Assert(m, DeepEquals, map[string]string{ + "snapd_recovery_system": "20191118", + "snapd_recovery_mode": "run", + }) + + // since we didn't mount data at all, we won't have copied in files from + // there and instead will copy safe defaults to the ephemeral data + c.Assert(filepath.Join(boot.InitramfsRunMntDir, "/data/system-data/var/lib/console-conf/complete"), testutil.FilePresent) + + c.Check(dataActivationAttempts, Equals, 2) + c.Check(saveUnsealActivationAttempted, Equals, true) + c.Check(unlockVolumeWithSealedKeyCalls, Equals, 3) + c.Check(measureEpochCalls, Equals, 1) + c.Check(measureModelCalls, Equals, 1) + c.Check(measuredModel, DeepEquals, s.model) + + c.Assert(filepath.Join(dirs.SnapBootstrapRunDir, "secboot-epoch-measured"), testutil.FilePresent) + c.Assert(filepath.Join(dirs.SnapBootstrapRunDir, fmt.Sprintf("%s-model-measured", s.sysLabel)), testutil.FilePresent) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsRecoverModeEncryptedMismatchedMarker(c *C) { + s.mockProcCmdlineContent(c, "snapd_recovery_mode=recover snapd_recovery_system="+s.sysLabel) + + restore := main.MockPartitionUUIDForBootedKernelDisk("") + defer restore() + + // setup a bootloader for setting the bootenv after we are done + bloader := bootloadertest.Mock("mock", c.MkDir()) + bootloader.Force(bloader) + defer bootloader.Force(nil) + + restore = disks.MockMountPointDisksToPartitionMapping( + map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuSeedDir}: defaultEncBootDisk, + {Mountpoint: boot.InitramfsUbuntuBootDir}: defaultEncBootDisk, + { + Mountpoint: boot.InitramfsHostUbuntuDataDir, + IsDecryptedDevice: true, + }: defaultEncBootDisk, + { + Mountpoint: boot.InitramfsUbuntuSaveDir, + IsDecryptedDevice: true, + }: defaultEncBootDisk, + }, + ) + defer restore() + + dataActivated := false + restore = main.MockSecbootUnlockVolumeUsingSealedKeyIfEncrypted(func(disk disks.Disk, name string, sealedEncryptionKeyFile string, opts *secboot.UnlockVolumeUsingSealedKeyOptions) (secboot.UnlockResult, error) { + c.Assert(name, Equals, "ubuntu-data") + c.Assert(sealedEncryptionKeyFile, Equals, filepath.Join(s.tmpDir, "run/mnt/ubuntu-boot/device/fde/ubuntu-data.sealed-key")) + + encDevPartUUID, err := disk.FindMatchingPartitionUUIDWithFsLabel(name + "-enc") + c.Assert(err, IsNil) + c.Assert(encDevPartUUID, Equals, "ubuntu-data-enc-partuuid") + c.Assert(opts.AllowRecoveryKey, Equals, false) + c.Assert(opts.WhichModel, NotNil) + dataActivated = true + return happyUnlocked("ubuntu-data", secboot.UnlockedWithSealedKey), nil + }) + defer restore() + + s.mockUbuntuSaveKeyAndMarker(c, filepath.Join(dirs.GlobalRootDir, "/run/mnt/host/ubuntu-data/system-data"), "foo", "other-marker") + s.mockUbuntuSaveMarker(c, boot.InitramfsUbuntuSaveDir, "marker") + + saveActivated := false + restore = main.MockSecbootUnlockEncryptedVolumeUsingKey(func(disk disks.Disk, name string, key []byte) (secboot.UnlockResult, error) { + c.Check(dataActivated, Equals, true, Commentf("ubuntu-data not activated yet")) + encDevPartUUID, err := disk.FindMatchingPartitionUUIDWithFsLabel(name + "-enc") + c.Assert(err, IsNil) + c.Assert(encDevPartUUID, Equals, "ubuntu-save-enc-partuuid") + c.Assert(key, DeepEquals, []byte("foo")) + saveActivated = true + return happyUnlocked("ubuntu-save", secboot.UnlockedWithKey), nil + }) + defer restore() + + measureEpochCalls := 0 + measureModelCalls := 0 + restore = main.MockSecbootMeasureSnapSystemEpochWhenPossible(func() error { + measureEpochCalls++ + return nil + }) + defer restore() + + var measuredModel *asserts.Model + restore = main.MockSecbootMeasureSnapModelWhenPossible(func(findModel func() (*asserts.Model, error)) error { + measureModelCalls++ + var err error + measuredModel, err = findModel() + if err != nil { + return err + } + return nil + }) + defer restore() + + restore = s.mockSystemdMountSequence(c, []systemdMount{ + s.ubuntuLabelMount("ubuntu-seed", "recover"), + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + s.makeSeedSnapSystemdMount(snap.TypeGadget), + { + "tmpfs", + boot.InitramfsDataDir, + tmpfsMountOpts, + nil, + }, + { + "/dev/disk/by-partuuid/ubuntu-boot-partuuid", + boot.InitramfsUbuntuBootDir, + needsFsckDiskMountOpts, + nil, + }, + { + "/dev/mapper/ubuntu-data-random", + boot.InitramfsHostUbuntuDataDir, + needsNoSuidDiskMountOpts, + nil, + }, + { + "/dev/mapper/ubuntu-save-random", + boot.InitramfsUbuntuSaveDir, + mountOpts, + nil, + }, + }, nil) + defer restore() + + // ensure that we check that access to sealed keys were locked + sealedKeysLocked := false + restore = main.MockSecbootLockSealedKeys(func() error { + sealedKeysLocked = true + return nil + }) + defer restore() + + _, err := main.Parser().ParseArgs([]string{"initramfs-mounts"}) + c.Assert(err, IsNil) + + // we always need to lock access to sealed keys + c.Check(sealedKeysLocked, Equals, true) + + modeEnv := filepath.Join(filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data"), "var/lib/snapd/modeenv") + c.Check(modeEnv, testutil.FileEquals, `mode=recover +recovery_system=20191118 +base=core20_1.snap +gadget=pc_1.snap +model=my-brand/my-model +grade=signed +`) + + checkDegradedJSON(c, "degraded.json", map[string]interface{}{ + "ubuntu-boot": map[string]interface{}{ + "device": "/dev/disk/by-partuuid/ubuntu-boot-partuuid", + "mount-state": "mounted", + "find-state": "found", + "mount-location": boot.InitramfsUbuntuBootDir, + }, + "ubuntu-data": map[string]interface{}{ + "device": "/dev/mapper/ubuntu-data-random", + "unlock-state": "unlocked", + "find-state": "found", + "mount-state": "mounted-untrusted", + "unlock-key": "run", + "mount-location": boot.InitramfsHostUbuntuDataDir, + }, + "ubuntu-save": map[string]interface{}{ + "device": "/dev/mapper/ubuntu-save-random", + "unlock-key": "run", + "unlock-state": "unlocked", + "mount-state": "mounted", + "find-state": "found", + "mount-location": boot.InitramfsUbuntuSaveDir, + }, + "error-log": []interface{}{"cannot trust ubuntu-data, ubuntu-save and ubuntu-data are not marked as from the same install"}, + }) + + bloader2, err := bootloader.Find("", nil) + c.Assert(err, IsNil) + m, err := bloader2.GetBootVars("snapd_recovery_system", "snapd_recovery_mode") + c.Assert(err, IsNil) + c.Assert(m, DeepEquals, map[string]string{ + "snapd_recovery_system": "20191118", + "snapd_recovery_mode": "run", + }) + + // since we didn't mount data at all, we won't have copied in files from + // there and instead will copy safe defaults to the ephemeral data + c.Assert(filepath.Join(boot.InitramfsRunMntDir, "/data/system-data/var/lib/console-conf/complete"), testutil.FilePresent) + + c.Check(dataActivated, Equals, true) + c.Check(saveActivated, Equals, true) + c.Check(measureEpochCalls, Equals, 1) + c.Check(measureModelCalls, Equals, 1) + c.Check(measuredModel, DeepEquals, s.model) + + c.Assert(filepath.Join(dirs.SnapBootstrapRunDir, "secboot-epoch-measured"), testutil.FilePresent) + c.Assert(filepath.Join(dirs.SnapBootstrapRunDir, fmt.Sprintf("%s-model-measured", s.sysLabel)), testutil.FilePresent) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsRecoverModeEncryptedAttackerFSAttachedHappy(c *C) { + s.mockProcCmdlineContent(c, "snapd_recovery_mode=recover snapd_recovery_system="+s.sysLabel) + + restore := main.MockPartitionUUIDForBootedKernelDisk("") + defer restore() + + // setup a bootloader for setting the bootenv + bloader := bootloadertest.Mock("mock", c.MkDir()) + bootloader.Force(bloader) + defer bootloader.Force(nil) + + mockDisk := &disks.MockDiskMapping{ + Structure: []disks.Partition{ + seedPart, + bootPart, + saveEncPart, + dataEncPart, + }, + DiskHasPartitions: true, + DevNum: "bootDev", + } + attackerDisk := &disks.MockDiskMapping{ + Structure: []disks.Partition{ + { + FilesystemLabel: "ubuntu-seed", + PartitionUUID: "ubuntu-seed-attacker-partuuid", + }, + { + FilesystemLabel: "ubuntu-boot", + PartitionUUID: "ubuntu-boot-attacker-partuuid", + }, + { + FilesystemLabel: "ubuntu-save-enc", + PartitionUUID: "ubuntu-save-enc-attacker-partuuid", + }, + { + FilesystemLabel: "ubuntu-data-enc", + PartitionUUID: "ubuntu-data-enc-attacker-partuuid", + }, + }, + DiskHasPartitions: true, + DevNum: "attackerDev", + } + + restore = disks.MockMountPointDisksToPartitionMapping( + map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuSeedDir}: mockDisk, + {Mountpoint: boot.InitramfsUbuntuBootDir}: mockDisk, + { + Mountpoint: boot.InitramfsHostUbuntuDataDir, + IsDecryptedDevice: true, + }: mockDisk, + { + Mountpoint: boot.InitramfsUbuntuSaveDir, + IsDecryptedDevice: true, + }: mockDisk, + // this is the attacker fs on a different disk + {Mountpoint: "somewhere-else"}: attackerDisk, + }, + ) + defer restore() + + activated := false + restore = main.MockSecbootUnlockVolumeUsingSealedKeyIfEncrypted(func(disk disks.Disk, name string, sealedEncryptionKeyFile string, opts *secboot.UnlockVolumeUsingSealedKeyOptions) (secboot.UnlockResult, error) { + c.Assert(name, Equals, "ubuntu-data") + encDevPartUUID, err := disk.FindMatchingPartitionUUIDWithFsLabel(name + "-enc") + c.Assert(err, IsNil) + c.Assert(encDevPartUUID, Equals, "ubuntu-data-enc-partuuid") + c.Assert(opts.AllowRecoveryKey, Equals, false) + c.Assert(opts.WhichModel, NotNil) + activated = true + return happyUnlocked("ubuntu-data", secboot.UnlockedWithSealedKey), nil + }) + defer restore() + + s.mockUbuntuSaveKeyAndMarker(c, filepath.Join(dirs.GlobalRootDir, "/run/mnt/host/ubuntu-data/system-data"), "foo", "marker") + s.mockUbuntuSaveMarker(c, boot.InitramfsUbuntuSaveDir, "marker") + + restore = main.MockSecbootUnlockEncryptedVolumeUsingKey(func(disk disks.Disk, name string, key []byte) (secboot.UnlockResult, error) { + encDevPartUUID, err := disk.FindMatchingPartitionUUIDWithFsLabel(name + "-enc") + c.Assert(err, IsNil) + c.Assert(encDevPartUUID, Equals, "ubuntu-save-enc-partuuid") + c.Assert(key, DeepEquals, []byte("foo")) + return happyUnlocked("ubuntu-save", secboot.UnlockedWithKey), nil + }) + defer restore() + + measureEpochCalls := 0 + measureModelCalls := 0 + restore = main.MockSecbootMeasureSnapSystemEpochWhenPossible(func() error { + measureEpochCalls++ + return nil + }) + defer restore() + + var measuredModel *asserts.Model + restore = main.MockSecbootMeasureSnapModelWhenPossible(func(findModel func() (*asserts.Model, error)) error { + measureModelCalls++ + var err error + measuredModel, err = findModel() + if err != nil { + return err + } + return nil + }) + defer restore() + + restore = s.mockSystemdMountSequence(c, []systemdMount{ + s.ubuntuLabelMount("ubuntu-seed", "recover"), + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + s.makeSeedSnapSystemdMount(snap.TypeGadget), + { + "tmpfs", + boot.InitramfsDataDir, + tmpfsMountOpts, + nil, + }, + { + "/dev/disk/by-partuuid/ubuntu-boot-partuuid", + boot.InitramfsUbuntuBootDir, + needsFsckDiskMountOpts, + nil, + }, + { + "/dev/mapper/ubuntu-data-random", + boot.InitramfsHostUbuntuDataDir, + needsNoSuidDiskMountOpts, + nil, + }, + { + "/dev/mapper/ubuntu-save-random", + boot.InitramfsUbuntuSaveDir, + mountOpts, + nil, + }, + }, nil) + defer restore() + + s.testRecoverModeHappy(c) + + c.Check(activated, Equals, true) + c.Check(measureEpochCalls, Equals, 1) + c.Check(measureModelCalls, Equals, 1) + c.Check(measuredModel, DeepEquals, s.model) + + c.Assert(filepath.Join(dirs.SnapBootstrapRunDir, "secboot-epoch-measured"), testutil.FilePresent) + c.Assert(filepath.Join(dirs.SnapBootstrapRunDir, fmt.Sprintf("%s-model-measured", s.sysLabel)), testutil.FilePresent) +} + +func (s *initramfsMountsSuite) testInitramfsMountsInstallRecoverModeMeasure(c *C, mode string) { + s.mockProcCmdlineContent(c, fmt.Sprintf("snapd_recovery_mode=%s snapd_recovery_system=%s", mode, s.sysLabel)) + + modeMnts := []systemdMount{ + s.ubuntuLabelMount("ubuntu-seed", mode), + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + s.makeSeedSnapSystemdMount(snap.TypeGadget), + { + "tmpfs", + boot.InitramfsDataDir, + tmpfsMountOpts, + nil, + }, + } + + mockDiskMapping := map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuSeedDir}: { + Structure: []disks.Partition{ + seedPart, + }, + DiskHasPartitions: true, + }, + } + + if mode == "recover" { + // setup a bootloader for setting the bootenv after we are done + bloader := bootloadertest.Mock("mock", c.MkDir()) + bootloader.Force(bloader) + defer bootloader.Force(nil) + + // add the expected mount of ubuntu-data onto the host data dir + modeMnts = append(modeMnts, + systemdMount{ + "/dev/disk/by-partuuid/ubuntu-boot-partuuid", + boot.InitramfsUbuntuBootDir, + needsFsckDiskMountOpts, + nil, + }, + systemdMount{ + "/dev/disk/by-partuuid/ubuntu-data-partuuid", + boot.InitramfsHostUbuntuDataDir, + needsNoSuidDiskMountOpts, + nil, + }, + systemdMount{ + "/dev/disk/by-partuuid/ubuntu-save-partuuid", + boot.InitramfsUbuntuSaveDir, + mountOpts, + nil, + }) + + // also add the ubuntu-data and ubuntu-save fs labels to the + // disk referenced by the ubuntu-seed partition + disk := mockDiskMapping[disks.Mountpoint{Mountpoint: boot.InitramfsUbuntuSeedDir}] + disk.Structure = append(disk.Structure, bootPart, savePart, dataPart) + + // and also add the /run/mnt/host/ubuntu-{boot,data,save} mountpoints + // for cross-checking after mounting + mockDiskMapping[disks.Mountpoint{Mountpoint: boot.InitramfsUbuntuBootDir}] = disk + mockDiskMapping[disks.Mountpoint{Mountpoint: boot.InitramfsHostUbuntuDataDir}] = disk + mockDiskMapping[disks.Mountpoint{Mountpoint: boot.InitramfsUbuntuSaveDir}] = disk + } + + restore := disks.MockMountPointDisksToPartitionMapping(mockDiskMapping) + defer restore() + + measureEpochCalls := 0 + measureModelCalls := 0 + restore = main.MockSecbootMeasureSnapSystemEpochWhenPossible(func() error { + measureEpochCalls++ + return nil + }) + defer restore() + + var measuredModel *asserts.Model + restore = main.MockSecbootMeasureSnapModelWhenPossible(func(findModel func() (*asserts.Model, error)) error { + measureModelCalls++ + var err error + measuredModel, err = findModel() + if err != nil { + return err + } + return nil + }) + defer restore() + + restore = s.mockSystemdMountSequence(c, modeMnts, nil) + defer restore() + + if mode == "recover" { + // use the helper + s.testRecoverModeHappy(c) + } else { + _, err := main.Parser().ParseArgs([]string{"initramfs-mounts"}) + c.Assert(err, IsNil) + + modeEnv := filepath.Join(boot.InitramfsDataDir, "/system-data/var/lib/snapd/modeenv") + c.Check(modeEnv, testutil.FileEquals, `mode=install +recovery_system=20191118 +base=core20_1.snap +gadget=pc_1.snap +model=my-brand/my-model +grade=signed +`) + } + + c.Check(measuredModel, NotNil) + c.Check(measuredModel, DeepEquals, s.model) + c.Check(measureEpochCalls, Equals, 1) + c.Check(measureModelCalls, Equals, 1) + c.Assert(filepath.Join(dirs.SnapBootstrapRunDir, "secboot-epoch-measured"), testutil.FilePresent) + c.Assert(filepath.Join(dirs.SnapBootstrapRunDir, s.sysLabel+"-model-measured"), testutil.FilePresent) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsInstallModeMeasure(c *C) { + s.testInitramfsMountsInstallRecoverModeMeasure(c, "install") +} + +func (s *initramfsMountsSuite) TestInitramfsMountsInstallModeUnsetMeasure(c *C) { + // TODO:UC20: eventually we should require snapd_recovery_mode to be set to + // explicitly "install" for install mode, but we originally allowed + // snapd_recovery_mode="" and interpreted it as install mode, so test that + // case too + s.testInitramfsMountsInstallRecoverModeMeasure(c, "") +} + +func (s *initramfsMountsSuite) TestInitramfsMountsRecoverModeMeasure(c *C) { + s.testInitramfsMountsInstallRecoverModeMeasure(c, "recover") +} + +func (s *baseInitramfsMountsSuite) runInitramfsMountsUnencryptedTryRecovery(c *C, triedSystem bool) (err error) { + s.mockProcCmdlineContent(c, "snapd_recovery_mode=recover snapd_recovery_system="+s.sysLabel) + + restore := main.MockPartitionUUIDForBootedKernelDisk("") + defer restore() + restore = disks.MockMountPointDisksToPartitionMapping( + map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuSeedDir}: defaultBootWithSaveDisk, + {Mountpoint: boot.InitramfsUbuntuBootDir}: defaultBootWithSaveDisk, + {Mountpoint: boot.InitramfsHostUbuntuDataDir}: defaultBootWithSaveDisk, + {Mountpoint: boot.InitramfsUbuntuSaveDir}: defaultBootWithSaveDisk, + }, + ) + defer restore() + restore = s.mockSystemdMountSequence(c, []systemdMount{ + s.ubuntuLabelMount("ubuntu-seed", "recover"), + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + s.makeSeedSnapSystemdMount(snap.TypeGadget), + { + "tmpfs", + boot.InitramfsDataDir, + tmpfsMountOpts, + nil, + }, + { + "/dev/disk/by-partuuid/ubuntu-boot-partuuid", + boot.InitramfsUbuntuBootDir, + needsFsckDiskMountOpts, + nil, + }, + { + "/dev/disk/by-partuuid/ubuntu-data-partuuid", + boot.InitramfsHostUbuntuDataDir, + needsNoSuidDiskMountOpts, + nil, + }, + { + "/dev/disk/by-partuuid/ubuntu-save-partuuid", + boot.InitramfsUbuntuSaveDir, + mountOpts, + nil, + }, + }, nil) + defer restore() + + if triedSystem { + defer func() { + err = recover().(error) + }() + } + + _, err = main.Parser().ParseArgs([]string{"initramfs-mounts"}) + return err +} + +func (s *baseInitramfsMountsSuite) testInitramfsMountsTryRecoveryHappy(c *C, happyStatus string) { + rebootCalls := 0 + restore := boot.MockInitramfsReboot(func() error { + rebootCalls++ + return nil + }) + defer restore() + + bl := bootloadertest.Mock("bootloader", c.MkDir()) + bl.BootVars = map[string]string{ + "recovery_system_status": happyStatus, + "try_recovery_system": s.sysLabel, + } + bootloader.Force(bl) + defer bootloader.Force(nil) + + hostUbuntuData := filepath.Join(boot.InitramfsRunMntDir, "host/ubuntu-data/") + var mockedState string + if s.isClassic { + mockedState = filepath.Join(hostUbuntuData, "var/lib/snapd/state.json") + } else { + mockedState = filepath.Join(hostUbuntuData, "system-data/var/lib/snapd/state.json") + } + c.Assert(os.MkdirAll(filepath.Dir(mockedState), 0750), IsNil) + c.Assert(os.WriteFile(mockedState, []byte(mockStateContent), 0640), IsNil) + + const triedSystem = true + err := s.runInitramfsMountsUnencryptedTryRecovery(c, triedSystem) + // due to hackery with replacing reboot, we expect a non nil error that + // actually indicates a success + c.Assert(err, ErrorMatches, `finalize try recovery system did not reboot, last error: `) + + // modeenv is not written as reboot happens before that + var modeEnv string + if s.isClassic { + modeEnv = dirs.SnapModeenvFileUnder(filepath.Join(dirs.GlobalRootDir, "/run/mnt/data")) + } else { + modeEnv = dirs.SnapModeenvFileUnder(filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data")) + } + c.Check(modeEnv, testutil.FileAbsent) + c.Check(bl.BootVars, DeepEquals, map[string]string{ + "recovery_system_status": "tried", + "try_recovery_system": s.sysLabel, + "snapd_recovery_mode": "run", + "snapd_recovery_system": "", + }) + c.Check(rebootCalls, Equals, 1) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsTryRecoveryHappyTry(c *C) { + s.testInitramfsMountsTryRecoveryHappy(c, "try") +} + +func (s *initramfsMountsSuite) TestInitramfsMountsTryRecoveryHappyTried(c *C) { + s.testInitramfsMountsTryRecoveryHappy(c, "tried") +} + +func (s *initramfsMountsSuite) testInitramfsMountsTryRecoveryInconsistent(c *C) { + s.mockProcCmdlineContent(c, "snapd_recovery_mode=recover snapd_recovery_system="+s.sysLabel) + + restore := main.MockPartitionUUIDForBootedKernelDisk("") + defer restore() + restore = disks.MockMountPointDisksToPartitionMapping( + map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuSeedDir}: defaultBootWithSaveDisk, + {Mountpoint: boot.InitramfsUbuntuBootDir}: defaultBootWithSaveDisk, + }, + ) + defer restore() + restore = s.mockSystemdMountSequence(c, []systemdMount{ + s.ubuntuLabelMount("ubuntu-seed", "recover"), + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + s.makeSeedSnapSystemdMount(snap.TypeGadget), + { + "tmpfs", + boot.InitramfsDataDir, + tmpfsMountOpts, + nil, + }, + }, nil) + defer restore() + + runParser := func() { + main.Parser().ParseArgs([]string{"initramfs-mounts"}) + } + c.Assert(runParser, PanicMatches, `finalize try recovery system did not reboot, last error: `) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsTryRecoveryInconsistentBogusStatus(c *C) { + rebootCalls := 0 + restore := boot.MockInitramfsReboot(func() error { + rebootCalls++ + return nil + }) + defer restore() + + bl := bootloadertest.Mock("bootloader", c.MkDir()) + err := bl.SetBootVars(map[string]string{ + "recovery_system_status": "bogus", + "try_recovery_system": s.sysLabel, + }) + c.Assert(err, IsNil) + bootloader.Force(bl) + defer bootloader.Force(nil) + + s.testInitramfsMountsTryRecoveryInconsistent(c) + + vars, err := bl.GetBootVars("recovery_system_status", "try_recovery_system", + "snapd_recovery_mode", "snapd_recovery_system") + c.Assert(err, IsNil) + c.Check(vars, DeepEquals, map[string]string{ + "recovery_system_status": "", + "try_recovery_system": s.sysLabel, + "snapd_recovery_mode": "run", + "snapd_recovery_system": "", + }) + c.Check(rebootCalls, Equals, 1) + c.Check(s.logs.String(), testutil.Contains, `try recovery system state is inconsistent: unexpected recovery system status "bogus"`) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsTryRecoveryInconsistentMissingLabel(c *C) { + rebootCalls := 0 + restore := boot.MockInitramfsReboot(func() error { + rebootCalls++ + return nil + }) + defer restore() + + bl := bootloadertest.Mock("bootloader", c.MkDir()) + err := bl.SetBootVars(map[string]string{ + "recovery_system_status": "try", + "try_recovery_system": "", + }) + c.Assert(err, IsNil) + bootloader.Force(bl) + defer bootloader.Force(nil) + + s.testInitramfsMountsTryRecoveryInconsistent(c) + + vars, err := bl.GetBootVars("recovery_system_status", "try_recovery_system", + "snapd_recovery_mode", "snapd_recovery_system") + c.Assert(err, IsNil) + c.Check(vars, DeepEquals, map[string]string{ + "recovery_system_status": "", + "try_recovery_system": "", + "snapd_recovery_mode": "run", + "snapd_recovery_system": "", + }) + c.Check(rebootCalls, Equals, 1) + c.Check(s.logs.String(), testutil.Contains, `try recovery system state is inconsistent: try recovery system is unset but status is "try"`) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsTryRecoveryDifferentSystem(c *C) { + rebootCalls := 0 + restore := boot.MockInitramfsReboot(func() error { + rebootCalls++ + return nil + }) + defer restore() + + bl := bootloadertest.Mock("bootloader", c.MkDir()) + bl.BootVars = map[string]string{ + "recovery_system_status": "try", + // a different system is expected to be tried + "try_recovery_system": "1234", + } + bootloader.Force(bl) + defer bootloader.Force(nil) + + hostUbuntuData := filepath.Join(boot.InitramfsRunMntDir, "host/ubuntu-data/") + mockedState := filepath.Join(hostUbuntuData, "system-data/var/lib/snapd/state.json") + c.Assert(os.MkdirAll(filepath.Dir(mockedState), 0750), IsNil) + c.Assert(os.WriteFile(mockedState, []byte(mockStateContent), 0640), IsNil) + + const triedSystem = false + err := s.runInitramfsMountsUnencryptedTryRecovery(c, triedSystem) + c.Assert(err, IsNil) + + // modeenv is written as we will seed the recovery system + modeEnv := dirs.SnapModeenvFileUnder(filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data")) + c.Check(modeEnv, testutil.FileEquals, `mode=recover +recovery_system=20191118 +base=core20_1.snap +gadget=pc_1.snap +model=my-brand/my-model +grade=signed +`) + c.Check(bl.BootVars, DeepEquals, map[string]string{ + // variables not modified since they were set up for a different + // system + "recovery_system_status": "try", + "try_recovery_system": "1234", + // system is set up to go into run mode if rebooted + "snapd_recovery_mode": "run", + "snapd_recovery_system": s.sysLabel, + }) + // no reboot requests + c.Check(rebootCalls, Equals, 0) +} + +func (s *initramfsMountsSuite) testInitramfsMountsTryRecoveryDegraded(c *C, expectedErr string, unlockDataFails, missingSaveKey bool) { + // unlocking data and save failed, thus we consider this candidate + // recovery system unusable + + s.mockProcCmdlineContent(c, "snapd_recovery_mode=recover snapd_recovery_system="+s.sysLabel) + + restore := main.MockPartitionUUIDForBootedKernelDisk("") + defer restore() + + bl := bootloadertest.Mock("bootloader", c.MkDir()) + bl.BootVars = map[string]string{ + "recovery_system_status": "try", + "try_recovery_system": s.sysLabel, + } + bootloader.Force(bl) + defer bootloader.Force(nil) + + mountMappings := map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuSeedDir}: defaultEncBootDisk, + {Mountpoint: boot.InitramfsUbuntuBootDir}: defaultEncBootDisk, + { + Mountpoint: boot.InitramfsUbuntuSaveDir, + IsDecryptedDevice: true, + }: defaultEncBootDisk, + } + mountSequence := []systemdMount{ + s.ubuntuLabelMount("ubuntu-seed", "recover"), + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + s.makeSeedSnapSystemdMount(snap.TypeGadget), + { + "tmpfs", + boot.InitramfsDataDir, + tmpfsMountOpts, + nil, + }, + { + "/dev/disk/by-partuuid/ubuntu-boot-partuuid", + boot.InitramfsUbuntuBootDir, + needsFsckDiskMountOpts, + nil, + }, + } + if !unlockDataFails { + // unlocking data is successful in this scenario + mountMappings[disks.Mountpoint{ + Mountpoint: boot.InitramfsHostUbuntuDataDir, + IsDecryptedDevice: true, + }] = defaultEncBootDisk + // and it got mounted too + mountSequence = append(mountSequence, systemdMount{ + "/dev/mapper/ubuntu-data-random", + boot.InitramfsHostUbuntuDataDir, + needsNoSuidDiskMountOpts, + nil, + }) + } + if !missingSaveKey { + s.mockUbuntuSaveKeyAndMarker(c, filepath.Join(dirs.GlobalRootDir, "/run/mnt/host/ubuntu-data/system-data"), "foo", "marker") + } + + restore = disks.MockMountPointDisksToPartitionMapping(mountMappings) + defer restore() + unlockVolumeWithSealedKeyCalls := 0 + restore = main.MockSecbootUnlockVolumeUsingSealedKeyIfEncrypted(func(disk disks.Disk, name string, sealedEncryptionKeyFile string, opts *secboot.UnlockVolumeUsingSealedKeyOptions) (secboot.UnlockResult, error) { + unlockVolumeWithSealedKeyCalls++ + switch unlockVolumeWithSealedKeyCalls { + + case 1: + // ubuntu data can't be unlocked with run key + c.Assert(name, Equals, "ubuntu-data") + c.Assert(sealedEncryptionKeyFile, Equals, filepath.Join(s.tmpDir, "run/mnt/ubuntu-boot/device/fde/ubuntu-data.sealed-key")) + if unlockDataFails { + // ubuntu-data can't be unlocked with the run key + return foundEncrypted("ubuntu-data"), fmt.Errorf("failed to unlock ubuntu-data with run object") + } + return happyUnlocked("ubuntu-data", secboot.UnlockedWithSealedKey), nil + default: + c.Errorf("unexpected call to UnlockVolumeUsingSealedKeyIfEncrypted (num %d)", unlockVolumeWithSealedKeyCalls) + return secboot.UnlockResult{}, fmt.Errorf("broken test") + } + }) + defer restore() + unlockVolumeWithKeyCalls := 0 + restore = main.MockSecbootUnlockEncryptedVolumeUsingKey(func(disk disks.Disk, name string, key []byte) (secboot.UnlockResult, error) { + unlockVolumeWithKeyCalls++ + switch unlockVolumeWithKeyCalls { + case 1: + if unlockDataFails { + // unlocking data failed, with fallback disabled we should never reach here + return secboot.UnlockResult{}, fmt.Errorf("unexpected call to unlock ubuntu-save, broken test") + } + // no attempts to activate ubuntu-save yet + c.Assert(name, Equals, "ubuntu-save") + c.Assert(key, DeepEquals, []byte("foo")) + return foundEncrypted("ubuntu-save"), fmt.Errorf("failed to unlock ubuntu-save with key object") + default: + c.Fatalf("unexpected call") + return secboot.UnlockResult{}, fmt.Errorf("unexpected call") + } + }) + defer restore() + restore = main.MockSecbootMeasureSnapSystemEpochWhenPossible(func() error { return nil }) + defer restore() + restore = main.MockSecbootMeasureSnapModelWhenPossible(func(findModel func() (*asserts.Model, error)) error { + return nil + }) + defer restore() + + restore = s.mockSystemdMountSequence(c, mountSequence, nil) + defer restore() + + restore = main.MockSecbootLockSealedKeys(func() error { + return nil + }) + defer restore() + + c.Assert(func() { main.Parser().ParseArgs([]string{"initramfs-mounts"}) }, PanicMatches, + expectedErr) + + modeEnv := filepath.Join(boot.InitramfsRunMntDir, "data/system-data/var/lib/snapd/modeenv") + // modeenv is not written when trying out a recovery system + c.Check(modeEnv, testutil.FileAbsent) + + // degraded file is not written out as we always reboot + c.Check(filepath.Join(dirs.SnapBootstrapRunDir, "degraded.json"), testutil.FileAbsent) + + c.Check(bl.BootVars, DeepEquals, map[string]string{ + // variables not modified since the system is unsuccessful + "recovery_system_status": "try", + "try_recovery_system": s.sysLabel, + // system is set up to go into run more if rebooted + "snapd_recovery_mode": "run", + // recovery system is cleared + "snapd_recovery_system": "", + }) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsTryRecoveryDegradedStopAfterData(c *C) { + rebootCalls := 0 + restore := boot.MockInitramfsReboot(func() error { + rebootCalls++ + return nil + }) + defer restore() + + expectedErr := `finalize try recovery system did not reboot, last error: ` + const unlockDataFails = true + const missingSaveKey = true + s.testInitramfsMountsTryRecoveryDegraded(c, expectedErr, unlockDataFails, missingSaveKey) + + // reboot was requested + c.Check(rebootCalls, Equals, 1) + c.Check(s.logs.String(), testutil.Contains, fmt.Sprintf(`try recovery system %q failed: cannot unlock ubuntu-data (fallback disabled)`, s.sysLabel)) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsTryRecoveryDegradedStopAfterSaveUnlockFailed(c *C) { + rebootCalls := 0 + restore := boot.MockInitramfsReboot(func() error { + rebootCalls++ + return nil + }) + defer restore() + + expectedErr := `finalize try recovery system did not reboot, last error: ` + const unlockDataFails = false + const missingSaveKey = false + s.testInitramfsMountsTryRecoveryDegraded(c, expectedErr, unlockDataFails, missingSaveKey) + + // reboot was requested + c.Check(rebootCalls, Equals, 1) + c.Check(s.logs.String(), testutil.Contains, fmt.Sprintf(`try recovery system %q failed: cannot unlock ubuntu-save (fallback disabled)`, s.sysLabel)) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsTryRecoveryDegradedStopAfterSaveMissingKey(c *C) { + rebootCalls := 0 + restore := boot.MockInitramfsReboot(func() error { + rebootCalls++ + return nil + }) + defer restore() + + expectedErr := `finalize try recovery system did not reboot, last error: ` + const unlockDataFails = false + const missingSaveKey = true + s.testInitramfsMountsTryRecoveryDegraded(c, expectedErr, unlockDataFails, missingSaveKey) + + // reboot was requested + c.Check(rebootCalls, Equals, 1) + c.Check(s.logs.String(), testutil.Contains, fmt.Sprintf(`try recovery system %q failed: cannot unlock ubuntu-save (fallback disabled)`, s.sysLabel)) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsTryRecoveryDegradedRebootFails(c *C) { + rebootCalls := 0 + restore := boot.MockInitramfsReboot(func() error { + rebootCalls++ + return fmt.Errorf("reboot fails") + }) + defer restore() + + expectedErr := `finalize try recovery system did not reboot, last error: cannot reboot to run system: reboot fails` + const unlockDataFails = false + const unlockSaveFails = false + s.testInitramfsMountsTryRecoveryDegraded(c, expectedErr, unlockDataFails, unlockSaveFails) + + // reboot was requested + c.Check(rebootCalls, Equals, 1) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsTryRecoveryHealthCheckFails(c *C) { + rebootCalls := 0 + restore := boot.MockInitramfsReboot(func() error { + rebootCalls++ + return nil + }) + defer restore() + + bl := bootloadertest.Mock("bootloader", c.MkDir()) + bl.BootVars = map[string]string{ + "recovery_system_status": "try", + "try_recovery_system": s.sysLabel, + } + bootloader.Force(bl) + defer bootloader.Force(nil) + + // prepare some state for the recovery process to reach a point where + // the health check can be executed + hostUbuntuData := filepath.Join(boot.InitramfsRunMntDir, "host/ubuntu-data/") + mockedState := filepath.Join(hostUbuntuData, "system-data/var/lib/snapd/state.json") + c.Assert(os.MkdirAll(filepath.Dir(mockedState), 0750), IsNil) + c.Assert(os.WriteFile(mockedState, []byte(mockStateContent), 0640), IsNil) + + restore = main.MockTryRecoverySystemHealthCheck(func(gadget.Model) error { + return fmt.Errorf("mock failure") + }) + defer restore() + + const triedSystem = true + err := s.runInitramfsMountsUnencryptedTryRecovery(c, triedSystem) + c.Assert(err, ErrorMatches, `finalize try recovery system did not reboot, last error: `) + + modeEnv := filepath.Join(boot.InitramfsRunMntDir, "data/system-data/var/lib/snapd/modeenv") + // modeenv is not written when trying out a recovery system + c.Check(modeEnv, testutil.FileAbsent) + c.Check(bl.BootVars, DeepEquals, map[string]string{ + // variables not modified since the health check failed + "recovery_system_status": "try", + "try_recovery_system": s.sysLabel, + // but system is set up to go back to run mode + "snapd_recovery_mode": "run", + "snapd_recovery_system": "", + }) + // reboot was requested + c.Check(rebootCalls, Equals, 1) + c.Check(s.logs.String(), testutil.Contains, `try recovery system health check failed: mock failure`) +} + +func (s *initramfsMountsSuite) TestMountNonDataPartitionPolls(c *C) { + restore := main.MockPartitionUUIDForBootedKernelDisk("some-uuid") + defer restore() + + var waitFile []string + var pollWait time.Duration + var pollIterations int + restore = main.MockWaitFile(func(path string, wait time.Duration, n int) error { + waitFile = append(waitFile, path) + pollWait = wait + pollIterations = n + return fmt.Errorf("error") + }) + defer restore() + + n := 0 + restore = main.MockSystemdMount(func(what, where string, opts *main.SystemdMountOptions) error { + n++ + return nil + }) + defer restore() + + err := main.MountNonDataPartitionMatchingKernelDisk("/some/target", "") + c.Check(err, ErrorMatches, "cannot find device: error") + c.Check(n, Equals, 0) + c.Check(waitFile, DeepEquals, []string{ + filepath.Join(dirs.GlobalRootDir, "/dev/disk/by-partuuid/some-uuid"), + }) + c.Check(pollWait, DeepEquals, 50*time.Millisecond) + c.Check(pollIterations, DeepEquals, 1200) + c.Check(s.logs.String(), Matches, "(?m).* waiting up to 1m0s for /dev/disk/by-partuuid/some-uuid to appear") + // there is only a single log msg + c.Check(strings.Count(s.logs.String(), "\n"), Equals, 1) +} + +func (s *initramfsMountsSuite) TestMountNonDataPartitionNoPollNoLogMsg(c *C) { + restore := main.MockPartitionUUIDForBootedKernelDisk("some-uuid") + defer restore() + + n := 0 + restore = main.MockSystemdMount(func(what, where string, opts *main.SystemdMountOptions) error { + n++ + return nil + }) + defer restore() + + fakedPartSrc := filepath.Join(dirs.GlobalRootDir, "/dev/disk/by-partuuid/some-uuid") + err := os.MkdirAll(filepath.Dir(fakedPartSrc), 0755) + c.Assert(err, IsNil) + err = os.WriteFile(fakedPartSrc, nil, 0644) + c.Assert(err, IsNil) + + err = main.MountNonDataPartitionMatchingKernelDisk("some-target", "") + c.Check(err, IsNil) + c.Check(s.logs.String(), Equals, "") + c.Check(n, Equals, 1) +} + +func (s *initramfsMountsSuite) TestWaitFileErr(c *C) { + err := main.WaitFile("/dev/does-not-exist", 10*time.Millisecond, 2) + c.Check(err, ErrorMatches, "no /dev/does-not-exist after waiting for 20ms") +} + +func (s *initramfsMountsSuite) TestWaitFile(c *C) { + existingPartSrc := filepath.Join(c.MkDir(), "does-exist") + err := os.WriteFile(existingPartSrc, nil, 0644) + c.Assert(err, IsNil) + + err = main.WaitFile(existingPartSrc, 5000*time.Second, 1) + c.Check(err, IsNil) + + err = main.WaitFile(existingPartSrc, 1*time.Second, 10000) + c.Check(err, IsNil) +} + +func (s *initramfsMountsSuite) TestWaitFileWorksWithFilesAppearingLate(c *C) { + eventuallyExists := filepath.Join(c.MkDir(), "eventually-exists") + go func() { + time.Sleep(40 * time.Millisecond) + err := os.WriteFile(eventuallyExists, nil, 0644) + c.Assert(err, IsNil) + }() + + err := main.WaitFile(eventuallyExists, 5*time.Millisecond, 1000) + c.Check(err, IsNil) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsFactoryResetModeHappyEncrypted(c *C) { + s.mockProcCmdlineContent(c, "snapd_recovery_mode=factory-reset snapd_recovery_system="+s.sysLabel) + + restore := main.MockPartitionUUIDForBootedKernelDisk("") + defer restore() + + // setup a bootloader for setting the bootenv after we are done + bloader := bootloadertest.Mock("mock", c.MkDir()) + bootloader.Force(bloader) + defer bootloader.Force(nil) + + restore = disks.MockMountPointDisksToPartitionMapping( + map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuSeedDir}: defaultEncBootDisk, + { + Mountpoint: boot.InitramfsUbuntuSaveDir, + IsDecryptedDevice: true, + }: defaultEncBootDisk, + }, + ) + defer restore() + + saveActivated := false + restore = main.MockSecbootUnlockVolumeUsingSealedKeyIfEncrypted(func(disk disks.Disk, name string, sealedEncryptionKeyFile string, opts *secboot.UnlockVolumeUsingSealedKeyOptions) (secboot.UnlockResult, error) { + c.Assert(name, Equals, "ubuntu-save") + c.Assert(sealedEncryptionKeyFile, Equals, filepath.Join(s.tmpDir, "run/mnt/ubuntu-seed/device/fde/ubuntu-save.recovery.sealed-key")) + + encDevPartUUID, err := disk.FindMatchingPartitionUUIDWithFsLabel(name + "-enc") + c.Assert(err, IsNil) + c.Assert(encDevPartUUID, Equals, "ubuntu-save-enc-partuuid") + c.Assert(opts.AllowRecoveryKey, Equals, true) + c.Assert(opts.WhichModel, NotNil) + mod, err := opts.WhichModel() + c.Assert(err, IsNil) + c.Check(mod.Model(), Equals, "my-model") + + saveActivated = true + return happyUnlocked("ubuntu-save", secboot.UnlockedWithSealedKey), nil + }) + defer restore() + + restore = main.MockSecbootUnlockEncryptedVolumeUsingKey(func(disk disks.Disk, name string, key []byte) (secboot.UnlockResult, error) { + c.Errorf("unexpected call") + return secboot.UnlockResult{}, fmt.Errorf("unexpected call") + }) + defer restore() + + measureEpochCalls := 0 + measureModelCalls := 0 + restore = main.MockSecbootMeasureSnapSystemEpochWhenPossible(func() error { + measureEpochCalls++ + return nil + }) + defer restore() + + var measuredModel *asserts.Model + restore = main.MockSecbootMeasureSnapModelWhenPossible(func(findModel func() (*asserts.Model, error)) error { + measureModelCalls++ + var err error + measuredModel, err = findModel() + if err != nil { + return err + } + return nil + }) + defer restore() + + restore = s.mockSystemdMountSequence(c, []systemdMount{ + s.ubuntuLabelMount("ubuntu-seed", "recover"), + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + s.makeSeedSnapSystemdMount(snap.TypeGadget), + { + "tmpfs", + boot.InitramfsDataDir, + tmpfsMountOpts, + nil, + }, + { + "/dev/mapper/ubuntu-save-random", + boot.InitramfsUbuntuSaveDir, + mountOpts, + nil, + }, + }, nil) + defer restore() + + // ensure that we check that access to sealed keys were locked + sealedKeysLocked := false + restore = main.MockSecbootLockSealedKeys(func() error { + sealedKeysLocked = true + return nil + }) + defer restore() + + // ubuntu-data in ephemeral system + ephemeralUbuntuData := filepath.Join(boot.InitramfsRunMntDir, "data/") + err := os.MkdirAll(ephemeralUbuntuData, 0755) + c.Assert(err, IsNil) + + _, err = main.Parser().ParseArgs([]string{"initramfs-mounts"}) + c.Assert(err, IsNil) + + // we always need to lock access to sealed keys + c.Check(sealedKeysLocked, Equals, true) + + modeEnv := filepath.Join(ephemeralUbuntuData, "/system-data/var/lib/snapd/modeenv") + c.Check(modeEnv, testutil.FileEquals, `mode=factory-reset +recovery_system=20191118 +base=core20_1.snap +gadget=pc_1.snap +model=my-brand/my-model +grade=signed +`) + + // we should have written a boot state file + checkDegradedJSON(c, "factory-reset-bootstrap.json", map[string]interface{}{ + "ubuntu-boot": map[string]interface{}{}, + "ubuntu-data": map[string]interface{}{}, + "ubuntu-save": map[string]interface{}{ + "device": "/dev/mapper/ubuntu-save-random", + "find-state": "found", + "unlock-state": "unlocked", + "unlock-key": "fallback", + "mount-state": "mounted", + "mount-location": boot.InitramfsUbuntuSaveDir, + }, + "error-log": []interface{}{}, + }) + + c.Check(saveActivated, Equals, true) + c.Check(measureEpochCalls, Equals, 1) + c.Check(measureModelCalls, Equals, 1) + c.Check(measuredModel, DeepEquals, s.model) + + c.Assert(filepath.Join(dirs.SnapBootstrapRunDir, "secboot-epoch-measured"), testutil.FilePresent) + c.Assert(filepath.Join(dirs.SnapBootstrapRunDir, fmt.Sprintf("%s-model-measured", s.sysLabel)), testutil.FilePresent) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsFactoryResetModeHappyUnencrypted(c *C) { + s.mockProcCmdlineContent(c, "snapd_recovery_mode=factory-reset snapd_recovery_system="+s.sysLabel) + + restore := main.MockPartitionUUIDForBootedKernelDisk("") + defer restore() + + // setup a bootloader for setting the bootenv after we are done + bloader := bootloadertest.Mock("mock", c.MkDir()) + bootloader.Force(bloader) + defer bootloader.Force(nil) + + restore = disks.MockMountPointDisksToPartitionMapping( + map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuSeedDir}: defaultBootWithSaveDisk, + {Mountpoint: boot.InitramfsUbuntuSaveDir}: defaultBootWithSaveDisk, + }, + ) + defer restore() + + restore = main.MockSecbootUnlockVolumeUsingSealedKeyIfEncrypted(func(disk disks.Disk, name string, sealedEncryptionKeyFile string, opts *secboot.UnlockVolumeUsingSealedKeyOptions) (secboot.UnlockResult, error) { + c.Errorf("unexpected call") + return secboot.UnlockResult{}, fmt.Errorf("unexpected call") + }) + defer restore() + restore = main.MockSecbootUnlockEncryptedVolumeUsingKey(func(disk disks.Disk, name string, key []byte) (secboot.UnlockResult, error) { + c.Errorf("unexpected call") + return secboot.UnlockResult{}, fmt.Errorf("unexpected call") + }) + defer restore() + restore = main.MockSecbootMeasureSnapSystemEpochWhenPossible(func() error { return nil }) + defer restore() + restore = main.MockSecbootMeasureSnapModelWhenPossible(func(findModel func() (*asserts.Model, error)) error { + return nil + }) + defer restore() + + restore = s.mockSystemdMountSequence(c, []systemdMount{ + s.ubuntuLabelMount("ubuntu-seed", "recover"), + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + s.makeSeedSnapSystemdMount(snap.TypeGadget), + { + "tmpfs", + boot.InitramfsDataDir, + tmpfsMountOpts, + nil, + }, + { + "/dev/disk/by-partuuid/ubuntu-save-partuuid", + boot.InitramfsUbuntuSaveDir, + mountOpts, + nil, + }, + }, nil) + defer restore() + + // ubuntu-data in ephemeral system + ephemeralUbuntuData := filepath.Join(boot.InitramfsRunMntDir, "data/") + err := os.MkdirAll(ephemeralUbuntuData, 0755) + c.Assert(err, IsNil) + + _, err = main.Parser().ParseArgs([]string{"initramfs-mounts"}) + c.Assert(err, IsNil) + + modeEnv := filepath.Join(ephemeralUbuntuData, "/system-data/var/lib/snapd/modeenv") + c.Check(modeEnv, testutil.FileEquals, `mode=factory-reset +recovery_system=20191118 +base=core20_1.snap +gadget=pc_1.snap +model=my-brand/my-model +grade=signed +`) + // we should have written a boot state file + checkDegradedJSON(c, "factory-reset-bootstrap.json", map[string]interface{}{ + "ubuntu-boot": map[string]interface{}{}, + "ubuntu-data": map[string]interface{}{}, + "ubuntu-save": map[string]interface{}{ + "device": "/dev/disk/by-partuuid/ubuntu-save-partuuid", + "find-state": "found", + "mount-state": "mounted", + "mount-location": boot.InitramfsUbuntuSaveDir, + }, + "error-log": []interface{}{}, + }) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsFactoryResetModeHappyUnencryptedNoSave(c *C) { + s.mockProcCmdlineContent(c, "snapd_recovery_mode=factory-reset snapd_recovery_system="+s.sysLabel) + + restore := main.MockPartitionUUIDForBootedKernelDisk("") + defer restore() + + // setup a bootloader for setting the bootenv after we are done + bloader := bootloadertest.Mock("mock", c.MkDir()) + bootloader.Force(bloader) + defer bootloader.Force(nil) + + restore = disks.MockMountPointDisksToPartitionMapping( + map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuSeedDir}: defaultBootDisk, + }, + ) + defer restore() + + restore = main.MockSecbootUnlockVolumeUsingSealedKeyIfEncrypted(func(disk disks.Disk, name string, sealedEncryptionKeyFile string, opts *secboot.UnlockVolumeUsingSealedKeyOptions) (secboot.UnlockResult, error) { + c.Errorf("unexpected call") + return secboot.UnlockResult{}, fmt.Errorf("unexpected call") + }) + defer restore() + restore = main.MockSecbootUnlockEncryptedVolumeUsingKey(func(disk disks.Disk, name string, key []byte) (secboot.UnlockResult, error) { + c.Errorf("unexpected call") + return secboot.UnlockResult{}, fmt.Errorf("unexpected call") + }) + defer restore() + restore = main.MockSecbootMeasureSnapSystemEpochWhenPossible(func() error { return nil }) + defer restore() + restore = main.MockSecbootMeasureSnapModelWhenPossible(func(findModel func() (*asserts.Model, error)) error { + return nil + }) + defer restore() + + restore = s.mockSystemdMountSequence(c, []systemdMount{ + s.ubuntuLabelMount("ubuntu-seed", "recover"), + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + s.makeSeedSnapSystemdMount(snap.TypeGadget), + { + "tmpfs", + boot.InitramfsDataDir, + tmpfsMountOpts, + nil, + }, + }, nil) + defer restore() + + // ubuntu-data in ephemeral system + ephemeralUbuntuData := filepath.Join(boot.InitramfsRunMntDir, "data/") + err := os.MkdirAll(ephemeralUbuntuData, 0755) + c.Assert(err, IsNil) + + _, err = main.Parser().ParseArgs([]string{"initramfs-mounts"}) + c.Assert(err, IsNil) + + modeEnv := filepath.Join(ephemeralUbuntuData, "/system-data/var/lib/snapd/modeenv") + c.Check(modeEnv, testutil.FileEquals, `mode=factory-reset +recovery_system=20191118 +base=core20_1.snap +gadget=pc_1.snap +model=my-brand/my-model +grade=signed +`) + // we should have written a boot state file with save marked as + // absent-but-optional + checkDegradedJSON(c, "factory-reset-bootstrap.json", map[string]interface{}{ + "ubuntu-boot": map[string]interface{}{}, + "ubuntu-data": map[string]interface{}{}, + "ubuntu-save": map[string]interface{}{ + "find-state": "not-found", + "mount-state": "absent-but-optional", + }, + "error-log": []interface{}{}, + }) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsFactoryResetModeUnhappyUnlockEncrypted(c *C) { + s.mockProcCmdlineContent(c, "snapd_recovery_mode=factory-reset snapd_recovery_system="+s.sysLabel) + + restore := main.MockPartitionUUIDForBootedKernelDisk("") + defer restore() + + // setup a bootloader for setting the bootenv after we are done + bloader := bootloadertest.Mock("mock", c.MkDir()) + bootloader.Force(bloader) + defer bootloader.Force(nil) + + restore = disks.MockMountPointDisksToPartitionMapping( + map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuSeedDir}: defaultEncBootDisk, + }, + ) + defer restore() + + saveActivated := false + restore = main.MockSecbootUnlockVolumeUsingSealedKeyIfEncrypted(func(disk disks.Disk, name string, sealedEncryptionKeyFile string, opts *secboot.UnlockVolumeUsingSealedKeyOptions) (secboot.UnlockResult, error) { + c.Assert(name, Equals, "ubuntu-save") + c.Assert(sealedEncryptionKeyFile, Equals, filepath.Join(s.tmpDir, "run/mnt/ubuntu-seed/device/fde/ubuntu-save.recovery.sealed-key")) + + encDevPartUUID, err := disk.FindMatchingPartitionUUIDWithFsLabel(name + "-enc") + c.Assert(err, IsNil) + c.Assert(encDevPartUUID, Equals, "ubuntu-save-enc-partuuid") + saveActivated = true + return foundEncrypted("ubuntu-save"), fmt.Errorf("ubuntu-save unlock fail") + }) + defer restore() + + restore = main.MockSecbootUnlockEncryptedVolumeUsingKey(func(disk disks.Disk, name string, key []byte) (secboot.UnlockResult, error) { + c.Errorf("unexpected call") + return secboot.UnlockResult{}, fmt.Errorf("unexpected call") + }) + defer restore() + + measureEpochCalls := 0 + measureModelCalls := 0 + restore = main.MockSecbootMeasureSnapSystemEpochWhenPossible(func() error { + measureEpochCalls++ + return nil + }) + defer restore() + + var measuredModel *asserts.Model + restore = main.MockSecbootMeasureSnapModelWhenPossible(func(findModel func() (*asserts.Model, error)) error { + measureModelCalls++ + var err error + measuredModel, err = findModel() + if err != nil { + return err + } + return nil + }) + defer restore() + + restore = s.mockSystemdMountSequence(c, []systemdMount{ + s.ubuntuLabelMount("ubuntu-seed", "recover"), + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + s.makeSeedSnapSystemdMount(snap.TypeGadget), + { + "tmpfs", + boot.InitramfsDataDir, + tmpfsMountOpts, + nil, + }, + }, nil) + defer restore() + + // ensure that we check that access to sealed keys were locked + sealedKeysLocked := false + restore = main.MockSecbootLockSealedKeys(func() error { + sealedKeysLocked = true + return nil + }) + defer restore() + + // ubuntu-data in ephemeral system + ephemeralUbuntuData := filepath.Join(boot.InitramfsRunMntDir, "data/") + err := os.MkdirAll(ephemeralUbuntuData, 0755) + c.Assert(err, IsNil) + + _, err = main.Parser().ParseArgs([]string{"initramfs-mounts"}) + c.Assert(err, IsNil) + + // we always need to lock access to sealed keys + c.Check(sealedKeysLocked, Equals, true) + + modeEnv := filepath.Join(ephemeralUbuntuData, "/system-data/var/lib/snapd/modeenv") + c.Check(modeEnv, testutil.FileEquals, `mode=factory-reset +recovery_system=20191118 +base=core20_1.snap +gadget=pc_1.snap +model=my-brand/my-model +grade=signed +`) + + // we should have written a boot state file + checkDegradedJSON(c, "factory-reset-bootstrap.json", map[string]interface{}{ + "ubuntu-boot": map[string]interface{}{}, + "ubuntu-data": map[string]interface{}{}, + "ubuntu-save": map[string]interface{}{ + "device": "/dev/disk/by-partuuid/ubuntu-save-enc-partuuid", + "unlock-state": "error-unlocking", + "find-state": "found", + }, + "error-log": []interface{}{ + "cannot unlock encrypted ubuntu-save partition with sealed fallback key: ubuntu-save unlock fail", + }, + }) + + c.Check(saveActivated, Equals, true) + c.Check(measureEpochCalls, Equals, 1) + c.Check(measureModelCalls, Equals, 1) + c.Check(measuredModel, DeepEquals, s.model) + + c.Assert(filepath.Join(dirs.SnapBootstrapRunDir, "secboot-epoch-measured"), testutil.FilePresent) + c.Assert(filepath.Join(dirs.SnapBootstrapRunDir, fmt.Sprintf("%s-model-measured", s.sysLabel)), testutil.FilePresent) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsFactoryResetModeUnhappyMountEncrypted(c *C) { + s.mockProcCmdlineContent(c, "snapd_recovery_mode=factory-reset snapd_recovery_system="+s.sysLabel) + + restore := main.MockPartitionUUIDForBootedKernelDisk("") + defer restore() + + // setup a bootloader for setting the bootenv after we are done + bloader := bootloadertest.Mock("mock", c.MkDir()) + bootloader.Force(bloader) + defer bootloader.Force(nil) + + restore = disks.MockMountPointDisksToPartitionMapping( + map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuSeedDir}: defaultEncBootDisk, + }, + ) + defer restore() + + saveActivated := false + restore = main.MockSecbootUnlockVolumeUsingSealedKeyIfEncrypted(func(disk disks.Disk, name string, sealedEncryptionKeyFile string, opts *secboot.UnlockVolumeUsingSealedKeyOptions) (secboot.UnlockResult, error) { + c.Assert(name, Equals, "ubuntu-save") + c.Assert(sealedEncryptionKeyFile, Equals, filepath.Join(s.tmpDir, "run/mnt/ubuntu-seed/device/fde/ubuntu-save.recovery.sealed-key")) + + encDevPartUUID, err := disk.FindMatchingPartitionUUIDWithFsLabel(name + "-enc") + c.Assert(err, IsNil) + c.Assert(encDevPartUUID, Equals, "ubuntu-save-enc-partuuid") + saveActivated = true + // all went good here + return happyUnlocked("ubuntu-save", secboot.UnlockedWithSealedKey), nil + }) + defer restore() + + restore = main.MockSecbootMeasureSnapSystemEpochWhenPossible(func() error { return nil }) + defer restore() + restore = main.MockSecbootMeasureSnapModelWhenPossible(func(findModel func() (*asserts.Model, error)) error { + return nil + }) + defer restore() + + restore = s.mockSystemdMountSequence(c, []systemdMount{ + s.ubuntuLabelMount("ubuntu-seed", "recover"), + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + s.makeSeedSnapSystemdMount(snap.TypeGadget), + { + "tmpfs", + boot.InitramfsDataDir, + tmpfsMountOpts, + nil, + }, + { + "/dev/mapper/ubuntu-save-random", + boot.InitramfsUbuntuSaveDir, + mountOpts, + fmt.Errorf("mount failed"), + }, + }, nil) + defer restore() + + // ensure that we check that access to sealed keys were locked + sealedKeysLocked := false + restore = main.MockSecbootLockSealedKeys(func() error { + sealedKeysLocked = true + return nil + }) + defer restore() + + // ubuntu-data in ephemeral system + ephemeralUbuntuData := filepath.Join(boot.InitramfsRunMntDir, "data/") + err := os.MkdirAll(ephemeralUbuntuData, 0755) + c.Assert(err, IsNil) + + _, err = main.Parser().ParseArgs([]string{"initramfs-mounts"}) + c.Assert(err, IsNil) + + // we always need to lock access to sealed keys + c.Check(sealedKeysLocked, Equals, true) + + modeEnv := filepath.Join(ephemeralUbuntuData, "/system-data/var/lib/snapd/modeenv") + c.Check(modeEnv, testutil.FileEquals, `mode=factory-reset +recovery_system=20191118 +base=core20_1.snap +gadget=pc_1.snap +model=my-brand/my-model +grade=signed +`) + + // we should have written a boot state file + checkDegradedJSON(c, "factory-reset-bootstrap.json", map[string]interface{}{ + "ubuntu-boot": map[string]interface{}{}, + "ubuntu-data": map[string]interface{}{}, + "ubuntu-save": map[string]interface{}{ + "device": "/dev/mapper/ubuntu-save-random", + "unlock-state": "unlocked", + "unlock-key": "fallback", + "find-state": "found", + "mount-state": "error-mounting", + }, + "error-log": []interface{}{ + "cannot mount ubuntu-save: mount failed", + }, + }) + + c.Check(saveActivated, Equals, true) + c.Assert(filepath.Join(dirs.SnapBootstrapRunDir, "secboot-epoch-measured"), testutil.FilePresent) + c.Assert(filepath.Join(dirs.SnapBootstrapRunDir, fmt.Sprintf("%s-model-measured", s.sysLabel)), testutil.FilePresent) +} + +type initramfsClassicMountsSuite struct { + baseInitramfsMountsSuite +} + +var _ = Suite(&initramfsClassicMountsSuite{}) + +func (s *initramfsClassicMountsSuite) SetUpTest(c *C) { + s.isClassic = true + s.baseInitramfsMountsSuite.SetUpTest(c) +} + +func writeGadget(c *C, espName, espRole, espLabel string) { + gadgetYaml := ` +volumes: + pc: + bootloader: grub + structure: + - name: ` + espName + + if espRole != "" { + gadgetYaml += ` + role: ` + espRole + } + if espLabel != "" { + gadgetYaml += ` + filesystem-label: ` + espLabel + } + + gadgetYaml += ` + filesystem: vfat + type: EF,C12A7328-F81F-11D2-BA4B-00A0C93EC93B + size: 99M + - name: ubuntu-boot + role: system-boot + filesystem: ext4 + type: 83,0FC63DAF-8483-4772-8E79-3D69D8477DE4 + offset: 1202M + size: 750M + - name: ubuntu-save + role: system-save + filesystem: ext4 + type: 83,0FC63DAF-8483-4772-8E79-3D69D8477DE4 + size: 16M + - name: ubuntu-data + role: system-data + filesystem: ext4 + type: 83,0FC63DAF-8483-4772-8E79-3D69D8477DE4 + size: 4312776192 +` + var err error + gadgetDir := filepath.Join(boot.InitramfsRunMntDir, "gadget", "meta") + err = os.MkdirAll(gadgetDir, 0755) + c.Assert(err, IsNil) + err = osutil.AtomicWriteFile(filepath.Join(gadgetDir, "gadget.yaml"), []byte(gadgetYaml), 0644, 0) + c.Assert(err, IsNil) +} + +func (s *initramfsClassicMountsSuite) TestInitramfsMountsRunModeUnencryptedWithSaveHappy(c *C) { + s.mockProcCmdlineContent(c, "snapd_recovery_mode=run") + + restore := disks.MockMountPointDisksToPartitionMapping( + map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuSeedDir}: defaultBootWithSaveDisk, + {Mountpoint: boot.InitramfsUbuntuBootDir}: defaultBootWithSaveDisk, + {Mountpoint: boot.InitramfsDataDir}: defaultBootWithSaveDisk, + {Mountpoint: boot.InitramfsUbuntuSaveDir}: defaultBootWithSaveDisk, + }, + ) + defer restore() + + restore = s.mockSystemdMountSequence(c, []systemdMount{ + s.ubuntuLabelMount("ubuntu-boot", "run"), + s.ubuntuPartUUIDMount("ubuntu-seed-partuuid", "run"), + s.ubuntuPartUUIDMount("ubuntu-data-partuuid", "run"), + s.ubuntuPartUUIDMount("ubuntu-save-partuuid", "run"), + s.makeRunSnapSystemdMount(snap.TypeGadget, s.gadget), + s.makeRunSnapSystemdMount(snap.TypeKernel, s.kernel), + }, nil) + defer restore() + + // mock a bootloader + bloader := boottest.MockUC20RunBootenv(bootloadertest.Mock("mock", c.MkDir())) + bootloader.Force(bloader) + defer bootloader.Force(nil) + + // set the current kernel + restore = bloader.SetEnabledKernel(s.kernel) + defer restore() + + s.makeSnapFilesOnEarlyBootUbuntuData(c, s.kernel, s.core20, s.gadget) + + // write modeenv + modeEnv := boot.Modeenv{ + Mode: "run", + Base: s.core20.Filename(), + Gadget: s.gadget.Filename(), + CurrentKernels: []string{s.kernel.Filename()}, + } + err := modeEnv.WriteTo(boot.InitramfsDataDir) + c.Assert(err, IsNil) + + // write gadget.yaml, which is checked for classic + writeGadget(c, "ubuntu-seed", "system-seed", "") + + _, err = main.Parser().ParseArgs([]string{"initramfs-mounts"}) + c.Assert(err, IsNil) +} + +func (s *initramfsClassicMountsSuite) TestInitramfsMountsRunModeUnencryptedSeedPartNotInGadget(c *C) { + s.mockProcCmdlineContent(c, "snapd_recovery_mode=run") + + restore := disks.MockMountPointDisksToPartitionMapping( + map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuSeedDir}: defaultBootWithSaveDisk, + {Mountpoint: boot.InitramfsUbuntuBootDir}: defaultBootWithSaveDisk, + {Mountpoint: boot.InitramfsDataDir}: defaultBootWithSaveDisk, + {Mountpoint: boot.InitramfsUbuntuSaveDir}: defaultBootWithSaveDisk, + }, + ) + defer restore() + + restore = s.mockSystemdMountSequence(c, []systemdMount{ + s.ubuntuLabelMount("ubuntu-boot", "run"), + s.ubuntuPartUUIDMount("ubuntu-seed-partuuid", "run"), + s.ubuntuPartUUIDMount("ubuntu-data-partuuid", "run"), + s.ubuntuPartUUIDMount("ubuntu-save-partuuid", "run"), + s.makeRunSnapSystemdMount(snap.TypeGadget, s.gadget), + s.makeRunSnapSystemdMount(snap.TypeKernel, s.kernel), + }, nil) + defer restore() + + // mock a bootloader + bloader := boottest.MockUC20RunBootenv(bootloadertest.Mock("mock", c.MkDir())) + bootloader.Force(bloader) + defer bootloader.Force(nil) + + // set the current kernel + restore = bloader.SetEnabledKernel(s.kernel) + defer restore() + + s.makeSnapFilesOnEarlyBootUbuntuData(c, s.kernel, s.core20, s.gadget) + + // write modeenv + modeEnv := boot.Modeenv{ + Mode: "run", + Base: s.core20.Filename(), + Gadget: s.gadget.Filename(), + CurrentKernels: []string{s.kernel.Filename()}, + } + err := modeEnv.WriteTo(boot.InitramfsDataDir) + c.Assert(err, IsNil) + + // write gadget.yaml with no ubuntu-seed label + writeGadget(c, "EFI System partition", "", "") + + _, err = main.Parser().ParseArgs([]string{"initramfs-mounts"}) + c.Assert(err, ErrorMatches, "ubuntu-seed partition found but not defined in the gadget") +} + +func (s *initramfsClassicMountsSuite) TestInitramfsMountsRunModeUnencryptedSeedInGadgetNotInVolume(c *C) { + s.mockProcCmdlineContent(c, "snapd_recovery_mode=run") + + restore := disks.MockMountPointDisksToPartitionMapping( + map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuBootDir}: defaultNoSeedWithSaveDisk, + {Mountpoint: boot.InitramfsDataDir}: defaultNoSeedWithSaveDisk, + {Mountpoint: boot.InitramfsUbuntuSaveDir}: defaultNoSeedWithSaveDisk, + }, + ) + defer restore() + + restore = s.mockSystemdMountSequence(c, []systemdMount{ + s.ubuntuLabelMount("ubuntu-boot", "run"), + s.ubuntuPartUUIDMount("ubuntu-data-partuuid", "run"), + s.ubuntuPartUUIDMount("ubuntu-save-partuuid", "run"), + s.makeRunSnapSystemdMount(snap.TypeGadget, s.gadget), + s.makeRunSnapSystemdMount(snap.TypeKernel, s.kernel), + }, nil) + defer restore() + + // mock a bootloader + bloader := boottest.MockUC20RunBootenv(bootloadertest.Mock("mock", c.MkDir())) + bootloader.Force(bloader) + defer bootloader.Force(nil) + + // set the current kernel + restore = bloader.SetEnabledKernel(s.kernel) + defer restore() + + s.makeSnapFilesOnEarlyBootUbuntuData(c, s.kernel, s.core20, s.gadget) + + // write modeenv + modeEnv := boot.Modeenv{ + Mode: "run", + Base: s.core20.Filename(), + Gadget: s.gadget.Filename(), + CurrentKernels: []string{s.kernel.Filename()}, + } + err := modeEnv.WriteTo(boot.InitramfsDataDir) + c.Assert(err, IsNil) + + // write gadget.yaml, which is checked for classic + writeGadget(c, "ubuntu-seed", "system-seed", "") + + _, err = main.Parser().ParseArgs([]string{"initramfs-mounts"}) + c.Assert(err, ErrorMatches, `ubuntu-seed partition not found but defined in the gadget \(system-seed\)`) +} + +func (s *initramfsClassicMountsSuite) TestInitramfsMountsRunModeUnencryptedNoSeedHappy(c *C) { + s.mockProcCmdlineContent(c, "snapd_recovery_mode=run") + + restore := disks.MockMountPointDisksToPartitionMapping( + map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuBootDir}: defaultNoSeedWithSaveDisk, + {Mountpoint: boot.InitramfsDataDir}: defaultNoSeedWithSaveDisk, + {Mountpoint: boot.InitramfsUbuntuSaveDir}: defaultNoSeedWithSaveDisk, + }, + ) + defer restore() + + restore = s.mockSystemdMountSequence(c, []systemdMount{ + s.ubuntuLabelMount("ubuntu-boot", "run"), + s.ubuntuPartUUIDMount("ubuntu-data-partuuid", "run"), + s.ubuntuPartUUIDMount("ubuntu-save-partuuid", "run"), + s.makeRunSnapSystemdMount(snap.TypeGadget, s.gadget), + s.makeRunSnapSystemdMount(snap.TypeKernel, s.kernel), + }, nil) + defer restore() + + // mock a bootloader + bloader := boottest.MockUC20RunBootenv(bootloadertest.Mock("mock", c.MkDir())) + bootloader.Force(bloader) + defer bootloader.Force(nil) + + // set the current kernel + restore = bloader.SetEnabledKernel(s.kernel) + defer restore() + + s.makeSnapFilesOnEarlyBootUbuntuData(c, s.kernel, s.core20, s.gadget) + + // write modeenv + modeEnv := boot.Modeenv{ + Mode: "run", + Base: s.core20.Filename(), + Gadget: s.gadget.Filename(), + CurrentKernels: []string{s.kernel.Filename()}, + } + err := modeEnv.WriteTo(boot.InitramfsDataDir) + c.Assert(err, IsNil) + + // write gadget.yaml with no ubuntu-seed label and no role + writeGadget(c, "EFI System partition", "", "") + + _, err = main.Parser().ParseArgs([]string{"initramfs-mounts"}) + c.Assert(err, IsNil) +} + +func (s *initramfsClassicMountsSuite) TestInitramfsMountsRunModeHappySystemSeedNull(c *C) { + s.mockProcCmdlineContent(c, "snapd_recovery_mode=run") + + restore := disks.MockMountPointDisksToPartitionMapping( + map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuSeedDir}: defaultBootWithSaveDisk, + {Mountpoint: boot.InitramfsUbuntuBootDir}: defaultBootWithSaveDisk, + {Mountpoint: boot.InitramfsDataDir}: defaultBootWithSaveDisk, + {Mountpoint: boot.InitramfsUbuntuSaveDir}: defaultBootWithSaveDisk, + }, + ) + defer restore() + + restore = s.mockSystemdMountSequence(c, []systemdMount{ + s.ubuntuLabelMount("ubuntu-boot", "run"), + s.ubuntuPartUUIDMount("ubuntu-seed-partuuid", "run"), + s.ubuntuPartUUIDMount("ubuntu-data-partuuid", "run"), + s.ubuntuPartUUIDMount("ubuntu-save-partuuid", "run"), + s.makeRunSnapSystemdMount(snap.TypeGadget, s.gadget), + s.makeRunSnapSystemdMount(snap.TypeKernel, s.kernel), + }, nil) + defer restore() + + // mock a bootloader + bloader := boottest.MockUC20RunBootenv(bootloadertest.Mock("mock", c.MkDir())) + bootloader.Force(bloader) + defer bootloader.Force(nil) + + // set the current kernel + restore = bloader.SetEnabledKernel(s.kernel) + defer restore() + + s.makeSnapFilesOnEarlyBootUbuntuData(c, s.kernel, s.core20, s.gadget) + + // write modeenv, with no gadget field so the gadget is not mounted + modeEnv := boot.Modeenv{ + Mode: "run", + Base: s.core20.Filename(), + Gadget: s.gadget.Filename(), + CurrentKernels: []string{s.kernel.Filename()}, + } + err := modeEnv.WriteTo(boot.InitramfsDataDir) + c.Assert(err, IsNil) + + // write gadget.yaml, which is checked for classic + writeGadget(c, "ubuntu-seed", "system-seed-null", "") + + _, err = main.Parser().ParseArgs([]string{"initramfs-mounts"}) + c.Assert(err, IsNil) +} + +func (s *initramfsClassicMountsSuite) TestInitramfsMountsRunModeEncryptedDataHappy(c *C) { + s.mockProcCmdlineContent(c, "snapd_recovery_mode=run") + + // ensure that we check that access to sealed keys were locked + sealedKeysLocked := false + defer main.MockSecbootLockSealedKeys(func() error { + sealedKeysLocked = true + return nil + })() + + restore := disks.MockMountPointDisksToPartitionMapping( + map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuSeedDir}: defaultEncBootDisk, + {Mountpoint: boot.InitramfsUbuntuBootDir}: defaultEncBootDisk, + {Mountpoint: boot.InitramfsDataDir, IsDecryptedDevice: true}: defaultEncBootDisk, + {Mountpoint: boot.InitramfsUbuntuSaveDir, IsDecryptedDevice: true}: defaultEncBootDisk, + }, + ) + defer restore() + + restore = s.mockSystemdMountSequence(c, []systemdMount{ + s.ubuntuLabelMount("ubuntu-boot", "run"), + s.ubuntuPartUUIDMount("ubuntu-seed-partuuid", "run"), + { + "/dev/mapper/ubuntu-data-random", + boot.InitramfsDataDir, + needsFsckNoPrivateDiskMountOpts, + nil, + }, + { + "/dev/mapper/ubuntu-save-random", + boot.InitramfsUbuntuSaveDir, + needsFsckDiskMountOpts, + nil, + }, + s.makeRunSnapSystemdMount(snap.TypeGadget, s.gadget), + s.makeRunSnapSystemdMount(snap.TypeKernel, s.kernel), + }, nil) + defer restore() + + // write the installed model like makebootable does it + err := os.MkdirAll(filepath.Join(boot.InitramfsUbuntuBootDir, "device"), 0755) + c.Assert(err, IsNil) + mf, err := os.Create(filepath.Join(boot.InitramfsUbuntuBootDir, "device/model")) + c.Assert(err, IsNil) + defer mf.Close() + err = asserts.NewEncoder(mf).Encode(s.model) + c.Assert(err, IsNil) + + // write gadget.yaml, which is checked for classic + writeGadget(c, "ubuntu-seed", "system-seed", "") + + dataActivated := false + restore = main.MockSecbootUnlockVolumeUsingSealedKeyIfEncrypted( + func(disk disks.Disk, name string, sealedEncryptionKeyFile string, opts *secboot.UnlockVolumeUsingSealedKeyOptions) (secboot.UnlockResult, error) { + c.Assert(name, Equals, "ubuntu-data") + c.Assert(sealedEncryptionKeyFile, Equals, filepath.Join(s.tmpDir, "run/mnt/ubuntu-boot/device/fde/ubuntu-data.sealed-key")) + c.Assert(opts.AllowRecoveryKey, Equals, true) + c.Assert(opts.WhichModel, NotNil) + mod, err := opts.WhichModel() + c.Assert(err, IsNil) + c.Check(mod.Model(), Equals, "my-model") + + dataActivated = true + // return true because we are using an encrypted device + return happyUnlocked("ubuntu-data", secboot.UnlockedWithSealedKey), nil + }) + defer restore() + + s.mockUbuntuSaveKeyAndMarker(c, boot.InitramfsDataDir, "foo", "marker") + s.mockUbuntuSaveMarker(c, boot.InitramfsUbuntuSaveDir, "marker") + + saveActivated := false + restore = main.MockSecbootUnlockEncryptedVolumeUsingKey(func(disk disks.Disk, name string, key []byte) (secboot.UnlockResult, error) { + c.Check(dataActivated, Equals, true, Commentf("ubuntu-data not activated yet")) + saveActivated = true + c.Assert(name, Equals, "ubuntu-save") + c.Assert(key, DeepEquals, []byte("foo")) + return happyUnlocked("ubuntu-save", secboot.UnlockedWithKey), nil + }) + defer restore() + + measureEpochCalls := 0 + measureModelCalls := 0 + restore = main.MockSecbootMeasureSnapSystemEpochWhenPossible(func() error { + measureEpochCalls++ + return nil + }) + defer restore() + + var measuredModel *asserts.Model + restore = main.MockSecbootMeasureSnapModelWhenPossible(func(findModel func() (*asserts.Model, error)) error { + measureModelCalls++ + var err error + measuredModel, err = findModel() + if err != nil { + return err + } + return nil + }) + defer restore() + + // mock a bootloader + bloader := boottest.MockUC20RunBootenv(bootloadertest.Mock("mock", c.MkDir())) + bootloader.Force(bloader) + defer bootloader.Force(nil) + + // set the current kernel + restore = bloader.SetEnabledKernel(s.kernel) + defer restore() + + s.makeSnapFilesOnEarlyBootUbuntuData(c, s.kernel, s.core20, s.gadget) + + // write modeenv + modeEnv := boot.Modeenv{ + Mode: "run", + Base: s.core20.Filename(), + Gadget: s.gadget.Filename(), + CurrentKernels: []string{s.kernel.Filename()}, + } + err = modeEnv.WriteTo(boot.InitramfsDataDir) + c.Assert(err, IsNil) + + _, err = main.Parser().ParseArgs([]string{"initramfs-mounts"}) + c.Assert(err, IsNil) + c.Check(dataActivated, Equals, true) + c.Check(saveActivated, Equals, true) + c.Check(measureEpochCalls, Equals, 1) + c.Check(measureModelCalls, Equals, 1) + c.Check(measuredModel, DeepEquals, s.model) + c.Check(sealedKeysLocked, Equals, true) + + c.Assert(filepath.Join(dirs.SnapBootstrapRunDir, "secboot-epoch-measured"), testutil.FilePresent) + c.Assert(filepath.Join(dirs.SnapBootstrapRunDir, "run-model-measured"), testutil.FilePresent) +} + +func (s *initramfsClassicMountsSuite) testInitramfsMountsRunModeHappySeedCapsLabel(c *C, role string) { + s.mockProcCmdlineContent(c, "snapd_recovery_mode=run") + + restore := disks.MockMountPointDisksToPartitionMapping( + map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuSeedDir}: defaultBootWithSaveDisk, + {Mountpoint: boot.InitramfsUbuntuBootDir}: defaultBootWithSaveDisk, + {Mountpoint: boot.InitramfsDataDir}: defaultBootWithSaveDisk, + {Mountpoint: boot.InitramfsUbuntuSaveDir}: defaultBootWithSaveDisk, + }, + ) + defer restore() + + restore = s.mockSystemdMountSequence(c, []systemdMount{ + s.ubuntuLabelMount("ubuntu-boot", "run"), + s.ubuntuPartUUIDMount("ubuntu-seed-partuuid", "run"), + s.ubuntuPartUUIDMount("ubuntu-data-partuuid", "run"), + s.ubuntuPartUUIDMount("ubuntu-save-partuuid", "run"), + s.makeRunSnapSystemdMount(snap.TypeGadget, s.gadget), + s.makeRunSnapSystemdMount(snap.TypeKernel, s.kernel), + }, nil) + defer restore() + + // mock a bootloader + bloader := boottest.MockUC20RunBootenv(bootloadertest.Mock("mock", c.MkDir())) + bootloader.Force(bloader) + defer bootloader.Force(nil) + + // set the current kernel + restore = bloader.SetEnabledKernel(s.kernel) + defer restore() + + s.makeSnapFilesOnEarlyBootUbuntuData(c, s.kernel, s.core20, s.gadget) + + // write modeenv, with no gadget field so the gadget is not mounted + modeEnv := boot.Modeenv{ + Mode: "run", + Base: s.core20.Filename(), + Gadget: s.gadget.Filename(), + CurrentKernels: []string{s.kernel.Filename()}, + } + err := modeEnv.WriteTo(boot.InitramfsDataDir) + c.Assert(err, IsNil) + + // write gadget.yaml, which is checked for classic + writeGadget(c, "ubuntu-seed", role, "UBUNTU-SEED") + + _, err = main.Parser().ParseArgs([]string{"initramfs-mounts"}) + c.Assert(err, IsNil) +} + +func (s *initramfsClassicMountsSuite) TestInitramfsMountsRunModeHappySeedCapsLabel(c *C) { + s.testInitramfsMountsRunModeHappySeedCapsLabel(c, "system-seed") +} + +func (s *initramfsClassicMountsSuite) TestInitramfsMountsRunModeHappySeedNullCapsLabel(c *C) { + s.testInitramfsMountsRunModeHappySeedCapsLabel(c, "system-seed-null") +} + +func (s *initramfsClassicMountsSuite) TestInitramfsMountsTryRecoveryHappyTry(c *C) { + s.testInitramfsMountsTryRecoveryHappy(c, "try") +} + +func (s *initramfsClassicMountsSuite) TestInitramfsMountsTryRecoveryHappyTried(c *C) { + s.testInitramfsMountsTryRecoveryHappy(c, "tried") +} + +func (s *initramfsClassicMountsSuite) TestInitramfsMountsSystemDiskParamName(c *C) { + s.mockProcCmdlineContent(c, "snapd_system_disk=/dev/sda snapd_recovery_mode=run") + + restore := main.MockPartitionUUIDForBootedKernelDisk("") + defer restore() + + restore = disks.MockMountPointDisksToPartitionMapping( + map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuSeedDir}: defaultBootWithSaveDisk, + {Mountpoint: boot.InitramfsUbuntuBootDir}: defaultBootWithSaveDisk, + {Mountpoint: boot.InitramfsDataDir}: defaultBootWithSaveDisk, + {Mountpoint: boot.InitramfsUbuntuSaveDir}: defaultBootWithSaveDisk, + }, + ) + defer restore() + + restore = disks.MockDeviceNameToDiskMapping(map[string]*disks.MockDiskMapping{ + "/dev/sda": defaultBootWithSaveDisk, + }) + defer restore() + + restore = s.mockSystemdMountSequence(c, []systemdMount{ + // This is the important test, /dev/disk/by-label is not used + { + "/dev/sda3", + boot.InitramfsUbuntuBootDir, + needsFsckDiskMountOpts, + nil, + }, + s.ubuntuPartUUIDMount("ubuntu-seed-partuuid", "run"), + s.ubuntuPartUUIDMount("ubuntu-data-partuuid", "run"), + s.ubuntuPartUUIDMount("ubuntu-save-partuuid", "run"), + s.makeRunSnapSystemdMount(snap.TypeGadget, s.gadget), + s.makeRunSnapSystemdMount(snap.TypeKernel, s.kernel), + }, nil) + defer restore() + + // mock a bootloader + bloader := boottest.MockUC20RunBootenv(bootloadertest.Mock("mock", c.MkDir())) + bootloader.Force(bloader) + defer bootloader.Force(nil) + + // set the current kernel + restore = bloader.SetEnabledKernel(s.kernel) + defer restore() + + writeGadget(c, "ubuntu-seed", "system-seed", "") + + s.makeSnapFilesOnEarlyBootUbuntuData(c, s.kernel, s.core20, s.gadget) + + // write modeenv + modeEnv := boot.Modeenv{ + Mode: "run", + Base: s.core20.Filename(), + Gadget: s.gadget.Filename(), + CurrentKernels: []string{s.kernel.Filename()}, + } + err := modeEnv.WriteTo(boot.InitramfsDataDir) + c.Assert(err, IsNil) + + _, err = main.Parser().ParseArgs([]string{"initramfs-mounts"}) + c.Assert(err, IsNil) +} + +func (s *initramfsClassicMountsSuite) TestInitramfsMountsSystemDiskParamPath(c *C) { + s.mockProcCmdlineContent(c, "snapd_system_disk=/devices/some/bus/disk snapd_recovery_mode=run") + + restore := main.MockPartitionUUIDForBootedKernelDisk("") + defer restore() + + restore = disks.MockMountPointDisksToPartitionMapping( + map[disks.Mountpoint]*disks.MockDiskMapping{ + {Mountpoint: boot.InitramfsUbuntuSeedDir}: defaultBootWithSaveDisk, + {Mountpoint: boot.InitramfsUbuntuBootDir}: defaultBootWithSaveDisk, + {Mountpoint: boot.InitramfsDataDir}: defaultBootWithSaveDisk, + {Mountpoint: boot.InitramfsUbuntuSaveDir}: defaultBootWithSaveDisk, + }, + ) + defer restore() + + restore = disks.MockDevicePathToDiskMapping(map[string]*disks.MockDiskMapping{ + "/devices/some/bus/disk": defaultBootWithSaveDisk, + }) + defer restore() + + restore = s.mockSystemdMountSequence(c, []systemdMount{ + // This is the important test, /dev/disk/by-label is not used + { + "/dev/sda3", + boot.InitramfsUbuntuBootDir, + needsFsckDiskMountOpts, + nil, + }, + s.ubuntuPartUUIDMount("ubuntu-seed-partuuid", "run"), + s.ubuntuPartUUIDMount("ubuntu-data-partuuid", "run"), + s.ubuntuPartUUIDMount("ubuntu-save-partuuid", "run"), + s.makeRunSnapSystemdMount(snap.TypeGadget, s.gadget), + s.makeRunSnapSystemdMount(snap.TypeKernel, s.kernel), + }, nil) + defer restore() + + // mock a bootloader + bloader := boottest.MockUC20RunBootenv(bootloadertest.Mock("mock", c.MkDir())) + bootloader.Force(bloader) + defer bootloader.Force(nil) + + // set the current kernel + restore = bloader.SetEnabledKernel(s.kernel) + defer restore() + + writeGadget(c, "ubuntu-seed", "system-seed", "") + + s.makeSnapFilesOnEarlyBootUbuntuData(c, s.kernel, s.core20, s.gadget) + + // write modeenv + modeEnv := boot.Modeenv{ + Mode: "run", + Base: s.core20.Filename(), + Gadget: s.gadget.Filename(), + CurrentKernels: []string{s.kernel.Filename()}, + } + err := modeEnv.WriteTo(boot.InitramfsDataDir) + c.Assert(err, IsNil) + + _, err = main.Parser().ParseArgs([]string{"initramfs-mounts"}) + c.Assert(err, IsNil) +} + +func (s *initramfsMountsSuite) TestGetDiskNotUEFINotKernelCmdlineFail(c *C) { + err := os.Remove(filepath.Join(s.byLabelDir, "ubuntu-seed")) + c.Assert(err, IsNil) + + path, err := main.GetNonUEFISystemDisk("ubuntu-seed") + c.Assert(err.Error(), Equals, `no candidate found for label "ubuntu-seed"`) + c.Assert(path, Equals, "") + + err = os.WriteFile(filepath.Join(s.byLabelDir, "UBUNTU-SEED"), nil, 0644) + c.Assert(err, IsNil) + err = os.WriteFile(filepath.Join(s.byLabelDir, "UBUNTU-FOO"), nil, 0644) + c.Assert(err, IsNil) + + // Mock udevadm calls + mockUdevadm := testutil.MockCommand(c, "udevadm", ` +if [[ "$5" == *UBUNTU-FOO* ]]; then + echo "Unknown device" + exit 1 +fi +echo "ID_FS_TYPE=ext4" +exit 0 +`) + defer mockUdevadm.Restore() + + // No device backend + path, err = main.GetNonUEFISystemDisk("ubuntu-foo") + c.Assert(err.Error(), Equals, `cannot find filesystem type: Unknown device`) + c.Assert(path, Equals, "") + + // Filesystem is not vfat + path, err = main.GetNonUEFISystemDisk("ubuntu-seed") + c.Assert(err.Error(), Equals, `no candidate found for label "ubuntu-seed" ("UBUNTU-SEED" is not vfat)`) + c.Assert(path, Equals, "") + + // More than one candidate + err = os.WriteFile(filepath.Join(s.byLabelDir, "UBUNTU-seed"), nil, 0644) + path, err = main.GetNonUEFISystemDisk("ubuntu-seed") + c.Assert(err.Error(), Equals, `more than one candidate for label "ubuntu-seed"`) + c.Assert(path, Equals, "") + + c.Assert(mockUdevadm.Calls(), DeepEquals, [][]string{ + {"udevadm", "info", "--query", "property", "--name", + filepath.Join(s.byLabelDir, "UBUNTU-FOO")}, + {"udevadm", "info", "--query", "property", "--name", + filepath.Join(s.byLabelDir, "UBUNTU-SEED")}, + }) +} + +func (s *initramfsMountsSuite) TestGetDiskNotUEFINotKernelCmdlineOk(c *C) { + mockUdevadm := testutil.MockCommand(c, "udevadm", ` + echo "ID_FS_TYPE=vfat" +`) + defer mockUdevadm.Restore() + + err := os.Remove(filepath.Join(s.byLabelDir, "ubuntu-seed")) + c.Assert(err, IsNil) + err = os.WriteFile(filepath.Join(s.byLabelDir, "UBUNTU-SEED"), nil, 0644) + c.Assert(err, IsNil) + + path, err := main.GetNonUEFISystemDisk("ubuntu-seed") + c.Assert(err, IsNil) + c.Assert(path, Equals, filepath.Join(s.byLabelDir, "UBUNTU-SEED")) + + c.Assert(mockUdevadm.Calls(), DeepEquals, [][]string{ + {"udevadm", "info", "--query", "property", "--name", + filepath.Join(s.byLabelDir, "UBUNTU-SEED")}, + }) +} + +func (s *initramfsMountsSuite) TestGetDiskNotUEFINotKernelCmdlineSomeItersOk(c *C) { + mockUdevadm := testutil.MockCommand(c, "udevadm", ` + echo "ID_FS_TYPE=vfat" +`) + defer mockUdevadm.Restore() + s.AddCleanup(main.MockPollWaitForLabel(50 * time.Millisecond)) + s.AddCleanup(main.MockPollWaitForLabelIters(100)) + + err := os.Remove(filepath.Join(s.byLabelDir, "ubuntu-seed")) + c.Assert(err, IsNil) + + ch := make(chan bool) + go func() { + path, err := main.GetNonUEFISystemDisk("ubuntu-seed") + c.Check(err, IsNil) + c.Check(path, Equals, filepath.Join(s.byLabelDir, "UBUNTU-SEED")) + ch <- true + }() + // Wait a bit so we get at least an iteration + time.Sleep(50 * time.Millisecond) + // Now create a file that matches the label + err = os.WriteFile(filepath.Join(s.byLabelDir, "UBUNTU-SEED"), nil, 0644) + c.Assert(err, IsNil) + + <-ch + + c.Assert(mockUdevadm.Calls(), DeepEquals, [][]string{ + {"udevadm", "info", "--query", "property", "--name", + filepath.Join(s.byLabelDir, "UBUNTU-SEED")}, + }) +} + +func (s *initramfsMountsSuite) TestGetDiskNotUEFINotKernelCmdlineFailNoFs(c *C) { + mockUdevadm := testutil.MockCommand(c, "udevadm", ` +`) + defer mockUdevadm.Restore() + + err := os.Remove(filepath.Join(s.byLabelDir, "ubuntu-seed")) + c.Assert(err, IsNil) + err = os.WriteFile(filepath.Join(s.byLabelDir, "UBUNTU-SEED"), nil, 0644) + c.Assert(err, IsNil) + + path, err := main.GetNonUEFISystemDisk("ubuntu-seed") + c.Assert(err.Error(), Equals, `no candidate found for label "ubuntu-seed" ("UBUNTU-SEED" is not vfat)`) + c.Assert(path, Equals, "") + + c.Assert(mockUdevadm.Calls(), DeepEquals, [][]string{ + {"udevadm", "info", "--query", "property", "--name", + filepath.Join(s.byLabelDir, "UBUNTU-SEED")}, + }) +} + +func (s *initramfsMountsSuite) TestGetDiskNotUEFISeedPartCapitalFsLabel(c *C) { + s.mockProcCmdlineContent(c, "snapd_system_disk=/dev/sda snapd_recovery_mode=run") + + restore := main.MockPartitionUUIDForBootedKernelDisk("") + defer restore() + + restore = disks.MockDeviceNameToDiskMapping(map[string]*disks.MockDiskMapping{ + "/dev/sda": defaultBootWithSeedPartCapitalFsLabel, + }) + defer restore() + + path, err := main.GetNonUEFISystemDisk("ubuntu-seed") + c.Assert(err, IsNil) + c.Assert(path, Equals, "/dev/sda2") + + path, err = main.GetNonUEFISystemDisk("UBUNTU-SEED") + c.Assert(err, IsNil) + c.Assert(path, Equals, "/dev/sda2") + + path, err = main.GetNonUEFISystemDisk("ubuntu-boot") + c.Assert(err, IsNil) + c.Assert(path, Equals, "/dev/sda3") + + path, err = main.GetNonUEFISystemDisk("UBUNTU-BOOT") + c.Assert(err.Error(), Equals, `filesystem label "UBUNTU-BOOT" not found`) + c.Assert(path, Equals, "") +} + +func (s *initramfsMountsSuite) TestInitramfsMountsInstallAndRunMissingFdeSetup(c *C) { + s.mockProcCmdlineContent(c, "snapd_recovery_mode=install snapd_recovery_system="+s.sysLabel) + + systemDir := filepath.Join(boot.InitramfsUbuntuSeedDir, "systems", s.sysLabel) + c.Assert(os.MkdirAll(filepath.Join(boot.InitramfsUbuntuSeedDir, "systems", s.sysLabel), 0755), IsNil) + c.Assert(os.WriteFile(filepath.Join(systemDir, "preseed.tgz"), []byte{}, 0640), IsNil) + + fdeSetupHook := filepath.Join(boot.InitramfsRunMntDir, "kernel", "meta", "hooks", "fde-setup") + c.Assert(os.MkdirAll(filepath.Dir(fdeSetupHook), 0755), IsNil) + c.Assert(os.WriteFile(fdeSetupHook, []byte{}, 0555), IsNil) + + // ensure that we check that access to sealed keys were locked + sealedKeysLocked := false + defer main.MockSecbootLockSealedKeys(func() error { + sealedKeysLocked = true + return nil + })() + + restore := s.mockSystemdMountSequence(c, []systemdMount{ + s.ubuntuLabelMount("ubuntu-seed", "install"), + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + s.makeSeedSnapSystemdMount(snap.TypeGadget), + { + "tmpfs", + boot.InitramfsDataDir, + tmpfsMountOpts, + nil, + }, + }, nil) + defer restore() + + _, err := main.Parser().ParseArgs([]string{"initramfs-mounts"}) + c.Assert(err, IsNil) + c.Check(sealedKeysLocked, Equals, true) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsInstallAndRunFdeSetupPresent(c *C) { + var efiArch string + switch runtime.GOARCH { + case "amd64": + efiArch = "x64" + case "arm64": + efiArch = "aa64" + default: + c.Skip("Unknown EFI arch") + } + + s.mockProcCmdlineContent(c, "snapd_recovery_mode=install snapd_recovery_system="+s.sysLabel) + + systemDir := filepath.Join(boot.InitramfsUbuntuSeedDir, "systems", s.sysLabel) + c.Assert(os.MkdirAll(filepath.Join(boot.InitramfsUbuntuSeedDir, "systems", s.sysLabel), 0755), IsNil) + c.Assert(os.WriteFile(filepath.Join(systemDir, "preseed.tgz"), []byte{}, 0640), IsNil) + + fdeSetupHook := filepath.Join(boot.InitramfsRunMntDir, "kernel", "meta", "hooks", "fde-setup") + c.Assert(os.MkdirAll(filepath.Dir(fdeSetupHook), 0755), IsNil) + c.Assert(os.WriteFile(fdeSetupHook, []byte{}, 0555), IsNil) + fdeRevealKeyHook := filepath.Join(boot.InitramfsRunMntDir, "kernel", "meta", "hooks", "fde-reveal-key") + c.Assert(os.MkdirAll(filepath.Dir(fdeRevealKeyHook), 0755), IsNil) + c.Assert(os.WriteFile(fdeRevealKeyHook, []byte{}, 0555), IsNil) + + fdeSetupMock := testutil.MockCommand(c, "fde-setup", fmt.Sprintf(` +tmpdir='%s' +cat >"${tmpdir}/fde-setup.input" +echo '{"features":[]}' +`, s.tmpDir)) + defer fdeSetupMock.Restore() + + fdeRevealKeyMock := testutil.MockCommand(c, "fde-reveal-key", ``) + defer fdeRevealKeyMock.Restore() + + kernelSnapYaml := filepath.Join(boot.InitramfsRunMntDir, "kernel", "meta", "snap.yaml") + c.Assert(os.MkdirAll(filepath.Dir(kernelSnapYaml), 0755), IsNil) + kernelSnapYamlContent := `{}` + c.Assert(os.WriteFile(kernelSnapYaml, []byte(kernelSnapYamlContent), 0555), IsNil) + + baseSnapYaml := filepath.Join(boot.InitramfsRunMntDir, "base", "meta", "snap.yaml") + c.Assert(os.MkdirAll(filepath.Dir(baseSnapYaml), 0755), IsNil) + baseSnapYamlContent := `{}` + c.Assert(os.WriteFile(baseSnapYaml, []byte(baseSnapYamlContent), 0555), IsNil) + + gadgetSnapYaml := filepath.Join(boot.InitramfsRunMntDir, "gadget", "meta", "snap.yaml") + c.Assert(os.MkdirAll(filepath.Dir(gadgetSnapYaml), 0755), IsNil) + gadgetSnapYamlContent := `{}` + c.Assert(os.WriteFile(gadgetSnapYaml, []byte(gadgetSnapYamlContent), 0555), IsNil) + + grubConf := filepath.Join(boot.InitramfsRunMntDir, "gadget", "grub.conf") + c.Assert(os.MkdirAll(filepath.Dir(grubConf), 0755), IsNil) + c.Assert(os.WriteFile(grubConf, nil, 0555), IsNil) + + bootloader := filepath.Join(boot.InitramfsRunMntDir, "ubuntu-seed", "EFI", "boot", fmt.Sprintf("boot%s.efi", efiArch)) + c.Assert(os.MkdirAll(filepath.Dir(bootloader), 0755), IsNil) + c.Assert(os.WriteFile(bootloader, nil, 0555), IsNil) + grub := filepath.Join(boot.InitramfsRunMntDir, "ubuntu-seed", "EFI", "boot", fmt.Sprintf("grub%s.efi", efiArch)) + c.Assert(os.MkdirAll(filepath.Dir(grub), 0755), IsNil) + c.Assert(os.WriteFile(grub, nil, 0555), IsNil) + + writeGadget(c, "ubuntu-seed", "system-seed", "") + + dataKey := keys.EncryptionKey{'d', 'a', 't', 'a', 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} + saveKey := keys.EncryptionKey{'s', 'a', 'v', 'e', 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} + + gadgetInstallCalled := false + restoreGadgetInstall := main.MockGadgetInstallRun(func(model gadget.Model, gadgetRoot, kernelRoot, bootDevice string, options gadgetInstall.Options, observer gadget.ContentObserver, perfTimings timings.Measurer) (*gadgetInstall.InstalledSystemSideData, error) { + gadgetInstallCalled = true + c.Assert(options.Mount, Equals, true) + c.Assert(string(options.EncryptionType), Equals, "cryptsetup") + c.Assert(bootDevice, Equals, "") + c.Assert(model.Classic(), Equals, false) + c.Assert(string(model.Grade()), Equals, "signed") + c.Assert(gadgetRoot, Equals, filepath.Join(boot.InitramfsRunMntDir, "gadget")) + c.Assert(kernelRoot, Equals, filepath.Join(boot.InitramfsRunMntDir, "kernel")) + + keyForRole := map[string]keys.EncryptionKey{ + gadget.SystemData: dataKey, + gadget.SystemSave: saveKey, + } + return &gadgetInstall.InstalledSystemSideData{KeyForRole: keyForRole}, nil + }) + defer restoreGadgetInstall() + + makeRunnableCalled := false + restoreMakeRunnableStandaloneSystem := main.MockMakeRunnableStandaloneSystem(func(model *asserts.Model, bootWith *boot.BootableSet, sealer *boot.TrustedAssetsInstallObserver) error { + makeRunnableCalled = true + c.Assert(model.Model(), Equals, "my-model") + c.Assert(bootWith.RecoverySystemLabel, Equals, s.sysLabel) + c.Assert(bootWith.BasePath, Equals, filepath.Join(s.seedDir, "snaps", "core20_1.snap")) + c.Assert(bootWith.KernelPath, Equals, filepath.Join(s.seedDir, "snaps", "pc-kernel_1.snap")) + c.Assert(bootWith.GadgetPath, Equals, filepath.Join(s.seedDir, "snaps", "pc_1.snap")) + return nil + }) + defer restoreMakeRunnableStandaloneSystem() + + applyPreseedCalled := false + restoreApplyPreseededData := main.MockApplyPreseededData(func(preseedSeed seed.PreseedCapable, writableDir string) error { + applyPreseedCalled = true + c.Assert(preseedSeed.ArtifactPath("preseed.tgz"), Equals, filepath.Join(systemDir, "preseed.tgz")) + c.Assert(writableDir, Equals, filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data")) + return nil + }) + defer restoreApplyPreseededData() + + // ensure that we check that access to sealed keys were locked + sealedKeysLocked := false + defer main.MockSecbootLockSealedKeys(func() error { + sealedKeysLocked = true + return nil + })() + + nextBooEnsured := false + defer main.MockEnsureNextBootToRunMode(func(systemLabel string) error { + nextBooEnsured = true + c.Assert(systemLabel, Equals, s.sysLabel) + return nil + })() + + restore := s.mockSystemdMountSequence(c, []systemdMount{ + s.ubuntuLabelMount("ubuntu-seed", "install"), + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + s.makeSeedSnapSystemdMount(snap.TypeGadget), + { + filepath.Join(s.tmpDir, "/run/mnt/ubuntu-data"), + boot.InitramfsDataDir, + bindOpts, + nil, + }, + }, nil) + defer restore() + + c.Assert(os.Remove(filepath.Join(boot.InitramfsUbuntuBootDir, "device/model")), IsNil) + + _, err := main.Parser().ParseArgs([]string{"initramfs-mounts"}) + c.Assert(err, IsNil) + c.Check(sealedKeysLocked, Equals, true) + + c.Assert(fdeSetupMock.Calls(), DeepEquals, [][]string{ + {"fde-setup"}, + }) + + fdeSetupInput, err := os.ReadFile(filepath.Join(s.tmpDir, "fde-setup.input")) + c.Assert(err, IsNil) + c.Assert(fdeSetupInput, DeepEquals, []byte(`{"op":"features"}`)) + + c.Assert(applyPreseedCalled, Equals, true) + c.Assert(makeRunnableCalled, Equals, true) + c.Assert(gadgetInstallCalled, Equals, true) + c.Assert(nextBooEnsured, Equals, true) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsInstallAndRunFdeSetupNotPresent(c *C) { + var efiArch string + switch runtime.GOARCH { + case "amd64": + efiArch = "x64" + case "arm64": + efiArch = "aa64" + default: + c.Skip("Unknown EFI arch") + } + + s.mockProcCmdlineContent(c, "snapd_recovery_mode=install snapd_recovery_system="+s.sysLabel) + + systemDir := filepath.Join(boot.InitramfsUbuntuSeedDir, "systems", s.sysLabel) + c.Assert(os.MkdirAll(filepath.Join(boot.InitramfsUbuntuSeedDir, "systems", s.sysLabel), 0755), IsNil) + c.Assert(os.WriteFile(filepath.Join(systemDir, "preseed.tgz"), []byte{}, 0640), IsNil) + + kernelSnapYaml := filepath.Join(boot.InitramfsRunMntDir, "kernel", "meta", "snap.yaml") + c.Assert(os.MkdirAll(filepath.Dir(kernelSnapYaml), 0755), IsNil) + kernelSnapYamlContent := `{}` + c.Assert(os.WriteFile(kernelSnapYaml, []byte(kernelSnapYamlContent), 0555), IsNil) + + baseSnapYaml := filepath.Join(boot.InitramfsRunMntDir, "base", "meta", "snap.yaml") + c.Assert(os.MkdirAll(filepath.Dir(baseSnapYaml), 0755), IsNil) + baseSnapYamlContent := `{}` + c.Assert(os.WriteFile(baseSnapYaml, []byte(baseSnapYamlContent), 0555), IsNil) + + gadgetSnapYaml := filepath.Join(boot.InitramfsRunMntDir, "gadget", "meta", "snap.yaml") + c.Assert(os.MkdirAll(filepath.Dir(gadgetSnapYaml), 0755), IsNil) + gadgetSnapYamlContent := `{}` + c.Assert(os.WriteFile(gadgetSnapYaml, []byte(gadgetSnapYamlContent), 0555), IsNil) + + grubConf := filepath.Join(boot.InitramfsRunMntDir, "gadget", "grub.conf") + c.Assert(os.MkdirAll(filepath.Dir(grubConf), 0755), IsNil) + c.Assert(os.WriteFile(grubConf, nil, 0555), IsNil) + + bootloader := filepath.Join(boot.InitramfsRunMntDir, "ubuntu-seed", "EFI", "boot", fmt.Sprintf("boot%s.efi", efiArch)) + c.Assert(os.MkdirAll(filepath.Dir(bootloader), 0755), IsNil) + c.Assert(os.WriteFile(bootloader, nil, 0555), IsNil) + grub := filepath.Join(boot.InitramfsRunMntDir, "ubuntu-seed", "EFI", "boot", fmt.Sprintf("grub%s.efi", efiArch)) + c.Assert(os.MkdirAll(filepath.Dir(grub), 0755), IsNil) + c.Assert(os.WriteFile(grub, nil, 0555), IsNil) + + writeGadget(c, "ubuntu-seed", "system-seed", "") + + gadgetInstallCalled := false + restoreGadgetInstall := main.MockGadgetInstallRun(func(model gadget.Model, gadgetRoot, kernelRoot, bootDevice string, options gadgetInstall.Options, observer gadget.ContentObserver, perfTimings timings.Measurer) (*gadgetInstall.InstalledSystemSideData, error) { + gadgetInstallCalled = true + c.Assert(options.Mount, Equals, true) + c.Assert(string(options.EncryptionType), Equals, "") + c.Assert(bootDevice, Equals, "") + c.Assert(model.Classic(), Equals, false) + c.Assert(string(model.Grade()), Equals, "signed") + c.Assert(gadgetRoot, Equals, filepath.Join(boot.InitramfsRunMntDir, "gadget")) + c.Assert(kernelRoot, Equals, filepath.Join(boot.InitramfsRunMntDir, "kernel")) + return &gadgetInstall.InstalledSystemSideData{}, nil + }) + defer restoreGadgetInstall() + + makeRunnableCalled := false + restoreMakeRunnableStandaloneSystem := main.MockMakeRunnableStandaloneSystem(func(model *asserts.Model, bootWith *boot.BootableSet, sealer *boot.TrustedAssetsInstallObserver) error { + makeRunnableCalled = true + c.Assert(model.Model(), Equals, "my-model") + c.Assert(bootWith.RecoverySystemLabel, Equals, s.sysLabel) + c.Assert(bootWith.BasePath, Equals, filepath.Join(s.seedDir, "snaps", "core20_1.snap")) + c.Assert(bootWith.KernelPath, Equals, filepath.Join(s.seedDir, "snaps", "pc-kernel_1.snap")) + c.Assert(bootWith.GadgetPath, Equals, filepath.Join(s.seedDir, "snaps", "pc_1.snap")) + return nil + }) + defer restoreMakeRunnableStandaloneSystem() + + applyPreseedCalled := false + restoreApplyPreseededData := main.MockApplyPreseededData(func(preseedSeed seed.PreseedCapable, writableDir string) error { + applyPreseedCalled = true + c.Assert(preseedSeed.ArtifactPath("preseed.tgz"), Equals, filepath.Join(systemDir, "preseed.tgz")) + c.Assert(writableDir, Equals, filepath.Join(dirs.GlobalRootDir, "/run/mnt/data/system-data")) + return nil + }) + defer restoreApplyPreseededData() + + // ensure that we check that access to sealed keys were locked + sealedKeysLocked := false + defer main.MockSecbootLockSealedKeys(func() error { + sealedKeysLocked = true + return nil + })() + + nextBootEnsured := false + defer main.MockEnsureNextBootToRunMode(func(systemLabel string) error { + nextBootEnsured = true + c.Assert(systemLabel, Equals, s.sysLabel) + return nil + })() + + restore := s.mockSystemdMountSequence(c, []systemdMount{ + s.ubuntuLabelMount("ubuntu-seed", "install"), + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + s.makeSeedSnapSystemdMount(snap.TypeGadget), + { + filepath.Join(s.tmpDir, "/run/mnt/ubuntu-data"), + boot.InitramfsDataDir, + bindOpts, + nil, + }, + }, nil) + defer restore() + + c.Assert(os.Remove(filepath.Join(boot.InitramfsUbuntuBootDir, "device/model")), IsNil) + + _, err := main.Parser().ParseArgs([]string{"initramfs-mounts"}) + c.Assert(err, IsNil) + c.Check(sealedKeysLocked, Equals, true) + + c.Assert(applyPreseedCalled, Equals, true) + c.Assert(makeRunnableCalled, Equals, true) + c.Assert(gadgetInstallCalled, Equals, true) + c.Assert(nextBootEnsured, Equals, true) +} + +func (s *initramfsMountsSuite) TestInitramfsMountsInstallAndRunInstallDeviceHook(c *C) { + s.mockProcCmdlineContent(c, "snapd_recovery_mode=install snapd_recovery_system="+s.sysLabel) + + systemDir := filepath.Join(boot.InitramfsUbuntuSeedDir, "systems", s.sysLabel) + c.Assert(os.MkdirAll(filepath.Join(boot.InitramfsUbuntuSeedDir, "systems", s.sysLabel), 0755), IsNil) + c.Assert(os.WriteFile(filepath.Join(systemDir, "preseed.tgz"), []byte{}, 0640), IsNil) + + installDeviceHook := filepath.Join(boot.InitramfsRunMntDir, "gadget", "meta", "hooks", "install-device") + c.Assert(os.MkdirAll(filepath.Dir(installDeviceHook), 0755), IsNil) + c.Assert(os.WriteFile(installDeviceHook, []byte{}, 0555), IsNil) + + fdeSetupHook := filepath.Join(boot.InitramfsRunMntDir, "kernel", "meta", "hooks", "fde-setup") + c.Assert(os.MkdirAll(filepath.Dir(fdeSetupHook), 0755), IsNil) + c.Assert(os.WriteFile(fdeSetupHook, []byte{}, 0555), IsNil) + + cmd := testutil.MockCommand(c, "fde-setup", ``) + defer cmd.Restore() + + // ensure that we check that access to sealed keys were locked + sealedKeysLocked := false + defer main.MockSecbootLockSealedKeys(func() error { + sealedKeysLocked = true + return nil + })() + + restore := s.mockSystemdMountSequence(c, []systemdMount{ + s.ubuntuLabelMount("ubuntu-seed", "install"), + s.makeSeedSnapSystemdMount(snap.TypeSnapd), + s.makeSeedSnapSystemdMount(snap.TypeKernel), + s.makeSeedSnapSystemdMount(snap.TypeBase), + s.makeSeedSnapSystemdMount(snap.TypeGadget), + { + "tmpfs", + boot.InitramfsDataDir, + tmpfsMountOpts, + nil, + }, + }, nil) + defer restore() + + _, err := main.Parser().ParseArgs([]string{"initramfs-mounts"}) + c.Assert(err, IsNil) + c.Check(sealedKeysLocked, Equals, true) +} diff --git a/cmd/snap-bootstrap/cmd_recovery_chooser_trigger.go b/cmd/snap-bootstrap/cmd_recovery_chooser_trigger.go new file mode 100644 index 00000000..fbb8f5fa --- /dev/null +++ b/cmd/snap-bootstrap/cmd_recovery_chooser_trigger.go @@ -0,0 +1,123 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "fmt" + "os" + "time" + + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/cmd/snap-bootstrap/triggerwatch" + "github.com/snapcore/snapd/logger" +) + +func init() { + const ( + short = "Detect Ubuntu Core recovery chooser trigger" + long = "" + ) + + addCommandBuilder(func(parser *flags.Parser) { + if _, err := parser.AddCommand("recovery-chooser-trigger", short, long, &cmdRecoveryChooserTrigger{}); err != nil { + panic(err) + } + }) +} + +var ( + triggerwatchWait = triggerwatch.Wait + + // default trigger wait timeout + defaultTimeout = 10 * time.Second + defaultDeviceTimeout = 2 * time.Second + + // default marker file location + defaultMarkerFile = "/run/snapd-recovery-chooser-triggered" +) + +type cmdRecoveryChooserTrigger struct { + MarkerFile string `long:"marker-file" value-name:"filename" description:"trigger marker file location"` + WaitTimeout string `long:"wait-timeout" value-name:"duration" description:"trigger wait timeout"` + DeviceTimeout string `long:"device-timeout" value-name:"duration" description:"timeout for devices to appear"` +} + +func (c *cmdRecoveryChooserTrigger) Execute(args []string) error { + // TODO:UC20: check in the gadget if there is a hook or some binary we + // should run for trigger detection. This will require some design work + // and also thinking if/how such a hook can be confined. + + timeout := defaultTimeout + deviceTimeout := defaultDeviceTimeout + markerFile := defaultMarkerFile + + if c.WaitTimeout != "" { + userTimeout, err := time.ParseDuration(c.WaitTimeout) + if err != nil { + logger.Noticef("cannot parse duration %q, using default", c.WaitTimeout) + } else { + timeout = userTimeout + } + } + if c.DeviceTimeout != "" { + userTimeout, err := time.ParseDuration(c.DeviceTimeout) + if err != nil { + logger.Noticef("cannot parse duration %q, using default", c.DeviceTimeout) + } else { + deviceTimeout = userTimeout + } + } + if c.MarkerFile != "" { + markerFile = c.MarkerFile + } + logger.Noticef("trigger wait timeout %v", timeout) + logger.Noticef("device timeout %v", deviceTimeout) + logger.Noticef("marker file %v", markerFile) + + _, err := os.Stat(markerFile) + if err == nil { + logger.Noticef("marker already present") + return nil + } + + err = triggerwatchWait(timeout, deviceTimeout) + if err != nil { + switch err { + case triggerwatch.ErrTriggerNotDetected: + logger.Noticef("trigger not detected") + return nil + case triggerwatch.ErrNoMatchingInputDevices: + logger.Noticef("no matching input devices") + return nil + default: + return err + } + } + + // got the trigger, try to create the marker file + m, err := os.Create(markerFile) + if err != nil { + return fmt.Errorf("cannot create the marker file: %q", err) + } + m.Close() + + return nil +} diff --git a/cmd/snap-bootstrap/cmd_recovery_chooser_trigger_test.go b/cmd/snap-bootstrap/cmd_recovery_chooser_trigger_test.go new file mode 100644 index 00000000..c2fa52d6 --- /dev/null +++ b/cmd/snap-bootstrap/cmd_recovery_chooser_trigger_test.go @@ -0,0 +1,195 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "errors" + "os" + "path/filepath" + "time" + + . "gopkg.in/check.v1" + + main "github.com/snapcore/snapd/cmd/snap-bootstrap" + "github.com/snapcore/snapd/cmd/snap-bootstrap/triggerwatch" + "github.com/snapcore/snapd/testutil" +) + +func (s *cmdSuite) TestRecoveryChooserTriggerDefaults(c *C) { + n := 0 + marker := filepath.Join(c.MkDir(), "marker") + passedTimeout := time.Duration(0) + passedDeviceTimeout := time.Duration(0) + + restore := main.MockDefaultMarkerFile(marker) + defer restore() + restore = main.MockTriggerwatchWait(func(timeout time.Duration, deviceTimeout time.Duration) error { + passedTimeout = timeout + passedDeviceTimeout = deviceTimeout + n++ + // trigger happened + return nil + }) + defer restore() + + rest, err := main.Parser().ParseArgs([]string{"recovery-chooser-trigger"}) + c.Assert(err, IsNil) + c.Assert(rest, HasLen, 0) + c.Check(n, Equals, 1) + c.Check(passedTimeout, Equals, main.DefaultTimeout) + c.Check(passedDeviceTimeout, Equals, main.DefaultDeviceTimeout) + c.Check(marker, testutil.FilePresent) +} + +func (s *cmdSuite) TestRecoveryChooserTriggerNoTrigger(c *C) { + n := 0 + marker := filepath.Join(c.MkDir(), "marker") + + restore := main.MockDefaultMarkerFile(marker) + defer restore() + restore = main.MockTriggerwatchWait(func(_ time.Duration, _ time.Duration) error { + n++ + // trigger did not happen + return triggerwatch.ErrTriggerNotDetected + }) + defer restore() + + _, err := main.Parser().ParseArgs([]string{"recovery-chooser-trigger"}) + c.Assert(err, IsNil) + c.Check(n, Equals, 1) + c.Check(marker, testutil.FileAbsent) +} + +func (s *cmdSuite) TestRecoveryChooserTriggerTakesOptions(c *C) { + marker := filepath.Join(c.MkDir(), "foobar") + n := 0 + passedTimeout := time.Duration(0) + passedDeviceTimeout := time.Duration(0) + + restore := main.MockTriggerwatchWait(func(timeout time.Duration, deviceTimeout time.Duration) error { + passedTimeout = timeout + passedDeviceTimeout = deviceTimeout + n++ + // trigger happened + return nil + }) + defer restore() + + rest, err := main.Parser().ParseArgs([]string{ + "recovery-chooser-trigger", + "--device-timeout", "1m", + "--wait-timeout", "2m", + "--marker-file", marker, + }) + c.Assert(err, IsNil) + c.Assert(rest, HasLen, 0) + c.Check(n, Equals, 1) + c.Check(passedTimeout, Equals, 2*time.Minute) + c.Check(passedDeviceTimeout, Equals, 1*time.Minute) + c.Check(marker, testutil.FilePresent) +} + +func (s *cmdSuite) TestRecoveryChooserTriggerDoesNothingWhenMarkerPresent(c *C) { + marker := filepath.Join(c.MkDir(), "foobar") + n := 0 + restore := main.MockTriggerwatchWait(func(_ time.Duration, _ time.Duration) error { + n++ + return errors.New("unexpected call") + }) + defer restore() + + err := os.WriteFile(marker, nil, 0644) + c.Assert(err, IsNil) + + rest, err := main.Parser().ParseArgs([]string{ + "recovery-chooser-trigger", + "--marker-file", marker, + }) + c.Assert(err, IsNil) + c.Assert(rest, HasLen, 0) + // not called + c.Check(n, Equals, 0) +} + +func (s *cmdSuite) TestRecoveryChooserTriggerBadDurationFallback(c *C) { + n := 0 + passedTimeout := time.Duration(0) + restore := main.MockDefaultMarkerFile(filepath.Join(c.MkDir(), "marker")) + defer restore() + + restore = main.MockTriggerwatchWait(func(timeout time.Duration, _ time.Duration) error { + passedTimeout = timeout + n++ + // trigger happened + return triggerwatch.ErrTriggerNotDetected + }) + defer restore() + + _, err := main.Parser().ParseArgs([]string{ + "recovery-chooser-trigger", + "--wait-timeout=foobar", + }) + c.Assert(err, IsNil) + c.Check(n, Equals, 1) + c.Check(passedTimeout, Equals, main.DefaultTimeout) +} + +func (s *cmdSuite) TestRecoveryChooserTriggerBadDeviceDurationFallback(c *C) { + n := 0 + passedTimeout := time.Duration(0) + restore := main.MockDefaultMarkerFile(filepath.Join(c.MkDir(), "marker")) + defer restore() + + restore = main.MockTriggerwatchWait(func(_ time.Duration, timeout time.Duration) error { + passedTimeout = timeout + n++ + // trigger happened + return triggerwatch.ErrTriggerNotDetected + }) + defer restore() + + _, err := main.Parser().ParseArgs([]string{ + "recovery-chooser-trigger", + "--device-timeout=foobar", + }) + c.Assert(err, IsNil) + c.Check(n, Equals, 1) + c.Check(passedTimeout, Equals, main.DefaultDeviceTimeout) +} + +func (s *cmdSuite) TestRecoveryChooserTriggerNoInputDevsNoError(c *C) { + n := 0 + marker := filepath.Join(c.MkDir(), "marker") + + restore := main.MockDefaultMarkerFile(marker) + defer restore() + restore = main.MockTriggerwatchWait(func(_ time.Duration, _ time.Duration) error { + n++ + // no input devices + return triggerwatch.ErrNoMatchingInputDevices + }) + defer restore() + + _, err := main.Parser().ParseArgs([]string{"recovery-chooser-trigger"}) + // does not trigger an error + c.Assert(err, IsNil) + c.Check(n, Equals, 1) + c.Check(marker, testutil.FileAbsent) +} diff --git a/cmd/snap-bootstrap/degraded-recover-mode.svg b/cmd/snap-bootstrap/degraded-recover-mode.svg new file mode 100644 index 00000000..92b40fc3 --- /dev/null +++ b/cmd/snap-bootstrap/degraded-recover-mode.svg @@ -0,0 +1,3 @@ + + +
start
start
start
start
success
success
fail
fail
mount boot
mount boot
fail
(encrypted)
fail...
success
success
fail
(unencrypted)
fail...
fail (no data partition)
fail (no data partition)
unlock data
run key
unlock data...
success
(encrypted)
success...
fail (encrypted)
fail (encrypted)
success or failure
(unencrypted)
success or failure...
mount data
mount data
fail
fail
success
success
unlock encrypted
save run key
unlock encrypted...
fail
fail
success
success
unlock encrypted save
fallback key
unlock encrypted sav...
success
success
fail (encrypted)
fail (encrypted)
unlock data
fallback key
unlock data...
done
done
mount save
mount save
done
done
found encrypted
save
found encrypted...
encrypted save
not found
encrypted save...
unlock maybe encrypted alone save fallback key
unlock maybe encrypted alone s...
found
found
not found
not found
open unencrypted save
open unencrypted save
NOTE: state names correspond to function names but without whitespace between words
NOTE: state names correspond to...
Viewer does not support full SVG 1.1
\ No newline at end of file diff --git a/cmd/snap-bootstrap/export_test.go b/cmd/snap-bootstrap/export_test.go new file mode 100644 index 00000000..f1f369ad --- /dev/null +++ b/cmd/snap-bootstrap/export_test.go @@ -0,0 +1,235 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016-2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "fmt" + "time" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/boot" + "github.com/snapcore/snapd/gadget" + gadgetInstall "github.com/snapcore/snapd/gadget/install" + "github.com/snapcore/snapd/osutil/disks" + "github.com/snapcore/snapd/secboot" + "github.com/snapcore/snapd/seed" + "github.com/snapcore/snapd/testutil" + "github.com/snapcore/snapd/timings" +) + +var ( + Parser = parser + + DoSystemdMount = doSystemdMountImpl + + MountNonDataPartitionMatchingKernelDisk = mountNonDataPartitionMatchingKernelDisk + + GetNonUEFISystemDisk = getNonUEFISystemDisk +) + +type SystemdMountOptions = systemdMountOptions + +type RecoverDegradedState = recoverDegradedState + +type PartitionState = partitionState + +func (r *RecoverDegradedState) Degraded(isEncrypted bool) bool { + m := recoverModeStateMachine{ + isEncryptedDev: isEncrypted, + degradedState: r, + } + return m.degraded() +} + +func MockPollWaitForLabel(newPollDur time.Duration) (restore func()) { + restore = testutil.Backup(&pollWaitForLabel) + pollWaitForLabel = newPollDur + return restore +} + +func MockPollWaitForLabelIters(newNumIters int) (restore func()) { + restore = testutil.Backup(&pollWaitForLabelIters) + pollWaitForLabelIters = newNumIters + return restore +} + +func MockTimeNow(f func() time.Time) (restore func()) { + old := timeNow + timeNow = f + return func() { + timeNow = old + } +} + +func MockOsutilSetTime(f func(t time.Time) error) (restore func()) { + old := osutilSetTime + osutilSetTime = f + return func() { + osutilSetTime = old + } +} + +func MockOsutilIsMounted(f func(string) (bool, error)) (restore func()) { + old := osutilIsMounted + osutilIsMounted = f + return func() { + osutilIsMounted = old + } +} + +func MockSystemdMount(f func(_, _ string, opts *SystemdMountOptions) error) (restore func()) { + old := doSystemdMount + doSystemdMount = f + return func() { + doSystemdMount = old + } +} + +func MockTriggerwatchWait(f func(_ time.Duration, _ time.Duration) error) (restore func()) { + oldTriggerwatchWait := triggerwatchWait + triggerwatchWait = f + return func() { + triggerwatchWait = oldTriggerwatchWait + } +} + +var DefaultTimeout = defaultTimeout +var DefaultDeviceTimeout = defaultDeviceTimeout + +func MockDefaultMarkerFile(p string) (restore func()) { + old := defaultMarkerFile + defaultMarkerFile = p + return func() { + defaultMarkerFile = old + } +} + +func MockSecbootUnlockVolumeUsingSealedKeyIfEncrypted(f func(disk disks.Disk, name string, sealedEncryptionKeyFile string, opts *secboot.UnlockVolumeUsingSealedKeyOptions) (secboot.UnlockResult, error)) (restore func()) { + old := secbootUnlockVolumeUsingSealedKeyIfEncrypted + secbootUnlockVolumeUsingSealedKeyIfEncrypted = f + return func() { + secbootUnlockVolumeUsingSealedKeyIfEncrypted = old + } +} + +func MockSecbootUnlockEncryptedVolumeUsingKey(f func(disk disks.Disk, name string, key []byte) (secboot.UnlockResult, error)) (restore func()) { + old := secbootUnlockEncryptedVolumeUsingKey + secbootUnlockEncryptedVolumeUsingKey = f + return func() { + secbootUnlockEncryptedVolumeUsingKey = old + } +} + +func MockSecbootProvisionForCVM(f func(_ string) error) (restore func()) { + old := secbootProvisionForCVM + secbootProvisionForCVM = f + return func() { + secbootProvisionForCVM = old + } +} + +func MockSecbootMeasureSnapSystemEpochWhenPossible(f func() error) (restore func()) { + old := secbootMeasureSnapSystemEpochWhenPossible + secbootMeasureSnapSystemEpochWhenPossible = f + return func() { + secbootMeasureSnapSystemEpochWhenPossible = old + } +} + +func MockSecbootMeasureSnapModelWhenPossible(f func(findModel func() (*asserts.Model, error)) error) (restore func()) { + old := secbootMeasureSnapModelWhenPossible + secbootMeasureSnapModelWhenPossible = f + return func() { + secbootMeasureSnapModelWhenPossible = old + } +} + +func MockSecbootLockSealedKeys(f func() error) (restore func()) { + old := secbootLockSealedKeys + secbootLockSealedKeys = f + return func() { + secbootLockSealedKeys = old + } +} + +func MockPartitionUUIDForBootedKernelDisk(uuid string) (restore func()) { + old := bootFindPartitionUUIDForBootedKernelDisk + bootFindPartitionUUIDForBootedKernelDisk = func() (string, error) { + if uuid == "" { + // mock error + return "", fmt.Errorf("mocked error") + } + return uuid, nil + } + + return func() { + bootFindPartitionUUIDForBootedKernelDisk = old + } +} + +func MockTryRecoverySystemHealthCheck(mock func(gadget.Model) error) (restore func()) { + old := tryRecoverySystemHealthCheck + tryRecoverySystemHealthCheck = mock + return func() { + tryRecoverySystemHealthCheck = old + } +} + +func MockWaitFile(f func(string, time.Duration, int) error) (restore func()) { + old := waitFile + waitFile = f + return func() { + waitFile = old + } +} + +var WaitFile = waitFile + +func MockGadgetInstallRun(f func(model gadget.Model, gadgetRoot, kernelRoot, bootDevice string, options gadgetInstall.Options, observer gadget.ContentObserver, perfTimings timings.Measurer) (*gadgetInstall.InstalledSystemSideData, error)) (restore func()) { + old := gadgetInstallRun + gadgetInstallRun = f + return func() { + gadgetInstallRun = old + } +} + +func MockMakeRunnableStandaloneSystem(f func(model *asserts.Model, bootWith *boot.BootableSet, sealer *boot.TrustedAssetsInstallObserver) error) (restore func()) { + old := bootMakeRunnableStandaloneSystem + bootMakeRunnableStandaloneSystem = f + return func() { + bootMakeRunnableStandaloneSystem = old + } +} + +func MockApplyPreseededData(f func(preseedSeed seed.PreseedCapable, writableDir string) error) (restore func()) { + old := installApplyPreseededData + installApplyPreseededData = f + return func() { + installApplyPreseededData = old + } +} + +func MockEnsureNextBootToRunMode(f func(systemLabel string) error) (restore func()) { + old := bootEnsureNextBootToRunMode + bootEnsureNextBootToRunMode = f + return func() { + bootEnsureNextBootToRunMode = old + } +} diff --git a/cmd/snap-bootstrap/initramfs_mounts_state.go b/cmd/snap-bootstrap/initramfs_mounts_state.go new file mode 100644 index 00000000..dc7f8f7e --- /dev/null +++ b/cmd/snap-bootstrap/initramfs_mounts_state.go @@ -0,0 +1,161 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/boot" + "github.com/snapcore/snapd/gadget" + "github.com/snapcore/snapd/logger" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/seed" + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/timings" +) + +var ( + osutilSetTime = osutil.SetTime + runtimeNumCPU = runtime.NumCPU +) + +// initramfsMountsState helps tracking the state and progress +// of the mounting driving process. +type initramfsMountsState struct { + mode string + recoverySystem string + + verifiedModel gadget.Model + seeds map[string]seed.Seed +} + +var errRunModeNoImpliedRecoverySystem = errors.New("internal error: no implied recovery system in run mode") + +// LoadSeed returns the seed for the recoverySystem. +// If recoverySystem is "" the implied one will be used (only for +// modes other than run). +func (mst *initramfsMountsState) LoadSeed(recoverySystem string) (seed.Seed, error) { + if recoverySystem == "" { + if mst.mode == "run" { + return nil, errRunModeNoImpliedRecoverySystem + } + recoverySystem = mst.recoverySystem + } + + if mst.seeds == nil { + mst.seeds = make(map[string]seed.Seed) + } + foundSeed, hasSeed := mst.seeds[recoverySystem] + if hasSeed { + return foundSeed, nil + } + + perf := timings.New(nil) + + // get the current time to pass to ReadSystemEssentialAndBetterEarliestTime + // note that we trust the time we have from the system, because that time + // comes from either: + // * a RTC on the system that the kernel/systemd consulted and used to move + // time forward + // * systemd using a built-in timestamp from the initrd which was stamped + // when the initrd was built, giving a lower bound on the current time if + // the RTC does not have a battery or is otherwise unreliable, etc. + now := timeNow() + + jobs := 1 + if runtimeNumCPU() > 1 { + jobs = 2 + } + seed20, newTrustedEarliestTime, err := seed.ReadSeedAndBetterEarliestTime(boot.InitramfsUbuntuSeedDir, recoverySystem, now, jobs, perf) + if err != nil { + return nil, err + } + + // set the time on the system to move forward if it is in the future - never + // move the time backwards + if newTrustedEarliestTime.After(now) { + if err := osutilSetTime(newTrustedEarliestTime); err != nil { + // log the error but don't fail on it, we should be able to continue + // even if the time can't be moved forward + logger.Noticef("failed to move time forward from %s to %s: %v", now, newTrustedEarliestTime, err) + } + } + + mst.seeds[recoverySystem] = seed20 + + return seed20, nil +} + +// SetVerifiedBootModel sets the "verifiedModel" field. It should only +// be called after the model is verified. Either via a successful unlock +// of the encrypted data or after validating the seed in install/recover +// mode. +func (mst *initramfsMountsState) SetVerifiedBootModel(m gadget.Model) { + mst.verifiedModel = m +} + +// UnverifiedBootModel returns the unverified model from the +// boot partition for run mode. The current and only use case +// is measuring the model for run mode. Otherwise no decisions +// should be based on an unverified model. Note that the model +// is verified at the time the key auth policy is computed. +func (mst *initramfsMountsState) UnverifiedBootModel() (*asserts.Model, error) { + if mst.mode != "run" { + return nil, fmt.Errorf("internal error: unverified boot model access is for limited run mode use") + } + + mf, err := os.Open(filepath.Join(boot.InitramfsUbuntuBootDir, "device/model")) + if err != nil { + return nil, fmt.Errorf("cannot read model assertion: %v", err) + } + defer mf.Close() + ma, err := asserts.NewDecoder(mf).Decode() + if err != nil { + return nil, fmt.Errorf("cannot decode assertion: %v", err) + } + if ma.Type() != asserts.ModelType { + return nil, fmt.Errorf("unexpected assertion: %q", ma.Type().Name) + } + return ma.(*asserts.Model), nil +} + +// EphemeralModeenvForModel generates a modeenv given the model and the snaps for the +// current mode and recovery system of the initramfsMountsState. +func (mst *initramfsMountsState) EphemeralModeenvForModel(model *asserts.Model, snaps map[snap.Type]*seed.Snap) (*boot.Modeenv, error) { + if mst.mode == "run" { + return nil, fmt.Errorf("internal error: initramfs should not write modeenv in run mode") + } + return &boot.Modeenv{ + Mode: mst.mode, + RecoverySystem: mst.recoverySystem, + Base: snaps[snap.TypeBase].PlaceInfo().Filename(), + Gadget: snaps[snap.TypeGadget].PlaceInfo().Filename(), + Model: model.Model(), + BrandID: model.BrandID(), + Grade: string(model.Grade()), + // TODO:UC20: what about current kernel snaps, trusted boot assets and + // kernel command lines? + }, nil +} diff --git a/cmd/snap-bootstrap/initramfs_systemd_mount.go b/cmd/snap-bootstrap/initramfs_systemd_mount.go new file mode 100644 index 00000000..9970c4ff --- /dev/null +++ b/cmd/snap-bootstrap/initramfs_systemd_mount.go @@ -0,0 +1,217 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/systemd" +) + +var ( + timeNow = time.Now + + // default 1:30, as that is how long systemd will wait for services by + // default so seems a sensible default + defaultMountUnitWaitTimeout = time.Minute + 30*time.Second + + unitFileDependOverride = `[Unit] +Wants=%[1]s +` + + doSystemdMount = doSystemdMountImpl +) + +// systemdMountOptions reflects the set of options for mounting something using +// systemd-mount(1) +type systemdMountOptions struct { + // Tmpfs indicates that "what" should be ignored and a new tmpfs should be + // mounted at the location. + Tmpfs bool + // Ephemeral indicates that the mount should not persist from the initramfs + // to after the pivot_root to normal userspace. The default value, false, + // means that the mount will persist across the transition, this is done by + // creating systemd unit overrides for various initrd targets in /run that + // systemd understands when it isolates to the initrd-cleanup.target when + // the pivot_root is performed. + Ephemeral bool + // NeedsFsck indicates that before returning to the caller, an fsck check + // should be performed on the thing being mounted. + NeedsFsck bool + // NoWait will not wait until the systemd unit is active and running, which + // is the default behavior. + NoWait bool + // NoSuid indicates that the partition should be mounted with nosuid set on + // it to prevent suid execution. + NoSuid bool + // Bind indicates a bind mount + Bind bool + // Read-only mount + ReadOnly bool + // Private mount + Private bool + // Umount the mountpoint + Umount bool +} + +// doSystemdMount will mount "what" at "where" using systemd-mount(1) with +// various options. Note that in some error cases, the mount unit may have +// already been created and it will not be deleted here, if that is the case +// callers should check manually if the unit needs to be removed on error +// conditions. +func doSystemdMountImpl(what, where string, opts *systemdMountOptions) error { + if opts == nil { + opts = &systemdMountOptions{} + } + + // doesn't make sense to fsck a tmpfs + if opts.NeedsFsck && opts.Tmpfs { + return fmt.Errorf("cannot mount %q at %q: impossible to fsck a tmpfs", what, where) + } + + whereEscaped := systemd.EscapeUnitNamePath(where) + unitName := whereEscaped + ".mount" + + args := []string{what, where, "--no-pager", "--no-ask-password"} + + if opts.Umount { + args = []string{where, "--umount", "--no-pager", "--no-ask-password"} + } + + if opts.Tmpfs { + args = append(args, "--type=tmpfs") + } + + if opts.NeedsFsck { + // note that with the --fsck=yes argument, systemd will block starting + // the mount unit on a new systemd-fsck@ unit that will run the + // fsck, so we don't need to worry about waiting for that to finish in + // the case where we are supposed to wait (which is the default for this + // function) + args = append(args, "--fsck=yes") + } else { + // the default is to use fsck=yes, so if it doesn't need fsck we need to + // explicitly turn it off + args = append(args, "--fsck=no") + } + + // Under all circumstances that we use systemd-mount here from + // snap-bootstrap, it is expected to be okay to block waiting for the unit + // to be started and become active, because snap-bootstrap is, by design, + // expected to run as late as possible in the initramfs, and so any + // dependencies there might be in systemd creating and starting these mount + // units should already be ready and so we will not block forever. If + // however there was something going on in systemd at the same time that the + // mount unit depended on, we could hit a deadlock blocking as systemd will + // not enqueue this job until it's dependencies are ready, and so if those + // things depend on this mount unit we are stuck. The solution to this + // situation is to make snap-bootstrap run as late as possible before + // mounting things. + // However, we leave in the option to not block if there is ever a reason + // we need to do so. + if opts.NoWait { + args = append(args, "--no-block") + } + + var options []string + if opts.NoSuid { + options = append(options, "nosuid") + } + if opts.Bind { + options = append(options, "bind") + } + if opts.ReadOnly { + options = append(options, "ro") + } + if opts.Private { + options = append(options, "private") + } + if len(options) > 0 { + args = append(args, "--options="+strings.Join(options, ",")) + } + + // if it should survive pivot_root() then we need to add overrides for this + // unit to /run/systemd units + if !opts.Ephemeral { + // to survive the pivot_root, mounts need to be "wanted" by + // initrd-switch-root.target directly or indirectly. The + // proper target to place them in is initrd-fs.target + // note we could do this statically in the initramfs main filesystem + // layout, but that means that changes to snap-bootstrap would block on + // waiting for those files to be added before things works here, this is + // a more flexible strategy that puts snap-bootstrap in control + overrideContent := []byte(fmt.Sprintf(unitFileDependOverride, unitName)) + for _, initrdUnit := range []string{"initrd-fs.target", "local-fs.target"} { + targetDir := filepath.Join(dirs.GlobalRootDir, "/run/systemd/system", initrdUnit+".d") + err := os.MkdirAll(targetDir, 0755) + if err != nil { + return err + } + + // add an override file for the initrd unit to depend on this mount + // unit so that when we isolate to the initrd unit, it does not get + // unmounted + fname := fmt.Sprintf("snap_bootstrap_%s.conf", whereEscaped) + err = os.WriteFile(filepath.Join(targetDir, fname), overrideContent, 0644) + if err != nil { + return err + } + } + // local-fs.target is already automatically a depenency + args = append(args, "--property=Before=initrd-fs.target") + } + + stdout, stderr, err := osutil.RunSplitOutput("systemd-mount", args...) + if err != nil { + return osutil.OutputErrCombine(stdout, stderr, err) + } + + if !opts.NoWait { + // TODO: is this necessary, systemd-mount seems to only return when the + // unit is active and the mount is there, but perhaps we should be a bit + // paranoid here and wait anyways? + // see systemd-mount(1) + + // wait for the mount to exist + start := timeNow() + var now time.Time + for now = timeNow(); now.Sub(start) < defaultMountUnitWaitTimeout; now = timeNow() { + mounted, err := osutilIsMounted(where) + if mounted == !opts.Umount { + break + } + if err != nil { + return err + } + } + + if now.Sub(start) > defaultMountUnitWaitTimeout { + return fmt.Errorf("timed out after %s waiting for mount %s on %s", defaultMountUnitWaitTimeout, what, where) + } + } + + return nil +} diff --git a/cmd/snap-bootstrap/initramfs_systemd_mount_test.go b/cmd/snap-bootstrap/initramfs_systemd_mount_test.go new file mode 100644 index 00000000..3d20eec4 --- /dev/null +++ b/cmd/snap-bootstrap/initramfs_systemd_mount_test.go @@ -0,0 +1,333 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "fmt" + "path/filepath" + "strings" + "time" + + . "gopkg.in/check.v1" + + main "github.com/snapcore/snapd/cmd/snap-bootstrap" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/systemd" + "github.com/snapcore/snapd/testutil" +) + +type doSystemdMountSuite struct { + testutil.BaseTest +} + +var _ = Suite(&doSystemdMountSuite{}) + +func (s *doSystemdMountSuite) SetUpTest(c *C) { + dirs.SetRootDir(c.MkDir()) + s.AddCleanup(func() { dirs.SetRootDir("") }) +} + +func (s *doSystemdMountSuite) TestDoSystemdMountUnhappy(c *C) { + cmd := testutil.MockCommand(c, "systemd-mount", ` +echo "mocked error" +exit 1 +`) + defer cmd.Restore() + + err := main.DoSystemdMount("something", "somewhere only we know", nil) + c.Assert(err, ErrorMatches, "mocked error") +} + +func (s *doSystemdMountSuite) TestDoSystemdMount(c *C) { + + testStart := time.Now() + + tt := []struct { + what string + where string + opts *main.SystemdMountOptions + timeNowTimes []time.Time + isMountedReturns []bool + expErr string + comment string + }{ + { + what: "/dev/sda3", + where: "/run/mnt/data", + timeNowTimes: []time.Time{testStart, testStart}, + isMountedReturns: []bool{true}, + comment: "happy default", + }, + { + what: "tmpfs", + where: "/run/mnt/data", + opts: &main.SystemdMountOptions{ + Tmpfs: true, + }, + timeNowTimes: []time.Time{testStart, testStart}, + isMountedReturns: []bool{true}, + comment: "happy tmpfs", + }, + { + what: "tmpfs", + where: "/run/mnt/data", + opts: &main.SystemdMountOptions{ + NeedsFsck: true, + }, + timeNowTimes: []time.Time{testStart, testStart}, + isMountedReturns: []bool{true}, + comment: "happy fsck", + }, + { + what: "tmpfs", + where: "/run/mnt/data", + opts: &main.SystemdMountOptions{ + Ephemeral: true, + }, + timeNowTimes: []time.Time{testStart, testStart}, + isMountedReturns: []bool{true}, + comment: "happy initramfs ephemeral", + }, + { + what: "tmpfs", + where: "/run/mnt/data", + opts: &main.SystemdMountOptions{ + NoWait: true, + }, + comment: "happy no wait", + }, + { + what: "what", + where: "where", + timeNowTimes: []time.Time{testStart, testStart, testStart, testStart.Add(2 * time.Minute)}, + isMountedReturns: []bool{false, false}, + expErr: "timed out after 1m30s waiting for mount what on where", + comment: "times out waiting for mount to appear", + }, + { + what: "what", + where: "where", + opts: &main.SystemdMountOptions{ + Tmpfs: true, + NeedsFsck: true, + }, + expErr: "cannot mount \"what\" at \"where\": impossible to fsck a tmpfs", + comment: "invalid tmpfs + fsck", + }, + { + what: "tmpfs", + where: "/run/mnt/data", + opts: &main.SystemdMountOptions{ + NoSuid: true, + }, + timeNowTimes: []time.Time{testStart, testStart}, + isMountedReturns: []bool{true}, + comment: "happy nosuid", + }, + { + what: "tmpfs", + where: "/run/mnt/data", + opts: &main.SystemdMountOptions{ + Bind: true, + }, + timeNowTimes: []time.Time{testStart, testStart}, + isMountedReturns: []bool{true}, + comment: "happy bind", + }, + { + what: "tmpfs", + where: "/run/mnt/data", + opts: &main.SystemdMountOptions{ + Umount: true, + }, + timeNowTimes: []time.Time{testStart, testStart}, + isMountedReturns: []bool{false}, + comment: "happy umount", + }, + { + what: "tmpfs", + where: "/run/mnt/data", + opts: &main.SystemdMountOptions{ + NoSuid: true, + Bind: true, + }, + timeNowTimes: []time.Time{testStart, testStart}, + isMountedReturns: []bool{true}, + comment: "happy nosuid+bind", + }, + { + what: "/run/mnt/data/some.snap", + where: "/run/mnt/base", + opts: &main.SystemdMountOptions{ + ReadOnly: true, + }, + timeNowTimes: []time.Time{testStart, testStart}, + isMountedReturns: []bool{true}, + comment: "happy ro", + }, + } + + for _, t := range tt { + comment := Commentf(t.comment) + + var cleanups []func() + + opts := t.opts + if opts == nil { + opts = &main.SystemdMountOptions{} + } + dirs.SetRootDir(c.MkDir()) + cleanups = append(cleanups, func() { dirs.SetRootDir("") }) + + cmd := testutil.MockCommand(c, "systemd-mount", ``) + cleanups = append(cleanups, cmd.Restore) + + timeCalls := 0 + restore := main.MockTimeNow(func() time.Time { + timeCalls++ + c.Assert(timeCalls <= len(t.timeNowTimes), Equals, true, comment) + if timeCalls > len(t.timeNowTimes) { + c.Errorf("too many time.Now calls (%d)", timeCalls) + // we want the test to fail at some point and not run forever, so + // move time way forward to make it for sure time out + return testStart.Add(10000 * time.Hour) + } + return t.timeNowTimes[timeCalls-1] + }) + cleanups = append(cleanups, restore) + + cleanups = append(cleanups, func() { + c.Assert(timeCalls, Equals, len(t.timeNowTimes), comment) + }) + + isMountedCalls := 0 + restore = main.MockOsutilIsMounted(func(where string) (bool, error) { + isMountedCalls++ + c.Assert(isMountedCalls <= len(t.isMountedReturns), Equals, true, comment) + if isMountedCalls > len(t.isMountedReturns) { + e := fmt.Sprintf("too many osutil.IsMounted calls (%d)", isMountedCalls) + c.Errorf(e) + // we want the test to fail at some point and not run forever, so + // move time way forward to make it for sure time out + return false, fmt.Errorf(e) + } + return t.isMountedReturns[isMountedCalls-1], nil + }) + cleanups = append(cleanups, restore) + + cleanups = append(cleanups, func() { + c.Assert(isMountedCalls, Equals, len(t.isMountedReturns), comment) + }) + + err := main.DoSystemdMount(t.what, t.where, t.opts) + if t.expErr != "" { + c.Assert(err, ErrorMatches, t.expErr) + } else { + c.Assert(err, IsNil) + + c.Assert(len(cmd.Calls()), Equals, 1) + call := cmd.Calls()[0] + args := []string{ + "systemd-mount", t.what, t.where, "--no-pager", "--no-ask-password", + } + if opts.Umount { + args = []string{ + "systemd-mount", t.where, "--umount", "--no-pager", "--no-ask-password", + } + } + c.Assert(call[:len(args)], DeepEquals, args) + + foundTypeTmpfs := false + foundFsckYes := false + foundFsckNo := false + foundNoBlock := false + foundBeforeInitrdfsTarget := false + foundNoSuid := false + foundBind := false + foundReadOnly := false + foundPrivate := false + + for _, arg := range call[len(args):] { + switch { + case arg == "--type=tmpfs": + foundTypeTmpfs = true + case arg == "--fsck=yes": + foundFsckYes = true + case arg == "--fsck=no": + foundFsckNo = true + case arg == "--no-block": + foundNoBlock = true + case arg == "--property=Before=initrd-fs.target": + foundBeforeInitrdfsTarget = true + case strings.HasPrefix(arg, "--options="): + for _, opt := range strings.Split(strings.TrimPrefix(arg, "--options="), ",") { + switch opt { + case "nosuid": + foundNoSuid = true + case "bind": + foundBind = true + case "ro": + foundReadOnly = true + case "private": + foundPrivate = true + default: + c.Logf("Option '%s' unexpected", opt) + c.Fail() + } + } + default: + c.Logf("Argument '%s' unexpected", arg) + c.Fail() + } + } + c.Assert(foundTypeTmpfs, Equals, opts.Tmpfs) + c.Assert(foundFsckYes, Equals, opts.NeedsFsck) + c.Assert(foundFsckNo, Equals, !opts.NeedsFsck) + c.Assert(foundNoBlock, Equals, opts.NoWait) + c.Assert(foundBeforeInitrdfsTarget, Equals, !opts.Ephemeral) + c.Assert(foundNoSuid, Equals, opts.NoSuid) + c.Assert(foundBind, Equals, opts.Bind) + c.Assert(foundReadOnly, Equals, opts.ReadOnly) + c.Assert(foundPrivate, Equals, opts.Private) + + // check that the overrides are present if opts.Ephemeral is false, + // or check the overrides are not present if opts.Ephemeral is true + for _, initrdUnit := range []string{ + "initrd-fs.target", + "local-fs.target", + } { + mountUnit := systemd.EscapeUnitNamePath(t.where) + fname := fmt.Sprintf("snap_bootstrap_%s.conf", mountUnit) + unitFile := filepath.Join(dirs.GlobalRootDir, "/run/systemd/system", initrdUnit+".d", fname) + if opts.Ephemeral { + c.Assert(unitFile, testutil.FileAbsent) + } else { + c.Assert(unitFile, testutil.FileEquals, fmt.Sprintf(`[Unit] +Wants=%[1]s +`, mountUnit+".mount")) + } + } + } + + for _, r := range cleanups { + r() + } + } +} diff --git a/cmd/snap-bootstrap/main.go b/cmd/snap-bootstrap/main.go new file mode 100644 index 00000000..82a649cd --- /dev/null +++ b/cmd/snap-bootstrap/main.go @@ -0,0 +1,80 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "fmt" + "os" + + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/logger" +) + +var ( + shortHelp = "Bootstrap a Ubuntu Core system" + longHelp = ` +snap-bootstrap is a tool to bootstrap Ubuntu Core from ephemeral systems +such as initramfs. +` + + opts struct{} + commandBuilders []func(*flags.Parser) +) + +func main() { + err := run(os.Args[1:]) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %s\n", err) + os.Exit(1) + } +} + +func run(args []string) error { + if os.Getuid() != 0 { + return fmt.Errorf("please run as root") + } + logger.BootSetup() + return parseArgs(args) +} + +func parseArgs(args []string) error { + p := parser() + + _, err := p.ParseArgs(args) + if err != nil { + logger.Noticef("execution error: %v", err) + } + return err +} + +func parser() *flags.Parser { + p := flags.NewParser(&opts, flags.HelpFlag|flags.PassDoubleDash|flags.PassAfterNonOption) + p.ShortDescription = shortHelp + p.LongDescription = longHelp + for _, builder := range commandBuilders { + builder(p) + } + return p +} + +func addCommandBuilder(builder func(*flags.Parser)) { + commandBuilders = append(commandBuilders, builder) +} diff --git a/cmd/snap-bootstrap/main_test.go b/cmd/snap-bootstrap/main_test.go new file mode 100644 index 00000000..739442c6 --- /dev/null +++ b/cmd/snap-bootstrap/main_test.go @@ -0,0 +1,50 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "testing" + + . "gopkg.in/check.v1" + + main "github.com/snapcore/snapd/cmd/snap-bootstrap" + "github.com/snapcore/snapd/logger" + "github.com/snapcore/snapd/testutil" +) + +// Hook up check.v1 into the "go test" runner +func Test(t *testing.T) { TestingT(t) } + +type cmdSuite struct { + testutil.BaseTest +} + +var _ = Suite(&cmdSuite{}) + +func (s *cmdSuite) SetUpTest(c *C) { + s.BaseTest.SetUpTest(c) + _, r := logger.MockLogger() + s.AddCleanup(r) +} + +func (s *cmdSuite) TestNoArgsErrors(c *C) { + _, err := main.Parser().ParseArgs(nil) + c.Assert(err, ErrorMatches, "Please specify .*") +} diff --git a/cmd/snap-bootstrap/triggerwatch/evdev.go b/cmd/snap-bootstrap/triggerwatch/evdev.go new file mode 100644 index 00000000..8ed10639 --- /dev/null +++ b/cmd/snap-bootstrap/triggerwatch/evdev.go @@ -0,0 +1,263 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package triggerwatch + +import ( + "fmt" + "syscall" + "time" + "unsafe" + + // TODO:UC20: not packaged, reimplement the minimal things we need? + evdev "github.com/gvalkov/golang-evdev" + + "github.com/snapcore/snapd/logger" +) + +type keyEvent struct { + Dev triggerDevice + Err error +} + +type triggerEventFilter struct { + Key string +} + +var ( + strToKey = map[string]int{ + "KEY_ESC": evdev.KEY_ESC, + "KEY_1": evdev.KEY_1, + "KEY_2": evdev.KEY_2, + "KEY_3": evdev.KEY_3, + "KEY_4": evdev.KEY_4, + "KEY_5": evdev.KEY_5, + "KEY_6": evdev.KEY_6, + "KEY_7": evdev.KEY_7, + "KEY_8": evdev.KEY_8, + "KEY_9": evdev.KEY_9, + "KEY_0": evdev.KEY_0, + } + evKeyCapability = evdev.CapabilityType{Type: evdev.EV_KEY, Name: "EV_KEY"} + + // hold time needed to trigger the event + holdToTrigger = 2 * time.Second +) + +func init() { + trigger = &evdevInput{} +} + +type evdevKeyboardInputDevice struct { + keyCode uint16 + dev *evdev.InputDevice +} + +func (e *evdevKeyboardInputDevice) probeKeyState() (bool, error) { + // XXX: evdev defines EVIOCGKEY using MAX_NAME_SIZE which is larger than + // what is needed to store the key bitmap with KEY_MAX bits, but we need + // to play along since the value is already encoded + keyBitmap := new([evdev.MAX_NAME_SIZE]byte) + + // obtain the large bitmap with all key states + // https://elixir.bootlin.com/linux/v5.5.5/source/drivers/input/evdev.c#L1163 + _, _, err := syscall.RawSyscall(syscall.SYS_IOCTL, e.dev.File.Fd(), uintptr(evdev.EVIOCGKEY), uintptr(unsafe.Pointer(keyBitmap))) + if err != 0 { + return false, err + } + byteIdx := e.keyCode / 8 + keyMask := byte(1 << (e.keyCode % 8)) + isDown := keyBitmap[byteIdx]&keyMask != 0 + return isDown, nil +} + +func (e *evdevKeyboardInputDevice) WaitForTrigger(ch chan keyEvent) { + logger.Noticef("%s: starting wait, hold %s to trigger", e, holdToTrigger) + + // XXX: do not mess with setting the key repeat rate, as it's cumbersome + // and golang-evdev SetRepeatRate() parameter order is actually reversed + // wrt. what the kernel does. The evdev interprets EVIOCSREP arguments + // as (delay, repeat) + // https://elixir.bootlin.com/linux/latest/source/drivers/input/evdev.c#L1072 + // but the wrapper is passing is passing (repeat, delay) + // https://github.com/gvalkov/golang-evdev/blob/287e62b94bcb850ab42e711bd74b2875da83af2c/device.go#L226-L230 + + keyDown, err := e.probeKeyState() + if err != nil { + ch <- keyEvent{Err: fmt.Errorf("cannot obtain initial key state: %v", err), Dev: e} + } + if keyDown { + // looks like the key is pressed initially, we don't know when + // that happened, but pretend it happened just now + logger.Noticef("%s: key is already down", e) + } + + type evdevEvent struct { + kev *evdev.KeyEvent + err error + } + + // buffer large enough to collect some events + evChan := make(chan evdevEvent, 10) + + monitorKey := func() { + for { + ies, err := e.dev.Read() + if err != nil { + evChan <- evdevEvent{err: err} + break + } + for _, ie := range ies { + if ie.Type != evdev.EV_KEY || ie.Code != e.keyCode { + continue + } + kev := evdev.NewKeyEvent(&ie) + evChan <- evdevEvent{kev: kev} + } + } + close(evChan) + } + + go monitorKey() + + holdTimer := time.NewTimer(holdToTrigger) + // no sense to keep it running later either + defer holdTimer.Stop() + + if !keyDown { + // key isn't held yet, stop the timer + holdTimer.Stop() + } + + // invariant: tholdTimer is running iff keyDown is true, otherwise is stopped +Loop: + for { + select { + case ev := <-evChan: + if ev.err != nil { + holdTimer.Stop() + ch <- keyEvent{Err: ev.err, Dev: e} + break Loop + } + kev := ev.kev + switch kev.State { + case evdev.KeyDown: + if keyDown { + // unexpected, but possible if we missed + // a key up event right after checking + // the initial keyboard state when the + // key was still down + if !holdTimer.Stop() { + // drain the channel before the + // timer gets reset + <-holdTimer.C + } + } + keyDown = true + // timer is stopped at this point + holdTimer.Reset(holdToTrigger) + logger.Noticef("%s: trigger key down", e) + case evdev.KeyHold: + if !keyDown { + keyDown = true + // timer is not running yet at this point + holdTimer.Reset(holdToTrigger) + logger.Noticef("%s: unexpected hold without down", e) + } + case evdev.KeyUp: + // no need to drain the channel, if it expired, + // we'll handle it in next iteration + holdTimer.Stop() + keyDown = false + logger.Noticef("%s: trigger key up", e) + } + case <-holdTimer.C: + logger.Noticef("%s: hold complete", e) + ch <- keyEvent{Dev: e} + break Loop + } + } +} + +func (e *evdevKeyboardInputDevice) String() string { + return fmt.Sprintf("%s: %s", e.dev.Phys, e.dev.Name) +} + +func (e *evdevKeyboardInputDevice) Close() { + e.dev.File.Close() +} + +type evdevInput struct{} + +func getCapabilityCode(Key string) (evdev.CapabilityCode, error) { + keyCode, ok := strToKey[Key] + if !ok { + return evdev.CapabilityCode{}, fmt.Errorf("cannot find a key matching the filter %q", Key) + } + return evdev.CapabilityCode{Code: keyCode, Name: Key}, nil +} + +func matchDevice(cap evdev.CapabilityCode, dev *evdev.InputDevice) triggerDevice { + for _, cc := range dev.Capabilities[evKeyCapability] { + if cc == cap { + return &evdevKeyboardInputDevice{ + dev: dev, + keyCode: uint16(cap.Code), + } + } + } + return nil +} + +func (e *evdevInput) Open(filter triggerEventFilter, node string) (triggerDevice, error) { + evdevDevice, err := evdev.Open(node) + if err != nil { + return nil, err + } + cap, err := getCapabilityCode(filter.Key) + if err != nil { + return nil, err + } + return matchDevice(cap, evdevDevice), nil +} + +func (e *evdevInput) FindMatchingDevices(filter triggerEventFilter) ([]triggerDevice, error) { + devices, err := evdev.ListInputDevices() + if err != nil { + return nil, fmt.Errorf("cannot list input devices: %v", err) + } + + // NOTE: this supports so far only key input devices + cap, err := getCapabilityCode(filter.Key) + if err != nil { + return nil, err + } + + // collect all input devices that can emit the trigger key + var devs []triggerDevice + for _, dev := range devices { + idev := matchDevice(cap, dev) + if idev != nil { + devs = append(devs, idev) + } else { + defer dev.File.Close() + } + } + return devs, nil +} diff --git a/cmd/snap-bootstrap/triggerwatch/export_test.go b/cmd/snap-bootstrap/triggerwatch/export_test.go new file mode 100644 index 00000000..07b2113c --- /dev/null +++ b/cmd/snap-bootstrap/triggerwatch/export_test.go @@ -0,0 +1,72 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package triggerwatch + +import ( + "time" + + "github.com/snapcore/snapd/osutil/udev/netlink" +) + +func MockInput(newInput TriggerProvider) (restore func()) { + oldInput := trigger + trigger = newInput + return func() { + trigger = oldInput + } +} + +type TriggerProvider = triggerProvider +type TriggerDevice = triggerDevice +type TriggerCapabilityFilter = triggerEventFilter +type KeyEvent = keyEvent + +type mockUEventConnection struct { + events []netlink.UEvent +} + +func (m *mockUEventConnection) Connect(mode netlink.Mode) error { + return nil +} + +func (m *mockUEventConnection) Close() error { + return nil +} + +func (m *mockUEventConnection) Monitor(queue chan netlink.UEvent, errors chan error, matcher netlink.Matcher) func(time.Duration) bool { + go func() { + for _, event := range m.events { + queue <- event + } + }() + return func(time.Duration) bool { + return true + } +} + +func MockUEvent(events []netlink.UEvent) (restore func()) { + oldGetUEventConn := getUEventConn + getUEventConn = func() ueventConnection { + return &mockUEventConnection{events} + } + + return func() { + getUEventConn = oldGetUEventConn + } +} diff --git a/cmd/snap-bootstrap/triggerwatch/triggerwatch.go b/cmd/snap-bootstrap/triggerwatch/triggerwatch.go new file mode 100644 index 00000000..89c1e2b4 --- /dev/null +++ b/cmd/snap-bootstrap/triggerwatch/triggerwatch.go @@ -0,0 +1,158 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package triggerwatch + +import ( + "errors" + "fmt" + "os" + "os/signal" + "syscall" + "time" + + "github.com/snapcore/snapd/logger" + "github.com/snapcore/snapd/osutil/udev/netlink" +) + +type triggerProvider interface { + Open(filter triggerEventFilter, node string) (triggerDevice, error) + FindMatchingDevices(filter triggerEventFilter) ([]triggerDevice, error) +} + +type triggerDevice interface { + WaitForTrigger(chan keyEvent) + String() string + Close() +} + +type ueventConnection interface { + Connect(mode netlink.Mode) error + Close() error + Monitor(queue chan netlink.UEvent, errors chan error, matcher netlink.Matcher) func(time.Duration) bool +} + +var ( + // trigger mechanism + trigger triggerProvider + getUEventConn = func() ueventConnection { + return &netlink.UEventConn{} + } + + // wait for '1' to be pressed + triggerFilter = triggerEventFilter{Key: "KEY_1"} + + ErrTriggerNotDetected = errors.New("trigger not detected") + ErrNoMatchingInputDevices = errors.New("no matching input devices") +) + +// Wait waits for a trigger on the available trigger devices for a given amount +// of time. Returns nil if one was detected, ErrTriggerNotDetected if timeout +// was hit, or other non-nil error. +func Wait(timeout time.Duration, deviceTimeout time.Duration) error { + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, syscall.SIGUSR1) + conn := getUEventConn() + if err := conn.Connect(netlink.UdevEvent); err != nil { + logger.Panicf("Unable to connect to Netlink Kobject UEvent socket") + } + defer conn.Close() + + add := "add" + matcher := &netlink.RuleDefinitions{ + Rules: []netlink.RuleDefinition{ + { + Action: &add, + Env: map[string]string{ + "SUBSYSTEM": "input", + "ID_INPUT_KEYBOARD": "1", + "DEVNAME": ".*", + }, + }, + }, + } + + ueventQueue := make(chan netlink.UEvent) + ueventErrors := make(chan error) + conn.Monitor(ueventQueue, ueventErrors, matcher) + + if trigger == nil { + logger.Panicf("trigger is unset") + } + + devices, err := trigger.FindMatchingDevices(triggerFilter) + if err != nil { + return fmt.Errorf("cannot list trigger devices: %v", err) + } + + if devices == nil { + devices = make([]triggerDevice, 0) + } + + logger.Noticef("waiting for trigger key: %v", triggerFilter.Key) + + detectKeyCh := make(chan keyEvent, len(devices)) + for _, dev := range devices { + go dev.WaitForTrigger(detectKeyCh) + defer dev.Close() + } + foundDevice := len(devices) != 0 + + start := time.Now() + for { + timePassed := time.Now().Sub(start) + relTimeout := timeout - timePassed + relDeviceTimeout := deviceTimeout - timePassed + select { + case kev := <-detectKeyCh: + if kev.Err != nil { + return kev.Err + } + // channel got closed without an error + logger.Noticef("%s: + got trigger key %v", kev.Dev, triggerFilter.Key) + return nil + case <-time.After(relTimeout): + return ErrTriggerNotDetected + case <-time.After(relDeviceTimeout): + if !foundDevice { + return ErrNoMatchingInputDevices + } + case uevent := <-ueventQueue: + dev, err := trigger.Open(triggerFilter, uevent.Env["DEVNAME"]) + if err != nil { + logger.Noticef("ignoring device %s that cannot be opened: %v", uevent.Env["DEVNAME"], err) + } else if dev != nil { + foundDevice = true + defer dev.Close() + go dev.WaitForTrigger(detectKeyCh) + } + case <-sigs: + logger.Noticef("Switching root") + if err := syscall.Chdir("/sysroot"); err != nil { + return fmt.Errorf("Cannot change directory: %w", err) + } + if err := syscall.Chroot("/sysroot"); err != nil { + return fmt.Errorf("Cannot change root: %w", err) + } + if err := syscall.Chdir("/"); err != nil { + return fmt.Errorf("Cannot change directory: %w", err) + } + } + } +} diff --git a/cmd/snap-bootstrap/triggerwatch/triggerwatch_test.go b/cmd/snap-bootstrap/triggerwatch/triggerwatch_test.go new file mode 100644 index 00000000..ba5a8c1c --- /dev/null +++ b/cmd/snap-bootstrap/triggerwatch/triggerwatch_test.go @@ -0,0 +1,235 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package triggerwatch_test + +import ( + "errors" + "fmt" + "sync" + "testing" + "time" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/cmd/snap-bootstrap/triggerwatch" + "github.com/snapcore/snapd/osutil/udev/netlink" +) + +// Hook up check.v1 into the "go test" runner +func Test(t *testing.T) { TestingT(t) } + +type triggerwatchSuite struct{} + +var _ = Suite(&triggerwatchSuite{}) + +type mockTriggerDevice struct { + sync.Mutex + + waitForTriggerCalls int + closeCalls int + ev *triggerwatch.KeyEvent +} + +func (m *mockTriggerDevice) WaitForTrigger(n chan triggerwatch.KeyEvent) { + m.withLocked(func() { + m.waitForTriggerCalls++ + if m.ev != nil { + ev := *m.ev + ev.Dev = m + n <- ev + } + }) +} + +func (m *mockTriggerDevice) String() string { return "mock-device" } +func (m *mockTriggerDevice) Close() { m.closeCalls++ } + +func (m *mockTriggerDevice) withLocked(f func()) { + m.Lock() + defer m.Unlock() + f() +} + +type mockTrigger struct { + f triggerwatch.TriggerCapabilityFilter + d *mockTriggerDevice + unlistedDevices map[string]*mockTriggerDevice + + err error + + findMatchingCalls int + openCalls int +} + +func (m *mockTrigger) FindMatchingDevices(f triggerwatch.TriggerCapabilityFilter) ([]triggerwatch.TriggerDevice, error) { + m.findMatchingCalls++ + + m.f = f + if m.err != nil { + return nil, m.err + } + if m.d != nil { + return []triggerwatch.TriggerDevice{m.d}, nil + } + return nil, nil +} + +func (m *mockTrigger) Open(filter triggerwatch.TriggerCapabilityFilter, node string) (triggerwatch.TriggerDevice, error) { + m.openCalls++ + device, ok := m.unlistedDevices[node] + if !ok { + return nil, errors.New("Not found") + } else { + return device, nil + } +} + +const testTriggerTimeout = 5 * time.Millisecond +const testDeviceTimeout = 2 * time.Millisecond + +func (s *triggerwatchSuite) TestNoDevsWaitKey(c *C) { + md := &mockTriggerDevice{ev: &triggerwatch.KeyEvent{}} + mi := &mockTrigger{d: md} + restore := triggerwatch.MockInput(mi) + defer restore() + + err := triggerwatch.Wait(testTriggerTimeout, testDeviceTimeout) + c.Assert(err, IsNil) + c.Assert(mi.findMatchingCalls, Equals, 1) + c.Assert(md.waitForTriggerCalls, Equals, 1) + c.Assert(md.closeCalls, Equals, 1) +} + +func (s *triggerwatchSuite) TestNoDevsWaitKeyTimeout(c *C) { + md := &mockTriggerDevice{} + mi := &mockTrigger{d: md} + restore := triggerwatch.MockInput(mi) + defer restore() + + err := triggerwatch.Wait(testTriggerTimeout, testDeviceTimeout) + c.Assert(err, Equals, triggerwatch.ErrTriggerNotDetected) + c.Assert(mi.findMatchingCalls, Equals, 1) + md.withLocked(func() { + c.Assert(md.waitForTriggerCalls, Equals, 1) + c.Assert(md.closeCalls, Equals, 1) + }) +} + +func (s *triggerwatchSuite) TestNoDevsWaitNoMatching(c *C) { + mi := &mockTrigger{} + restore := triggerwatch.MockInput(mi) + defer restore() + + err := triggerwatch.Wait(testTriggerTimeout, testDeviceTimeout) + c.Assert(err, Equals, triggerwatch.ErrNoMatchingInputDevices) +} + +func (s *triggerwatchSuite) TestNoDevsWaitMatchingError(c *C) { + mi := &mockTrigger{err: fmt.Errorf("failed")} + restore := triggerwatch.MockInput(mi) + defer restore() + + err := triggerwatch.Wait(testTriggerTimeout, testDeviceTimeout) + c.Assert(err, ErrorMatches, "cannot list trigger devices: failed") +} + +func (s *triggerwatchSuite) TestChecksInput(c *C) { + restore := triggerwatch.MockInput(nil) + defer restore() + + c.Assert(func() { triggerwatch.Wait(testTriggerTimeout, testDeviceTimeout) }, + Panics, "trigger is unset") +} + +func (s *triggerwatchSuite) TestUdevEvent(c *C) { + nodepath := "/dev/input/event0" + devpath := "/devices/SOMEBUS/input/input0/event0" + + md := &mockTriggerDevice{ev: &triggerwatch.KeyEvent{}} + mi := &mockTrigger{ + unlistedDevices: map[string]*mockTriggerDevice{ + "/dev/input/event0": md, + }, + } + restore := triggerwatch.MockInput(mi) + defer restore() + + events := []netlink.UEvent{ + { + Action: netlink.ADD, + KObj: devpath, + Env: map[string]string{ + "SUBSYSTEM": "input", + "DEVNAME": nodepath, + "DEVPATH": devpath, + }, + }, + } + restoreUevents := triggerwatch.MockUEvent(events) + defer restoreUevents() + + err := triggerwatch.Wait(testTriggerTimeout, testDeviceTimeout) + c.Assert(err, IsNil) + c.Assert(mi.findMatchingCalls, Equals, 1) + + c.Assert(mi.openCalls, Equals, 1) + md.withLocked(func() { + c.Assert(md.waitForTriggerCalls, Equals, 1) + c.Assert(md.closeCalls, Equals, 1) + }) +} + +func (s *triggerwatchSuite) TestUdevEventNoKeyEvent(c *C) { + nodepath := "/dev/input/event0" + devpath := "/devices/SOMEBUS/input/input0/event0" + + md := &mockTriggerDevice{} + mi := &mockTrigger{ + unlistedDevices: map[string]*mockTriggerDevice{ + "/dev/input/event0": md, + }, + } + restore := triggerwatch.MockInput(mi) + defer restore() + + events := []netlink.UEvent{ + { + Action: netlink.ADD, + KObj: devpath, + Env: map[string]string{ + "SUBSYSTEM": "input", + "DEVNAME": nodepath, + "DEVPATH": devpath, + }, + }, + } + restoreUevents := triggerwatch.MockUEvent(events) + defer restoreUevents() + + err := triggerwatch.Wait(testTriggerTimeout, testDeviceTimeout) + c.Assert(err, Equals, triggerwatch.ErrTriggerNotDetected) + c.Assert(mi.findMatchingCalls, Equals, 1) + + c.Assert(mi.openCalls, Equals, 1) + md.withLocked(func() { + c.Assert(md.waitForTriggerCalls, Equals, 1) + c.Assert(md.closeCalls, Equals, 1) + }) +} diff --git a/cmd/snap-confine/PORTING b/cmd/snap-confine/PORTING new file mode 100644 index 00000000..afea0879 --- /dev/null +++ b/cmd/snap-confine/PORTING @@ -0,0 +1,15 @@ +Welcome brave porters! + +This file is intended to guide you towards porting snappy (comprised of snapd +and this project, snap-confine) to work on a new kernel. The confinement setup by +snap-confine has several requirements on the kernel. + +TODO: list required patches (apparmor, seccomp) +TODO: list required kernel configuration +TODO: list minimum supported kernel version + +While you are working on porting those patches to your kernel of choice, you +may configure snap-confine with --disable-security. This switch drops +requirement on apparmor, seccomp and udev and reduces snap-confine to arrange +the filesystem in a correct way for snaps to operate without really confining +them in any way. diff --git a/cmd/snap-confine/README.mount_namespace b/cmd/snap-confine/README.mount_namespace new file mode 100644 index 00000000..fecf87cb --- /dev/null +++ b/cmd/snap-confine/README.mount_namespace @@ -0,0 +1,92 @@ += Mount namespace setup in snap-confine = + +This document provides a terse explanation of the mount setup using syscall +traces to show precisely what is happening and show the difference between +all snaps images and classic. + +Obtain traces with (ignoring select helps keep strace from hanging): +$ sudo snap install hello-world +$ sudo /usr/lib/snapd/snap-discard-ns hello-world +$ sudo strace -f -vv -s8192 -o /tmp/trace.unshare -e trace='!select' /snap/bin/hello-world +$ sudo strace -f -vv -s8192 -o /tmp/trace.setns -e trace='!select' /snap/bin/hello-world + +Examine /tmp/trace.unshare for initial mount namespace setup and +/tmp/trace.setns for seeing how the mount namespace is reused on subsequent +runs. Note that running /usr/lib/snapd/snap-discard-ns prior to running the +command is required for creating the new mount namespace (otherwise the +previous mount namespace will be reused). + + += Mount namespace setup in detail = +Here are the steps snap-confine takes when setting up the mount namespace for a +given snap: + +# Create the /run/snapd/ns directory to save off the mount namespace to be +# shared on other app-invocations +open("/", O_RDONLY|O_DIRECTORY|O_NOFOLLOW|O_CLOEXEC) = 3 +mkdirat(3, "run", 0755) = -1 EEXIST (File exists) +openat(3, "run", O_RDONLY|O_DIRECTORY|O_NOFOLLOW|O_CLOEXEC) = 4 +mkdirat(4, "snapd", 0755) = -1 EEXIST (File exists) +openat(4, "snapd", O_RDONLY|O_DIRECTORY|O_NOFOLLOW|O_CLOEXEC) = 3 +mkdirat(3, "ns", 0755) = -1 EEXIST (File exists) +openat(3, "ns", O_RDONLY|O_DIRECTORY|O_NOFOLLOW|O_CLOEXEC) = 4 + +# If /run/snapd/ns/.mnt exists, enter that namespace: +openat(3, "hello-world.mnt", O_RDONLY|O_CREAT|O_NOFOLLOW|O_CLOEXEC, 0600) = 5 +fstatfs(5, {f_type=0x6e736673, ...) = 0 +setns(5, CLONE_NEWNS) = 0 +... mount namespace setup finished, go on to setup the rest of the sandbox ... + + +# Otherwise, create a new mount namespace +unshare(CLONE_NEWNS) +mount("none", "/", NULL, MS_REC|MS_SLAVE, NULL) = 0 + +# Classic-only - mount rootfs in the namespace +mkdir("/tmp/snap.rootfs_HkQghZ", 0700) = 0 +mount("/snap/ubuntu-core/current", "/tmp/snap.rootfs_HkQghZ", NULL, MS_BIND, NULL) = 0 + +# Classic only - mount directories from host over rootfs +mount("/dev", "/tmp/snap.rootfs_HkQghZ/dev", NULL, MS_BIND|MS_REC|MS_SLAVE, NULL) = 0 +mount("/etc", "/tmp/snap.rootfs_HkQghZ/etc", NULL, MS_BIND|MS_REC|MS_SLAVE, NULL) = 0 +mount("/home", "/tmp/snap.rootfs_HkQghZ/home", NULL, MS_BIND|MS_REC|MS_SLAVE, NULL) = 0 +mount("/root", "/tmp/snap.rootfs_HkQghZ/root", NULL, MS_BIND|MS_REC|MS_SLAVE, NULL) = 0 +mount("/proc", "/tmp/snap.rootfs_HkQghZ/proc", NULL, MS_BIND|MS_REC|MS_SLAVE, NULL) = 0 +mount("/sys", "/tmp/snap.rootfs_HkQghZ/sys", NULL, MS_BIND|MS_REC|MS_SLAVE, NULL) = 0 +mount("/tmp", "/tmp/snap.rootfs_HkQghZ/tmp", NULL, MS_BIND|MS_REC|MS_SLAVE, NULL) = 0 +mount("/var/snap", "/tmp/snap.rootfs_HkQghZ/var/snap", NULL, MS_BIND|MS_REC|MS_SLAVE, NULL) = 0 +mount("/var/lib/snapd", "/tmp/snap.rootfs_HkQghZ/var/lib/snapd", NULL, MS_BIND|MS_REC|MS_SLAVE, NULL) = 0 +mount("/var/tmp", "/tmp/snap.rootfs_HkQghZ/var/tmp", NULL, MS_BIND|MS_REC|MS_SLAVE, NULL) = 0 +mount("/run", "/tmp/snap.rootfs_HkQghZ/run", NULL, MS_BIND|MS_REC|MS_SLAVE, NULL) = 0 +mount("/media", "/tmp/snap.rootfs_HkQghZ/media", NULL, MS_BIND|MS_REC|MS_SLAVE, NULL) = 0 +mount("/lib/modules", "/tmp/snap.rootfs_HkQghZ/lib/modules", NULL, MS_BIND|MS_REC|MS_SLAVE, NULL) = 0 +mount("/usr/src", "/tmp/snap.rootfs_HkQghZ/usr/src", NULL, MS_BIND|MS_REC|MS_SLAVE, NULL) = 0 +mount("/var/log", "/tmp/snap.rootfs_HkQghZ/var/log", NULL, MS_BIND|MS_REC|MS_SLAVE, NULL) = 0 +mount("/snap", "/tmp/snap.rootfs_HkQghZ/snap", NULL, MS_BIND|MS_REC|MS_SLAVE, NULL) = 0 +mount("/snap/ubuntu-core/current/etc/alternatives", "/tmp/snap.rootfs_HkQghZ/etc/alternatives", NULL, MS_BIND|MS_SLAVE, NULL) = 0 +mount("/", "/tmp/snap.rootfs_HkQghZ/var/lib/snapd/hostfs", NULL, MS_RDONLY|MS_BIND, NULL) = 0 + +# Classic only - pivot_root into the rootfs +pivot_root(".", ".") = 0 +umount2(".", MNT_DETACH) = 0 + +# Create a bind-mounted private /tmp +mkdir("/tmp/snap.0_snap.hello-world.hello-world_QXGSt1", 0700) = 0 +mkdir("/tmp/snap.0_snap.hello-world.hello-world_QXGSt1/tmp", 01777) = 0 +mount("/tmp/snap.0_snap.hello-world.hello-world_QXGSt1/tmp", "/tmp", NULL, MS_BIND, NULL) = 0 +mount("none", "/tmp", NULL, MS_PRIVATE, NULL) = 0 + +# Create a per-snap /dev/pts +mount("devpts", "/dev/pts", "devpts", MS_MGC_VAL, "newinstance,ptmxmode=0666,mode=0"...) +mount("/dev/pts/ptmx", "/dev/ptmx", 0x5574dfe9a5c3, MS_BIND, NULL) + +# Process snap-defined mounts (eg, for content interface, mount the source to +# the target as defined in /var/lib/snapd/mount/snap...fstab) +# Eg: +mount("/snap/some-content-snap/current/src", "/snap/hello-world/current/dst", NULL, MS_RDONLY|MS_NOSUID|MS_NODEV|MS_BIND, NULL) + +# Bind mount this namespace to the application-specific NSFS magic file to +# preserve it across snap invocations (an fchdir() happened just after the +# unshare(), above). +mount("/proc/12887/ns/mnt", "hello-world.mnt", NULL, MS_BIND, NULL) = 0 +... mount namespace setup finished, go on to setup the rest of the sandbox ... diff --git a/cmd/snap-confine/README.nvidia b/cmd/snap-confine/README.nvidia new file mode 100644 index 00000000..972b48d8 --- /dev/null +++ b/cmd/snap-confine/README.nvidia @@ -0,0 +1,26 @@ +Nvidia on Arch +============== + +On Arch nvidia support differs depending on the version of the driver used. +Free drivers should work out of the box without any changes. Proprietary +drivers were tested on the following driver versions: + +nvidia-340xx 340.96-13 +nvidia-340xx-libgl 340.96-1 +nvidia-340xx-utils 340.96-1 + +The way the driver stack works was changed significantly in driver 364 and that +version does not yet work correctly (we will gladly take patches if you beat us +to the punch!). There is some ongoing work but it needs more investigation. + +Nvidia on Ubuntu +================ + +On Ubuntu nvidia drivers are provided in a different way and we believe that +all versions work correctly. + +Nvidia on $DISTRO +================= + +Free drivers should work everywhere. Support for proprietary drivers will be +added on a case-by-case basis. diff --git a/cmd/snap-confine/README.syscalls b/cmd/snap-confine/README.syscalls new file mode 100644 index 00000000..fee72df1 --- /dev/null +++ b/cmd/snap-confine/README.syscalls @@ -0,0 +1,436 @@ +To get all the syscalls, grab all the linux-libc-dev packages for all the +architectures (eg, amd64, arm64, armhf, i386, powerpc, ppc64el) and put then +in a directory. Then: + +mkdir extracted +for i in ./*deb ; do + dpkg-deb -x $i ./extracted +done + +for i in `find . -name "unistd*.h"|grep gnu` ; do egrep '^#define .*_NR_([a-z0-9_\-]*)' $i | awk '{print $2}' | sed 's/.*_NR_//' ; done|sort -u + +NOTE: syscall() isn't actually a syscall, it is a glibc wrapping to reference +a syscall by number (therefore, it should be omitted from filter policy). ARM +OABI did define this, but it has been obsoleted in EABI. + +For example, on Ubuntu 16.04 with the 4.4.0-16.32 Linux kernel, these are the +syscalls: +accept +accept4 +access +acct +add_key +adjtimex +afs_syscall +alarm +arch_prctl +arm_fadvise64_64 +arm_sync_file_range +bdflush +bind +bpf +break +breakpoint +brk +cacheflush +capget +capset +chdir +chmod +chown +chown32 +chroot +clock_adjtime +clock_getres +clock_gettime +clock_nanosleep +clock_settime +clone +close +connect +creat +create_module +delete_module +dup +dup2 +dup3 +epoll_create +epoll_create1 +epoll_ctl +epoll_ctl_old +epoll_pwait +epoll_wait +epoll_wait_old +eventfd +eventfd2 +execve +execveat +exit +exit_group +faccessat +fadvise64 +fadvise64_64 +fallocate +fanotify_init +fanotify_mark +fchdir +fchmod +fchmodat +fchown +fchown32 +fchownat +fcntl +fcntl64 +fdatasync +fgetxattr +finit_module +flistxattr +flock +fork +fremovexattr +fsetxattr +fstat +fstat64 +fstatat64 +fstatfs +fstatfs64 +fsync +ftime +ftruncate +ftruncate64 +futex +futimesat +getcpu +getcwd +getdents +getdents64 +getegid +getegid32 +geteuid +geteuid32 +getgid +getgid32 +getgroups +getgroups32 +getitimer +get_kernel_syms +get_mempolicy +getpeername +getpgid +getpgrp +getpid +getpmsg +getppid +getpriority +getrandom +getresgid +getresgid32 +getresuid +getresuid32 +getrlimit +get_robust_list +getrusage +getsid +getsockname +getsockopt +get_thread_area +gettid +gettimeofday +getuid +getuid32 +getxattr +gtty +idle +init_module +inotify_add_watch +inotify_init +inotify_init1 +inotify_rm_watch +io_cancel +ioctl +io_destroy +io_getevents +ioperm +iopl +ioprio_get +ioprio_set +io_setup +io_submit +ipc +kcmp +kexec_file_load +kexec_load +keyctl +kill +lchown +lchown32 +lgetxattr +link +linkat +listen +listxattr +llistxattr +_llseek +lock +lookup_dcookie +lremovexattr +lseek +lsetxattr +lstat +lstat64 +madvise +mbind +membarrier +memfd_create +migrate_pages +mincore +mkdir +mkdirat +mknod +mknodat +mlock +mlock2 +mlockall +mmap +mmap2 +modify_ldt +mount +move_pages +mprotect +mpx +mq_getsetattr +mq_notify +mq_open +mq_timedreceive +mq_timedsend +mq_unlink +mremap +msgctl +msgget +msgrcv +msgsnd +msync +multiplexer +munlock +munlockall +munmap +name_to_handle_at +nanosleep +newfstatat +_newselect +nfsservctl +nice +oldfstat +oldlstat +oldolduname +oldstat +olduname +open +openat +open_by_handle_at +pause +pciconfig_iobase +pciconfig_read +pciconfig_write +perf_event_open +personality +pipe +pipe2 +pivot_root +poll +ppoll +prctl +pread64 +preadv +prlimit64 +process_vm_readv +process_vm_writev +prof +profil +pselect6 +ptrace +putpmsg +pwrite64 +pwritev +query_module +quotactl +read +readahead +readdir +readlink +readlinkat +readv +reboot +recv +recvfrom +recvmmsg +recvmsg +remap_file_pages +removexattr +rename +renameat +renameat2 +request_key +restart_syscall +rmdir +rtas +rt_sigaction +rt_sigpending +rt_sigprocmask +rt_sigqueueinfo +rt_sigreturn +rt_sigsuspend +rt_sigtimedwait +rt_tgsigqueueinfo +s390_pci_mmio_read +s390_pci_mmio_write +s390_runtime_instr +sched_getaffinity +sched_getattr +sched_getparam +sched_get_priority_max +sched_get_priority_min +sched_getscheduler +sched_rr_get_interval +sched_setaffinity +sched_setattr +sched_setparam +sched_setscheduler +sched_yield +seccomp +security +select +semctl +semget +semop +semtimedop +send +sendfile +sendfile64 +sendmmsg +sendmsg +sendto +setdomainname +setfsgid +setfsgid32 +setfsuid +setfsuid32 +setgid +setgid32 +setgroups +setgroups32 +sethostname +setitimer +set_mempolicy +setns +setpgid +setpriority +setregid +setregid32 +setresgid +setresgid32 +setresuid +setresuid32 +setreuid +setreuid32 +setrlimit +set_robust_list +setsid +setsockopt +set_thread_area +set_tid_address +settimeofday +set_tls +setuid +setuid32 +setxattr +sgetmask +shmat +shmctl +shmdt +shmget +shutdown +sigaction +sigaltstack +signal +signalfd +signalfd4 +sigpending +sigprocmask +sigreturn +sigsuspend +socket +socketcall +socketpair +splice +spu_create +spu_run +ssetmask +stat +stat64 +statfs +statfs64 +stime +stty +subpage_prot +swapcontext +swapoff +swapon +switch_endian +symlink +symlinkat +sync +sync_file_range +sync_file_range2 +syncfs +syscall +_sysctl +sys_debug_setcontext +sysfs +sysinfo +syslog +tee +tgkill +time +timer_create +timer_delete +timerfd +timerfd_create +timerfd_gettime +timerfd_settime +timer_getoverrun +timer_gettime +timer_settime +times +tkill +truncate +truncate64 +tuxcall +ugetrlimit +ulimit +umask +umount +umount2 +uname +unlink +unlinkat +unshare +uselib +userfaultfd +usr26 +usr32 +ustat +utime +utimensat +utimes +vfork +vhangup +vm86 +vm86old +vmsplice +vserver +wait4 +waitid +waitpid +write +writev diff --git a/cmd/snap-confine/cookie-support-test.c b/cmd/snap-confine/cookie-support-test.c new file mode 100644 index 00000000..f8ba5440 --- /dev/null +++ b/cmd/snap-confine/cookie-support-test.c @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "cookie-support.h" +#include "cookie-support.c" + +#include "../libsnap-confine-private/cleanup-funcs.h" +#include "../libsnap-confine-private/test-utils.h" + +#include +#include +#include +#include + +// Set alternate cookie directory +static void set_cookie_dir(const char *dir) +{ + sc_cookie_dir = dir; +} + +static void set_fake_cookie_dir(void) +{ + char *ctx_dir = NULL; + ctx_dir = g_dir_make_tmp(NULL, NULL); + g_assert_nonnull(ctx_dir); + g_test_queue_free(ctx_dir); + + g_test_queue_destroy((GDestroyNotify) rm_rf_tmp, ctx_dir); + g_test_queue_destroy((GDestroyNotify) set_cookie_dir, SC_COOKIE_DIR); + + set_cookie_dir(ctx_dir); +} + +static void create_dumy_cookie_file(const char *snap_name, + const char *dummy_cookie) +{ + char path[PATH_MAX] = { 0 }; + FILE *f; + int n; + + snprintf(path, sizeof(path), "%s/snap.%s", sc_cookie_dir, snap_name); + + f = fopen(path, "w"); + g_assert_nonnull(f); + + n = fwrite(dummy_cookie, 1, strlen(dummy_cookie), f); + g_assert_cmpint(n, ==, strlen(dummy_cookie)); + + fclose(f); +} + +static void test_cookie_get_from_snapd__successful(void) +{ + struct sc_error *err SC_CLEANUP(sc_cleanup_error) = NULL; + char *cookie SC_CLEANUP(sc_cleanup_string) = NULL; + + char *dummy = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijmnopqrst"; + + set_fake_cookie_dir(); + create_dumy_cookie_file("test-snap", dummy); + + cookie = sc_cookie_get_from_snapd("test-snap", &err); + g_assert_null(err); + g_assert_nonnull(cookie); + g_assert_cmpint(strlen(cookie), ==, 44); + g_assert_cmpstr(cookie, ==, dummy); +} + +static void test_cookie_get_from_snapd__nofile(void) +{ + struct sc_error *err SC_CLEANUP(sc_cleanup_error) = NULL; + char *cookie SC_CLEANUP(sc_cleanup_string) = NULL; + + set_fake_cookie_dir(); + + cookie = sc_cookie_get_from_snapd("test-snap2", &err); + g_assert_nonnull(err); + g_assert_cmpstr(sc_error_domain(err), ==, SC_ERRNO_DOMAIN); + g_assert_nonnull(strstr(sc_error_msg(err), "cannot open cookie file")); + g_assert_null(cookie); +} + +static void __attribute__((constructor)) init(void) +{ + g_test_add_func("/snap-cookie/cookie_get_from_snapd/successful", + test_cookie_get_from_snapd__successful); + g_test_add_func("/snap-cookie/cookie_get_from_snapd/no_cookie_file", + test_cookie_get_from_snapd__nofile); +} diff --git a/cmd/snap-confine/cookie-support.c b/cmd/snap-confine/cookie-support.c new file mode 100644 index 00000000..1d267fb7 --- /dev/null +++ b/cmd/snap-confine/cookie-support.c @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "cookie-support.h" + +#include "../libsnap-confine-private/cleanup-funcs.h" +#include "../libsnap-confine-private/string-utils.h" +#include "../libsnap-confine-private/utils.h" + +#include "config.h" + +#include +#include +#include +#include +#include +#include + +#define SC_COOKIE_DIR "/var/lib/snapd/cookie" + +/** + * Effective value of SC_COOKIE_DIR + **/ +static const char *sc_cookie_dir = SC_COOKIE_DIR; + +char *sc_cookie_get_from_snapd(const char *snap_name, struct sc_error **errorp) +{ + char context_path[PATH_MAX] = { 0 }; + struct sc_error *err = NULL; + char *context = NULL; + + sc_must_snprintf(context_path, sizeof(context_path), "%s/snap.%s", + sc_cookie_dir, snap_name); + int fd SC_CLEANUP(sc_cleanup_close) = -1; + fd = open(context_path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC); + if (fd < 0) { + err = + sc_error_init_from_errno(errno, + "warning: cannot open cookie file %s", + context_path); + goto out; + } + // large enough buffer for opaque cookie string + char context_val[255] = { 0 }; + ssize_t n = read(fd, context_val, sizeof(context_val) - 1); + if (n < 0) { + err = + sc_error_init_from_errno(errno, + "cannot read cookie file %s", + context_path); + goto out; + } + context = strndup(context_val, n); + if (context == NULL) { + die("cannot duplicate snap cookie value"); + } + + out: + sc_error_forward(errorp, err); + return context; +} diff --git a/cmd/snap-confine/cookie-support.h b/cmd/snap-confine/cookie-support.h new file mode 100644 index 00000000..49774b0c --- /dev/null +++ b/cmd/snap-confine/cookie-support.h @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#ifndef SNAP_CONFINE_CONTEXT_SUPPORT_H +#define SNAP_CONFINE_CONTEXT_SUPPORT_H + +#include "../libsnap-confine-private/error.h" + +/** + * Return snap cookie string for given snap. + * + * The context value is read from /var/lib/snapd/cookie/snap. + * file. The caller of the function takes the ownership of the returned cookie + * string. + * If the file cannot be read then an error is returned in errorp and + * the function returns NULL. + **/ +char *sc_cookie_get_from_snapd(const char *snap_name, struct sc_error **errorp); + +#endif diff --git a/cmd/snap-confine/misc/0001-Add-printk-based-debugging-to-pivot_root.patch b/cmd/snap-confine/misc/0001-Add-printk-based-debugging-to-pivot_root.patch new file mode 100644 index 00000000..225a47b8 --- /dev/null +++ b/cmd/snap-confine/misc/0001-Add-printk-based-debugging-to-pivot_root.patch @@ -0,0 +1,132 @@ +From 1ef45eb31cacd58c4c62e1fd26aa63a1f3d031a7 Mon Sep 17 00:00:00 2001 +From: Zygmunt Krynicki +Date: Thu, 29 Sep 2016 15:11:15 +0200 +Subject: [PATCH] Add printk-based debugging to pivot_root + +This patch changes pivot_root to make it obvious which error exit path +was taken. It might be useful to apply to debug and investigate how +undocumented requirements of pivot_root are not met. + +Signed-off-by: Zygmunt Krynicki +--- + fs/namespace.c | 70 ++++++++++++++++++++++++++++++++++++++++++++-------------- + 1 file changed, 53 insertions(+), 17 deletions(-) + +diff --git a/fs/namespace.c b/fs/namespace.c +index 877fc2c..6e15d1d 100644 +--- a/fs/namespace.c ++++ b/fs/namespace.c +@@ -2993,57 +2993,93 @@ SYSCALL_DEFINE2(pivot_root, const char __user *, new_root, + return -EPERM; + + error = user_path_dir(new_root, &new); +- if (error) ++ if (error) { ++ printk(KERN_ERR "user_path_dir(new_root, &new) returned an error\n"); + goto out0; ++ } + + error = user_path_dir(put_old, &old); +- if (error) ++ if (error) { ++ printk(KERN_ERR "user_path_dir(put_old, &old) returned an error\n"); + goto out1; ++ } + + error = security_sb_pivotroot(&old, &new); +- if (error) ++ if (error) { ++ printk(KERN_ERR "security_sb_pivotroot(&old, &new) returned an error\n"); + goto out2; ++ } + + get_fs_root(current->fs, &root); + old_mp = lock_mount(&old); + error = PTR_ERR(old_mp); +- if (IS_ERR(old_mp)) ++ if (IS_ERR(old_mp)) { ++ printk(KERN_ERR "IS_ERR(old_mp)\n"); + goto out3; ++ } + + error = -EINVAL; + new_mnt = real_mount(new.mnt); + root_mnt = real_mount(root.mnt); + old_mnt = real_mount(old.mnt); +- if (IS_MNT_SHARED(old_mnt) || +- IS_MNT_SHARED(new_mnt->mnt_parent) || +- IS_MNT_SHARED(root_mnt->mnt_parent)) ++ if (IS_MNT_SHARED(old_mnt)) { ++ printk(KERN_ERR "IS_MNT_SHARED(old_mnt)\n"); ++ goto out4; ++ } ++ if (IS_MNT_SHARED(new_mnt->mnt_parent)) { ++ printk(KERN_ERR "IS_MNT_SHARED(new_mnt->mnt_parent)\n"); + goto out4; +- if (!check_mnt(root_mnt) || !check_mnt(new_mnt)) ++ } ++ if (IS_MNT_SHARED(root_mnt->mnt_parent)) { ++ printk(KERN_ERR "IS_MNT_SHARED(root_mnt->mnt_parent)\n"); + goto out4; +- if (new_mnt->mnt.mnt_flags & MNT_LOCKED) ++ } ++ if (!check_mnt(root_mnt) || !check_mnt(new_mnt)) { ++ printk(KERN_ERR "!check_mnt(root_mnt) || !check_mnt(new_mnt)\n"); ++ goto out4; ++ } ++ if (new_mnt->mnt.mnt_flags & MNT_LOCKED) { ++ printk(KERN_ERR "new_mnt->mnt.mnt_flags & MNT_LOCKED\n"); + goto out4; ++ } + error = -ENOENT; +- if (d_unlinked(new.dentry)) ++ if (d_unlinked(new.dentry)) { ++ printk(KERN_ERR "d_unlinked(new.dentry)\n"); + goto out4; ++ } + error = -EBUSY; +- if (new_mnt == root_mnt || old_mnt == root_mnt) ++ if (new_mnt == root_mnt || old_mnt == root_mnt) { ++ printk(KERN_ERR "new_mnt == root_mnt || old_mnt == root_mnt\n"); + goto out4; /* loop, on the same file system */ ++ } + error = -EINVAL; +- if (root.mnt->mnt_root != root.dentry) ++ if (root.mnt->mnt_root != root.dentry) { ++ printk(KERN_ERR "root.mnt->mnt_root != root.dentry\n"); + goto out4; /* not a mountpoint */ +- if (!mnt_has_parent(root_mnt)) ++ } ++ if (!mnt_has_parent(root_mnt)) { ++ printk(KERN_ERR "!mnt_has_parent(root_mnt)\n"); + goto out4; /* not attached */ ++ } + root_mp = root_mnt->mnt_mp; +- if (new.mnt->mnt_root != new.dentry) ++ if (new.mnt->mnt_root != new.dentry) { ++ printk(KERN_ERR "new.mnt->mnt_root != new.dentry\n"); + goto out4; /* not a mountpoint */ +- if (!mnt_has_parent(new_mnt)) ++ } ++ if (!mnt_has_parent(new_mnt)) { ++ printk(KERN_ERR "!mnt_has_parent(new_mnt)\n"); + goto out4; /* not attached */ ++ } + /* make sure we can reach put_old from new_root */ +- if (!is_path_reachable(old_mnt, old.dentry, &new)) ++ if (!is_path_reachable(old_mnt, old.dentry, &new)) { ++ printk(KERN_ERR "!is_path_reachable(old_mnt, old.dentry, &new)\n"); + goto out4; ++ } + /* make certain new is below the root */ +- if (!is_path_reachable(new_mnt, new.dentry, &root)) ++ if (!is_path_reachable(new_mnt, new.dentry, &root)) { ++ printk(KERN_ERR "!is_path_reachable(new_mnt, new.dentry, &root)\n"); + goto out4; ++ } + root_mp->m_count++; /* pin it so it won't go away */ + lock_mount_hash(); + detach_mnt(new_mnt, &parent_path); +-- +2.7.4 + diff --git a/cmd/snap-confine/mount-support-nvidia.c b/cmd/snap-confine/mount-support-nvidia.c new file mode 100644 index 00000000..c4079485 --- /dev/null +++ b/cmd/snap-confine/mount-support-nvidia.c @@ -0,0 +1,638 @@ +/* + * Copyright (C) 2015 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "config.h" +#include "mount-support-nvidia.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +/* POSIX version of basename() and dirname() */ +#include + +#include "../libsnap-confine-private/classic.h" +#include "../libsnap-confine-private/cleanup-funcs.h" +#include "../libsnap-confine-private/string-utils.h" +#include "../libsnap-confine-private/utils.h" +#include "mount-support.h" + +#define SC_NVIDIA_DRIVER_VERSION_FILE "/sys/module/nvidia/version" + +#define SC_LIBGL_DIR SC_EXTRA_LIB_DIR "/gl" +#define SC_LIBGL32_DIR SC_EXTRA_LIB_DIR "/gl32" +#define SC_VULKAN_DIR SC_EXTRA_LIB_DIR "/vulkan" +#define SC_GLVND_DIR SC_EXTRA_LIB_DIR "/glvnd" + +#define SC_VULKAN_SOURCE_DIR "/usr/share/vulkan" +#define SC_EGL_VENDOR_SOURCE_DIR "/usr/share/glvnd" + +// Location for NVIDIA vulkan files (including _wayland) +static const char *vulkan_globs[] = { + "icd.d/*nvidia*.json", +}; + +static const size_t vulkan_globs_len = + sizeof vulkan_globs / sizeof *vulkan_globs; + +// Location of EGL vendor files +static const char *egl_vendor_globs[] = { + "egl_vendor.d/*nvidia*.json", +}; + +static const size_t egl_vendor_globs_len = + sizeof egl_vendor_globs / sizeof *egl_vendor_globs; + +#if defined(NVIDIA_BIARCH) || defined(NVIDIA_MULTIARCH) + +// List of globs that describe nvidia userspace libraries. +// This list was compiled from the following packages. +// +// https://www.archlinux.org/packages/extra/x86_64/nvidia-304xx-libgl/files/ +// https://www.archlinux.org/packages/extra/x86_64/nvidia-304xx-utils/files/ +// https://www.archlinux.org/packages/extra/x86_64/nvidia-340xx-libgl/files/ +// https://www.archlinux.org/packages/extra/x86_64/nvidia-340xx-utils/files/ +// https://www.archlinux.org/packages/extra/x86_64/nvidia-libgl/files/ +// https://www.archlinux.org/packages/extra/x86_64/nvidia-utils/files/ +// +// FIXME: this doesn't yet work with libGLX and libglvnd redirector +// FIXME: this still doesn't work with the 361 driver +static const char *nvidia_globs[] = { + "libEGL_nvidia.so*", + "libGLESv1_CM_nvidia.so*", + "libGLESv2_nvidia.so*", + "libGLX_nvidia.so*", + "libXvMCNVIDIA.so*", + "libXvMCNVIDIA_dynamic.so*", + "libnvidia-cfg.so*", + "libnvidia-compiler.so*", + "libnvidia-eglcore.so*", + "libnvidia-egl-wayland*", + "libnvidia-encode.so*", + "libnvidia-fatbinaryloader.so*", + "libnvidia-fbc.so*", + "libnvidia-glcore.so*", + "libnvidia-glsi.so*", + "libnvidia-glvkspirv.so*", + "libnvidia-gpucomp.so*", + "libnvidia-ifr.so*", + "libnvidia-ml.so*", + "libnvidia-opencl.so*", + "libnvidia-opticalflow.so*", + "libnvidia-ptxjitcompiler.so*", + "libnvidia-rtcore.so*", + "libnvidia-tls.so*", + "libnvoptix.so*", + "tls/libnvidia-tls.so*", + "vdpau/libvdpau_nvidia.so*", + + // additional libraries for Tegra + // https://docs.nvidia.com/jetson/l4t/index.html#page/Tegra%20Linux%20Driver%20Package%20Development%20Guide/manifest_tx2_tx2i.html + "libnvdc.so*", + "libnvos.so*", + "libnvrm_gpu.so*", + "libnvimp.so*", + "libnvrm.so*", + "libnvrm_graphics.so*", + // CUDA + // https://docs.nvidia.com/cuda/#cuda-api-references + "libcuda.so*", + "libcudart.so*", + "libnvcuvid.so*", + "libcufft.so*", + "libcublas.so*", + "libcublasLt.so*", + "libcusolver.so*", + "libcuparse.so*", + "libcurand.so*", + "libnppc.so*", + "libnppig.so*", + "libnppial.so*", + "libnppicc.so*", + "libnppidei.so*", + "libnppist.so*", + "libnppcif.so*", + "libnppim.so*", + "libnppitc.so*", + "libnvrtc*", + "libnvrtc-builtins*", + "libnvToolsExt.so*", + // libraries for CUDA DNN + // https://docs.nvidia.com/deeplearning/cudnn/api/index.html + // https://docs.nvidia.com/deeplearning/cudnn/install-guide/index.html + "libcudnn.so*", + "libcudnn_adv_infer*", + "libcudnn_adv_train*", + "libcudnn_cnn_infer*", + "libcudnn_cnn_train*", + "libcudnn_ops_infer*", + "libcudnn_ops_train*", +}; + +static const size_t nvidia_globs_len = + sizeof nvidia_globs / sizeof *nvidia_globs; + +static const char *glvnd_globs[] = { + "libEGL.so*", + "libGL.so*", + "libOpenGL.so*", + "libGLESv1_CM.so*", + "libGLESv2.so*", + "libGLX_indirect.so*", + "libGLX.so*", + "libGLdispatch.so*", + "libGLU.so*", +}; + +static const size_t glvnd_globs_len = sizeof glvnd_globs / sizeof *glvnd_globs; + +#endif // defined(NVIDIA_BIARCH) || defined(NVIDIA_MULTIARCH) + +// Populate libgl_dir with a symlink farm to files matching glob_list. +// +// The symbolic links are made in one of two ways. If the library found is a +// file a regular symlink "$libname" -> "/path/to/hostfs/$libname" is created. +// If the library is a symbolic link then relative links are kept as-is but +// absolute links are translated to have "/path/to/hostfs" up front so that +// they work after the pivot_root elsewhere. +// +// The glob list passed to us is produced with paths relative to source dir, +// to simplify the various tie-in points with this function. +static void sc_populate_libgl_with_hostfs_symlinks(const char *libgl_dir, + const char *source_dir, + const char *glob_list[], + size_t glob_list_len) +{ + size_t source_dir_len = strlen(source_dir); + glob_t glob_res SC_CLEANUP(globfree) = { + .gl_pathv = NULL + }; + // Find all the entries matching the list of globs + for (size_t i = 0; i < glob_list_len; ++i) { + const char *glob_pattern = glob_list[i]; + char glob_pattern_full[512] = { 0 }; + sc_must_snprintf(glob_pattern_full, sizeof glob_pattern_full, + "%s/%s", source_dir, glob_pattern); + + int err = glob(glob_pattern_full, i ? GLOB_APPEND : 0, NULL, + &glob_res); + // Not all of the files have to be there (they differ depending on the + // driver version used). Ignore all errors that are not GLOB_NOMATCH. + if (err != 0 && err != GLOB_NOMATCH) { + die("cannot search using glob pattern %s: %d", + glob_pattern_full, err); + } + } + // Symlink each file found + for (size_t i = 0; i < glob_res.gl_pathc; ++i) { + char symlink_name[512] = { 0 }; + char symlink_target[512] = { 0 }; + char prefix_dir[512] = { 0 }; + const char *pathname = glob_res.gl_pathv[i]; + char *pathname_copy1 + SC_CLEANUP(sc_cleanup_string) = sc_strdup(pathname); + char *pathname_copy2 + SC_CLEANUP(sc_cleanup_string) = sc_strdup(pathname); + // POSIX dirname() and basename() may modify their input arguments + char *filename = basename(pathname_copy1); + char *directory_name = dirname(pathname_copy2); + sc_must_snprintf(prefix_dir, sizeof prefix_dir, "%s", + libgl_dir); + + if (strlen(directory_name) > source_dir_len) { + // Additional path elements between source_dir and dirname, meaning the + // actual file is not placed directly under source_dir but under one or + // more directories below source_dir. Make sure to recreate the whole + // prefix + sc_must_snprintf(prefix_dir, sizeof prefix_dir, + "%s%s", libgl_dir, + &directory_name[source_dir_len]); + sc_identity old = + sc_set_effective_identity(sc_root_group_identity()); + if (sc_nonfatal_mkpath(prefix_dir, 0755) != 0) { + die("failed to create prefix path: %s", + prefix_dir); + } + (void)sc_set_effective_identity(old); + } + + struct stat stat_buf; + int err = lstat(pathname, &stat_buf); + if (err != 0) { + die("cannot stat file %s", pathname); + } + switch (stat_buf.st_mode & S_IFMT) { + case S_IFLNK:; + // Read the target of the symbolic link + char hostfs_symlink_target[512] = { 0 }; + ssize_t num_read; + hostfs_symlink_target[0] = 0; + num_read = + readlink(pathname, hostfs_symlink_target, + sizeof hostfs_symlink_target - 1); + if (num_read == -1) { + die("cannot read symbolic link %s", pathname); + } + hostfs_symlink_target[num_read] = 0; + if (hostfs_symlink_target[0] == '/') { + sc_must_snprintf(symlink_target, + sizeof symlink_target, + "/var/lib/snapd/hostfs%s", + hostfs_symlink_target); + } else { + // Keep relative symlinks as-is, so that they point to -> libfoo.so.0.123 + sc_must_snprintf(symlink_target, + sizeof symlink_target, "%s", + hostfs_symlink_target); + } + break; + case S_IFREG: + sc_must_snprintf(symlink_target, + sizeof symlink_target, + "/var/lib/snapd/hostfs%s", pathname); + break; + default: + debug("ignoring unsupported entry: %s", pathname); + continue; + } + sc_must_snprintf(symlink_name, sizeof symlink_name, + "%s/%s", prefix_dir, filename); + debug("creating symbolic link %s -> %s", symlink_name, + symlink_target); + + // Make sure we don't have some link already (merged GLVND systems) + if (lstat(symlink_name, &stat_buf) == 0) { + if (unlink(symlink_name) != 0) { + die("cannot remove symbolic link target %s", + symlink_name); + } + } + + if (symlink(symlink_target, symlink_name) != 0) { + die("cannot create symbolic link %s -> %s", + symlink_name, symlink_target); + } + } +} + +static void sc_mkdir_and_mount_and_glob_files(const char *rootfs_dir, + const char *source_dir[], + size_t source_dir_len, + const char *tgt_dir, + const char *glob_list[], + size_t glob_list_len) +{ + // Bind mount a tmpfs on $rootfs_dir/$tgt_dir (i.e. /var/lib/snapd/lib/gl) + char buf[512] = { 0 }; + sc_must_snprintf(buf, sizeof(buf), "%s%s", rootfs_dir, tgt_dir); + const char *libgl_dir = buf; + + sc_identity old = sc_set_effective_identity(sc_root_group_identity()); + int res = mkdir(libgl_dir, 0755); + if (res != 0 && errno != EEXIST) { + die("cannot create tmpfs target %s", libgl_dir); + } + if (res == 0 && (chown(libgl_dir, 0, 0) < 0)) { + // Adjust the ownership only if we created the directory. + die("cannot change ownership of %s", libgl_dir); + } + (void)sc_set_effective_identity(old); + + debug("mounting tmpfs at %s", libgl_dir); + if (mount("none", libgl_dir, "tmpfs", MS_NODEV | MS_NOEXEC, NULL) != 0) { + die("cannot mount tmpfs at %s", libgl_dir); + }; + + for (size_t i = 0; i < source_dir_len; i++) { + // Populate libgl_dir with symlinks to libraries from hostfs + sc_populate_libgl_with_hostfs_symlinks(libgl_dir, source_dir[i], + glob_list, + glob_list_len); + } + // Remount $tgt_dir (i.e. .../lib/gl) read only + debug("remounting tmpfs as read-only %s", libgl_dir); + if (mount(NULL, buf, NULL, MS_REMOUNT | MS_BIND | MS_RDONLY, NULL) != 0) { + die("cannot remount %s as read-only", buf); + } +} + +#ifdef NVIDIA_BIARCH + +// Expose host NVIDIA drivers to the snap on biarch systems. +// +// Order is absolutely imperative here. We'll attempt to find the +// primary files for the architecture in the main directory, and end +// up copying any files across. However it is possible we're using a +// GLVND enabled host, in which case we copied libGL* to the farm. +// The next step in the list is to look within the private nvidia +// directory, exposed using ld.so.conf tricks within the host OS. +// In some distros (i.e. Solus) only the private libGL/libEGL files +// may be found here, and they'll clobber the existing GLVND files from +// the previous run. +// In other distros (like Fedora) all NVIDIA libraries are contained +// within the private directory, so we clobber the GLVND files and we +// also grab all the private NVIDIA libraries. +// +// In non GLVND cases we just copy across the exposed libGLs and NVIDIA +// libraries from wherever we find, and clobbering is also harmless. +static void sc_mount_nvidia_driver_biarch(const char *rootfs_dir, + const char **globs, size_t globs_len) +{ + + static const char *native_sources[] = { + NATIVE_LIBDIR, + NATIVE_LIBDIR "/nvidia*", + }; + const size_t native_sources_len = + sizeof native_sources / sizeof *native_sources; + +#if UINTPTR_MAX == 0xffffffffffffffff + // Alternative 32-bit support + static const char *lib32_sources[] = { + LIB32_DIR, + LIB32_DIR "/nvidia*", + }; + const size_t lib32_sources_len = + sizeof lib32_sources / sizeof *lib32_sources; +#endif + + // Primary arch + sc_mkdir_and_mount_and_glob_files(rootfs_dir, + native_sources, native_sources_len, + SC_LIBGL_DIR, globs, globs_len); + +#if UINTPTR_MAX == 0xffffffffffffffff + // Alternative 32-bit support + sc_mkdir_and_mount_and_glob_files(rootfs_dir, lib32_sources, + lib32_sources_len, SC_LIBGL32_DIR, + globs, globs_len); +#endif +} + +#endif // ifdef NVIDIA_BIARCH + +#ifdef NVIDIA_MULTIARCH + +typedef struct { + int major; + // Driver version format is MAJOR.MINOR[.MICRO] but we only care about the + // major version and the full version string. The micro component has been + // seen with relevant leading zeros (e.g. "440.48.02"). + char raw[128]; // The size was picked as "big enough" for version strings. +} sc_nv_version; + +static void sc_probe_nvidia_driver(sc_nv_version *version) +{ + memset(version, 0, sizeof *version); + + FILE *file SC_CLEANUP(sc_cleanup_file) = NULL; + debug("opening file describing nvidia driver version"); + file = fopen(SC_NVIDIA_DRIVER_VERSION_FILE, "rt"); + if (file == NULL) { + if (errno == ENOENT) { + debug("nvidia driver version file doesn't exist"); + return; + } + die("cannot open file describing nvidia driver version"); + } + int nread = fread(version->raw, 1, sizeof version->raw - 1, file); + if (nread < 0) { + die("cannot read nvidia driver version string"); + } + if (nread == sizeof version->raw - 1 && !feof(file)) { + die("cannot fit entire nvidia driver version string"); + } + version->raw[nread] = '\0'; + if (nread > 0 && version->raw[nread - 1] == '\n') { + version->raw[nread - 1] = '\0'; + } + if (sscanf(version->raw, "%d.", &version->major) != 1) { + die("cannot parse major version from nvidia driver version string"); + } +} + +static void sc_mkdir_and_mount_and_bind(const char *rootfs_dir, + const char *src_dir, + const char *tgt_dir) +{ + sc_nv_version version; + + // Probe sysfs to get the version of the driver that is currently inserted. + sc_probe_nvidia_driver(&version); + + // If there's driver in the kernel then don't mount userspace. + if (version.major == 0) { + return; + } + // Construct the paths for the driver userspace libraries + // and for the gl directory. + char src[PATH_MAX] = { 0 }; + char dst[PATH_MAX] = { 0 }; + sc_must_snprintf(src, sizeof src, "%s-%d", src_dir, version.major); + sc_must_snprintf(dst, sizeof dst, "%s%s", rootfs_dir, tgt_dir); + + // If there is no userspace driver available then don't try to mount it. + // This can happen for any number of reasons but one interesting one is + // that that snapd runs in a lxd container on a host that uses nvidia. In + // that case the container may not have the userspace library installed but + // the kernel will still have the module around. + if (access(src, F_OK) != 0) { + return; + } + sc_identity old = sc_set_effective_identity(sc_root_group_identity()); + int res = mkdir(dst, 0755); + if (res != 0 && errno != EEXIST) { + die("cannot create directory %s", dst); + } + if (res == 0 && (chown(dst, 0, 0) < 0)) { + // Adjust the ownership only if we created the directory. + die("cannot change ownership of %s", dst); + } + (void)sc_set_effective_identity(old); + // Bind mount the binary nvidia driver into $tgt_dir (i.e. /var/lib/snapd/lib/gl). + debug("bind mounting nvidia driver %s -> %s", src, dst); + if (mount(src, dst, NULL, MS_BIND, NULL) != 0) { + die("cannot bind mount nvidia driver %s -> %s", src, dst); + } +} + +static int sc_mount_nvidia_is_driver_in_dir(const char *dir) +{ + char driver_path[512] = { 0 }; + + sc_nv_version version; + + // Probe sysfs to get the version of the driver that is currently inserted. + sc_probe_nvidia_driver(&version); + + // If there's no driver then we should not bother ourselves with finding the + // matching library + if (version.major == 0) { + return 0; + } + + // Probe if a well known library is found in directory dir. We must use the + // raw version because it may contain more than just major.minor. In + // practice the micro version may have leading zeros that are relevant. + sc_must_snprintf(driver_path, sizeof driver_path, + "%s/libnvidia-glcore.so.%s", dir, version.raw); + + debug("looking for nvidia canary file %s", driver_path); + if (access(driver_path, F_OK) == 0) { + debug("nvidia library detected at path %s", driver_path); + return 1; + } + return 0; +} + +static void sc_mount_nvidia_driver_multiarch(const char *rootfs_dir, + const char **globs, + size_t globs_len) +{ + const char *native_libdir = NATIVE_LIBDIR "/" HOST_ARCH_TRIPLET; + const char *lib32_libdir = NATIVE_LIBDIR "/" HOST_ARCH32_TRIPLET; + + if ((strlen(HOST_ARCH_TRIPLET) > 0) && + (sc_mount_nvidia_is_driver_in_dir(native_libdir) == 1)) { + + // sc_mkdir_and_mount_and_glob_files() takes an array of strings, so + // initialize native_sources accordingly, but calculate the array length + // dynamically to make adjustments to native_sources easier. + const char *native_sources[] = { native_libdir }; + const size_t native_sources_len = + sizeof native_sources / sizeof *native_sources; + // Primary arch + sc_mkdir_and_mount_and_glob_files(rootfs_dir, + native_sources, + native_sources_len, + SC_LIBGL_DIR, globs, + globs_len); + + // Alternative 32-bit support + if ((strlen(HOST_ARCH32_TRIPLET) > 0) && + (sc_mount_nvidia_is_driver_in_dir(lib32_libdir) == 1)) { + + // sc_mkdir_and_mount_and_glob_files() takes an array of strings, so + // initialize lib32_sources accordingly, but calculate the array length + // dynamically to make adjustments to lib32_sources easier. + const char *lib32_sources[] = { lib32_libdir }; + const size_t lib32_sources_len = + sizeof lib32_sources / sizeof *lib32_sources; + sc_mkdir_and_mount_and_glob_files(rootfs_dir, + lib32_sources, + lib32_sources_len, + SC_LIBGL32_DIR, + globs, globs_len); + } + } else { + // Attempt mount of both the native and 32-bit variants of the driver if they exist + sc_mkdir_and_mount_and_bind(rootfs_dir, "/usr/lib/nvidia", + SC_LIBGL_DIR); + // Alternative 32-bit support + sc_mkdir_and_mount_and_bind(rootfs_dir, "/usr/lib32/nvidia", + SC_LIBGL32_DIR); + } +} + +#endif // ifdef NVIDIA_MULTIARCH + +static void sc_mount_vulkan(const char *rootfs_dir) +{ + const char *vulkan_sources[] = { + SC_VULKAN_SOURCE_DIR, + }; + const size_t vulkan_sources_len = + sizeof vulkan_sources / sizeof *vulkan_sources; + + sc_mkdir_and_mount_and_glob_files(rootfs_dir, vulkan_sources, + vulkan_sources_len, SC_VULKAN_DIR, + vulkan_globs, vulkan_globs_len); +} + +static void sc_mount_egl(const char *rootfs_dir) +{ + const char *egl_vendor_sources[] = { SC_EGL_VENDOR_SOURCE_DIR }; + const size_t egl_vendor_sources_len = + sizeof egl_vendor_sources / sizeof *egl_vendor_sources; + + sc_mkdir_and_mount_and_glob_files(rootfs_dir, egl_vendor_sources, + egl_vendor_sources_len, SC_GLVND_DIR, + egl_vendor_globs, + egl_vendor_globs_len); +} + +void sc_mount_nvidia_driver(const char *rootfs_dir, const char *base_snap_name) +{ + /* If NVIDIA module isn't loaded, don't attempt to mount the drivers */ + if (access(SC_NVIDIA_DRIVER_VERSION_FILE, F_OK) != 0) { + return; + } + + sc_identity old = sc_set_effective_identity(sc_root_group_identity()); + int res = sc_nonfatal_mkpath(SC_EXTRA_LIB_DIR, 0755); + if (res != 0) { + die("cannot create " SC_EXTRA_LIB_DIR); + } + if (res == 0 && (chown(SC_EXTRA_LIB_DIR, 0, 0) < 0)) { + // Adjust the ownership only if we created the directory. + die("cannot change ownership of " SC_EXTRA_LIB_DIR); + } + (void)sc_set_effective_identity(old); + +#if defined(NVIDIA_BIARCH) || defined(NVIDIA_MULTIARCH) + /* We include the globs for the glvnd libraries for old snaps + * based on core, Ubuntu 16.04 did not include glvnd itself. + * + * While there is no guarantee that the host system's glvnd + * libGL will be compatible (as it is built with the host + * system's glibc), the Mesa libGL included with the snap will + * definitely not be compatible (as it expects to find the Mesa + * implementation of the GLX extension).. + */ + const char **globs = nvidia_globs; + size_t globs_len = nvidia_globs_len; + const char **full_globs SC_CLEANUP(sc_cleanup_shallow_strv) = NULL; + if (sc_streq(base_snap_name, "core")) { + full_globs = malloc(sizeof nvidia_globs + sizeof glvnd_globs); + if (full_globs == NULL) { + die("cannot allocate globs array"); + } + memcpy(full_globs, nvidia_globs, sizeof nvidia_globs); + memcpy(&full_globs[nvidia_globs_len], glvnd_globs, + sizeof glvnd_globs); + globs = full_globs; + globs_len = nvidia_globs_len + glvnd_globs_len; + } +#endif + +#ifdef NVIDIA_MULTIARCH + sc_mount_nvidia_driver_multiarch(rootfs_dir, globs, globs_len); +#endif // ifdef NVIDIA_MULTIARCH +#ifdef NVIDIA_BIARCH + sc_mount_nvidia_driver_biarch(rootfs_dir, globs, globs_len); +#endif // ifdef NVIDIA_BIARCH + + // Common for both driver mechanisms + sc_mount_vulkan(rootfs_dir); + sc_mount_egl(rootfs_dir); +} diff --git a/cmd/snap-confine/mount-support-nvidia.h b/cmd/snap-confine/mount-support-nvidia.h new file mode 100644 index 00000000..9835fb42 --- /dev/null +++ b/cmd/snap-confine/mount-support-nvidia.h @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2015 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#ifndef SNAP_CONFINE_MOUNT_SUPPORT_NVIDIA_H +#define SNAP_CONFINE_MOUNT_SUPPORT_NVIDIA_H + +/** + * Make the Nvidia driver from the classic distribution available in the snap + * execution environment. + * + * This function may be a no-op, depending on build-time configuration options. + * If enabled the behavior differs from one distribution to another because of + * differences in classic packaging and perhaps version of the Nvidia driver. + * This function is designed to be called before pivot_root() switched the root + * filesystem. + * + * On Ubuntu, there are several versions of the binary Nvidia driver. The + * drivers are all installed in /usr/lib/nvidia-$MAJOR_VERSION where + * MAJOR_VERSION is an integer like 304, 331, 340, 346, 352 or 361. The driver + * is located by inspecting /sys/modules/nvidia/version which contains the + * string "$MAJOR_VERSION.$MINOR_VERSION". The appropriate directory is then + * bind mounted to /var/lib/snapd/lib/gl relative relative to the location of + * the root filesystem directory provided as an argument. + * + * On Arch another approach is used. Because the actual driver installs a + * number of shared objects into /usr/lib, they cannot be bind mounted + * directly. Instead a tmpfs is mounted on /var/lib/snapd/lib/gl. The tmpfs is + * subsequently populated with symlinks that point to a number of files in the + * /usr/lib directory on the classic filesystem. After the pivot_root() call + * those symlinks rely on the /var/lib/snapd/hostfs directory as a "gateway". + **/ +void sc_mount_nvidia_driver(const char *rootfs_dir, const char *base_snap_name); + +#endif diff --git a/cmd/snap-confine/mount-support-test.c b/cmd/snap-confine/mount-support-test.c new file mode 100644 index 00000000..0580d433 --- /dev/null +++ b/cmd/snap-confine/mount-support-test.c @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2015 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "mount-support.h" +#include "mount-support.c" +#include "mount-support-nvidia.h" +#include "mount-support-nvidia.c" + +#include +#include + +static void replace_slashes_with_NUL(char *path, size_t len) +{ + for (size_t i = 0; i < len; i++) { + if (path[i] == '/') + path[i] = '\0'; + } +} + +static void test_get_nextpath__typical(void) +{ + char path[] = "/some/path"; + size_t offset = 0; + size_t fulllen = strlen(path); + + // Prepare path for usage with get_nextpath() by replacing + // all path separators with the NUL byte. + replace_slashes_with_NUL(path, fulllen); + + // Run get_nextpath a few times to see what happens. + char *result; + result = get_nextpath(path, &offset, fulllen); + g_assert_cmpstr(result, ==, "some"); + result = get_nextpath(path, &offset, fulllen); + g_assert_cmpstr(result, ==, "path"); + result = get_nextpath(path, &offset, fulllen); + g_assert_cmpstr(result, ==, NULL); +} + +static void test_get_nextpath__weird(void) +{ + char path[] = "..///path"; + size_t offset = 0; + size_t fulllen = strlen(path); + + // Prepare path for usage with get_nextpath() by replacing + // all path separators with the NUL byte. + replace_slashes_with_NUL(path, fulllen); + + // Run get_nextpath a few times to see what happens. + char *result; + result = get_nextpath(path, &offset, fulllen); + g_assert_cmpstr(result, ==, "path"); + result = get_nextpath(path, &offset, fulllen); + g_assert_cmpstr(result, ==, NULL); +} + +static void test_is_subdir(void) +{ + // Sensible exaples are sensible + g_assert_true(is_subdir("/dir/subdir", "/dir/")); + g_assert_true(is_subdir("/dir/subdir", "/dir")); + g_assert_true(is_subdir("/dir/", "/dir")); + g_assert_true(is_subdir("/dir", "/dir")); + // Also without leading slash + g_assert_true(is_subdir("dir/subdir", "dir/")); + g_assert_true(is_subdir("dir/subdir", "dir")); + g_assert_true(is_subdir("dir/", "dir")); + g_assert_true(is_subdir("dir", "dir")); + // Some more ideas + g_assert_true(is_subdir("//", "/")); + g_assert_true(is_subdir("/", "/")); + g_assert_true(is_subdir("", "")); + // but this is not true + g_assert_false(is_subdir("/", "/dir")); + g_assert_false(is_subdir("/rid", "/dir")); + g_assert_false(is_subdir("/different/dir", "/dir")); + g_assert_false(is_subdir("/", "")); +} + +static void __attribute__((constructor)) init(void) +{ + g_test_add_func("/mount/get_nextpath/typical", + test_get_nextpath__typical); + g_test_add_func("/mount/get_nextpath/weird", test_get_nextpath__weird); + g_test_add_func("/mount/is_subdir", test_is_subdir); +} diff --git a/cmd/snap-confine/mount-support.c b/cmd/snap-confine/mount-support.c new file mode 100644 index 00000000..c555a25f --- /dev/null +++ b/cmd/snap-confine/mount-support.c @@ -0,0 +1,1145 @@ +/* + * Copyright (C) 2015 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#ifdef HAVE_CONFIG_H +#include "config.h" +#endif + +#include "mount-support.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../libsnap-confine-private/apparmor-support.h" +#include "../libsnap-confine-private/classic.h" +#include "../libsnap-confine-private/cleanup-funcs.h" +#include "../libsnap-confine-private/mount-opt.h" +#include "../libsnap-confine-private/mountinfo.h" +#include "../libsnap-confine-private/snap.h" +#include "../libsnap-confine-private/string-utils.h" +#include "../libsnap-confine-private/tool.h" +#include "../libsnap-confine-private/utils.h" +#include "mount-support-nvidia.h" + +#define MAX_BUF 1000 +#define SNAP_PRIVATE_TMP_ROOT_DIR "/tmp/snap-private-tmp" + +static void sc_detach_views_of_writable(sc_distro distro, bool normal_mode); + +// TODO: simplify this, after all it is just a tmpfs +// TODO: fold this into bootstrap +static void setup_private_tmp(const char *snap_instance) +{ + // Create a 0700 base directory. This is the "base" directory that is + // protected from other users. This directory name is NOT randomly + // generated. This has several properties: + // + // Users can relate to the name and can find the temporary directory as + // visible from within the snap. If this directory was random it would be + // harder to find because there may be situations in which multiple + // directories related to the same snap name would exist. + // + // Snapd can partially manage the directory. Specifically on snap remove + // snapd could remove the directory and everything in it, potentially + // avoiding runaway disk use on a machine that either never reboots or uses + // persistent /tmp directory. + // + // Underneath the base directory there is a "tmp" sub-directory that has + // mode 1777 and behaves as a typical /tmp directory would. That directory + // is used as a bind-mounted /tmp directory. + // + // Because the directories are reused across invocations by distinct users + // and because the directories are trivially guessable, each invocation + // unconditionally chowns/chmods them to appropriate values. + char base[MAX_BUF] = { 0 }; + char tmp_dir[MAX_BUF] = { 0 }; + int private_tmp_root_fd SC_CLEANUP(sc_cleanup_close) = -1; + int base_dir_fd SC_CLEANUP(sc_cleanup_close) = -1; + int tmp_dir_fd SC_CLEANUP(sc_cleanup_close) = -1; + + /* Switch to root group so that mkdir and open calls below create + * filesystem elements that are not owned by the user calling into + * snap-confine. */ + sc_identity old = sc_set_effective_identity(sc_root_group_identity()); + + // /tmp/snap-private-tmp should have already been created by + // systemd-tmpfiles but we can try create it anyway since snapd may have + // just been installed in which case the tmpfiles conf would not have + // got executed yet + if (mkdir(SNAP_PRIVATE_TMP_ROOT_DIR, 0700) < 0 && errno != EEXIST) { + die("cannot create /tmp/snap-private-tmp"); + } + private_tmp_root_fd = open(SNAP_PRIVATE_TMP_ROOT_DIR, + O_RDONLY | O_DIRECTORY | O_CLOEXEC | + O_NOFOLLOW); + if (private_tmp_root_fd < 0) { + die("cannot open %s", SNAP_PRIVATE_TMP_ROOT_DIR); + } + struct stat st; + if (fstat(private_tmp_root_fd, &st) < 0) { + die("cannot stat %s", SNAP_PRIVATE_TMP_ROOT_DIR); + } + if (st.st_uid != 0 || st.st_gid != 0 || st.st_mode != (S_IFDIR | 0700)) { + die("%s has unexpected ownership / permissions", + SNAP_PRIVATE_TMP_ROOT_DIR); + } + // Create /tmp/snap-private-tmp/snap.$SNAP_INSTANCE_NAME/ 0700 root.root. + sc_must_snprintf(base, sizeof(base), "snap.%s", snap_instance); + if (mkdirat(private_tmp_root_fd, base, 0700) < 0 && errno != EEXIST) { + die("cannot create base directory: %s", base); + } + base_dir_fd = + openat(private_tmp_root_fd, base, + O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW); + if (base_dir_fd < 0) { + die("cannot open base directory: %s", base); + } + if (fstat(base_dir_fd, &st) < 0) { + die("cannot stat %s/%s", SNAP_PRIVATE_TMP_ROOT_DIR, base); + } + if (st.st_uid != 0 || st.st_gid != 0 || st.st_mode != (S_IFDIR | 0700)) { + die("%s/%s has unexpected ownership / permissions", + SNAP_PRIVATE_TMP_ROOT_DIR, base); + } + // Create /tmp/$PRIVATE/snap.$SNAP_NAME/tmp 01777 root.root Ignore EEXIST since we + // want to reuse and we will open with O_NOFOLLOW, below. + if (mkdirat(base_dir_fd, "tmp", 01777) < 0 && errno != EEXIST) { + die("cannot create private tmp directory %s/tmp", base); + } + (void)sc_set_effective_identity(old); + tmp_dir_fd = openat(base_dir_fd, "tmp", + O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW); + if (tmp_dir_fd < 0) { + die("cannot open private tmp directory %s/tmp", base); + } + if (fstat(tmp_dir_fd, &st) < 0) { + die("cannot stat %s/%s/tmp", SNAP_PRIVATE_TMP_ROOT_DIR, base); + } + if (st.st_uid != 0 || st.st_gid != 0 || st.st_mode != (S_IFDIR | 01777)) { + die("%s/%s/tmp has unexpected ownership / permissions", + SNAP_PRIVATE_TMP_ROOT_DIR, base); + } + // use the path to the file-descriptor in proc as the source mount point + // as this is a symlink itself to the real directory at + // /tmp/snap-private-tmp/snap.$SNAP_INSTANCE/tmp but doing it this way + // helps avoid any potential race + sc_must_snprintf(tmp_dir, sizeof(tmp_dir), + "/proc/self/fd/%d", tmp_dir_fd); + sc_do_mount(tmp_dir, "/tmp", NULL, MS_BIND, NULL); + sc_do_mount("none", "/tmp", NULL, MS_PRIVATE, NULL); +} + +// TODO: fold this into bootstrap +static void setup_private_pts(void) +{ + // See https://www.kernel.org/doc/Documentation/filesystems/devpts.txt + // + // Ubuntu by default uses devpts 'single-instance' mode where + // /dev/pts/ptmx is mounted with ptmxmode=0000. We don't want to change + // the startup scripts though, so we follow the instructions in point + // '4' of 'User-space changes' in the above doc. In other words, after + // unshare(CLONE_NEWNS), we mount devpts with -o + // newinstance,ptmxmode=0666 and then bind mount /dev/pts/ptmx onto + // /dev/ptmx + + struct stat st; + + // Make sure /dev/pts/ptmx exists, otherwise the system doesn't provide the + // isolation we require. + if (stat("/dev/pts/ptmx", &st) != 0) { + die("cannot stat /dev/pts/ptmx"); + } + // Make sure /dev/ptmx exists so we can bind mount over it + if (stat("/dev/ptmx", &st) != 0) { + die("cannot stat /dev/ptmx"); + } + // Since multi-instance, use ptmxmode=0666. The other options are + // copied from /etc/default/devpts + sc_do_mount("devpts", "/dev/pts", "devpts", MS_MGC_VAL, + "newinstance,ptmxmode=0666,mode=0620,gid=5"); + sc_do_mount("/dev/pts/ptmx", "/dev/ptmx", "none", MS_BIND, NULL); +} + +struct sc_mount { + const char *path; + bool is_bidirectional; + // Alternate path defines the rbind mount "alternative" of path. + // It exists so that we can make /media on systems that use /run/media. + const char *altpath; + // Optional mount points are not processed unless the source and + // destination both exist. + bool is_optional; +}; + +struct sc_mount_config { + const char *rootfs_dir; + // The struct is terminated with an entry with NULL path. + const struct sc_mount *mounts; + // Same as the structure above, but this is malloc-allocated. + struct sc_mount *dynamic_mounts; + sc_distro distro; + bool normal_mode; + const char *base_snap_name; + const char *snap_instance; +}; + +/** + * Ensures all required mount points have been created + */ +static void sc_create_mount_points(const char *scratch_dir, + const struct sc_mount *mounts) +{ + char dst[PATH_MAX] = { 0 }; + sc_identity old = sc_set_effective_identity(sc_root_group_identity()); + for (const struct sc_mount * mnt = mounts; mnt && mnt->path != NULL; + mnt++) { + sc_must_snprintf(dst, sizeof(dst), "%s/%s", scratch_dir, + mnt->path); + if (sc_nonfatal_mkpath(dst, 0755) < 0) { + die("cannot create mount point %s", dst); + } + } + (void)sc_set_effective_identity(old); +} + +/** + * Perform all the given bind mounts + * + * `mounts` is an array of sc_mount structures, each describing a bind mount + * operation to be performed. An element carrying a `path` field set to NULL + * marks the end of the list. + * + * Preconditions: + * + * - All the target directories must exist + * - All the source directories must exist, unless the mount is bi-directional + */ +static void sc_do_mounts(const char *scratch_dir, const struct sc_mount *mounts) +{ + char dst[PATH_MAX] = { 0 }; + // Bind mount certain directories from the host filesystem to the scratch + // directory. By default mount events will propagate in both into and out + // of the peer group. This way the running application can alter any global + // state visible on the host and in other snaps. This can be restricted by + // disabling the "is_bidirectional" flag as can be seen below. + for (const struct sc_mount * mnt = mounts; mnt && mnt->path != NULL; + mnt++) { + + if (mnt->is_bidirectional) { + sc_identity old = + sc_set_effective_identity(sc_root_group_identity()); + if (mkdir(mnt->path, 0755) < 0 && errno != EEXIST) { + die("cannot create %s", mnt->path); + } + (void)sc_set_effective_identity(old); + } + sc_must_snprintf(dst, sizeof dst, "%s/%s", scratch_dir, + mnt->path); + if (mnt->is_optional) { + bool ok = sc_do_optional_mount(mnt->path, dst, NULL, + MS_REC | MS_BIND, NULL); + if (!ok) { + // If we cannot mount it, just continue. + continue; + } + } else { + sc_do_mount(mnt->path, dst, NULL, MS_REC | MS_BIND, + NULL); + } + if (!mnt->is_bidirectional) { + // Mount events will only propagate inwards to the namespace. This + // way the running application cannot alter any global state apart + // from that of its own snap. + sc_do_mount("none", dst, NULL, MS_REC | MS_SLAVE, NULL); + } + if (mnt->altpath == NULL) { + continue; + } + // An alternate path of mnt->path is provided at another location. + // It should behave exactly the same as the original. + sc_must_snprintf(dst, sizeof dst, "%s/%s", scratch_dir, + mnt->altpath); + struct stat stat_buf; + if (lstat(dst, &stat_buf) < 0) { + die("cannot lstat %s", dst); + } + if ((stat_buf.st_mode & S_IFMT) == S_IFLNK) { + die("cannot bind mount alternate path over a symlink: %s", dst); + } + sc_do_mount(mnt->path, dst, NULL, MS_REC | MS_BIND, NULL); + if (!mnt->is_bidirectional) { + sc_do_mount("none", dst, NULL, MS_REC | MS_SLAVE, NULL); + } + } +} + +/** + * Create the /run/snapd/ns/snap..fstab file. + * + * Initially, this will just contain the entry for the snap root filesystem (a + * tmpfs), so that snap-update-ns will know about it and won't try to unmount + * it. + */ +static void sc_initialize_ns_fstab(const char *snap_instance_name) +{ + FILE *stream SC_CLEANUP(sc_cleanup_file) = NULL; + char info_path[PATH_MAX] = { 0 }; + sc_must_snprintf(info_path, sizeof info_path, + "/run/snapd/ns/snap.%s.fstab", snap_instance_name); + int fd = -1; + fd = open(info_path, + O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC | O_NOFOLLOW, 0644); + if (fd < 0) { + die("cannot open %s", info_path); + } + if (fchown(fd, 0, 0) < 0) { + die("cannot chown %s to root.root", info_path); + } + // The stream now owns the file descriptor. + stream = fdopen(fd, "w"); + if (stream == NULL) { + die("cannot get stream from file descriptor"); + } + // We need to store an entry for the root directory, so that snap-update-ns + // will know that it's a tmpfs created by us. It's not going to remount it, + // so there's no need to be precise with the mount flags. + fprintf(stream, "tmpfs / tmpfs x-snapd.origin=rootfs 0 0\n"); + if (ferror(stream) != 0) { + die("I/O error when writing to %s", info_path); + } + if (fflush(stream) == EOF) { + die("cannot flush %s", info_path); + } + debug("saved rootfs fstab entry to %s", info_path); +} + +/** + * Create root mountpoints and symbolic links. + * + * Enumerate the root entries in the filesystem provided by the provided + * rootfs, and recreate all regular directories and symbolic links into the + * scratch_dir. + * + * The root_mounts parameter lists the mounts that are going to be performed + * later directly from the "/" directory of the system, so this function will + * not touch them. + */ +static void sc_replicate_base_rootfs(const char *scratch_dir, + const char *rootfs_dir, + const struct sc_mount *root_mounts) +{ + // First of all, fix the root filesystem: + // - remove write permissions for group and others + // - set the owner to root:root + if (chmod(scratch_dir, 0755) < 0) { + die("cannot change permissions on \"%s\"", scratch_dir); + } + if (chown(scratch_dir, 0, 0) < 0) { + die("cannot change ownership on \"%s\"", scratch_dir); + } + + int rootfs_fd = -1; + // Note that the rootfs here is a path like /snap//current, which is + // always a symbolic link. Therefore, we cannot use O_NOFOLLOW here. + rootfs_fd = open(rootfs_dir, O_RDONLY | O_DIRECTORY | O_CLOEXEC); + if (rootfs_fd < 0) { + die("cannot open directory \"%s\"", rootfs_dir); + } + // rootfs_fd is now managed by fdopendir() and should not be used after + DIR *rootfs SC_CLEANUP(sc_cleanup_closedir) = fdopendir(rootfs_fd); + if (rootfs == NULL) { + die("cannot open directory \"%s\" from file descriptor", + rootfs_dir); + } + // Will create folders/links as 0:0 + sc_identity old = sc_set_effective_identity(sc_root_group_identity()); + + char full_path[PATH_MAX]; + // After we construct each entry's full path, we'll need to obtain the + // entry's absolute path in the new rootfs, that is with the `scratch_dir` + // prefix removed (the path_in_rootfs variable below). We'll do this by + // computing the length of the `scratch_dir` prefix now and then using it + // as the offset in `full_path` where the '/' of the confined silesystem is + // located. + const size_t scratch_dir_length = strlen(scratch_dir); + + while (true) { + errno = 0; + struct dirent *ent = readdir(rootfs); + if (ent == NULL) + break; + + if (sc_streq(ent->d_name, ".") || sc_streq(ent->d_name, "..")) { + continue; + } + + sc_must_snprintf(full_path, sizeof(full_path), "%s/%s", + scratch_dir, ent->d_name); + if (ent->d_type == DT_DIR) { + if (mkdir(full_path, 0755) < 0) { + die("cannot create directory \"%s\"", + full_path); + } + // If the directory is listed in root_mounts skip it, + // as it will be created and mounted in + // sc_bootstrap_mount_namespace() later. + bool skip_dir = false; + const char *path_in_rootfs = + full_path + scratch_dir_length; + for (const struct sc_mount * mnt = root_mounts; + mnt->path != NULL; mnt++) { + if (sc_streq(path_in_rootfs, mnt->path) + || sc_streq(path_in_rootfs, mnt->altpath)) { + skip_dir = true; + break; + } + } + if (skip_dir) { + continue; + } + // Also skip the /snap directory, as we'll mount it later + if (sc_streq(path_in_rootfs, "/snap")) { + continue; + } + + char src_path[PATH_MAX]; + sc_must_snprintf(src_path, sizeof(src_path), "%s/%s", + rootfs_dir, ent->d_name); + sc_do_mount(src_path, full_path, NULL, MS_REC | MS_BIND, + NULL); + } else if (ent->d_type == DT_LNK) { + char link_target[PATH_MAX + 1]; + ssize_t len = readlinkat(rootfs_fd, ent->d_name, + link_target, + sizeof(link_target) - 1); + if (len < 0) { + die("cannot read symbolic link \"%s/%s\"", + rootfs_dir, ent->d_name); + } + // make sure the string is null terminated + link_target[len] = '\0'; + + // Both relative and absolute links will work out of the box, since + // we are going to do a pivot_root to scratch_dir. + if (symlink(link_target, full_path) < 0) { + die("cannot create symbolic link \"%s\"", + full_path); + } + } else if (ent->d_type == DT_REG) { + // Create an empty file which can be used as a mount point + int fd = open(full_path, O_CREAT | O_TRUNC, 0644); + if (fd < 0) { + die("cannot create mount point for file \"%s\"", + full_path); + } + close(fd); + char src_path[PATH_MAX]; + sc_must_snprintf(src_path, sizeof(src_path), "%s/%s", + rootfs_dir, ent->d_name); + sc_do_mount(src_path, full_path, NULL, MS_BIND, NULL); + } else { + die("unexpected directory entry \"%s\" of type %i encountered in \"%s\"", ent->d_name, ent->d_type, rootfs_dir); + } + } + + if (errno != 0) { + die("cannot read directory entry in \"%s\"", rootfs_dir); + } + + (void)sc_set_effective_identity(old); +} + +/** + * Bootstrap mount namespace. + * + * This is a chunk of tricky code that lets us have full control over the + * layout and direction of propagation of mount events. The documentation below + * assumes knowledge of the 'sharedsubtree.txt' document from the kernel source + * tree. + * + * As a reminder two definitions are quoted below: + * + * A 'propagation event' is defined as event generated on a vfsmount + * that leads to mount or unmount actions in other vfsmounts. + * + * A 'peer group' is defined as a group of vfsmounts that propagate + * events to each other. + * + * (end of quote). + * + * The main idea is to setup a mount namespace that has a root filesystem with + * vfsmounts and peer groups that, depending on the location, either isolate + * or share with the rest of the system. + * + * The vast majority of the filesystem is shared in one direction. Events from + * the outside (from the main mount namespace) propagate inside (to namespaces + * of particular snaps) so things like new snap revisions, mounted drives, etc, + * just show up as expected but even if a snap is exploited or malicious in + * nature it cannot affect anything in another namespace where it might cause + * security or stability issues. + * + * Selected directories (today just /media) can be shared in both directions. + * This allows snaps with sufficient privileges to either create, through the + * mount system call, additional mount points that are visible by the rest of + * the system (both the main mount namespace and namespaces of individual + * snaps) or remove them, through the unmount system call. + **/ +static void sc_bootstrap_mount_namespace(const struct sc_mount_config *config) +{ + char scratch_dir[] = "/tmp/snap.rootfs_XXXXXX"; + char src[PATH_MAX] = { 0 }; + char dst[PATH_MAX] = { 0 }; + if (mkdtemp(scratch_dir) == NULL) { + die("cannot create temporary directory for the root file system"); + } + // NOTE: at this stage we just called unshare(CLONE_NEWNS). We are in a new + // mount namespace and have a private list of mounts. + debug("scratch directory for constructing namespace: %s", scratch_dir); + // Make the root filesystem recursively shared. This way propagation events + // will be shared with main mount namespace. + sc_do_mount("none", "/", NULL, MS_REC | MS_SHARED, NULL); + // Bind mount the temporary scratch directory for root filesystem over + // itself so that it is a mount point. This is done so that it can become + // unbindable as explained below. + sc_do_mount(scratch_dir, scratch_dir, NULL, MS_BIND, NULL); + // Make the scratch directory unbindable. + // + // This is necessary as otherwise a mount loop can occur and the kernel + // would crash. The term unbindable simply states that it cannot be bind + // mounted anywhere. When we construct recursive bind mounts below this + // guarantees that this directory will not be replicated anywhere. + sc_do_mount("none", scratch_dir, NULL, MS_UNBINDABLE, NULL); + if (config->normal_mode) { + sc_initialize_ns_fstab(config->snap_instance); + // Create a tmpfs on scratch_dir; we'll them mount all the root + // directories of the base snap onto it. + sc_do_mount("none", scratch_dir, "tmpfs", 0, NULL); + sc_replicate_base_rootfs(scratch_dir, config->rootfs_dir, + config->mounts); + } else { + // Recursively bind mount desired root filesystem directory over the + // scratch directory. This puts the initial content into the scratch + // space and serves as a foundation for all subsequent operations + // below. + // + // The mount is recursive because we need to accurately replicate the + // state of the root filesystem into the scratch directory. + sc_do_mount(config->rootfs_dir, scratch_dir, NULL, + MS_REC | MS_BIND, NULL); + } + // Make the scratch directory recursively slave. Nothing done there will be + // shared with the initial mount namespace. This effectively detaches us, + // in one way, from the original namespace and coupled with pivot_root + // below serves as the foundation of the mount sandbox. + sc_do_mount("none", scratch_dir, NULL, MS_REC | MS_SLAVE, NULL); + sc_do_mounts(scratch_dir, config->mounts); + + // Dynamic mounts handle things like user-specified home directories. These + // can change between runs, so they are stored separately. As we don't know + // these in advance, make sure paths also exist in the scratch dir. + sc_create_mount_points(scratch_dir, config->dynamic_mounts); + sc_do_mounts(scratch_dir, config->dynamic_mounts); + + if (config->normal_mode) { + // Since we mounted /etc from the host filesystem to the scratch directory, + // we may need to put certain directories from the desired root filesystem + // (e.g. the core snap) back. This way the behavior of running snaps is not + // affected by the alternatives directory from the host, if one exists. + // + // Fixes the following bugs: + // - https://bugs.launchpad.net/snap-confine/+bug/1580018 + // - https://bugzilla.opensuse.org/show_bug.cgi?id=1028568 + static const char *dirs_from_core[] = { + "/etc/alternatives", "/etc/nsswitch.conf", + // Some specific and privileged interfaces (e.g docker-support) give + // access to apparmor_parser from the base snap which at a minimum + // needs to use matching configuration from the base snap instead + // of from the users host system. + "/etc/apparmor", "/etc/apparmor.d", + // Use ssl certs from the base by default unless + // using Debian/Ubuntu classic (see below) + "/etc/ssl", + NULL + }; + + for (const char **dirs = dirs_from_core; *dirs != NULL; dirs++) { + const char *dir = *dirs; + + // Special case for ubuntu/debian based + // classic distros that use the core* snap: + // here we use the host /etc/ssl + // to support custom ca-cert setups + if (sc_streq(dir, "/etc/ssl") && + config->distro == SC_DISTRO_CLASSIC && + sc_is_debian_like() && + sc_startswith(config->base_snap_name, "core")) { + continue; + } + + if (access(dir, F_OK) != 0) { + continue; + } + struct stat dst_stat; + struct stat src_stat; + sc_must_snprintf(src, sizeof src, "%s%s", + config->rootfs_dir, dir); + sc_must_snprintf(dst, sizeof dst, "%s%s", + scratch_dir, dir); + if (lstat(src, &src_stat) != 0) { + if (errno == ENOENT) { + continue; + } + die("cannot stat %s from desired rootfs", src); + } + if (!S_ISREG(src_stat.st_mode) + && !S_ISDIR(src_stat.st_mode)) { + debug + ("entry %s from the desired rootfs is not a file or directory, skipping mount", + src); + continue; + } + + if (lstat(dst, &dst_stat) != 0) { + if (errno == ENOENT) { + continue; + } + die("cannot stat %s from host", src); + } + if (!S_ISREG(dst_stat.st_mode) + && !S_ISDIR(dst_stat.st_mode)) { + debug + ("entry %s from the host is not a file or directory, skipping mount", + src); + continue; + } + + if ((dst_stat.st_mode & S_IFMT) != + (src_stat.st_mode & S_IFMT)) { + debug + ("entries %s and %s are of different types, skipping mount", + dst, src); + continue; + } + // both source and destination exist where both are either files + // or both are directories + sc_do_mount(src, dst, NULL, MS_BIND, NULL); + sc_do_mount("none", dst, NULL, MS_SLAVE, NULL); + } + } + // The "core" base snap is special as it contains snapd and friends. + // Other base snaps do not, so whenever a base snap other than core is + // in use we need extra provisions for setting up internal tooling to + // be available. + // + // However on a core18 (and similar) system the core snap is not + // a special base anymore and we should map our own tooling in. + if (config->distro == SC_DISTRO_CORE_OTHER + || !sc_streq(config->base_snap_name, "core")) { + // when bases are used we need to bind-mount the libexecdir + // (that contains snap-exec) into /usr/lib/snapd of the + // base snap so that snap-exec is available for the snaps + // (base snaps do not ship snapd) + + // dst is always /usr/lib/snapd as this is where snapd + // assumes to find snap-exec + sc_must_snprintf(dst, sizeof dst, "%s/usr/lib/snapd", + scratch_dir); + + // bind mount the current $ROOT/usr/lib/snapd path, + // where $ROOT is either "/" or the "/snap/{core,snapd}/current" + // that we are re-execing from + char *src = NULL; + char self[PATH_MAX + 1] = { 0 }; + ssize_t nread; + nread = readlink("/proc/self/exe", self, sizeof self - 1); + if (nread < 0) { + die("cannot read /proc/self/exe"); + } + // Though we initialized self to NULs and passed one less to + // readlink, therefore guaranteeing that self is + // zero-terminated, perform an explicit assignment to make + // Coverity happy. + self[nread] = '\0'; + // this cannot happen except when the kernel is buggy + if (strstr(self, "/snap-confine") == NULL) { + die("cannot use result from readlink: %s", self); + } + src = dirname(self); + // dirname(path) might return '.' depending on path. + // /proc/self/exe should always point + // to an absolute path, but let's guarantee that. + if (src[0] != '/') { + die("cannot use the result of dirname(): %s", src); + } + + sc_do_mount(src, dst, NULL, MS_BIND | MS_RDONLY, NULL); + sc_do_mount("none", dst, NULL, MS_SLAVE, NULL); + } + // Bind mount the directory where all snaps are mounted. The location of + // the this directory on the host filesystem may not match the location in + // the desired root filesystem. In the "core" and "ubuntu-core" snaps the + // directory is always /snap. On the host it is a build-time configuration + // option stored in SNAP_MOUNT_DIR. In legacy mode (or in other words, not + // in normal mode), we don't need to do this because /snap is fixed and + // already contains the correct view of the mounted snaps. + if (config->normal_mode) { + sc_must_snprintf(dst, sizeof dst, "%s/snap", scratch_dir); + sc_do_mount(SNAP_MOUNT_DIR, dst, NULL, MS_BIND | MS_REC, NULL); + sc_do_mount("none", dst, NULL, MS_REC | MS_SLAVE, NULL); + } + // Ensure that hostfs exists and is group-owned by root. We may have (now + // or earlier) created the directory as the user who first ran a snap on a + // given system and the group identity of that user is visible on disk. + // This was LP:#1665004. We do this by trying to create the hostfs directory + // if one is missing. This directory is a part of packaging now so perhaps + // this code can be removed later. Note: we use 0000 as permissions here, to + // avoid the risk that the user manages to fiddle with the newly created + // directory before we have the chance to chown it to root:root. We are + // setting the usual 0755 permissions just after the chown below. + if (mkdir(SC_HOSTFS_DIR, 0000) < 0) { + if (errno == EEXIST) { + // The directory exists, verify its ownership. + struct stat sb; + if (stat(SC_HOSTFS_DIR, &sb) < 0) { + die("cannot stat %s", SC_HOSTFS_DIR); + } else if (sb.st_uid != 0 || sb.st_gid != 0) { + die("%s is not owned by root", SC_HOSTFS_DIR); + } + } else { + die("cannot perform operation: mkdir %s", + SC_HOSTFS_DIR); + } + } else { + if (chown(SC_HOSTFS_DIR, 0, 0) < 0) { + die("cannot set root ownership on %s directory", + SC_HOSTFS_DIR); + } + if (chmod(SC_HOSTFS_DIR, 0755) < 0) { + die("cannot set 0755 permissions on %s directory", + SC_HOSTFS_DIR); + } + } + // Make the upcoming "put_old" directory for pivot_root private so that + // mount events don't propagate to any peer group. In practice pivot root + // has a number of undocumented requirements and one of them is that the + // "put_old" directory (the second argument) cannot be shared in any way. + sc_must_snprintf(dst, sizeof dst, "%s/%s", scratch_dir, SC_HOSTFS_DIR); + sc_do_mount(dst, dst, NULL, MS_BIND, NULL); + sc_do_mount("none", dst, NULL, MS_PRIVATE, NULL); + // On classic mount the nvidia driver. Ideally this would be done in an + // uniform way after pivot_root but this is good enough and requires less + // code changes the nvidia code assumes it has access to the existing + // pre-pivot filesystem. + if (config->distro == SC_DISTRO_CLASSIC) { + sc_mount_nvidia_driver(scratch_dir, config->base_snap_name); + } + // XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX + // pivot_root + // XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX + // Use pivot_root to "chroot" into the scratch directory. + // + // Q: Why are we using something as esoteric as pivot_root(2)? + // A: Because this makes apparmor handling easy. Using a normal chroot + // makes all apparmor rules conditional. We are either running on an + // all-snap system where this would-be chroot didn't happen and all the + // rules see / as the root file system _OR_ we are running on top of a + // classic distribution and this chroot has now moved all paths to + // /tmp/snap.rootfs_*. + // + // Because we are using unshare(2) with CLONE_NEWNS we can essentially use + // pivot_root just like chroot but this makes apparmor unaware of the old + // root so everything works okay. + // + // HINT: If you are debugging this and are trying to see why pivot_root + // happens to return EINVAL with any changes you may be making, please + // consider applying + // misc/0001-Add-printk-based-debugging-to-pivot_root.patch to your tree + // kernel. + debug("performing operation: pivot_root %s %s", scratch_dir, dst); + if (syscall(SYS_pivot_root, scratch_dir, dst) < 0) { + die("cannot perform operation: pivot_root %s %s", scratch_dir, + dst); + } + // Unmount the self-bind mount over the scratch directory created earlier + // in the original root filesystem (which is now mounted on SC_HOSTFS_DIR). + // This way we can remove the temporary directory we created and "clean up" + // after ourselves nicely. + sc_must_snprintf(dst, sizeof dst, "%s/%s", SC_HOSTFS_DIR, scratch_dir); + sc_do_umount(dst, UMOUNT_NOFOLLOW); + // Remove the scratch directory. Note that we are using the path that is + // based on the old root filesystem as after pivot_root we cannot guarantee + // what is present at the same location normally. (It is probably an empty + // /tmp directory that is populated in another place). + debug("performing operation: rmdir %s", dst); + if (rmdir(scratch_dir) < 0) { + die("cannot perform operation: rmdir %s", dst); + }; + // Make the old root filesystem recursively slave. This way operations + // performed in this mount namespace will not propagate to the peer group. + // This is another essential part of the confinement system. + sc_do_mount("none", SC_HOSTFS_DIR, NULL, MS_REC | MS_SLAVE, NULL); + // Detach the redundant hostfs version of sysfs since it shows up in the + // mount table and software inspecting the mount table may become confused + // (eg, docker and LP:# 162601). + sc_must_snprintf(src, sizeof src, "%s/sys", SC_HOSTFS_DIR); + sc_do_umount(src, UMOUNT_NOFOLLOW | MNT_DETACH); + // Detach the redundant hostfs version of /dev since it shows up in the + // mount table and software inspecting the mount table may become confused. + sc_must_snprintf(src, sizeof src, "%s/dev", SC_HOSTFS_DIR); + sc_do_umount(src, UMOUNT_NOFOLLOW | MNT_DETACH); + // Detach the redundant hostfs version of /proc since it shows up in the + // mount table and software inspecting the mount table may become confused. + sc_must_snprintf(src, sizeof src, "%s/proc", SC_HOSTFS_DIR); + sc_do_umount(src, UMOUNT_NOFOLLOW | MNT_DETACH); + // Detach both views of /writable: the one from hostfs and the one directly + // visible in /writable. Interfaces don't grant access to this directory + // and it has a large duplicated view of many mount points. Note that this + // is only applicable to ubuntu-core systems. + sc_detach_views_of_writable(config->distro, config->normal_mode); +} + +static void sc_detach_views_of_writable(sc_distro distro, bool normal_mode) +{ + // Note that prior to detaching either mount point we switch the + // propagation to private to both limit the change to just this view and to + // prevent otherwise occurring event propagation from self-conflicting and + // returning EBUSY. A similar approach is used by snap-update-ns and is + // documented in umount(2). + const char *writable_dir = "/writable"; + const char *hostfs_writable_dir = "/var/lib/snapd/hostfs/writable"; + + // Writable only exists on ubuntu-core. + if (distro == SC_DISTRO_CLASSIC) { + return; + } + // On all core distributions we see /var/lib/snapd/hostfs/writable that + // exposes writable, with a structure specific to ubuntu-core. + debug("detaching %s", hostfs_writable_dir); + sc_do_mount("none", hostfs_writable_dir, NULL, + MS_REC | MS_PRIVATE, NULL); + sc_do_umount(hostfs_writable_dir, UMOUNT_NOFOLLOW | MNT_DETACH); + + // On ubuntu-core 16, when the executed snap uses core as base we also see + // the /writable that we directly inherited from the initial mount + // namespace. + if (distro == SC_DISTRO_CORE16 && !normal_mode) { + debug("detaching %s", writable_dir); + sc_do_mount("none", writable_dir, NULL, MS_REC | MS_PRIVATE, + NULL); + sc_do_umount(writable_dir, UMOUNT_NOFOLLOW | MNT_DETACH); + } +} + +/** + * @path: a pathname where / replaced with '\0'. + * @offsetp: pointer to int showing which path segment was last seen. + * Updated on return to reflect the next segment. + * @fulllen: full original path length. + * Returns a pointer to the next path segment, or NULL if done. + */ +static char * __attribute__((used)) + get_nextpath(char *path, size_t *offsetp, size_t fulllen) +{ + size_t offset = *offsetp; + + if (offset >= fulllen) + return NULL; + + while (offset < fulllen && path[offset] != '\0') + offset++; + while (offset < fulllen && path[offset] == '\0') + offset++; + + *offsetp = offset; + return (offset < fulllen) ? &path[offset] : NULL; +} + +/** + * Check that @subdir is a subdir of @dir. +**/ +static bool __attribute__((used)) + is_subdir(const char *subdir, const char *dir) +{ + size_t dirlen = strlen(dir); + size_t subdirlen = strlen(subdir); + + // @dir has to be at least as long as @subdir + if (subdirlen < dirlen) + return false; + // @dir has to be a prefix of @subdir + if (strncmp(subdir, dir, dirlen) != 0) + return false; + // @dir can look like "path/" (that is, end with the directory separator). + // When that is the case then given the test above we can be sure @subdir + // is a real subdirectory. + if (dirlen > 0 && dir[dirlen - 1] == '/') + return true; + // @subdir can look like "path/stuff" and when the directory separator + // is exactly at the spot where @dir ends (that is, it was not caught + // by the test above) then @subdir is a real subdirectory. + if (subdir[dirlen] == '/' && dirlen > 0) + return true; + // If both @dir and @subdir have identical length then given that the + // prefix check above @subdir is a real subdirectory. + if (subdirlen == dirlen) + return true; + return false; +} + +static struct sc_mount *sc_homedir_mounts(const struct sc_invocation *inv) +{ + if (inv->num_homedirs == 0) { + return NULL; + } + // We add one element for the end-of-array indicator. + struct sc_mount *mounts = + calloc(inv->num_homedirs + 1, sizeof(struct sc_mount)); + if (mounts == NULL) { + die("cannot allocate mount data for homedirs"); + } + // Copy inv->homedirs to the mount structures + for (int i = 0; i < inv->num_homedirs; i++) { + debug("Adding homedir: %s", inv->homedirs[i]); + mounts[i].path = sc_strdup(inv->homedirs[i]); + // Note that we are not setting bidirectional flag, so anything mounted + // here will not propagate to the host. + } + return mounts; +} + +static void sc_free_dynamic_mounts(struct sc_mount *mounts) +{ + // This is in line with normal free semantics. + if (mounts == NULL) { + return; + } + // Cleanup allocated resources by each of the mount + // structures. The array will be terminated by a single zeroed + // entry. + for (int i = 0; mounts[i].path != NULL; i++) { + free((void *)mounts[i].path); + } + free(mounts); +} + +void sc_populate_mount_ns(struct sc_apparmor *apparmor, int snap_update_ns_fd, + const sc_invocation *inv, const gid_t real_gid, + const gid_t saved_gid) +{ + // Classify the current distribution, as claimed by /etc/os-release. + sc_distro distro = sc_classify_distro(); + + // Check which mode we should run in, normal or legacy. + if (inv->is_normal_mode) { + // In normal mode we use the base snap as / and set up several bind mounts. + static const struct sc_mount mounts[] = { + {.path = "/dev"}, // because it contains devices on host OS + {.path = "/etc"}, // because that's where /etc/resolv.conf lives, perhaps a bad idea + {.path = "/home"}, // to support /home/*/snap and home interface + {.path = "/root"}, // because that is $HOME for services + {.path = "/proc"}, // fundamental filesystem + {.path = "/sys"}, // fundamental filesystem + {.path = "/tmp"}, // to get writable tmp + {.path = "/var/snap"}, // to get access to global snap data + {.path = "/var/lib/snapd"}, // to get access to snapd state and seccomp profiles + {.path = "/var/tmp"}, // to get access to the other temporary directory + {.path = "/run"}, // to get /run with sockets and what not + {.path = "/lib/modules",.is_optional = true}, // access to the modules of the running kernel + {.path = "/lib/firmware",.is_optional = true}, // access to the firmware of the running kernel + {.path = "/usr/src"}, // FIXME: move to SecurityMounts in system-trace interface + {.path = "/var/log"}, // FIXME: move to SecurityMounts in log-observe interface +#ifdef MERGED_USR + {.path = "/run/media",.is_bidirectional = true,.altpath = "/media"}, // access to the users removable devices +#else + {.path = "/media",.is_bidirectional = true}, // access to the users removable devices +#endif // MERGED_USR + {.path = "/run/netns",.is_bidirectional = true}, // access to the 'ip netns' network namespaces + // The /mnt directory is optional in base snaps to ensure backwards + // compatibility with the first version of base snaps that was + // released. + {.path = "/mnt",.is_optional = true}, // to support the removable-media interface + {.path = "/var/lib/extrausers",.is_optional = true}, // access to UID/GID of extrausers (if available) + {}, + }; + struct sc_mount_config normal_config = { + .rootfs_dir = inv->rootfs_dir, + .mounts = mounts, + // Homedir mounts are user-specified paths that snaps are allowed + // to access, which don't reside in the regular home path. They can change + // between runs, so we must dynamically handle them. + .dynamic_mounts = sc_homedir_mounts(inv), + .distro = distro, + .normal_mode = true, + .base_snap_name = inv->base_snap_name, + .snap_instance = inv->snap_instance, + }; + sc_bootstrap_mount_namespace(&normal_config); + sc_free_dynamic_mounts(normal_config.dynamic_mounts); + normal_config.dynamic_mounts = NULL; + } else { + // In legacy mode we don't pivot to a base snap's rootfs and instead + // just arrange bi-directional mount propagation for two directories. + static const struct sc_mount mounts[] = { + {.path = "/media",.is_bidirectional = true}, + {.path = "/run/netns",.is_bidirectional = true}, + {}, + }; + struct sc_mount_config legacy_config = { + .rootfs_dir = "/", + .mounts = mounts, + // XXX: should we support Homedir mount in legacy mode? + .distro = distro, + .normal_mode = false, + .base_snap_name = inv->base_snap_name, + }; + sc_bootstrap_mount_namespace(&legacy_config); + } + + // TODO: rename this and fold it into bootstrap + setup_private_tmp(inv->snap_instance); + // set up private /dev/pts + // TODO: fold this into bootstrap + setup_private_pts(); + + // setup the security backend bind mounts + sc_call_snap_update_ns(snap_update_ns_fd, inv->snap_instance, apparmor); +} + +static bool is_mounted_with_shared_option(const char *dir) + __attribute__((nonnull(1))); + +static bool is_mounted_with_shared_option(const char *dir) +{ + sc_mountinfo *sm SC_CLEANUP(sc_cleanup_mountinfo) = NULL; + sm = sc_parse_mountinfo(NULL); + if (sm == NULL) { + die("cannot parse /proc/self/mountinfo"); + } + sc_mountinfo_entry *entry = sc_first_mountinfo_entry(sm); + while (entry != NULL) { + const char *mount_dir = entry->mount_dir; + if (sc_streq(mount_dir, dir)) { + const char *optional_fields = entry->optional_fields; + if (strstr(optional_fields, "shared:") != NULL) { + return true; + } + } + entry = sc_next_mountinfo_entry(entry); + } + return false; +} + +void sc_ensure_shared_snap_mount(void) +{ + if (!is_mounted_with_shared_option("/") + && !is_mounted_with_shared_option(SNAP_MOUNT_DIR)) { + // TODO: We could be more aggressive and refuse to function but since + // we have no data on actual environments that happen to limp along in + // this configuration let's not do that yet. This code should be + // removed once we have a measurement and feedback mechanism that lets + // us decide based on measurable data. + sc_do_mount(SNAP_MOUNT_DIR, SNAP_MOUNT_DIR, "none", + MS_BIND | MS_REC, NULL); + sc_do_mount("none", SNAP_MOUNT_DIR, NULL, MS_SHARED | MS_REC, + NULL); + } +} + +void sc_setup_user_mounts(struct sc_apparmor *apparmor, int snap_update_ns_fd, + const char *snap_name) +{ + debug("%s: %s", __FUNCTION__, snap_name); + + char profile_path[PATH_MAX]; + struct stat st; + + sc_must_snprintf(profile_path, sizeof(profile_path), + "/var/lib/snapd/mount/snap.%s.user-fstab", snap_name); + if (stat(profile_path, &st) != 0) { + // It is ok for the user fstab to not exist. + return; + } + + // In our new mount namespace, recursively change all mounts + // to slave mode, so we see changes from the parent namespace + // but don't propagate our own changes. + sc_do_mount("none", "/", NULL, MS_REC | MS_SLAVE, NULL); + sc_identity old = sc_set_effective_identity(sc_root_group_identity()); + sc_call_snap_update_ns_as_user(snap_update_ns_fd, snap_name, apparmor); + (void)sc_set_effective_identity(old); +} + +void sc_ensure_snap_dir_shared_mounts(void) +{ + const char *dirs[] = { SNAP_MOUNT_DIR, "/var/snap", NULL }; + for (int i = 0; dirs[i] != NULL; i++) { + const char *dir = dirs[i]; + if (!is_mounted_with_shared_option(dir)) { + /* Since this directory isn't yet shared (but it should be), + * recursively bind mount it, then recursively share it so that + * changes to the host are seen in the snap and vice-versa. This + * allows us to fine-tune propagation events elsewhere for this new + * mountpoint. + * + * Not using MS_SLAVE because it's too late for SNAP_MOUNT_DIR, + * since snaps are already mounted, and it's not needed for + * /var/snap. + */ + sc_do_mount(dir, dir, "none", MS_BIND | MS_REC, NULL); + sc_do_mount("none", dir, NULL, MS_REC | MS_SHARED, + NULL); + } + } +} + +void sc_setup_parallel_instance_classic_mounts(const char *snap_name, + const char *snap_instance_name) +{ + char src[PATH_MAX] = { 0 }; + char dst[PATH_MAX] = { 0 }; + + const char *dirs[] = { SNAP_MOUNT_DIR, "/var/snap", NULL }; + for (int i = 0; dirs[i] != NULL; i++) { + const char *dir = dirs[i]; + sc_do_mount("none", dir, NULL, MS_REC | MS_SLAVE, NULL); + } + + /* Mount SNAP_MOUNT_DIR/_ on SNAP_MOUNT_DIR/ */ + sc_must_snprintf(src, sizeof src, "%s/%s", SNAP_MOUNT_DIR, + snap_instance_name); + sc_must_snprintf(dst, sizeof dst, "%s/%s", SNAP_MOUNT_DIR, snap_name); + sc_do_mount(src, dst, "none", MS_BIND | MS_REC, NULL); + + /* Mount /var/snap/_ on /var/snap/ */ + sc_must_snprintf(src, sizeof src, "/var/snap/%s", snap_instance_name); + sc_must_snprintf(dst, sizeof dst, "/var/snap/%s", snap_name); + sc_do_mount(src, dst, "none", MS_BIND | MS_REC, NULL); +} diff --git a/cmd/snap-confine/mount-support.h b/cmd/snap-confine/mount-support.h new file mode 100644 index 00000000..84f0b6de --- /dev/null +++ b/cmd/snap-confine/mount-support.h @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2015 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#ifndef SNAP_MOUNT_SUPPORT_H +#define SNAP_MOUNT_SUPPORT_H + +#include "../libsnap-confine-private/apparmor-support.h" +#include "snap-confine-invocation.h" +#include + +/* Base location where extra libraries might be made available to the snap. + * This is currently used for graphics drivers, but could pontentially be used + * for other goals as well. + * + * NOTE: do not bind-mount anything directly onto this directory! This is only + * a *base* directory: for exposing drivers and libraries, create a + * sub-directory in SC_EXTRA_LIB_DIR and use that one as the bind mount target. + */ +#define SC_EXTRA_LIB_DIR "/var/lib/snapd/lib" + +/** + * Assuming a new mountspace, populate it accordingly. + * + * This function performs many internal tasks: + * - prepares and chroots into the core snap (on classic systems) + * - creates private /tmp + * - creates private /dev/pts + * - processes mount profiles + **/ +void sc_populate_mount_ns(struct sc_apparmor *apparmor, int snap_update_ns_fd, + const sc_invocation * inv, const gid_t real_gid, + const gid_t saved_gid); + +/** + * Ensure that / or /snap is mounted with the SHARED option. + * + * If the system is found to be not having a shared mount for "/" + * snap-confine will create a shared bind mount for "/snap" to + * ensure that "/snap" is mounted shared. See LP:#1668659 + */ +void sc_ensure_shared_snap_mount(void); + +/** + * Set up user mounts, private to this process. + * + * If any user mounts have been configured for this process, this does + * the following: + * - create a new mount namespace + * - reconfigure all existing mounts to slave mode + * - perform all user mounts + */ +void sc_setup_user_mounts(struct sc_apparmor *apparmor, int snap_update_ns_fd, + const char *snap_name); + +/** + * Ensure that SNAP_MOUNT_DIR and /var/snap are mount points. + * + * Create bind mounts and set up shared propagation for SNAP_MOUNT_DIR and + * /var/snap as needed. This allows for further propagation changes after the + * initial mount namespace is unshared. + */ +void sc_ensure_snap_dir_shared_mounts(void); + +/** + * Set up mount namespace for parallel installed classic snap + * + * Create bind mounts from instance specific locations to non-instance ones. + */ +void sc_setup_parallel_instance_classic_mounts(const char *snap_name, + const char *snap_instance_name); +#endif diff --git a/cmd/snap-confine/ns-support-test.c b/cmd/snap-confine/ns-support-test.c new file mode 100644 index 00000000..c49686db --- /dev/null +++ b/cmd/snap-confine/ns-support-test.c @@ -0,0 +1,154 @@ +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "ns-support.h" +#include "ns-support.c" + +#include "../libsnap-confine-private/cleanup-funcs.h" +#include "../libsnap-confine-private/test-utils.h" + +#include +#include // for NSFS_MAGIC +#include +#include + +#include +#include + +// Set alternate namespace directory +static void sc_set_ns_dir(const char *dir) +{ + sc_ns_dir = dir; +} + +// A variant of unsetenv that is compatible with GDestroyNotify +static void my_unsetenv(const char *k) +{ + unsetenv(k); +} + +// Use temporary directory for namespace groups. +// +// The directory is automatically reset to the real value at the end of the +// test. +static const char *sc_test_use_fake_ns_dir(void) +{ + char *ns_dir = NULL; + if (g_test_subprocess()) { + // Check if the environment variable is set. If so then someone is already + // managing the temporary directory and we should not create a new one. + ns_dir = getenv("SNAP_CONFINE_NS_DIR"); + g_assert_nonnull(ns_dir); + } else { + ns_dir = g_dir_make_tmp(NULL, NULL); + g_assert_nonnull(ns_dir); + g_test_queue_free(ns_dir); + g_assert_cmpint(setenv("SNAP_CONFINE_NS_DIR", ns_dir, 0), ==, + 0); + g_test_queue_destroy((GDestroyNotify) my_unsetenv, + "SNAP_CONFINE_NS_DIR"); + g_test_queue_destroy((GDestroyNotify) rm_rf_tmp, ns_dir); + } + g_test_queue_destroy((GDestroyNotify) sc_set_ns_dir, SC_NS_DIR); + sc_set_ns_dir(ns_dir); + return ns_dir; +} + +// Check that allocating a namespace group sets up internal data structures to +// safe values. +static void test_sc_alloc_mount_ns(void) +{ + struct sc_mount_ns *group = NULL; + group = sc_alloc_mount_ns(); + g_test_queue_free(group); + g_assert_nonnull(group); + g_assert_cmpint(group->dir_fd, ==, -1); + g_assert_cmpint(group->pipe_master[0], ==, -1); + g_assert_cmpint(group->pipe_master[1], ==, -1); + g_assert_cmpint(group->pipe_helper[0], ==, -1); + g_assert_cmpint(group->pipe_helper[1], ==, -1); + g_assert_cmpint(group->child, ==, 0); + g_assert_null(group->name); +} + +// Initialize a namespace group. +// +// The group is automatically destroyed at the end of the test. +static struct sc_mount_ns *sc_test_open_mount_ns(const char *group_name) +{ + // Initialize a namespace group + struct sc_mount_ns *group = NULL; + if (group_name == NULL) { + group_name = "test-group"; + } + group = sc_open_mount_ns(group_name); + g_test_queue_destroy((GDestroyNotify) sc_close_mount_ns, group); + // Check if the returned group data looks okay + g_assert_nonnull(group); + g_assert_cmpint(group->dir_fd, !=, -1); + g_assert_cmpint(group->pipe_master[0], ==, -1); + g_assert_cmpint(group->pipe_master[1], ==, -1); + g_assert_cmpint(group->pipe_helper[0], ==, -1); + g_assert_cmpint(group->pipe_helper[1], ==, -1); + g_assert_cmpint(group->child, ==, 0); + g_assert_cmpstr(group->name, ==, group_name); + return group; +} + +// Check that initializing a namespace group creates the appropriate +// filesystem structure. +static void test_sc_open_mount_ns(void) +{ + const char *ns_dir = sc_test_use_fake_ns_dir(); + sc_test_open_mount_ns(NULL); + // Check that the group directory exists + g_assert_true(g_file_test + (ns_dir, G_FILE_TEST_EXISTS | G_FILE_TEST_IS_DIR)); +} + +// Sanity check, ensure that the namespace filesystem identifier is what we +// expect, aka NSFS_MAGIC. +static void test_nsfs_fs_id(void) +{ + struct utsname uts; + if (uname(&uts) < 0) { + g_test_message("cannot use uname(2)"); + g_test_fail(); + return; + } + int major, minor; + if (sscanf(uts.release, "%d.%d", &major, &minor) != 2) { + g_test_message("cannot use sscanf(2) to parse kernel release"); + g_test_fail(); + return; + } + if (major < 3 || (major == 3 && minor < 19)) { + g_test_skip("this test needs kernel 3.19+"); + return; + } + struct statfs buf; + int err = statfs("/proc/self/ns/mnt", &buf); + g_assert_cmpint(err, ==, 0); + g_assert_cmpint(buf.f_type, ==, NSFS_MAGIC); +} + +static void __attribute__((constructor)) init(void) +{ + g_test_add_func("/ns/sc_alloc_mount_ns", test_sc_alloc_mount_ns); + g_test_add_func("/ns/sc_open_mount_ns", test_sc_open_mount_ns); + g_test_add_func("/ns/nsfs_fs_id", test_nsfs_fs_id); +} diff --git a/cmd/snap-confine/ns-support.c b/cmd/snap-confine/ns-support.c new file mode 100644 index 00000000..fdf26f08 --- /dev/null +++ b/cmd/snap-confine/ns-support.c @@ -0,0 +1,996 @@ +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "ns-support.h" + +#ifdef HAVE_CONFIG_H +#include "config.h" +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../libsnap-confine-private/cgroup-freezer-support.h" +#include "../libsnap-confine-private/cgroup-support.h" +#include "../libsnap-confine-private/classic.h" +#include "../libsnap-confine-private/cleanup-funcs.h" +#include "../libsnap-confine-private/feature.h" +#include "../libsnap-confine-private/infofile.h" +#include "../libsnap-confine-private/locking.h" +#include "../libsnap-confine-private/mountinfo.h" +#include "../libsnap-confine-private/string-utils.h" +#include "../libsnap-confine-private/tool.h" +#include "../libsnap-confine-private/utils.h" +#include "user-support.h" +#include "mount-support.h" + +/** + * Directory where snap-confine keeps namespace files. + **/ +#define SC_NS_DIR "/run/snapd/ns" + +/** + * Effective value of SC_NS_DIR. + * + * We use 'const char *' so we can update sc_ns_dir in the testsuite + **/ +static const char *sc_ns_dir = SC_NS_DIR; + +enum { + HELPER_CMD_EXIT, + HELPER_CMD_CAPTURE_MOUNT_NS, + HELPER_CMD_CAPTURE_PER_USER_MOUNT_NS, +}; + +void sc_reassociate_with_pid1_mount_ns(void) +{ + int init_mnt_fd SC_CLEANUP(sc_cleanup_close) = -1; + int self_mnt_fd SC_CLEANUP(sc_cleanup_close) = -1; + const char *path_pid_1 = "/proc/1/ns/mnt"; + const char *path_pid_self = "/proc/self/ns/mnt"; + + init_mnt_fd = open(path_pid_1, + O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_PATH); + if (init_mnt_fd < 0) { + die("cannot open path %s", path_pid_1); + } + self_mnt_fd = open(path_pid_self, + O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_PATH); + if (self_mnt_fd < 0) { + die("cannot open path %s", path_pid_1); + } + char init_buf[128] = { 0 }; + char self_buf[128] = { 0 }; + memset(init_buf, 0, sizeof init_buf); + if (readlinkat(init_mnt_fd, "", init_buf, sizeof init_buf) < 0) { + if (errno == ENOENT) { + // According to namespaces(7) on a pre 3.8 kernel the namespace + // files are hardlinks, not symlinks. If that happens readlinkat + // fails with ENOENT. As a quick workaround for this special-case + // functionality, just bail out and do nothing without raising an + // error. + return; + } + die("cannot read mount namespace identifier of pid 1"); + } + memset(self_buf, 0, sizeof self_buf); + if (readlinkat(self_mnt_fd, "", self_buf, sizeof self_buf) < 0) { + die("cannot read mount namespace identifier of the current process"); + } + if (memcmp(init_buf, self_buf, sizeof init_buf) != 0) { + debug("moving to mount namespace of pid 1"); + // We cannot use O_NOFOLLOW here because that file will always be a + // symbolic link. We actually want to open it this way. + int init_mnt_fd_real SC_CLEANUP(sc_cleanup_close) = -1; + init_mnt_fd_real = open(path_pid_1, O_RDONLY | O_CLOEXEC); + if (init_mnt_fd_real < 0) { + die("cannot open %s", path_pid_1); + } + if (setns(init_mnt_fd_real, CLONE_NEWNS) < 0) { + die("cannot join mount namespace of pid 1"); + } + } +} + +void sc_initialize_mount_ns(unsigned int experimental_features) +{ + debug("unsharing snap namespace directory"); + + /* Ensure that /run/snapd/ns is a directory. */ + sc_identity old = sc_set_effective_identity(sc_root_group_identity()); + if (sc_nonfatal_mkpath(sc_ns_dir, 0755) < 0) { + die("cannot create directory %s", sc_ns_dir); + } + (void)sc_set_effective_identity(old); + + /* Read and analyze the mount table. We need to see whether /run/snapd/ns + * is a mount point with private event propagation. */ + sc_mountinfo *info SC_CLEANUP(sc_cleanup_mountinfo) = NULL; + info = sc_parse_mountinfo(NULL); + if (info == NULL) { + die("cannot parse /proc/self/mountinfo"); + } + + bool is_mnt = false; + bool is_private = false; + for (sc_mountinfo_entry * entry = sc_first_mountinfo_entry(info); + entry != NULL; entry = sc_next_mountinfo_entry(entry)) { + /* Find /run/snapd/ns */ + if (!sc_streq(entry->mount_dir, sc_ns_dir)) { + continue; + } + is_mnt = true; + if (strstr(entry->optional_fields, "shared:") == NULL) { + /* Mount event propagation is not set to shared, good. */ + is_private = true; + } + break; + } + + if (!is_mnt) { + if (mount(sc_ns_dir, sc_ns_dir, NULL, MS_BIND | MS_REC, NULL) < + 0) { + die("cannot self-bind mount %s", sc_ns_dir); + } + } + + if (!is_private) { + if (mount(NULL, sc_ns_dir, NULL, MS_PRIVATE, NULL) < 0) { + die("cannot change propagation type to MS_PRIVATE in %s", sc_ns_dir); + } + } + + /* code that follows is experimental */ + if (experimental_features & SC_FEATURE_PARALLEL_INSTANCES) { + // Ensure that SNAP_MOUNT_DIR and /var/snap are shared mount points + debug + ("(experimental) ensuring snap mount and data directories are mount points"); + sc_ensure_snap_dir_shared_mounts(); + } +} + +struct sc_mount_ns { + // Name of the namespace group ($SNAP_NAME). + char *name; + // Descriptor to the namespace group control directory. This descriptor is + // opened with O_PATH|O_DIRECTORY so it's only used for openat() calls. + int dir_fd; + // Pair of descriptors for a pair for a pipe file descriptors (read end, + // write end) that snap-confine uses to send messages to the helper + // process and back. + int pipe_helper[2]; + int pipe_master[2]; + // Identifier of the child process that is used during the one-time (per + // group) initialization and capture process. + pid_t child; +}; + +static struct sc_mount_ns *sc_alloc_mount_ns(void) +{ + struct sc_mount_ns *group = calloc(1, sizeof *group); + if (group == NULL) { + die("cannot allocate memory for sc_mount_ns"); + } + group->dir_fd = -1; + group->pipe_helper[0] = -1; + group->pipe_helper[1] = -1; + group->pipe_master[0] = -1; + group->pipe_master[1] = -1; + // Redundant with calloc but some functions check for the non-zero value so + // I'd like to keep this explicit in the code. + group->child = 0; + return group; +} + +struct sc_mount_ns *sc_open_mount_ns(const char *group_name) +{ + struct sc_mount_ns *group = sc_alloc_mount_ns(); + group->dir_fd = open(sc_ns_dir, + O_DIRECTORY | O_PATH | O_CLOEXEC | O_NOFOLLOW); + if (group->dir_fd < 0) { + die("cannot open directory %s", sc_ns_dir); + } + group->name = sc_strdup(group_name); + return group; +} + +void sc_close_mount_ns(struct sc_mount_ns *group) +{ + if (group->child != 0) { + sc_wait_for_helper(group); + } + sc_cleanup_close(&group->dir_fd); + sc_cleanup_close(&group->pipe_master[0]); + sc_cleanup_close(&group->pipe_master[1]); + sc_cleanup_close(&group->pipe_helper[0]); + sc_cleanup_close(&group->pipe_helper[1]); + free(group->name); + free(group); +} + +static dev_t find_base_snap_device(const char *base_snap_name, + const char *base_snap_rev) +{ + // Find the backing device of the base snap. + // TODO: add support for "try mode" base snaps that also need + // consideration of the mie->root component. + dev_t base_snap_dev = 0; + char base_squashfs_path[PATH_MAX]; + sc_must_snprintf(base_squashfs_path, + sizeof base_squashfs_path, "%s/%s/%s", + SNAP_MOUNT_DIR, base_snap_name, base_snap_rev); + sc_mountinfo *mi SC_CLEANUP(sc_cleanup_mountinfo) = NULL; + mi = sc_parse_mountinfo(NULL); + if (mi == NULL) { + die("cannot parse mountinfo of the current process"); + } + bool found = false; + for (sc_mountinfo_entry * mie = + sc_first_mountinfo_entry(mi); mie != NULL; + mie = sc_next_mountinfo_entry(mie)) { + if (sc_streq(mie->mount_dir, base_squashfs_path)) { + base_snap_dev = makedev(mie->dev_major, mie->dev_minor); + debug("block device of snap %s, revision %s is %d:%d", + base_snap_name, base_snap_rev, mie->dev_major, + mie->dev_minor); + // Don't break when found, we are interested in the last + // entry as this is the "effective" one. + found = true; + } + } + if (!found) { + die("cannot find mount entry for snap %s revision %s", + base_snap_name, base_snap_rev); + } + return base_snap_dev; +} + +static bool base_snap_device_changed(sc_mountinfo *mi, dev_t base_snap_dev) +{ + sc_mountinfo_entry *mie; + + /* We are looking for a mount entry matching the device ID of the base + * snap. We need to take these cases into account: + * 1) In the typical case, this will be mounted on the "/" directory. + * 2) If the root directory is a tmpfs, the base snap would be mounted + * under /usr. + * 3) If the snap has a layout that adds directories or files directly + * under /usr, a writable mimic will be created: /usr will be a tmpfs, + * with all of the original directory entries inside of /usr being + * bind-mounted onto mount-points created into the tmpfs. + * In light of the above, we do ignore all tmpfs entries and accept that + * our base snap might be mounted under /, /usr, or anywhere under /usr. + */ + for (mie = sc_first_mountinfo_entry(mi); mie != NULL; + mie = sc_next_mountinfo_entry(mie)) { + if (sc_streq(mie->fs_type, "tmpfs")) { + continue; + } + + if (base_snap_dev == makedev(mie->dev_major, mie->dev_minor) && + (sc_streq(mie->mount_dir, "/") || + sc_streq(mie->mount_dir, "/usr") || + sc_startswith(mie->mount_dir, "/usr/"))) { + debug("found base snap device %d:%d on %s", + mie->dev_major, mie->dev_minor, mie->mount_dir); + return false; + } + } + debug("base snap device %d:%d not found in existing mount ns", + major(base_snap_dev), minor(base_snap_dev)); + return true; +} + +static bool homedirs_are_mounted(sc_mountinfo *mi, char **homedirs, + int num_homedirs) +{ + if (num_homedirs == 0) { + return true; + } + + /* We know that the number of homedirs is not going to be huge, so let's + * just allocare this vector on the stack */ + bool homedir_seen[num_homedirs]; + for (int i = 0; i < num_homedirs; i++) { + homedir_seen[i] = false; + } + + sc_mountinfo_entry *mie; + for (mie = sc_first_mountinfo_entry(mi); mie != NULL; + mie = sc_next_mountinfo_entry(mie)) { + for (int i = 0; i < num_homedirs; i++) { + if (sc_streq(mie->mount_dir, homedirs[i])) { + homedir_seen[i] = true; + } + } + } + + bool all_seen = true; + for (int i = 0; i < num_homedirs; i++) { + if (!homedir_seen[i]) { + debug("Homedir %s missing from namespace", homedirs[i]); + all_seen = false; + break; + } + } + return all_seen; +} + +// Inspect the namespace and check if we should discard it. +static bool should_discard_current_ns(const struct sc_invocation *inv, + dev_t base_snap_dev) +{ + sc_mountinfo *mi SC_CLEANUP(sc_cleanup_mountinfo) = NULL; + + mi = sc_parse_mountinfo(NULL); + if (mi == NULL) { + die("cannot parse mountinfo of the current process"); + } + // The namespace may become "stale" when the rootfs is not the same + // device we found above. This will happen whenever the base snap is + // refreshed since the namespace was first created. + if (base_snap_device_changed(mi, base_snap_dev)) { + return true; + } + // Another reason for becoming stale is if the homedirs configuration has + // changed: so this code will check that all homedirs are mounted in the + // namespace. + if (!homedirs_are_mounted(mi, inv->homedirs, inv->num_homedirs)) { + return true; + } + + return false; +} + +enum sc_discard_vote { + /** + * SC_DISCARD_NO denotes that the mount namespace doesn't have to be + * discarded. This happens when the base snap has not changed. + **/ + SC_DISCARD_NO = 1, + /** + * SC_DISCARD_SHOULD indicates that the mount namespace should be discarded + * but may be reused if it is still inhabited by processes. This only + * happens when the base snap revision changes but the name of the base + * snap is the same as before. + **/ + SC_DISCARD_SHOULD = 2, + /** + * SC_DISCARD_MUST indicates that the mount namespace must be discarded + * even if it still inhabited by processes. This only happens when the name + * of the base snap changes. + **/ + SC_DISCARD_MUST = 3, +}; + +/** + * is_base_transition returns true if a base transition is occurring. + * + * The function inspects /run/snapd/ns/snap.$SNAP_INSTANCE_NAME.info as well + * as the invocation parameters of snap-confine. If the base snap name, as + * encoded in the info file and as described by the invocation parameters + * differ then a base transition is occurring. If the info file is absent or + * does not record the name of the base snap then transition cannot be + * detected. +**/ +static bool is_base_transition(const sc_invocation *inv) +{ + char info_path[PATH_MAX] = { 0 }; + sc_must_snprintf(info_path, + sizeof info_path, + "/run/snapd/ns/snap.%s.info", inv->snap_instance); + + FILE *stream SC_CLEANUP(sc_cleanup_file) = NULL; + stream = fopen(info_path, "r"); + if (stream == NULL && errno == ENOENT) { + // If the info file is absent then we cannot decide if a transition had + // occurred. For people upgrading from snap-confine without the info + // file, that is the best we can do. + return false; + } + if (stream == NULL) { + die("cannot open %s", info_path); + } + + char *base_snap_name SC_CLEANUP(sc_cleanup_string) = NULL; + sc_error *err = NULL; + if (sc_infofile_get_key + (stream, "base-snap-name", &base_snap_name, &err) < 0) { + sc_die_on_error(err); + } + + if (base_snap_name == NULL) { + // If the info file doesn't record the name of the base snap then, + // again, we cannot decide if a transition had occurred. + return false; + } + + return !sc_streq(inv->orig_base_snap_name, base_snap_name); +} + +static bool sc_is_mount_ns_in_use(const char *snap_instance); + +// The namespace may be stale. To check this we must actually switch into it +// but then we use up our setns call (the kernel misbehaves if we setns twice). +// To work around this we'll fork a child and use it to probe. The child will +// inspect the namespace and send information back via eventfd and then exit +// unconditionally. +static int sc_inspect_and_maybe_discard_stale_ns(int mnt_fd, + const sc_invocation *inv, + int snap_discard_ns_fd) +{ + char base_snap_rev[PATH_MAX] = { 0 }; + dev_t base_snap_dev; + int event_fd SC_CLEANUP(sc_cleanup_close) = -1; + + // Read the revision of the base snap by looking at the current symlink. + if (readlink(inv->rootfs_dir, base_snap_rev, sizeof base_snap_rev) < 0) { + die("cannot read current revision of snap %s", + inv->snap_instance); + } + if (base_snap_rev[sizeof base_snap_rev - 1] != '\0') { + die("cannot read current revision of snap %s: value too long", + inv->snap_instance); + } + // Find the device that is backing the current revision of the base snap. + base_snap_dev = + find_base_snap_device(inv->base_snap_name, base_snap_rev); + + // Store the PID of this process. This is done instead of calls to + // getppid() below because then we can reliably track the PID of the + // parent even if the child process is re-parented. + pid_t parent = getpid(); + + // Create an eventfd for the communication with the child. + event_fd = eventfd(0, EFD_CLOEXEC); + if (event_fd < 0) { + die("cannot create eventfd"); + } + // Fork a child, it will do the inspection for us. + pid_t child = fork(); + if (child < 0) { + die("cannot fork support process"); + } + + if (child == 0) { + // This is the child process which will inspect the mount namespace. + // + // Configure the child to die as soon as the parent dies. In an odd + // case where the parent is killed then we don't want to complete our + // task or wait for anything. + if (prctl(PR_SET_PDEATHSIG, SIGINT, 0, 0, 0) < 0) { + die("cannot set parent process death notification signal to SIGINT"); + } + // Check that parent process is still alive. If this is the case then + // we can *almost* reliably rely on the PR_SET_PDEATHSIG signal to wake + // us up from eventfd_read() below. In the rare case that the PID + // numbers overflow and the now-dead parent PID is recycled we will + // still hang forever on the read from eventfd below. + if (kill(parent, 0) < 0) { + switch (errno) { + case ESRCH: + debug("parent process has terminated"); + abort(); + default: + die("cannot confirm that parent process is alive"); + break; + } + } + + debug("joining preserved mount namespace for inspection"); + // Move to the mount namespace of the snap we're trying to inspect. + if (setns(mnt_fd, CLONE_NEWNS) < 0) { + die("cannot join preserved mount namespace"); + } + // Check if the namespace needs to be discarded. + eventfd_t value = SC_DISCARD_NO; + const char *value_str = "no"; + + // TODO: enable this for core distributions. This is complex because on + // core the rootfs is mounted in initrd and is _not_ changed (no + // pivot_root) and the base snap is again mounted (2nd time) by + // systemd. This makes us end up in a situation where the outer base + // snap will never match the rootfs inside the mount namespace. + if (inv->is_normal_mode + && should_discard_current_ns(inv, base_snap_dev)) { + value = SC_DISCARD_SHOULD; + value_str = "should"; + } + // If the base snap changed, we must discard the mount namespace and + // start over to allow the newly started process to see the requested + // base snap. Due to the TODO above always perform explicit transition + // check to protect against LP:#1819875 and LP:#1861901 + if (is_base_transition(inv)) { + // The base snap has changed. We must discard ... + value = SC_DISCARD_MUST; + value_str = "must"; + } + // Send this back to the parent: 3 - force discard 2 - prefer discard, 1 - keep. + // Note that we cannot just use 0 and 1 because of the semantics of eventfd(2). + if (eventfd_write(event_fd, value) < 0) { + die("cannot send information to %s preserved mount namespace", value_str); + } + // Exit, we're done. + exit(0); + } + // This is back in the parent process. + // + // Enable a sanity timeout in case the read blocks for unbound amount of + // time. This will ensure we will not hang around while holding the lock. + // Next, read the value written by the child process. + sc_enable_sanity_timeout(); + eventfd_t value = 0; + if (eventfd_read(event_fd, &value) < 0) { + die("cannot read from eventfd"); + } + sc_disable_sanity_timeout(); + + // Wait for the child process to exit and collect its exit status. + errno = 0; + int status = 0; + if (waitpid(child, &status, 0) < 0) { + die("cannot wait for the support process for mount namespace inspection"); + } + if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) { + die("support process for mount namespace inspection exited abnormally"); + } + // If the namespace is up-to-date then we are done. + switch (value) { + case SC_DISCARD_NO: + debug("preserved mount is not stale, reusing"); + return 0; + case SC_DISCARD_SHOULD: + if (sc_is_mount_ns_in_use(inv->snap_instance)) { + // Some processes are still using the namespace so we cannot discard it + // as that would fracture the view that the set of processes inside + // have on what is mounted. + debug + ("preserved mount namespace is stale but occupied, reusing"); + return 0; + } + break; + case SC_DISCARD_MUST: + debug + ("preserved mount namespace is stale and base snap has changed, discarding"); + break; + } + sc_call_snap_discard_ns(snap_discard_ns_fd, inv->snap_instance); + return EAGAIN; +} + +static void helper_fork(struct sc_mount_ns *group, + struct sc_apparmor *apparmor); +static void helper_main(struct sc_mount_ns *group, struct sc_apparmor *apparmor, + pid_t parent); +static void helper_capture_ns(struct sc_mount_ns *group, pid_t parent); +static void helper_capture_per_user_ns(struct sc_mount_ns *group, pid_t parent); + +int sc_join_preserved_ns(struct sc_mount_ns *group, struct sc_apparmor + *apparmor, const sc_invocation *inv, + int snap_discard_ns_fd) +{ + // Open the mount namespace file. + char mnt_fname[PATH_MAX] = { 0 }; + sc_must_snprintf(mnt_fname, sizeof mnt_fname, "%s.mnt", group->name); + int mnt_fd SC_CLEANUP(sc_cleanup_close) = -1; + // NOTE: There is no O_EXCL here because the file can be around but + // doesn't have to be a mounted namespace. + mnt_fd = openat(group->dir_fd, mnt_fname, + O_RDONLY | O_CLOEXEC | O_NOFOLLOW, 0600); + if (mnt_fd < 0 && errno == ENOENT) { + return ESRCH; + } + if (mnt_fd < 0) { + die("cannot open preserved mount namespace %s", group->name); + } + // Check if we got an nsfs-based or procfs file or a regular file. This can + // be reliably tested because nsfs has an unique filesystem type + // NSFS_MAGIC. On older kernels that don't support nsfs yet we can look + // for PROC_SUPER_MAGIC instead. + // We can just ensure that this is the case thanks to fstatfs. + struct statfs ns_statfs_buf; + if (fstatfs(mnt_fd, &ns_statfs_buf) < 0) { + die("cannot inspect filesystem of preserved mount namespace file"); + } + // Stat the mount namespace as well, this is later used to check if the + // namespace is used by other processes if we are considering discarding a + // stale namespace. + struct stat ns_stat_buf; + if (fstat(mnt_fd, &ns_stat_buf) < 0) { + die("cannot inspect preserved mount namespace file"); + } +#ifndef NSFS_MAGIC +// Account for kernel headers old enough to not know about NSFS_MAGIC. +#define NSFS_MAGIC 0x6e736673 +#endif + if (ns_statfs_buf.f_type == NSFS_MAGIC + || ns_statfs_buf.f_type == PROC_SUPER_MAGIC) { + + // Inspect and perhaps discard the preserved mount namespace. + if (sc_inspect_and_maybe_discard_stale_ns + (mnt_fd, inv, snap_discard_ns_fd) == EAGAIN) { + return ESRCH; + } + // Move to the mount namespace of the snap we're trying to start. + if (setns(mnt_fd, CLONE_NEWNS) < 0) { + die("cannot join preserved mount namespace %s", + group->name); + } + debug("joined preserved mount namespace %s", group->name); + return 0; + } + return ESRCH; +} + +int sc_join_preserved_per_user_ns(struct sc_mount_ns *group, + const char *snap_name) +{ + uid_t uid = getuid(); + char mnt_fname[PATH_MAX] = { 0 }; + sc_must_snprintf(mnt_fname, sizeof mnt_fname, "%s.%d.mnt", group->name, + (int)uid); + + int mnt_fd SC_CLEANUP(sc_cleanup_close) = -1; + mnt_fd = openat(group->dir_fd, mnt_fname, + O_RDONLY | O_CLOEXEC | O_NOFOLLOW, 0600); + if (mnt_fd < 0 && errno == ENOENT) { + return ESRCH; + } + if (mnt_fd < 0) { + die("cannot open preserved mount namespace %s", group->name); + } + struct statfs ns_statfs_buf; + if (fstatfs(mnt_fd, &ns_statfs_buf) < 0) { + die("cannot inspect filesystem of preserved mount namespace file"); + } + struct stat ns_stat_buf; + if (fstat(mnt_fd, &ns_stat_buf) < 0) { + die("cannot inspect preserved mount namespace file"); + } +#ifndef NSFS_MAGIC + /* Define NSFS_MAGIC for Ubuntu 14.04 and other older systems. */ +#define NSFS_MAGIC 0x6e736673 +#endif + if (ns_statfs_buf.f_type == NSFS_MAGIC + || ns_statfs_buf.f_type == PROC_SUPER_MAGIC) { + if (setns(mnt_fd, CLONE_NEWNS) < 0) { + die("cannot join preserved per-user mount namespace %s", + group->name); + } + debug("joined preserved mount namespace %s", group->name); + return 0; + } + return ESRCH; +} + +static void setup_signals_for_helper(void) +{ + /* Ignore the SIGPIPE signal so that we get EPIPE on the read / write + * operations attempting to work with a closed pipe. This ensures that we + * are not killed by the default disposition (terminate) and can return a + * non-signal-death return code to the program invoking snap-confine. */ + if (signal(SIGPIPE, SIG_IGN) == SIG_ERR) { + die("cannot install ignore handler for SIGPIPE"); + } +} + +static void teardown_signals_for_helper(void) +{ + /* Undo operations done by setup_signals_for_helper. */ + if (signal(SIGPIPE, SIG_DFL) == SIG_ERR) { + die("cannot restore default handler for SIGPIPE"); + } +} + +static void helper_fork(struct sc_mount_ns *group, struct sc_apparmor *apparmor) +{ + // Create a pipe for sending commands to the helper process. + if (pipe2(group->pipe_master, O_CLOEXEC | O_DIRECT) < 0) { + die("cannot create pipes for commanding the helper process"); + } + if (pipe2(group->pipe_helper, O_CLOEXEC | O_DIRECT) < 0) { + die("cannot create pipes for responding to master process"); + } + // Store the PID of the "parent" process. This done instead of calls to + // getppid() because then we can reliably track the PID of the parent even + // if the child process is re-parented. + pid_t parent = getpid(); + + // For rationale of forking see this: + // https://lists.linuxfoundation.org/pipermail/containers/2013-August/033386.html + pid_t pid = fork(); + if (pid < 0) { + die("cannot fork helper process for mount namespace capture"); + } + if (pid == 0) { + /* helper */ + sc_cleanup_close(&group->pipe_master[1]); + sc_cleanup_close(&group->pipe_helper[0]); + helper_main(group, apparmor, parent); + } else { + setup_signals_for_helper(); + + /* master */ + sc_cleanup_close(&group->pipe_master[0]); + sc_cleanup_close(&group->pipe_helper[1]); + + // Glibc defines pid as a signed 32bit integer. There's no standard way to + // print pid's portably so this is the best we can do. + debug("forked support process %d", (int)pid); + group->child = pid; + } +} + +static void helper_main(struct sc_mount_ns *group, struct sc_apparmor *apparmor, + pid_t parent) +{ + // This is the child process which will capture the mount namespace. + // + // It will do so by bind-mounting the .mnt after the parent process calls + // unshare() and finishes setting up the namespace completely. Change the + // hat to a sub-profile that has limited permissions necessary to + // accomplish the capture of the mount namespace. + sc_maybe_aa_change_hat(apparmor, "mount-namespace-capture-helper", 0); + // Configure the child to die as soon as the parent dies. In an odd + // case where the parent is killed then we don't want to complete our + // task or wait for anything. + if (prctl(PR_SET_PDEATHSIG, SIGINT, 0, 0, 0) < 0) { + die("cannot set parent process death notification signal to SIGINT"); + } + // Check that parent process is still alive. If this is the case then we + // can *almost* reliably rely on the PR_SET_PDEATHSIG signal to wake us up + // from read(2) below. In the rare case that the PID numbers overflow and + // the now-dead parent PID is recycled we will still hang forever on the + // read from the pipe below. + if (kill(parent, 0) < 0) { + switch (errno) { + case ESRCH: + // When snap-confine executes it will fork a helper process. That + // process establishes an elaborate dance to ensure both itself and + // the parent are operating exactly as specified, so that no + // processes are left behind for unbound amount of time. As a part + // of that dance the child pings the parent to ensure it is still + // alive after establishing a notification signal to be sent in + // case the parent dies. This is a race avoidance mechanism, we set + // up the notification and then check if the parent is alive by the + // time we are done. + // + // In the case when the parent does go away we used to call + // abort(). On some distributions this would trigger an unclean + // process termination error report to be sent. One such example is + // the Ubuntu error tracker. Since the parent process can be + // legitimately interrupted and killed, this should not generate an + // error report. As such, perform clean exit in this specific case. + debug("parent process has terminated"); + exit(0); + default: + die("cannot confirm that parent process is alive"); + break; + } + } + if (fchdir(group->dir_fd) < 0) { + die("cannot move to directory with preserved namespaces"); + } + int command = -1; + int run = 1; + while (run) { + debug("helper process waiting for command"); + sc_enable_sanity_timeout(); + if (read(group->pipe_master[0], &command, sizeof command) < 0) { + int saved_errno = errno; + // This will ensure we get the correct error message + // if there is a read error because the timeout + // expired. + sc_disable_sanity_timeout(); + errno = saved_errno; + die("cannot read command from the pipe"); + } + sc_disable_sanity_timeout(); + debug("helper process received command %d", command); + switch (command) { + case HELPER_CMD_EXIT: + run = 0; + break; + case HELPER_CMD_CAPTURE_MOUNT_NS: + helper_capture_ns(group, parent); + break; + case HELPER_CMD_CAPTURE_PER_USER_MOUNT_NS: + helper_capture_per_user_ns(group, parent); + break; + } + if (write(group->pipe_helper[1], &command, sizeof command) < 0) { + die("cannot write ack"); + } + } + debug("helper process exiting"); + exit(0); +} + +static void helper_capture_ns(struct sc_mount_ns *group, pid_t parent) +{ + char src[PATH_MAX] = { 0 }; + char dst[PATH_MAX] = { 0 }; + + debug("capturing per-snap mount namespace"); + sc_must_snprintf(src, sizeof src, "/proc/%d/ns/mnt", (int)parent); + sc_must_snprintf(dst, sizeof dst, "%s.mnt", group->name); + + /* Ensure the bind mount destination exists. */ + int fd = open(dst, O_CREAT | O_CLOEXEC | O_NOFOLLOW | O_RDONLY, 0600); + if (fd < 0) { + die("cannot create file %s", dst); + } + close(fd); + + if (mount(src, dst, NULL, MS_BIND, NULL) < 0) { + die("cannot preserve mount namespace of process %d as %s", + (int)parent, dst); + } + debug("mount namespace of process %d preserved as %s", + (int)parent, dst); +} + +static void helper_capture_per_user_ns(struct sc_mount_ns *group, pid_t parent) +{ + char src[PATH_MAX] = { 0 }; + char dst[PATH_MAX] = { 0 }; + uid_t uid = getuid(); + + debug("capturing per-snap, per-user mount namespace"); + sc_must_snprintf(src, sizeof src, "/proc/%d/ns/mnt", (int)parent); + sc_must_snprintf(dst, sizeof dst, "%s.%d.mnt", group->name, (int)uid); + + /* Ensure the bind mount destination exists. */ + int fd = open(dst, O_CREAT | O_CLOEXEC | O_NOFOLLOW | O_RDONLY, 0600); + if (fd < 0) { + die("cannot create file %s", dst); + } + close(fd); + + if (mount(src, dst, NULL, MS_BIND, NULL) < 0) { + die("cannot preserve per-user mount namespace of process %d as %s", (int)parent, dst); + } + debug("per-user mount namespace of process %d preserved as %s", + (int)parent, dst); +} + +static void sc_message_capture_helper(struct sc_mount_ns *group, int command_id) +{ + int ack; + if (group->child == 0) { + die("precondition failed: we don't have a helper process"); + } + if (group->pipe_master[1] < 0) { + die("precondition failed: we don't have a pipe"); + } + if (group->pipe_helper[0] < 0) { + die("precondition failed: we don't have a pipe"); + } + debug("sending command %d to helper process (pid: %d)", + command_id, group->child); + if (write(group->pipe_master[1], &command_id, sizeof command_id) < 0) { + die("cannot send command %d to helper process", command_id); + } + debug("waiting for response from helper"); + int read_n = read(group->pipe_helper[0], &ack, sizeof ack); + if (read_n < 0) { + die("cannot receive ack from helper process"); + } + if (read_n == 0) { + die("unexpected eof from helper process"); + } +} + +static void sc_wait_for_capture_helper(struct sc_mount_ns *group) +{ + if (group->child == 0) { + die("precondition failed: we don't have a helper process"); + } + debug("waiting for the helper process to exit"); + int status = 0; + errno = 0; + if (waitpid(group->child, &status, 0) < 0) { + die("cannot wait for the helper process"); + } + if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) { + die("helper process exited abnormally"); + } + debug("helper process exited normally"); + group->child = 0; + teardown_signals_for_helper(); +} + +void sc_fork_helper(struct sc_mount_ns *group, struct sc_apparmor *apparmor) +{ + helper_fork(group, apparmor); +} + +void sc_preserve_populated_mount_ns(struct sc_mount_ns *group) +{ + sc_message_capture_helper(group, HELPER_CMD_CAPTURE_MOUNT_NS); +} + +void sc_preserve_populated_per_user_mount_ns(struct sc_mount_ns *group) +{ + sc_message_capture_helper(group, HELPER_CMD_CAPTURE_PER_USER_MOUNT_NS); +} + +void sc_wait_for_helper(struct sc_mount_ns *group) +{ + sc_message_capture_helper(group, HELPER_CMD_EXIT); + sc_wait_for_capture_helper(group); +} + +void sc_store_ns_info(const sc_invocation *inv) +{ + FILE *stream SC_CLEANUP(sc_cleanup_file) = NULL; + char info_path[PATH_MAX] = { 0 }; + sc_must_snprintf(info_path, sizeof info_path, + "/run/snapd/ns/snap.%s.info", inv->snap_instance); + int fd = -1; + fd = open(info_path, + O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC | O_NOFOLLOW, 0644); + if (fd < 0) { + die("cannot open %s", info_path); + } + if (fchown(fd, 0, 0) < 0) { + die("cannot chown %s to root.root", info_path); + } + // The stream now owns the file descriptor. + stream = fdopen(fd, "w"); + if (stream == NULL) { + die("cannot get stream from file descriptor"); + } + fprintf(stream, "base-snap-name=%s\n", inv->orig_base_snap_name); + if (ferror(stream) != 0) { + die("I/O error when writing to %s", info_path); + } + if (fflush(stream) == EOF) { + die("cannot flush %s", info_path); + } + debug("saved mount namespace meta-data to %s", info_path); +} + +bool sc_is_mount_ns_in_use(const char *snap_instance) +{ + // perform an indirect check of whether the mount namespace is occupied, + // with cgroups v1, each snap process is attached to a group under the + // freezer controller, however with cgroups v2, we must check for any groups + // tracking the snap + bool occupied = false; + if (sc_cgroup_is_v2()) { + // cgroup v2 must consult the tracking groups + occupied = sc_cgroup_v2_is_tracking_snap(snap_instance); + } else { + occupied = sc_cgroup_freezer_occupied(snap_instance); + } + return occupied; +} diff --git a/cmd/snap-confine/ns-support.h b/cmd/snap-confine/ns-support.h new file mode 100644 index 00000000..9752eecc --- /dev/null +++ b/cmd/snap-confine/ns-support.h @@ -0,0 +1,153 @@ +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#ifndef SNAP_NAMESPACE_SUPPORT +#define SNAP_NAMESPACE_SUPPORT + +#include + +#include "../libsnap-confine-private/apparmor-support.h" +#include "snap-confine-invocation.h" + +/** + * Re-associate the current process with the mount namespace of pid 1. + * + * This function inspects the mount namespace of the current process and that + * of pid 1. In case they differ the current process is re-associated with the + * mount namespace of pid 1. + * + * This function should be called before sc_initialize_mount_ns(). + **/ +void sc_reassociate_with_pid1_mount_ns(void); + +/** + * Initialize namespace sharing. + * + * This function must be called once in each process that wishes to create or + * join a namespace group. + * + * It is responsible for bind mounting the control directory over itself and + * making it private (unsharing it with all the other peers) so that it can be + * used for storing preserved namespaces as bind-mounted files from the nsfs + * filesystem (namespace filesystem). + * + * This function should be called with a global lock (see sc_lock_global) held + * to ensure that no other instance of snap-confine attempts to do this + * concurrently. + * + * This function inspects /proc/self/mountinfo to determine if the directory + * where namespaces are kept (/run/snapd/ns) is correctly prepared as described + * above. + * + * Experimental features can be enabled via optional feature flags. + * + * For more details see namespaces(7). + **/ +void sc_initialize_mount_ns(unsigned int experimental_features); + +/** + * Data required to manage namespaces amongst a group of processes. + */ +struct sc_mount_ns; + +/** + * Open a namespace group. + * + * This will open and keep file descriptors for /run/snapd/ns/. + * + * The following methods should be called only while holding a lock protecting + * that specific snap namespace: + * - sc_create_or_join_mount_ns() + * - sc_preserve_populated_mount_ns() + */ +struct sc_mount_ns *sc_open_mount_ns(const char *group_name); + +/** + * Close namespace group. + * + * This will close all of the open file descriptors and release allocated memory. + */ +void sc_close_mount_ns(struct sc_mount_ns *group); + +/** + * Join a preserved mount namespace if one exists. + * + * Technically the function opens /run/snapd/ns/${group_name}.mnt and tries to + * use setns() with the obtained file descriptor. + * + * If the preserved mount namespace does not exist or exists but is stale and + * was discarded the function returns ESRCH. If the mount namespace was joined + * it returns zero. + **/ +int sc_join_preserved_ns(struct sc_mount_ns *group, struct sc_apparmor + *apparmor, const sc_invocation * inv, + int snap_discard_ns_fd); + +/** + * Join a preserved, per-user, mount namespace if one exists. + * + * Technically the function opens /run/snapd/ns/snap.$SNAP_NAME.$UID.mnt and + * tries to use setns() with the obtained file descriptor. + * + * The return is ESRCH if a preserved per-user mount namespace does not exist + * and cannot be joined or zero otherwise. +**/ +int sc_join_preserved_per_user_ns(struct sc_mount_ns *group, + const char *snap_name); + +/** + * Fork off a helper process for mount namespace capture. + * + * This function forks the helper process. It needs to be paired with + * sc_wait_for_helper which instructs the helper to shut down and waits for + * that to happen. + * + * For rationale for forking and using a helper process please see + * https://lists.linuxfoundation.org/pipermail/containers/2013-August/033386.html + **/ +void sc_fork_helper(struct sc_mount_ns *group, struct sc_apparmor *apparmor); + +/** + * Preserve prepared namespace group. + * + * This function signals the child support process for namespace capture to + * perform the capture. + * + * Technically this function writes to pipe that causes the child process to + * wake up and bind mount /proc/$ppid/ns/mnt to + * /run/snapd/ns/${group_name}.mnt. + * + * The helper process will wait for subsequent commands. Please call + * sc_wait_for_helper() to terminate it. + **/ +void sc_preserve_populated_mount_ns(struct sc_mount_ns *group); + +void sc_preserve_populated_per_user_mount_ns(struct sc_mount_ns *group); + +/** + * Ask the helper process to terminate and wait for it to finish. + * + * This function asks the helper process to exit by writing an appropriate + * command to the pipe used for the inter process communication between the + * main snap-confine process and the helper and then waits for the process to + * terminate cleanly. + **/ +void sc_wait_for_helper(struct sc_mount_ns *group); + +void sc_store_ns_info(const sc_invocation * inv); + +#endif diff --git a/cmd/snap-confine/seccomp-support-ext.c b/cmd/snap-confine/seccomp-support-ext.c new file mode 100644 index 00000000..05edc04e --- /dev/null +++ b/cmd/snap-confine/seccomp-support-ext.c @@ -0,0 +1,98 @@ +/* + * Copyright (C) 2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +#ifdef HAVE_CONFIG_H +#include "config.h" +#endif + +#include "seccomp-support-ext.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "../libsnap-confine-private/utils.h" + +#ifndef SECCOMP_FILTER_FLAG_LOG +#define SECCOMP_FILTER_FLAG_LOG 2 +#endif + +#ifndef seccomp +// prototype because we build with -Wstrict-prototypes +int seccomp(unsigned int operation, unsigned int flags, void *args); + +int seccomp(unsigned int operation, unsigned int flags, void *args) { + errno = 0; + return syscall(__NR_seccomp, operation, flags, args); +} +#endif + +size_t sc_read_seccomp_filter(const char *filename, char *buf, size_t buf_size) { + if (buf_size == 0) { + die("seccomp load buffer cannot be empty"); + } + FILE *file = fopen(filename, "rb"); + if (file == NULL) { + die("cannot open seccomp filter %s", filename); + } + size_t num_read = fread(buf, 1, buf_size - 1, file); + buf[num_read] = '\0'; + if (ferror(file) != 0) { + die("cannot read seccomp profile %s", filename); + } + if (feof(file) == 0) { + die("cannot fit seccomp profile %s to memory buffer", filename); + } + fclose(file); + debug("read %zu bytes from %s", num_read, filename); + return num_read; +} + +void sc_apply_seccomp_filter(struct sock_fprog *prog) { + int err; + + // Load filter into the kernel (by this point we have dropped to the + // calling user but still retain CAP_SYS_ADMIN). + // + // Importantly we are intentionally *not* setting NO_NEW_PRIVS because it + // interferes with exec transitions in AppArmor with certain snapd + // interfaces. Not setting NO_NEW_PRIVS does mean that applications can + // adjust their sandbox if they have CAP_SYS_ADMIN or, if running on < 4.8 + // kernels, break out of the seccomp via ptrace. Both CAP_SYS_ADMIN and + // 'ptrace (trace)' are blocked by AppArmor with typical snapd interfaces. + err = seccomp(SECCOMP_SET_MODE_FILTER, SECCOMP_FILTER_FLAG_LOG, prog); + if (err != 0) { + /* The profile may fail to load using the "modern" interface. + * In such case use the older prctl-based interface instead. */ + switch (errno) { + case ENOSYS: + debug("kernel doesn't support the seccomp(2) syscall"); + break; + case EINVAL: + debug("kernel may not support the SECCOMP_FILTER_FLAG_LOG flag"); + break; + } + debug("falling back to prctl(2) syscall to load seccomp filter"); + err = prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, prog); + if (err != 0) { + die("cannot apply seccomp profile"); + } + } +} diff --git a/cmd/snap-confine/seccomp-support-ext.h b/cmd/snap-confine/seccomp-support-ext.h new file mode 100644 index 00000000..a3a028f9 --- /dev/null +++ b/cmd/snap-confine/seccomp-support-ext.h @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +#ifndef SNAP_CONFINE_SECCOMP_SUPPORT_EXT_H +#define SNAP_CONFINE_SECCOMP_SUPPORT_EXT_H + +#include +#include + +size_t sc_read_seccomp_filter(const char *filename, char *buf, size_t buf_size); + +/** + * Apply a given bpf program as a seccomp system call filter. + **/ +void sc_apply_seccomp_filter(struct sock_fprog *prog); + +#endif diff --git a/cmd/snap-confine/seccomp-support-test.c b/cmd/snap-confine/seccomp-support-test.c new file mode 100644 index 00000000..078b0c63 --- /dev/null +++ b/cmd/snap-confine/seccomp-support-test.c @@ -0,0 +1,193 @@ +/* + * Copyright (C) 2024 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "seccomp-support-ext.c" +#include "seccomp-support.c" + +#include +#include + +static void make_seccomp_profile(struct sc_seccomp_file_header *hdr, int *fd, + char **fname) +{ + *fd = g_file_open_tmp(NULL, fname, NULL); + g_assert_true(*fd > 0); + int written = write(*fd, hdr, sizeof(struct sc_seccomp_file_header)); + g_assert_true(written == sizeof(struct sc_seccomp_file_header)); +} + +static void test_must_read_and_validate_header_from_file__happy(void) +{ + struct sc_seccomp_file_header hdr = { + .header[0] = 'S', + .header[1] = 'C', + .version = 1, + }; + char SC_CLEANUP(sc_cleanup_string) * profile = NULL; + int SC_CLEANUP(sc_cleanup_close) fd = 0; + make_seccomp_profile(&hdr, &fd, &profile); + + FILE *file SC_CLEANUP(sc_cleanup_file) = fopen(profile, "rb"); + sc_must_read_and_validate_header_from_file(file, profile, &hdr); + g_assert_true(file != NULL); +} + +static void test_must_read_and_validate_header_from_file__missing_file(void) +{ + struct sc_seccomp_file_header hdr; + const char *profile = "/path/to/missing/file"; + const char *expected_err = + "cannot open seccomp filter /path/to/missing/file: No such file or directory\n"; + + if (g_test_subprocess()) { + FILE *file SC_CLEANUP(sc_cleanup_file) = fopen(profile, "rb"); + sc_must_read_and_validate_header_from_file(file, profile, &hdr); + // the function above is expected to call die() + g_assert_not_reached(); + // reference "file" to keep the compiler from warning + // that "file" is unused + g_assert_null(file); + } + g_test_trap_subprocess(NULL, 0, 0); + g_test_trap_assert_failed(); + g_test_trap_assert_stderr(expected_err); +} + +static void must_read_and_validate_header_from_file_dies_with(struct + sc_seccomp_file_header + hdr, const char + *err_msg) +{ + if (g_test_subprocess()) { + char SC_CLEANUP(sc_cleanup_string) * profile = NULL; + int SC_CLEANUP(sc_cleanup_close) fd = 0; + make_seccomp_profile(&hdr, &fd, &profile); + + FILE *file SC_CLEANUP(sc_cleanup_file) = fopen(profile, "rb"); + sc_must_read_and_validate_header_from_file(file, profile, &hdr); + // the function above is expected to call die() + g_assert_not_reached(); + // reference "file" to keep the compiler from warning + // that "file" is unused + g_assert_null(file); + } + + g_test_trap_subprocess(NULL, 0, 0); + g_test_trap_assert_failed(); + g_test_trap_assert_stderr(err_msg); +} + +static void test_must_read_and_validate_header_from_file__invalid_header(void) +{ + // when we stop supporting 14.04 we could just use hdr = {0} + struct sc_seccomp_file_header hdr; + memset(&hdr, 0, sizeof hdr); + const char *expected_err = "unexpected seccomp header: 00\n"; + must_read_and_validate_header_from_file_dies_with(hdr, expected_err); +} + +static void test_must_read_and_validate_header_from_file__invalid_version(void) +{ + struct sc_seccomp_file_header hdr = { + .header[0] = 'S', + .header[1] = 'C', + .version = 0, + }; + const char *expected_err = "unexpected seccomp file version: 0\n"; + must_read_and_validate_header_from_file_dies_with(hdr, expected_err); +} + +static void +test_must_read_and_validate_header_from_file__len_allow_too_big(void) +{ + struct sc_seccomp_file_header hdr = { + .header[0] = 'S', + .header[1] = 'C', + .version = 1, + .len_allow_filter = MAX_BPF_SIZE + 1, + }; + const char *expected_err = "allow filter size too big 32769\n"; + must_read_and_validate_header_from_file_dies_with(hdr, expected_err); +} + +static void +test_must_read_and_validate_header_from_file__len_allow_no_multiplier(void) +{ + struct sc_seccomp_file_header hdr = { + .header[0] = 'S', + .header[1] = 'C', + .version = 1, + .len_allow_filter = sizeof(struct sock_filter) + 1, + }; + const char *expected_err = + "allow filter size not multiple of sock_filter\n"; + must_read_and_validate_header_from_file_dies_with(hdr, expected_err); +} + +static void test_must_read_and_validate_header_from_file__len_deny_too_big(void) +{ + struct sc_seccomp_file_header hdr = { + .header[0] = 'S', + .header[1] = 'C', + .version = 1, + .len_deny_filter = MAX_BPF_SIZE + 1, + }; + const char *expected_err = "deny filter size too big 32769\n"; + must_read_and_validate_header_from_file_dies_with(hdr, expected_err); +} + +static void +test_must_read_and_validate_header_from_file__len_deny_no_multiplier(void) +{ + struct sc_seccomp_file_header hdr = { + .header[0] = 'S', + .header[1] = 'C', + .version = 1, + .len_deny_filter = sizeof(struct sock_filter) + 1, + }; + const char *expected_err = + "deny filter size not multiple of sock_filter\n"; + must_read_and_validate_header_from_file_dies_with(hdr, expected_err); +} + +static void __attribute__((constructor)) init(void) +{ + g_test_add_func + ("/seccomp/must_read_and_validate_header_from_file/happy", + test_must_read_and_validate_header_from_file__happy); + g_test_add_func + ("/seccomp/must_read_and_validate_header_from_file/missing_file", + test_must_read_and_validate_header_from_file__missing_file); + g_test_add_func + ("/seccomp/must_read_and_validate_header_from_file/invalid_header", + test_must_read_and_validate_header_from_file__invalid_header); + g_test_add_func + ("/seccomp/must_read_and_validate_header_from_file/invalid_version", + test_must_read_and_validate_header_from_file__invalid_version); + g_test_add_func + ("/seccomp/must_read_and_validate_header_from_file/len_allow_too_big", + test_must_read_and_validate_header_from_file__len_allow_too_big); + g_test_add_func + ("/seccomp/must_read_and_validate_header_from_file/len_allow_no_multiplier", + test_must_read_and_validate_header_from_file__len_allow_no_multiplier); + g_test_add_func + ("/seccomp/must_read_and_validate_header_from_file/len_deny_too_big", + test_must_read_and_validate_header_from_file__len_deny_too_big); + g_test_add_func + ("/seccomp/must_read_and_validate_header_from_file/len_deny_no_multiplier", + test_must_read_and_validate_header_from_file__len_deny_no_multiplier); +} diff --git a/cmd/snap-confine/seccomp-support.c b/cmd/snap-confine/seccomp-support.c new file mode 100644 index 00000000..26c3ee45 --- /dev/null +++ b/cmd/snap-confine/seccomp-support.c @@ -0,0 +1,301 @@ +/* + * Copyright (C) 2015-2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +#include "config.h" +#include "seccomp-support.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "../libsnap-confine-private/cleanup-funcs.h" +#include "../libsnap-confine-private/secure-getenv.h" +#include "../libsnap-confine-private/string-utils.h" +#include "../libsnap-confine-private/utils.h" + +#include "seccomp-support-ext.h" + +static const char *filter_profile_dir = "/var/lib/snapd/seccomp/bpf/"; + +// MAX_BPF_SIZE is an arbitrary limit. +#define MAX_BPF_SIZE (32 * 1024) + +typedef struct sock_filter bpf_instr; + +// Keep in sync with snap-seccomp/main.go +// +// Header of a seccomp.bin2 filter file in native byte order. +struct __attribute__((__packed__)) sc_seccomp_file_header { + // header: "SC" + char header[2]; + // version: 0x1 + uint8_t version; + // flags + uint8_t unrestricted; + // unused + uint8_t padding[4]; + + // size of allow filter in byte + uint32_t len_allow_filter; + // size of deny filter in byte + uint32_t len_deny_filter; + // reserved for future use + uint8_t reserved2[112]; +}; + +static_assert(sizeof(struct sc_seccomp_file_header) == 128, + "unexpected struct size"); + +static void validate_path_has_strict_perms(const char *path) +{ + struct stat stat_buf; + if (stat(path, &stat_buf) < 0) { + die("cannot stat %s", path); + } + + errno = 0; + if (stat_buf.st_uid != 0 || stat_buf.st_gid != 0) { + die("%s not root-owned %i:%i", path, stat_buf.st_uid, + stat_buf.st_gid); + } + + if (stat_buf.st_mode & S_IWOTH) { + die("%s has 'other' write %o", path, stat_buf.st_mode); + } +} + +static void validate_bpfpath_is_safe(const char *path) +{ + if (path == NULL || strlen(path) == 0 || path[0] != '/') { + die("valid_bpfpath_is_safe needs an absolute path as input"); + } + // strtok_r() modifies its first argument, so work on a copy + char *tokenized SC_CLEANUP(sc_cleanup_string) = NULL; + tokenized = sc_strdup(path); + // allocate a string large enough to hold path, and initialize it to + // '/' + size_t checked_path_size = strlen(path) + 1; + char *checked_path SC_CLEANUP(sc_cleanup_string) = NULL; + checked_path = calloc(checked_path_size, 1); + if (checked_path == NULL) { + die("cannot allocate memory for checked_path"); + } + + checked_path[0] = '/'; + checked_path[1] = '\0'; + + // validate '/' + validate_path_has_strict_perms(checked_path); + + // strtok_r needs a pointer to keep track of where it is in the + // string. + char *buf_saveptr = NULL; + + // reconstruct the path from '/' down to profile_name + char *buf_token = strtok_r(tokenized, "/", &buf_saveptr); + while (buf_token != NULL) { + char *prev SC_CLEANUP(sc_cleanup_string) = NULL; + prev = sc_strdup(checked_path); // needed by vsnprintf in sc_must_snprintf + // append '' if checked_path is '/', otherwise '/' + if (strlen(checked_path) == 1) { + sc_must_snprintf(checked_path, checked_path_size, + "%s%s", prev, buf_token); + } else { + sc_must_snprintf(checked_path, checked_path_size, + "%s/%s", prev, buf_token); + } + validate_path_has_strict_perms(checked_path); + + buf_token = strtok_r(NULL, "/", &buf_saveptr); + } +} + +static void sc_cleanup_sock_fprog(struct sock_fprog *prog) +{ + free(prog->filter); + prog->filter = NULL; +} + +static void sc_must_read_filter_from_file(FILE *file, uint32_t len_bytes, + char *what, struct sock_fprog *prog) +{ + if (len_bytes == 0) { + die("%s filter may only be empty in unrestricted profiles", + what); + } + prog->len = len_bytes / sizeof(struct sock_filter); + prog->filter = malloc(len_bytes); + if (prog->filter == NULL) { + die("cannot allocate %u bytes of memory for %s seccomp filter ", + len_bytes, what); + } + size_t num_read = + fread(prog->filter, 1, prog->len * sizeof(struct sock_filter), + file); + if (ferror(file)) { + die("cannot read %s filter", what); + } + if (num_read != len_bytes) { + die("short read for filter %s %zu != %i", what, num_read, + len_bytes); + } +} + +static void sc_must_read_and_validate_header_from_file(FILE *file, + const char *profile_path, + struct + sc_seccomp_file_header + *hdr) +{ + if (file == NULL) { + die("cannot open seccomp filter %s", profile_path); + } + size_t num_read = + fread(hdr, 1, sizeof(struct sc_seccomp_file_header), file); + if (ferror(file) != 0) { + die("cannot read seccomp profile %s", profile_path); + } + if (num_read < sizeof(struct sc_seccomp_file_header)) { + die("short read on seccomp header: %zu", num_read); + } + if (hdr->header[0] != 'S' || hdr->header[1] != 'C') { + die("unexpected seccomp header: %x%x", hdr->header[0], + hdr->header[1]); + } + if (hdr->version != 1) { + die("unexpected seccomp file version: %x", hdr->version); + } + if (hdr->len_allow_filter > MAX_BPF_SIZE) { + die("allow filter size too big %u", hdr->len_allow_filter); + } + if (hdr->len_allow_filter % sizeof(struct sock_filter) != 0) { + die("allow filter size not multiple of sock_filter"); + } + if (hdr->len_deny_filter > MAX_BPF_SIZE) { + die("deny filter size too big %u", hdr->len_deny_filter); + } + if (hdr->len_deny_filter % sizeof(struct sock_filter) != 0) { + die("deny filter size not multiple of sock_filter"); + } + struct stat stat_buf; + if (fstat(fileno(file), &stat_buf) != 0) { + die("cannot fstat the seccomp file"); + } + off_t expected_size = + sizeof(struct sc_seccomp_file_header) + hdr->len_allow_filter + + hdr->len_deny_filter; + if (stat_buf.st_size != expected_size) { + die("unexpected filesize %ju != %ju", stat_buf.st_size, + expected_size); + } +} + +bool sc_apply_seccomp_profile_for_security_tag(const char *security_tag) +{ + debug("loading bpf program for security tag %s", security_tag); + + char profile_path[PATH_MAX] = { 0 }; + struct sock_fprog SC_CLEANUP(sc_cleanup_sock_fprog) prog_allow = { 0 }; + struct sock_fprog SC_CLEANUP(sc_cleanup_sock_fprog) prog_deny = { 0 }; + sc_must_snprintf(profile_path, sizeof(profile_path), "%s/%s.bin2", + filter_profile_dir, security_tag); + + // Wait some time for the security profile to show up. When + // the system boots snapd will created security profiles, but + // a service snap (e.g. network-manager) starts in parallel with + // snapd so for such snaps, the profiles may not be generated + // yet + long max_wait = 120; + const char *MAX_PROFILE_WAIT = getenv("SNAP_CONFINE_MAX_PROFILE_WAIT"); + if (MAX_PROFILE_WAIT != NULL) { + char *endptr = NULL; + errno = 0; + long env_max_wait = strtol(MAX_PROFILE_WAIT, &endptr, 10); + if (errno != 0 || MAX_PROFILE_WAIT == endptr || *endptr != '\0' + || env_max_wait <= 0) { + die("SNAP_CONFINE_MAX_PROFILE_WAIT invalid"); + } + max_wait = env_max_wait > 0 ? env_max_wait : max_wait; + } + if (max_wait > 3600) { + max_wait = 3600; + } + + if (!sc_wait_for_file(profile_path, max_wait)) { + /* log but proceed, we'll die a bit later */ + debug("timeout waiting for seccomp binary profile file at %s", + profile_path); + } + // TODO: move over to open/openat as an additional hardening measure. + + // validate '/' down to profile_path are root-owned and not + // 'other' writable to avoid possibility of privilege + // escalation via bpf program load when paths are incorrectly + // set on the system. + validate_bpfpath_is_safe(profile_path); + + // when we stop supporting 14.04 we could just use hdr = {0} + struct sc_seccomp_file_header hdr; + memset(&hdr, 0, sizeof hdr); + FILE *file SC_CLEANUP(sc_cleanup_file) = fopen(profile_path, "rb"); + + sc_must_read_and_validate_header_from_file(file, profile_path, &hdr); + if (hdr.unrestricted & 0x1) { + return false; + } + // populate allow + sc_must_read_filter_from_file(file, hdr.len_allow_filter, "allow", + &prog_allow); + sc_must_read_filter_from_file(file, hdr.len_deny_filter, "deny", + &prog_deny); + + // apply both filters + sc_apply_seccomp_filter(&prog_deny); + sc_apply_seccomp_filter(&prog_allow); + + return true; +} + +void sc_apply_global_seccomp_profile(void) +{ + const char *profile_path = "/var/lib/snapd/seccomp/bpf/global.bin"; + /* The profile may be absent. */ + if (access(profile_path, F_OK) != 0) { + return; + } + // TODO: move over to open/openat as an additional hardening measure. + validate_bpfpath_is_safe(profile_path); + + char bpf[MAX_BPF_SIZE + 1] = { 0 }; + size_t num_read = sc_read_seccomp_filter(profile_path, bpf, sizeof bpf); + struct sock_fprog prog = { + .len = num_read / sizeof(struct sock_filter), + .filter = (struct sock_filter *)bpf, + }; + sc_apply_seccomp_filter(&prog); +} diff --git a/cmd/snap-confine/seccomp-support.h b/cmd/snap-confine/seccomp-support.h new file mode 100644 index 00000000..f31c14b9 --- /dev/null +++ b/cmd/snap-confine/seccomp-support.h @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2015-2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +#ifndef SNAP_CONFINE_SECCOMP_SUPPORT_H +#define SNAP_CONFINE_SECCOMP_SUPPORT_H + +#include + +/** + * sc_apply_seccomp_profile_for_security_tag applies a seccomp profile to the + * current process. The filter is loaded from a pre-compiled bpf bytecode + * stored in "/var/lib/snap/seccomp/bpf" using the security tag and the + * extension ".bin2". All components along that path must be owned by root and + * cannot be writable by UNIX _other_. + * + * The security tag is shared with other parts of snapd. + * For applications it is the string "snap.${SNAP_INSTANCE_NAME}.${app}". + * For hooks it is "snap.${SNAP_INSTANCE_NAME}.hook.{hook_name}". + * + * Profiles must be present in the file-system. If a profile is not present + * then several attempts are made, each coupled with a sleep period. Up 3600 + * seconds may elapse before the function gives up. Unless + * $SNAP_CONFINE_MAX_PROFILE_WAIT environment variable dictates otherwise, the + * default wait time is 120 seconds. + * + * A profile may contain valid BPF program or the string "@unrestricted\n". In + * the former case the profile is applied to the current process using + * sc_apply_seccomp_filter. In the latter case no action takes place. + * + * The return value indicates if the process uses confinement or runs under the + * special non-confining "@unrestricted" profile. + **/ +bool sc_apply_seccomp_profile_for_security_tag(const char *security_tag); + +void sc_apply_global_seccomp_profile(void); + +#endif diff --git a/cmd/snap-confine/selinux-support.c b/cmd/snap-confine/selinux-support.c new file mode 100644 index 00000000..a65c0263 --- /dev/null +++ b/cmd/snap-confine/selinux-support.c @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2018 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +#include "selinux-support.h" +#include "config.h" + +#include +#include + +#include "../libsnap-confine-private/cleanup-funcs.h" +#include "../libsnap-confine-private/string-utils.h" +#include "../libsnap-confine-private/utils.h" + +static void sc_freecon(char **ctx) { + if (ctx != NULL && *ctx != NULL) { + freecon(*ctx); + *ctx = NULL; + } +} + +static void sc_context_free(context_t *ctx) { + if (ctx != NULL && *ctx != NULL) { + context_free(*ctx); + *ctx = NULL; + } +} + +/** + * Set security context for the snap. + * + * Sets up SELinux context transition to unconfined_service_t. + **/ +int sc_selinux_set_snap_execcon(void) { + if (is_selinux_enabled() < 1) { + debug("SELinux not enabled"); + return 0; + } + + char *ctx_str SC_CLEANUP(sc_freecon) = NULL; + if (getcon(&ctx_str) < 0) { + die("cannot obtain current SELinux process context"); + } + debug("current SELinux process context: %s", ctx_str); + + context_t ctx SC_CLEANUP(sc_context_free) = context_new(ctx_str); + if (ctx == NULL) { + die("cannot create SELinux context from context string %s", ctx_str); + } + + /* freed by context_free(ctx) */ + const char *ctx_type = context_type_get(ctx); + + if (ctx_type == NULL) { + die("cannot obtain type from SELinux context string %s", ctx_str); + } + + if (sc_streq(ctx_type, "snappy_confine_t")) { + /* We are running under a targeted policy which ended up transitioning + * to snappy_confine_t domain, at this point we are right before + * executing snap-exec. However we do not have a full SELinux support + * for services running in snaps, only the snapd bits and helpers are + * covered by the policy. + * + * At this point transition to the unconfined_service_t domain (allowed + * by snap_confine_t policy) upon the next exec() call. + */ + if (context_type_set(ctx, "unconfined_service_t") != 0) { + die("cannot update SELinux context %s type to unconfined_service_t", ctx_str); + } + + /* freed by context_free(ctx) */ + const char *new_ctx_str = context_str(ctx); + if (new_ctx_str == NULL) { + die("cannot obtain updated SELinux context string"); + } + if (setexeccon(new_ctx_str) < 0) { + die("cannot set SELinux exec context to %s", new_ctx_str); + } + debug("SELinux context after next exec: %s", new_ctx_str); + } + + return 0; +} diff --git a/cmd/snap-confine/selinux-support.h b/cmd/snap-confine/selinux-support.h new file mode 100644 index 00000000..ffe29340 --- /dev/null +++ b/cmd/snap-confine/selinux-support.h @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2018 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +#ifndef SNAP_CONFINE_SELINUX_SUPPORT_H +#define SNAP_CONFINE_SELINUX_SUPPORT_H + +/** + * Set security context for the snap + * + * Sets up SELinux context transition to unconfined_service_t. + **/ +int sc_selinux_set_snap_execcon(void); + +#endif /* SNAP_CONFINE_SELINUX_SUPPORT_H */ diff --git a/cmd/snap-confine/snap-confine-args-test.c b/cmd/snap-confine/snap-confine-args-test.c new file mode 100644 index 00000000..34216a6b --- /dev/null +++ b/cmd/snap-confine/snap-confine-args-test.c @@ -0,0 +1,432 @@ +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "snap-confine-args.h" +#include "snap-confine-args.c" +#include "../libsnap-confine-private/cleanup-funcs.h" + +#include + +#include + +static void test_sc_nonfatal_parse_args__typical(void) +{ + // Test that typical invocation of snap-confine is parsed correctly. + sc_error *err SC_CLEANUP(sc_cleanup_error) = NULL; + struct sc_args *args SC_CLEANUP(sc_cleanup_args) = NULL; + + int argc; + char **argv; + test_argc_argv(&argc, &argv, + "/usr/lib/snapd/snap-confine", "snap.SNAP_NAME.APP_NAME", + "/usr/lib/snapd/snap-exec", "--option", "arg", NULL); + + args = sc_nonfatal_parse_args(&argc, &argv, &err); + g_assert_null(err); + g_assert_nonnull(args); + + // Check supported switches and arguments + g_assert_cmpstr(sc_args_security_tag(args), ==, + "snap.SNAP_NAME.APP_NAME"); + g_assert_cmpstr(sc_args_executable(args), ==, + "/usr/lib/snapd/snap-exec"); + g_assert_cmpint(sc_args_is_version_query(args), ==, false); + g_assert_cmpint(sc_args_is_classic_confinement(args), ==, false); + g_assert_null(sc_args_base_snap(args)); + + // Check remaining arguments + g_assert_cmpint(argc, ==, 3); + g_assert_cmpstr(argv[0], ==, "/usr/lib/snapd/snap-confine"); + g_assert_cmpstr(argv[1], ==, "--option"); + g_assert_cmpstr(argv[2], ==, "arg"); + g_assert_null(argv[3]); +} + +static void test_sc_cleanup_args(void) +{ + // Check that NULL argument parser can be cleaned up + sc_error *err SC_CLEANUP(sc_cleanup_error) = NULL; + struct sc_args *args = NULL; + sc_cleanup_args(&args); + + // Check that a non-NULL argument parser can be cleaned up + int argc; + char **argv; + test_argc_argv(&argc, &argv, "/usr/lib/snapd/snap-confine", + "snap.SNAP_NAME.APP_NAME", "/usr/lib/snapd/snap-exec", + NULL); + args = sc_nonfatal_parse_args(&argc, &argv, &err); + g_assert_null(err); + g_assert_nonnull(args); + + sc_cleanup_args(&args); + g_assert_null(args); +} + +static void test_sc_nonfatal_parse_args__typical_classic(void) +{ + // Test that typical invocation of snap-confine is parsed correctly. + sc_error *err SC_CLEANUP(sc_cleanup_error) = NULL; + struct sc_args *args SC_CLEANUP(sc_cleanup_args) = NULL; + + int argc; + char **argv; + test_argc_argv(&argc, &argv, + "/usr/lib/snapd/snap-confine", "--classic", + "snap.SNAP_NAME.APP_NAME", "/usr/lib/snapd/snap-exec", + "--option", "arg", NULL); + + args = sc_nonfatal_parse_args(&argc, &argv, &err); + g_assert_null(err); + g_assert_nonnull(args); + + // Check supported switches and arguments + g_assert_cmpstr(sc_args_security_tag(args), ==, + "snap.SNAP_NAME.APP_NAME"); + g_assert_cmpstr(sc_args_executable(args), ==, + "/usr/lib/snapd/snap-exec"); + g_assert_cmpint(sc_args_is_version_query(args), ==, false); + g_assert_cmpint(sc_args_is_classic_confinement(args), ==, true); + + // Check remaining arguments + g_assert_cmpint(argc, ==, 3); + g_assert_cmpstr(argv[0], ==, "/usr/lib/snapd/snap-confine"); + g_assert_cmpstr(argv[1], ==, "--option"); + g_assert_cmpstr(argv[2], ==, "arg"); + g_assert_null(argv[3]); +} + +static void test_sc_nonfatal_parse_args__ubuntu_core_launcher(void) +{ + // Test that typical legacy invocation of snap-confine via the + // ubuntu-core-launcher symlink, with duplicated security tag, is parsed + // correctly. + sc_error *err SC_CLEANUP(sc_cleanup_error) = NULL; + struct sc_args *args SC_CLEANUP(sc_cleanup_args) = NULL; + + int argc; + char **argv; + test_argc_argv(&argc, &argv, + "/usr/bin/ubuntu-core-launcher", + "snap.SNAP_NAME.APP_NAME", "snap.SNAP_NAME.APP_NAME", + "/usr/lib/snapd/snap-exec", "--option", "arg", NULL); + + args = sc_nonfatal_parse_args(&argc, &argv, &err); + g_assert_null(err); + g_assert_nonnull(args); + + // Check supported switches and arguments + g_assert_cmpstr(sc_args_security_tag(args), ==, + "snap.SNAP_NAME.APP_NAME"); + g_assert_cmpstr(sc_args_executable(args), ==, + "/usr/lib/snapd/snap-exec"); + g_assert_cmpint(sc_args_is_version_query(args), ==, false); + g_assert_cmpint(sc_args_is_classic_confinement(args), ==, false); + + // Check remaining arguments + g_assert_cmpint(argc, ==, 3); + g_assert_cmpstr(argv[0], ==, "/usr/bin/ubuntu-core-launcher"); + g_assert_cmpstr(argv[1], ==, "--option"); + g_assert_cmpstr(argv[2], ==, "arg"); + g_assert_null(argv[3]); +} + +static void test_sc_nonfatal_parse_args__version(void) +{ + // Test that snap-confine --version is detected. + sc_error *err SC_CLEANUP(sc_cleanup_error) = NULL; + struct sc_args *args SC_CLEANUP(sc_cleanup_args) = NULL; + + int argc; + char **argv; + test_argc_argv(&argc, &argv, + "/usr/lib/snapd/snap-confine", "--version", "ignored", + "garbage", NULL); + + args = sc_nonfatal_parse_args(&argc, &argv, &err); + g_assert_null(err); + g_assert_nonnull(args); + + // Check supported switches and arguments + g_assert_null(sc_args_security_tag(args)); + g_assert_null(sc_args_executable(args)); + g_assert_cmpint(sc_args_is_version_query(args), ==, true); + g_assert_cmpint(sc_args_is_classic_confinement(args), ==, false); + + // Check remaining arguments + g_assert_cmpint(argc, ==, 3); + g_assert_cmpstr(argv[0], ==, "/usr/lib/snapd/snap-confine"); + g_assert_cmpstr(argv[1], ==, "ignored"); + g_assert_cmpstr(argv[2], ==, "garbage"); + g_assert_null(argv[3]); +} + +static void test_sc_nonfatal_parse_args__evil_input(void) +{ + // Check that calling without any arguments is reported as error. + sc_error *err SC_CLEANUP(sc_cleanup_error) = NULL; + struct sc_args *args SC_CLEANUP(sc_cleanup_args) = NULL; + + // NULL argcp/argvp attack + args = sc_nonfatal_parse_args(NULL, NULL, &err); + + g_assert_nonnull(err); + g_assert_null(args); + g_assert_cmpstr(sc_error_msg(err), ==, + "cannot parse arguments, argcp or argvp is NULL"); + sc_cleanup_error(&err); + + int argc; + char **argv; + + // NULL argv attack + argc = 0; + argv = NULL; + args = sc_nonfatal_parse_args(&argc, &argv, &err); + + g_assert_nonnull(err); + g_assert_null(args); + g_assert_cmpstr(sc_error_msg(err), ==, + "cannot parse arguments, argc is zero or argv is NULL"); + sc_cleanup_error(&err); + + // NULL argv[i] attack + test_argc_argv(&argc, &argv, + "/usr/lib/snapd/snap-confine", "--version", "ignored", + "garbage", NULL); + argv[1] = NULL; // overwrite --version with NULL + args = sc_nonfatal_parse_args(&argc, &argv, &err); + + g_assert_nonnull(err); + g_assert_null(args); + g_assert_cmpstr(sc_error_msg(err), ==, + "cannot parse arguments, argument at index 1 is NULL"); +} + +static void test_sc_nonfatal_parse_args__nothing_to_parse(void) +{ + // Check that calling without any arguments is reported as error. + sc_error *err SC_CLEANUP(sc_cleanup_error) = NULL; + struct sc_args *args SC_CLEANUP(sc_cleanup_args) = NULL; + + int argc; + char **argv; + test_argc_argv(&argc, &argv, NULL); + + args = sc_nonfatal_parse_args(&argc, &argv, &err); + g_assert_nonnull(err); + g_assert_null(args); + + // Check the error that we've got + g_assert_cmpstr(sc_error_msg(err), ==, + "cannot parse arguments, argc is zero or argv is NULL"); +} + +static void test_sc_nonfatal_parse_args__no_security_tag(void) +{ + // Check that lack of security tag is reported as error. + sc_error *err SC_CLEANUP(sc_cleanup_error) = NULL; + struct sc_args *args SC_CLEANUP(sc_cleanup_args) = NULL; + + int argc; + char **argv; + test_argc_argv(&argc, &argv, "/usr/lib/snapd/snap-confine", NULL); + + args = sc_nonfatal_parse_args(&argc, &argv, &err); + g_assert_nonnull(err); + g_assert_null(args); + + // Check the error that we've got + g_assert_cmpstr(sc_error_msg(err), ==, + "Usage: snap-confine \n" + "\napplication or hook security tag was not provided"); + + g_assert_true(sc_error_match(err, SC_ARGS_DOMAIN, SC_ARGS_ERR_USAGE)); +} + +static void test_sc_nonfatal_parse_args__no_executable(void) +{ + // Check that lack of security tag is reported as error. + sc_error *err SC_CLEANUP(sc_cleanup_error) = NULL; + struct sc_args *args SC_CLEANUP(sc_cleanup_args) = NULL; + + int argc; + char **argv; + test_argc_argv(&argc, &argv, "/usr/lib/snapd/snap-confine", + "snap.SNAP_NAME.APP_NAME", NULL); + + args = sc_nonfatal_parse_args(&argc, &argv, &err); + g_assert_nonnull(err); + g_assert_null(args); + + // Check the error that we've got + g_assert_cmpstr(sc_error_msg(err), ==, + "Usage: snap-confine \n" + "\nexecutable name was not provided"); + g_assert_true(sc_error_match(err, SC_ARGS_DOMAIN, SC_ARGS_ERR_USAGE)); +} + +static void test_sc_nonfatal_parse_args__unknown_option(void) +{ + // Check that unrecognized option switch is reported as error. + sc_error *err SC_CLEANUP(sc_cleanup_error) = NULL; + struct sc_args *args SC_CLEANUP(sc_cleanup_args) = NULL; + + int argc; + char **argv; + test_argc_argv(&argc, &argv, "/usr/lib/snapd/snap-confine", + "--frozbonicator", NULL); + + args = sc_nonfatal_parse_args(&argc, &argv, &err); + g_assert_nonnull(err); + g_assert_null(args); + + // Check the error that we've got + g_assert_cmpstr(sc_error_msg(err), ==, + "Usage: snap-confine \n" + "\nunrecognized command line option: --frozbonicator"); + g_assert_true(sc_error_match(err, SC_ARGS_DOMAIN, SC_ARGS_ERR_USAGE)); +} + +static void test_sc_nonfatal_parse_args__forwards_error(void) +{ + // Check that sc_nonfatal_parse_args() forwards errors. + if (g_test_subprocess()) { + int argc; + char **argv; + test_argc_argv(&argc, &argv, "/usr/lib/snapd/snap-confine", + "--frozbonicator", NULL); + + // Call sc_nonfatal_parse_args() without an error handle + struct sc_args *args SC_CLEANUP(sc_cleanup_args) = NULL; + args = sc_nonfatal_parse_args(&argc, &argv, NULL); + (void)args; + + g_test_message("expected not to reach this place"); + g_test_fail(); + return; + } + g_test_trap_subprocess(NULL, 0, 0); + g_test_trap_assert_failed(); + g_test_trap_assert_stderr + ("Usage: snap-confine \n" + "\nunrecognized command line option: --frozbonicator\n"); +} + +static void test_sc_nonfatal_parse_args__base_snap(void) +{ + // Check that --base specifies the name of the base snap. + sc_error *err SC_CLEANUP(sc_cleanup_error) = NULL; + struct sc_args *args SC_CLEANUP(sc_cleanup_args) = NULL; + + int argc; + char **argv; + test_argc_argv(&argc, &argv, + "/usr/lib/snapd/snap-confine", "--base", "base-snap", + "snap.SNAP_NAME.APP_NAME", "/usr/lib/snapd/snap-exec", + NULL); + + args = sc_nonfatal_parse_args(&argc, &argv, &err); + g_assert_null(err); + g_assert_nonnull(args); + + // Check the --base switch + g_assert_cmpstr(sc_args_base_snap(args), ==, "base-snap"); + // Check other arguments + g_assert_cmpstr(sc_args_security_tag(args), ==, + "snap.SNAP_NAME.APP_NAME"); + g_assert_cmpstr(sc_args_executable(args), ==, + "/usr/lib/snapd/snap-exec"); + g_assert_cmpint(sc_args_is_version_query(args), ==, false); + g_assert_cmpint(sc_args_is_classic_confinement(args), ==, false); +} + +static void test_sc_nonfatal_parse_args__base_snap__missing_arg(void) +{ + // Check that --base specifies the name of the base snap. + sc_error *err SC_CLEANUP(sc_cleanup_error) = NULL; + struct sc_args *args SC_CLEANUP(sc_cleanup_args) = NULL; + + int argc; + char **argv; + test_argc_argv(&argc, &argv, + "/usr/lib/snapd/snap-confine", "--base", NULL); + + args = sc_nonfatal_parse_args(&argc, &argv, &err); + g_assert_nonnull(err); + g_assert_null(args); + + // Check the error that we've got + g_assert_cmpstr(sc_error_msg(err), ==, + "Usage: snap-confine \n" + "\nthe --base option requires an argument"); + g_assert_true(sc_error_match(err, SC_ARGS_DOMAIN, SC_ARGS_ERR_USAGE)); +} + +static void test_sc_nonfatal_parse_args__base_snap__twice(void) +{ + // Check that --base specifies the name of the base snap. + sc_error *err SC_CLEANUP(sc_cleanup_error) = NULL; + struct sc_args *args SC_CLEANUP(sc_cleanup_args) = NULL; + + int argc; + char **argv; + test_argc_argv(&argc, &argv, + "/usr/lib/snapd/snap-confine", + "--base", "base1", "--base", "base2", NULL); + + args = sc_nonfatal_parse_args(&argc, &argv, &err); + g_assert_nonnull(err); + g_assert_null(args); + + // Check the error that we've got + g_assert_cmpstr(sc_error_msg(err), ==, + "Usage: snap-confine \n" + "\nthe --base option can be used only once"); + g_assert_true(sc_error_match(err, SC_ARGS_DOMAIN, SC_ARGS_ERR_USAGE)); +} + +static void __attribute__((constructor)) init(void) +{ + g_test_add_func("/args/sc_cleanup_args", test_sc_cleanup_args); + g_test_add_func("/args/sc_nonfatal_parse_args/typical", + test_sc_nonfatal_parse_args__typical); + g_test_add_func("/args/sc_nonfatal_parse_args/typical_classic", + test_sc_nonfatal_parse_args__typical_classic); + g_test_add_func("/args/sc_nonfatal_parse_args/ubuntu_core_launcher", + test_sc_nonfatal_parse_args__ubuntu_core_launcher); + g_test_add_func("/args/sc_nonfatal_parse_args/version", + test_sc_nonfatal_parse_args__version); + g_test_add_func("/args/sc_nonfatal_parse_args/nothing_to_parse", + test_sc_nonfatal_parse_args__nothing_to_parse); + g_test_add_func("/args/sc_nonfatal_parse_args/evil_input", + test_sc_nonfatal_parse_args__evil_input); + g_test_add_func("/args/sc_nonfatal_parse_args/no_security_tag", + test_sc_nonfatal_parse_args__no_security_tag); + g_test_add_func("/args/sc_nonfatal_parse_args/no_executable", + test_sc_nonfatal_parse_args__no_executable); + g_test_add_func("/args/sc_nonfatal_parse_args/unknown_option", + test_sc_nonfatal_parse_args__unknown_option); + g_test_add_func("/args/sc_nonfatal_parse_args/forwards_error", + test_sc_nonfatal_parse_args__forwards_error); + g_test_add_func("/args/sc_nonfatal_parse_args/base_snap", + test_sc_nonfatal_parse_args__base_snap); + g_test_add_func("/args/sc_nonfatal_parse_args/base_snap/missing-arg", + test_sc_nonfatal_parse_args__base_snap__missing_arg); + g_test_add_func("/args/sc_nonfatal_parse_args/base_snap/twice", + test_sc_nonfatal_parse_args__base_snap__twice); +} diff --git a/cmd/snap-confine/snap-confine-args.c b/cmd/snap-confine/snap-confine-args.c new file mode 100644 index 00000000..691960e6 --- /dev/null +++ b/cmd/snap-confine/snap-confine-args.c @@ -0,0 +1,256 @@ +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "snap-confine-args.h" + +#include + +#include "../libsnap-confine-private/utils.h" +#include "../libsnap-confine-private/string-utils.h" +#include "../libsnap-confine-private/test-utils.h" + +struct sc_args { + // The security tag that the application is intended to run with + char *security_tag; + // The executable that should be invoked + char *executable; + // Name of the base snap to use. + char *base_snap; + + // Flag indicating that --version was passed on command line. + bool is_version_query; + // Flag indicating that --classic was passed on command line. + bool is_classic_confinement; +}; + +struct sc_args *sc_nonfatal_parse_args(int *argcp, char ***argvp, + sc_error **errorp) +{ + struct sc_args *args = NULL; + sc_error *err = NULL; + + if (argcp == NULL || argvp == NULL) { + err = sc_error_init(SC_ARGS_DOMAIN, 0, + "cannot parse arguments, argcp or argvp is NULL"); + goto out; + } + // Use dereferenced versions of argcp and argvp for convenience. + int argc = *argcp; + char **const argv = *argvp; + + if (argc == 0 || argv == NULL) { + err = sc_error_init(SC_ARGS_DOMAIN, 0, + "cannot parse arguments, argc is zero or argv is NULL"); + goto out; + } + // Sanity check, look for NULL argv entries. + for (int i = 0; i < argc; ++i) { + if (argv[i] == NULL) { + err = sc_error_init(SC_ARGS_DOMAIN, 0, + "cannot parse arguments, argument at index %d is NULL", + i); + goto out; + } + } + + args = calloc(1, sizeof *args); + if (args == NULL) { + die("cannot allocate memory for command line arguments object"); + } + // Check if we're being called through the ubuntu-core-launcher symlink. + // When this happens we want to skip the first positional argument as it is + // the security tag repeated (legacy). + bool ignore_first_tag = false; + char *basename = strrchr(argv[0], '/'); + if (basename != NULL) { + // NOTE: this is safe because we, at most, may move to the NUL byte + // that compares to an empty string. + basename += 1; + if (strcmp(basename, "ubuntu-core-launcher") == 0) { + ignore_first_tag = true; + } + } + // Parse option switches. + int optind; + for (optind = 1; optind < argc; ++optind) { + // Look at all the options switches that start with the minus sign ('-') + if (argv[optind][0] != '-') { + // On first non-switch argument break the loop. The next loop looks + // just for non-option arguments. This ensures that options and + // positional arguments cannot be mixed. + break; + } + // Handle option switches + if (strcmp(argv[optind], "--version") == 0) { + args->is_version_query = true; + // NOTE: --version short-circuits the parser to finish + goto done; + } else if (strcmp(argv[optind], "--classic") == 0) { + args->is_classic_confinement = true; + } else if (strcmp(argv[optind], "--base") == 0) { + if (optind + 1 >= argc) { + err = + sc_error_init(SC_ARGS_DOMAIN, + SC_ARGS_ERR_USAGE, + "Usage: snap-confine \n" + "\n" + "the --base option requires an argument"); + goto out; + } + if (args->base_snap != NULL) { + err = + sc_error_init(SC_ARGS_DOMAIN, + SC_ARGS_ERR_USAGE, + "Usage: snap-confine \n" + "\n" + "the --base option can be used only once"); + goto out; + + } + args->base_snap = sc_strdup(argv[optind + 1]); + optind += 1; + } else { + // Report unhandled option switches + err = sc_error_init(SC_ARGS_DOMAIN, SC_ARGS_ERR_USAGE, + "Usage: snap-confine \n" + "\n" + "unrecognized command line option: %s", + argv[optind]); + goto out; + } + } + + // Parse positional arguments. + // + // NOTE: optind is not reset, we just continue from where we left off in + // the loop above. + for (; optind < argc; ++optind) { + if (args->security_tag == NULL) { + // The first positional argument becomes the security tag. + if (ignore_first_tag) { + // Unless we are called as ubuntu-core-launcher, then we just + // swallow and ignore that security tag altogether. + ignore_first_tag = false; + continue; + } + args->security_tag = sc_strdup(argv[optind]); + } else if (args->executable == NULL) { + // The second positional argument becomes the executable name. + args->executable = sc_strdup(argv[optind]); + // No more positional arguments are required. + // Stop the parsing process. + break; + } + } + + // Verify that all mandatory positional arguments are present. + // Ensure that we have the security tag + if (args->security_tag == NULL) { + err = sc_error_init(SC_ARGS_DOMAIN, SC_ARGS_ERR_USAGE, + "Usage: snap-confine \n" + "\n" + "application or hook security tag was not provided"); + goto out; + } + // Ensure that we have the executable name + if (args->executable == NULL) { + err = sc_error_init(SC_ARGS_DOMAIN, SC_ARGS_ERR_USAGE, + "Usage: snap-confine \n" + "\n" "executable name was not provided"); + goto out; + } + + int i; + done: + // "shift" the argument vector left, except for argv[0], to "consume" the + // arguments that were scanned / parsed correctly. + for (i = 1; optind + i < argc; ++i) { + argv[i] = argv[optind + i]; + } + argv[i] = NULL; + + // Write the updated argc back, argv is never modified. + *argcp = argc - optind; + + out: + // Don't return anything in case of an error. + if (err != NULL) { + sc_cleanup_args(&args); + } + // Forward the error and return + sc_error_forward(errorp, err); + return args; +} + +void sc_args_free(struct sc_args *args) +{ + if (args != NULL) { + free(args->security_tag); + args->security_tag = NULL; + free(args->executable); + args->executable = NULL; + free(args->base_snap); + args->base_snap = NULL; + free(args); + } +} + +void sc_cleanup_args(struct sc_args **ptr) +{ + sc_args_free(*ptr); + *ptr = NULL; +} + +bool sc_args_is_version_query(const struct sc_args *args) +{ + if (args == NULL) { + die("cannot obtain version query flag from NULL argument parser"); + } + return args->is_version_query; +} + +bool sc_args_is_classic_confinement(const struct sc_args *args) +{ + if (args == NULL) { + die("cannot obtain classic confinement flag from NULL argument parser"); + } + return args->is_classic_confinement; +} + +const char *sc_args_security_tag(const struct sc_args *args) +{ + if (args == NULL) { + die("cannot obtain security tag from NULL argument parser"); + } + return args->security_tag; +} + +const char *sc_args_executable(const struct sc_args *args) +{ + if (args == NULL) { + die("cannot obtain executable from NULL argument parser"); + } + return args->executable; +} + +const char *sc_args_base_snap(const struct sc_args *args) +{ + if (args == NULL) { + die("cannot obtain base snap name from NULL argument parser"); + } + return args->base_snap; +} diff --git a/cmd/snap-confine/snap-confine-args.h b/cmd/snap-confine/snap-confine-args.h new file mode 100644 index 00000000..9aa3111f --- /dev/null +++ b/cmd/snap-confine/snap-confine-args.h @@ -0,0 +1,124 @@ +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#ifndef SC_SNAP_CONFINE_ARGS_H +#define SC_SNAP_CONFINE_ARGS_H + +#include + +#include "../libsnap-confine-private/error.h" + +/** + * Error domain for errors related to argument parsing. + **/ +#define SC_ARGS_DOMAIN "args" + +enum { + /** + * Error indicating that the command line arguments could not be parsed + * correctly and usage message should be displayed to the user. + **/ + SC_ARGS_ERR_USAGE = 1, +}; + +/** + * Opaque structure describing command-line arguments to snap-confine. + **/ +struct sc_args; + +/** + * Parse command line arguments for snap-confine. + * + * Snap confine understands very specific arguments. + * + * The argument vector can begin with "ubuntu-core-launcher" (with an optional + * path) which implies that the first arctual argument (argv[1]) is a copy of + * argv[2] and can be discarded. + * + * The argument vector is scanned, left to right, to look for switches that + * start with the minus sign ('-'). Recognized options are stored and + * memorized. Unrecognized options return an appropriate error object. + * + * Currently only one option is understood, that is "--version". It is simply + * scanned, memorized and discarded. The presence of this switch can be + * retrieved with sc_args_is_version_query(). + * + * After all the option switches are scanned it is expected to scan two more + * arguments: the security tag and the name of the executable to run. An error + * object is returned when those is missing. + * + * Both argc and argv are modified so the caller can look at the first unparsed + * argument at argc[0]. This is only done if argument parsing is successful. + **/ +__attribute__((warn_unused_result)) +struct sc_args *sc_nonfatal_parse_args(int *argcp, char ***argvp, + sc_error **errorp); + +/** + * Free the object describing command-line arguments to snap-confine. + **/ +void sc_args_free(struct sc_args *args); + +/** + * Cleanup an error with sc_args_free() + * + * This function is designed to be used with + * SC_CLEANUP(sc_cleanup_args). + **/ +void sc_cleanup_args(struct sc_args **ptr); + +/** + * Check if snap-confine was invoked with the --version switch. + **/ +bool sc_args_is_version_query(const struct sc_args *args); + +/** + * Check if snap-confine was invoked with the --classic switch. + **/ +bool sc_args_is_classic_confinement(const struct sc_args *args); + +/** + * Get the security tag passed to snap-confine. + * + * The return value may be NULL if snap-confine was invoked with --version. It + * is never NULL otherwise. + * + * The return value must not be freed(). It is bound to the lifetime of + * the argument parser. + **/ +const char *sc_args_security_tag(const struct sc_args *args); + +/** + * Get the executable name passed to snap-confine. + * + * The return value may be NULL if snap-confine was invoked with --version. It + * is never NULL otherwise. + * + * The return value must not be freed(). It is bound to the lifetime of + * the argument parser. + **/ +const char *sc_args_executable(const struct sc_args *args); + +/** + * Get the name of the base snap to use. + * + * The return value must not be freed(). It is bound to the lifetime of + * the argument parser. + **/ +const char *sc_args_base_snap(const struct sc_args *args); + +#endif diff --git a/cmd/snap-confine/snap-confine-invocation-test.c b/cmd/snap-confine/snap-confine-invocation-test.c new file mode 100644 index 00000000..6f1369c2 --- /dev/null +++ b/cmd/snap-confine/snap-confine-invocation-test.c @@ -0,0 +1,162 @@ +/* + * Copyright (C) 2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "snap-confine-invocation.h" +#include "snap-confine-args.h" +#include "snap-confine-invocation.c" + +#include "../libsnap-confine-private/cleanup-funcs.h" +#include "../libsnap-confine-private/test-utils.h" + +#include + +#include + +static struct sc_args *test_prepare_args(const char *base, const char *tag) { + struct sc_args *args = NULL; + sc_error *err SC_CLEANUP(sc_cleanup_error) = NULL; + int argc; + char **argv; + + test_argc_argv(&argc, &argv, "/usr/lib/snapd/snap-confine", "--base", (base != NULL) ? base : "core", + (tag != NULL) ? tag : "snap.foo.app", "/usr/lib/snapd/snap-exec", NULL); + args = sc_nonfatal_parse_args(&argc, &argv, &err); + g_assert_null(err); + g_assert_nonnull(args); + + return args; +} + +static void test_sc_invocation_basic(void) { + struct sc_args *args SC_CLEANUP(sc_cleanup_args) = test_prepare_args("core", NULL); + + sc_invocation inv SC_CLEANUP(sc_cleanup_invocation); + ; + sc_init_invocation(&inv, args, "foo"); + + g_assert_cmpstr(inv.base_snap_name, ==, "core"); + g_assert_cmpstr(inv.executable, ==, "/usr/lib/snapd/snap-exec"); + g_assert_cmpstr(inv.orig_base_snap_name, ==, "core"); + g_assert_cmpstr(inv.rootfs_dir, ==, SNAP_MOUNT_DIR "/core/current"); + g_assert_cmpstr(inv.security_tag, ==, "snap.foo.app"); + g_assert_cmpstr(inv.snap_instance, ==, "foo"); + g_assert_cmpstr(inv.snap_name, ==, "foo"); + g_assert_false(inv.classic_confinement); + /* derived later */ + g_assert_false(inv.is_normal_mode); +} + +static void test_sc_invocation_instance_key(void) { + struct sc_args *args SC_CLEANUP(sc_cleanup_args) = test_prepare_args("core", "snap.foo_bar.app"); + + sc_invocation inv SC_CLEANUP(sc_cleanup_invocation); + ; + sc_init_invocation(&inv, args, "foo_bar"); + + // Check the error that we've got + g_assert_cmpstr(inv.snap_instance, ==, "foo_bar"); + g_assert_cmpstr(inv.snap_name, ==, "foo"); + g_assert_cmpstr(inv.orig_base_snap_name, ==, "core"); + g_assert_cmpstr(inv.security_tag, ==, "snap.foo_bar.app"); + g_assert_cmpstr(inv.executable, ==, "/usr/lib/snapd/snap-exec"); + g_assert_false(inv.classic_confinement); + g_assert_cmpstr(inv.rootfs_dir, ==, SNAP_MOUNT_DIR "/core/current"); + g_assert_cmpstr(inv.base_snap_name, ==, "core"); + /* derived later */ + g_assert_false(inv.is_normal_mode); +} + +static void test_sc_invocation_base_name(void) { + struct sc_args *args SC_CLEANUP(sc_cleanup_args) = test_prepare_args("base-snap", NULL); + + sc_invocation inv SC_CLEANUP(sc_cleanup_invocation); + sc_init_invocation(&inv, args, "foo"); + + g_assert_cmpstr(inv.base_snap_name, ==, "base-snap"); + g_assert_cmpstr(inv.executable, ==, "/usr/lib/snapd/snap-exec"); + g_assert_cmpstr(inv.orig_base_snap_name, ==, "base-snap"); + g_assert_cmpstr(inv.rootfs_dir, ==, SNAP_MOUNT_DIR "/base-snap/current"); + g_assert_cmpstr(inv.security_tag, ==, "snap.foo.app"); + g_assert_cmpstr(inv.snap_instance, ==, "foo"); + g_assert_cmpstr(inv.snap_name, ==, "foo"); + g_assert_false(inv.classic_confinement); + /* derived later */ + g_assert_false(inv.is_normal_mode); +} + +static void test_sc_invocation_bad_instance_name(void) { + struct sc_args *args SC_CLEANUP(sc_cleanup_args) = test_prepare_args(NULL, NULL); + + if (g_test_subprocess()) { + sc_invocation inv SC_CLEANUP(sc_cleanup_invocation) = {0}; + sc_init_invocation(&inv, args, "foo_bar_bar_bar"); + return; + } + + g_test_trap_subprocess(NULL, 0, 0); + g_test_trap_assert_failed(); + g_test_trap_assert_stderr("snap instance name can contain only one underscore\n"); +} + +static void test_sc_invocation_classic(void) { + struct sc_args *args SC_CLEANUP(sc_cleanup_args) = NULL; + sc_error *err SC_CLEANUP(sc_cleanup_error) = NULL; + int argc; + char **argv = NULL; + + test_argc_argv(&argc, &argv, "/usr/lib/snapd/snap-confine", "--classic", "--base", "core", "snap.foo-classic.app", + "/usr/lib/snapd/snap-exec", NULL); + args = sc_nonfatal_parse_args(&argc, &argv, &err); + g_assert_null(err); + g_assert_nonnull(args); + + sc_invocation inv SC_CLEANUP(sc_cleanup_invocation) = {0}; + sc_init_invocation(&inv, args, "foo-classic"); + + g_assert_cmpstr(inv.base_snap_name, ==, "core"); + g_assert_cmpstr(inv.executable, ==, "/usr/lib/snapd/snap-exec"); + g_assert_cmpstr(inv.orig_base_snap_name, ==, "core"); + g_assert_cmpstr(inv.rootfs_dir, ==, SNAP_MOUNT_DIR "/core/current"); + g_assert_cmpstr(inv.security_tag, ==, "snap.foo-classic.app"); + g_assert_cmpstr(inv.snap_instance, ==, "foo-classic"); + g_assert_cmpstr(inv.snap_name, ==, "foo-classic"); + g_assert_true(inv.classic_confinement); +} + +static void test_sc_invocation_tag_name_mismatch(void) { + struct sc_args *args SC_CLEANUP(sc_cleanup_args) = test_prepare_args("core", "snap.foo.app"); + + if (g_test_subprocess()) { + sc_invocation inv SC_CLEANUP(sc_cleanup_invocation); + ; + sc_init_invocation(&inv, args, "foo-not-foo"); + return; + } + + g_test_trap_subprocess(NULL, 0, 0); + g_test_trap_assert_failed(); + g_test_trap_assert_stderr("security tag snap.foo.app not allowed\n"); +} + +static void __attribute__((constructor)) init(void) { + g_test_add_func("/invocation/bad_instance_name", test_sc_invocation_bad_instance_name); + g_test_add_func("/invocation/base_name", test_sc_invocation_base_name); + g_test_add_func("/invocation/basic", test_sc_invocation_basic); + g_test_add_func("/invocation/classic", test_sc_invocation_classic); + g_test_add_func("/invocation/instance_key", test_sc_invocation_instance_key); + g_test_add_func("/invocation/tag_name_mismatch", test_sc_invocation_tag_name_mismatch); +} diff --git a/cmd/snap-confine/snap-confine-invocation.c b/cmd/snap-confine/snap-confine-invocation.c new file mode 100644 index 00000000..4ab479e6 --- /dev/null +++ b/cmd/snap-confine/snap-confine-invocation.c @@ -0,0 +1,207 @@ +/* + * Copyright (C) 2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +#include "snap-confine-invocation.h" + +#include +#include +#include + +#include "../libsnap-confine-private/cleanup-funcs.h" +#include "../libsnap-confine-private/snap.h" +#include "../libsnap-confine-private/string-utils.h" +#include "../libsnap-confine-private/utils.h" + +void sc_init_invocation(sc_invocation *inv, const struct sc_args *args, const char *snap_instance) { + /* Snap instance name is conveyed via untrusted environment. It may be + * unset (typically when experimenting with snap-confine by hand). It + * must also be a valid snap instance name. */ + if (snap_instance == NULL) { + die("cannot use NULL snap instance name"); + } + sc_instance_name_validate(snap_instance, NULL); + + /* The security tag is conveyed via untrusted command line. It must be + * in agreement with snap instance name and must be a valid security + * tag. */ + const char *security_tag = sc_args_security_tag(args); + if (!sc_security_tag_validate(security_tag, snap_instance)) { + die("security tag %s not allowed", security_tag); + } + + /* The base snap name is conveyed via untrusted, optional, command line + * argument. It may be omitted where it implies the "core" snap is the + * base. */ + const char *base_snap_name = sc_args_base_snap(args); + if (base_snap_name == NULL) { + base_snap_name = "core"; + } + sc_snap_name_validate(base_snap_name, NULL); + + /* The executable is conveyed via untrusted command line. It must be set + * but cannot be validated further than that at this time. It might be + * arguable to validate it to be snap-exec in one of the well-known + * locations or one of the special-cases like strace / gdb but this is + * not done at this time. */ + const char *executable = sc_args_executable(args); + if (executable == NULL) { + die("cannot run with NULL executable"); + } + + /* Instance name length + NULL termination */ + char snap_name[SNAP_NAME_LEN + 1] = {0}; + sc_snap_drop_instance_key(snap_instance, snap_name, sizeof snap_name); + + /* Invocation helps to pass relevant data to various parts of snap-confine. */ + memset(inv, 0, sizeof *inv); + inv->base_snap_name = sc_strdup(base_snap_name); + inv->orig_base_snap_name = sc_strdup(base_snap_name); + inv->executable = sc_strdup(executable); + inv->security_tag = sc_strdup(security_tag); + inv->snap_instance = sc_strdup(snap_instance); + inv->snap_name = sc_strdup(snap_name); + inv->classic_confinement = sc_args_is_classic_confinement(args); + + // construct rootfs_dir based on base_snap_name + char mount_point[PATH_MAX] = {0}; + sc_must_snprintf(mount_point, sizeof mount_point, "%s/%s/current", SNAP_MOUNT_DIR, inv->base_snap_name); + inv->rootfs_dir = sc_strdup(mount_point); + + debug("security tag: %s", inv->security_tag); + debug("executable: %s", inv->executable); + debug("confinement: %s", inv->classic_confinement ? "classic" : "non-classic"); + debug("base snap: %s", inv->base_snap_name); +} + +void sc_cleanup_invocation(sc_invocation *inv) { + if (inv != NULL) { + sc_cleanup_string(&inv->snap_instance); + sc_cleanup_string(&inv->snap_name); + sc_cleanup_string(&inv->base_snap_name); + sc_cleanup_string(&inv->orig_base_snap_name); + sc_cleanup_string(&inv->security_tag); + sc_cleanup_string(&inv->executable); + sc_cleanup_string(&inv->rootfs_dir); + sc_cleanup_deep_strv(&inv->homedirs); + } +} + +void sc_check_rootfs_dir(sc_invocation *inv) { + if (access(inv->rootfs_dir, F_OK) == 0) { + return; + } + + /* As a special fallback, allow the base snap to degrade from "core" to + * "ubuntu-core". This is needed for the migration from old + * ubuntu-core based systems to the new core. + */ + if (sc_streq(inv->base_snap_name, "core")) { + char mount_point[PATH_MAX] = {0}; + + /* For "core" we can still use the ubuntu-core snap. This is helpful in + * the migration path when new snap-confine runs before snapd has + * finished obtaining the core snap. */ + sc_must_snprintf(mount_point, sizeof mount_point, "%s/%s/current", SNAP_MOUNT_DIR, "ubuntu-core"); + if (access(mount_point, F_OK) == 0) { + sc_cleanup_string(&inv->base_snap_name); + inv->base_snap_name = sc_strdup("ubuntu-core"); + sc_cleanup_string(&inv->rootfs_dir); + inv->rootfs_dir = sc_strdup(mount_point); + debug("falling back to ubuntu-core instead of unavailable core snap"); + return; + } + } + + if (sc_streq(inv->base_snap_name, "core16")) { + char mount_point[PATH_MAX] = {0}; + + /* For "core16" we can still use the "core" snap. This is useful + * to help people transition to core16 bases without requiring + * twice the disk space. + */ + sc_must_snprintf(mount_point, sizeof mount_point, "%s/%s/current", SNAP_MOUNT_DIR, "core"); + if (access(mount_point, F_OK) == 0) { + sc_cleanup_string(&inv->base_snap_name); + inv->base_snap_name = sc_strdup("core"); + sc_cleanup_string(&inv->rootfs_dir); + inv->rootfs_dir = sc_strdup(mount_point); + debug("falling back to core instead of unavailable core16 snap"); + return; + } + } + + die("cannot locate base snap %s", inv->base_snap_name); +} + +static char *read_homedirs_from_system_params(void) { + FILE *f SC_CLEANUP(sc_cleanup_file) = NULL; + f = fopen("/var/lib/snapd/system-params", "r"); + if (f == NULL) { + return NULL; + } + + char *line SC_CLEANUP(sc_cleanup_string) = NULL; + size_t line_size = 0; + while (getline(&line, &line_size, f) != -1) { + if (sc_startswith(line, "homedirs=")) { + return sc_strdup(line + (sizeof("homedirs=") - 1)); + } + } + return NULL; +} + +void sc_invocation_init_homedirs(sc_invocation *inv) { + char *config_line SC_CLEANUP(sc_cleanup_string) = read_homedirs_from_system_params(); + if (config_line == NULL) { + return; + } + + /* The homedirs setting is a comma-separated list. In order to allocate the + * right number of strings, let's count how many commas we have. + */ + int num_commas = 0; + for (char *c = config_line; *c != '\0'; c++) { + if (*c == ',') { + num_commas++; + } + } + + /* We add *two* elements here: one is because of course the number of + * actual homedirs is the number of commas plus one, and the extra one is + * used as an end-of-array indicator. */ + inv->homedirs = calloc(num_commas + 2, sizeof(char *)); + if (inv->homedirs == NULL) { + die("cannot allocate memory for homedirs"); + } + + // strtok_r needs a pointer to keep track of where it is in the + // string. + char *buf_saveptr = NULL; + + int current_index = 0; + char *homedir = strtok_r(config_line, ",\n", &buf_saveptr); + while (homedir != NULL) { + if (homedir[0] == '\0') { + // Deal with the case of an empty homedir line (e.g "homedirs=") + continue; + } + inv->homedirs[current_index++] = sc_strdup(homedir); + homedir = strtok_r(NULL, ",\n", &buf_saveptr); + } + + // Store the actual amount of homedirs created + inv->num_homedirs = current_index; +} diff --git a/cmd/snap-confine/snap-confine-invocation.h b/cmd/snap-confine/snap-confine-invocation.h new file mode 100644 index 00000000..64778883 --- /dev/null +++ b/cmd/snap-confine/snap-confine-invocation.h @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#ifndef SC_SNAP_CONFINE_INVOCATION_H +#define SC_SNAP_CONFINE_INVOCATION_H + +#include + +#include "snap-confine-args.h" + +/** + * sc_invocation contains information about how snap-confine was invoked. + * + * All of the pointer fields have the life-cycle bound to the main process. + **/ +typedef struct sc_invocation { + /* Things declared by the system. */ + char *snap_instance; /* snap instance name (_) */ + char *snap_name; /* snap name (without instance key) */ + char *orig_base_snap_name; + char *security_tag; + char *executable; + bool classic_confinement; + /* Things derived at runtime. */ + char *base_snap_name; + char *rootfs_dir; + char **homedirs; + int num_homedirs; + bool is_normal_mode; +} sc_invocation; + +/** + * sc_init_invocation initializes the invocation object. + * + * Invocation is constructed based on command line arguments as well as + * environment value (SNAP_INSTANCE_NAME). All input is untrusted and is + * validated internally. + **/ +void sc_init_invocation(sc_invocation *inv, const struct sc_args *args, const char *snap_instance); + +/** + * sc_cleanup_invocation is a cleanup function for sc_invocation. + * + * Cleanup functions are automatically called by the compiler whenever a + * variable gets out of scope, like C++ destructors would. + * + * This function is designed to be used with SC_CLEANUP(sc_cleanup_invocation). + **/ +void sc_cleanup_invocation(sc_invocation *inv); + +/** + * sc_check_rootfs_dir checks the rootfs_dir and applies potential fall-backs. + * + * Checks that the rootfs_dir for the given base_snap exists and may apply + * the fallback logic below. Will die() if no base_snap can be found. + * + * When performing ubuntu-core to core migration, the snap "core" may not be + * mounted yet. In that mode when snapd instructs us to use "core" as the base + * snap name snap-confine may choose to transparently fallback to "ubuntu-core" + * it that is available instead. + * + * This check must be performed in the regular mount namespace (that is, that + * of the init process) because it relies on the value of compile-time-choice + * of SNAP_MOUNT_DIR. + **/ +void sc_check_rootfs_dir(sc_invocation *inv); + +/** + * sc_invocation_init_homedirs() reads the homedirs configuration + * file of snapd and fills the "homedirs" string vector in the + * sc_invocation structure. + */ +void sc_invocation_init_homedirs(sc_invocation *inv); + +#endif diff --git a/cmd/snap-confine/snap-confine.apparmor.in b/cmd/snap-confine/snap-confine.apparmor.in new file mode 100644 index 00000000..f3a717db --- /dev/null +++ b/cmd/snap-confine/snap-confine.apparmor.in @@ -0,0 +1,637 @@ +# Author: Jamie Strandboge +#include + +@LIBEXECDIR@/snap-confine (attach_disconnected) { + # Include any additional files that snapd chose to generate. + # - for $HOME on remote file system. + # - for $HOME on encrypted media + # + # Those are discussed on https://forum.snapcraft.io/t/snapd-vs-upstream-kernel-vs-apparmor + # and https://forum.snapcraft.io/t/snaps-and-nfs-home/ + #include "/var/lib/snapd/apparmor/snap-confine" + + # We run privileged, so be fanatical about what we include and don't use + # any abstractions + /etc/ld.so.cache r, + /etc/ld.so.preload r, + + # Do not assume that the interpreter is always named like + # ld-linux-x86_64.so, as on some architectures there can be a version after + # the .so suffix, eg. ld-linux-aarch64.so.1 + /{,usr/}lib{,32,64,x32}/{,@{multiarch}/{,atomics/}}ld{-*,64}.so* mrix, + # libc, you are funny + /{,usr/}lib{,32,64,x32}/{,@{multiarch}/{,atomics/}}libc{,-[0-9]*}.so* mr, + /{,usr/}lib{,32,64,x32}/{,@{multiarch}/{,atomics/}}libpthread{,-[0-9]*}.so* mr, + /{,usr/}lib{,32,64,x32}/{,@{multiarch}/}libreadline{,-[0-9]*}.so* mr, + /{,usr/}lib{,32,64,x32}/{,@{multiarch}/{,atomics/}}librt{,-[0-9]*}.so* mr, + /{,usr/}lib{,32,64,x32}/{,@{multiarch}/}libgcc_s.so* mr, + /{,usr/}lib{,32,64,x32}/{,@{multiarch}/}libncursesw{,-[0-9]*}.so* mr, + /{,usr/}lib{,32,64,x32}/{,@{multiarch}/{,atomics/}}libresolv{,-[0-9]*}.so* mr, + /{,usr/}lib{,32,64,x32}/{,@{multiarch}/}libselinux.so* mr, + /{,usr/}lib{,32,64,x32}/{,@{multiarch}/}libpcre{,2}{,-[0-9]*}.so* mr, + /{,usr/}lib{,32,64,x32}/{,@{multiarch}/}libmount.so* mr, + /{,usr/}lib{,32,64,x32}/{,@{multiarch}/}libblkid.so* mr, + /{,usr/}lib{,32,64,x32}/{,@{multiarch}/}libuuid.so* mr, + # normal libs in order + /{,usr/}lib{,32,64,x32}/{,@{multiarch}/}libapparmor.so* mr, + /{,usr/}lib{,32,64,x32}/{,@{multiarch}/}libcgmanager.so* mr, + /{,usr/}lib{,32,64,x32}/{,@{multiarch}/{,atomics/}}libdl{,-[0-9]*}.so* mr, + /{,usr/}lib{,32,64,x32}/{,@{multiarch}/}libnih.so* mr, + /{,usr/}lib{,32,64,x32}/{,@{multiarch}/}libnih-dbus.so* mr, + /{,usr/}lib{,32,64,x32}/{,@{multiarch}/}libdbus-1.so* mr, + /{,usr/}lib{,32,64,x32}/{,@{multiarch}/}libudev.so* mr, + /{,usr/}lib{,32,64,x32}/{,@{multiarch}/}libseccomp.so* mr, + /{,usr/}lib{,32,64,x32}/{,@{multiarch}/}libcap.so* mr, + + @LIBEXECDIR@/snap-confine mr, + + # This rule is needed when executing from a "base: core" devmode snap on + # UC18 and newer where the /usr/lib/snapd/snap-confine inside the + # "base: core" mount namespace always comes from the snapd snap, and thus + # we will execute snap-confine via this path, and thus need to be able to + # read this path when executing. It's also necessary on classic where both + # the snapd and the core snap are installed at the same time. + # TODO: remove this rule when we stop supporting executing other snaps from + # inside devmode snaps, ideally even in the short term we would only include + # this rule on core only, and specifically uc18 and newer where we need it + #@VERBATIM_LIBEXECDIR_SNAP_CONFINE@ mr, + + /dev/null rw, + /dev/full rw, + /dev/zero rw, + /dev/random r, + /dev/urandom r, + /dev/pts/[0-9]* rw, + /dev/tty rw, + + # cgroup: devices + capability sys_admin, + capability dac_read_search, + capability dac_override, + /sys/fs/cgroup/ r, + /sys/fs/cgroup/devices/ r, + /sys/fs/cgroup/devices/snap.*/ rw, + /sys/fs/cgroup/devices/snap.*/cgroup.procs w, + /sys/fs/cgroup/devices/snap.*/devices.{allow,deny} w, + + # cgroup: freezer + # Allow creating per-snap cgroup freezers and adding snap command (task) + # invocations to the freezer. This allows for reliably enumerating all + # running processes for the snap. In addition, allow enumerating processes + # in the cgroup to determine if it is occupied. + /sys/fs/cgroup/freezer/ r, + /sys/fs/cgroup/freezer/snap.*/ w, + /sys/fs/cgroup/freezer/snap.*/cgroup.procs rw, + /sys/fs/cgroup/ r, + /sys/fs/cgroup/** r, + + # cgroup: reading own cgroup + @{PROC}/@{pid}/cgroup r, + + # cgroup: manage bpf map for device cgroup + /sys/fs/bpf/ r, + /sys/fs/bpf/snap/ rw, + /sys/fs/bpf/snap/* rw, + # s-c may need to raise the memlock limit + capability sys_resource, + + # querying udev + /etc/udev/udev.conf r, + /sys/**/uevent r, + /run/udev/** rw, + /{,usr/}bin/tr ixr, + /usr/lib/locale/** r, + /usr/lib/@{multiarch}/gconv/gconv-modules r, + /usr/lib/@{multiarch}/gconv/gconv-modules.cache r, + + # priv dropping + capability setuid, + capability setgid, + + # changing profile + @{PROC}/[0-9]*/attr/{,apparmor/}exec w, + # Reading current profile + @{PROC}/[0-9]*/attr/{,apparmor/}current r, + # Reading available filesystems + @{PROC}/filesystems r, + + # To find where apparmor is mounted + @{PROC}/[0-9]*/mounts r, + # To find if apparmor is enabled + /sys/module/apparmor/parameters/enabled r, + + # For detecting if we're in a container + /run/systemd/container r, + + # Don't allow changing profile to unconfined or profiles that start with + # '/'. Use 'unsafe' to support snap-exec on armhf and its reliance on + # the environment for determining the capabilities of the architecture. + # 'unsafe' is ok here because the kernel will have already cleared the + # environment as part of launching snap-confine with CAP_SYS_ADMIN. This + # does leave directories as configured by ld.so.preload as well as + # LD_PRELOAD to be set to a library which is in a directory configured by + # ld.so.conf, but access to those locations is mediated by this profile + # (which requires rules for specific locations). + change_profile unsafe /** -> [^u/]**, + change_profile unsafe /** -> u[^n]**, + change_profile unsafe /** -> un[^c]**, + change_profile unsafe /** -> unc[^o]**, + change_profile unsafe /** -> unco[^n]**, + change_profile unsafe /** -> uncon[^f]**, + change_profile unsafe /** -> unconf[^i]**, + change_profile unsafe /** -> unconfi[^n]**, + change_profile unsafe /** -> unconfin[^e]**, + change_profile unsafe /** -> unconfine[^d]**, + change_profile unsafe /** -> unconfined?**, + + # allow changing to a few not caught above + change_profile unsafe /** -> {u,un,unc,unco,uncon,unconf,unconfi,unconfin,unconfine}, + + # LP: #1446794 - when this bug is fixed, change the above to: + # deny change_profile unsafe /** -> {unconfined,/**}, + # change_profile unsafe /** -> **, + + # reading seccomp filters. + # Note 1: We still need to consider .bin extension because of global.bin file. + # Note 2: This rule is not needed because of rule '/var/lib/** rw', however we keep it because at + # some point we want to investigate if we can narrow the scope of the aforementioned rule. + /{tmp/snap.rootfs_*/,}var/lib/snapd/seccomp/bpf/*.bin{,2} r, + + # adding a missing bpf mount + mount fstype=bpf options=(rw) bpf -> /sys/fs/bpf/, + + # For mounting base dir by dir (write dirs and mount on them) + /tmp/snap.rootfs_** rw, + mount options=(remount ro) -> /tmp/snap.rootfs_*/, + mount options=(rw rbind) @SNAP_MOUNT_DIR@/*/*/**/ -> /tmp/snap.rootfs_**/, + # For mounting individual files + mount options=(rw bind) @SNAP_MOUNT_DIR@/*/*/** -> /tmp/snap.rootfs_*/**, + mount options=(rw rslave) -> /tmp/snap.rootfs_**/, + # Allow mounting dirs from / + mount options=(rw rbind) /*/ -> /tmp/snap.rootfs_**/, + + # LP: #1668659 and parallel instaces of classic snaps + mount options=(rw rbind) /snap/ -> /snap/, + mount options=(rw rshared) -> /snap/, + mount options=(rw rbind) /var/lib/snapd/snap/ -> /var/lib/snapd/snap/, + mount options=(rw rshared) -> /var/lib/snapd/snap/, + + # boostrapping the mount namespace + /tmp/snap.rootfs_*/ rw, + mount fstype=tmpfs none -> /tmp/snap.rootfs_*/, + mount options=(rw rshared) -> /, + mount options=(rw bind) /tmp/snap.rootfs_*/ -> /tmp/snap.rootfs_*/, + mount options=(rw unbindable) -> /tmp/snap.rootfs_*/, + # the next line is for classic system + mount options=(rw rbind) @SNAP_MOUNT_DIR@/*/*/ -> /tmp/snap.rootfs_*/, + # the next line is for core system + mount options=(rw rbind) / -> /tmp/snap.rootfs_*/, + # all of the constructed rootfs is a rslave + mount options=(rw rslave) -> /tmp/snap.rootfs_*/, + # bidirectional mounts (for both classic and core) + # NOTE: this doesn't capture the MERGED_USR configuration option so that + # when a distro with merged /usr and / that uses apparmor shows up it + # should be handled here. + /{,run/}media/ w, + mount options=(rw rbind) /{,run/}media/ -> /tmp/snap.rootfs_*/{,run/}media/, + /run/netns/ w, + mount options=(rw rbind) /run/netns/ -> /tmp/snap.rootfs_*/run/netns/, + # unidirectional mounts (only for classic system) + mount options=(rw rbind) /dev/ -> /tmp/snap.rootfs_*/dev/, + mount options=(rw rslave) -> /tmp/snap.rootfs_*/dev/, + + mount options=(rw rbind) /etc/ -> /tmp/snap.rootfs_*/etc/, + mount options=(rw rslave) -> /tmp/snap.rootfs_*/etc/, + + mount options=(rw rbind) /home/ -> /tmp/snap.rootfs_*/home/, + mount options=(rw rslave) -> /tmp/snap.rootfs_*/home/, + + mount options=(rw rbind) /root/ -> /tmp/snap.rootfs_*/root/, + mount options=(rw rslave) -> /tmp/snap.rootfs_*/root/, + + mount options=(rw rbind) /proc/ -> /tmp/snap.rootfs_*/proc/, + mount options=(rw rslave) -> /tmp/snap.rootfs_*/proc/, + + mount options=(rw rbind) /sys/ -> /tmp/snap.rootfs_*/sys/, + mount options=(rw rslave) -> /tmp/snap.rootfs_*/sys/, + + mount options=(rw rbind) /tmp/ -> /tmp/snap.rootfs_*/tmp/, + mount options=(rw rslave) -> /tmp/snap.rootfs_*/tmp/, + + mount options=(rw rbind) /var/lib/dhcp/ -> /tmp/snap.rootfs_*/var/lib/dhcp/, + mount options=(rw rslave) -> /tmp/snap.rootfs_*/var/lib/dhcp/, + + mount options=(rw rbind) /var/lib/snapd/ -> /tmp/snap.rootfs_*/var/lib/snapd/, + mount options=(rw rslave) -> /tmp/snap.rootfs_*/var/lib/snapd/, + + mount options=(rw rbind) /var/snap/ -> /tmp/snap.rootfs_*/var/snap/, + mount options=(rw rslave) -> /tmp/snap.rootfs_*/var/snap/, + + mount options=(rw rbind) /var/tmp/ -> /tmp/snap.rootfs_*/var/tmp/, + # /var/volatile is the default volatile location on Yocto/Poky, typically used with read-only rootfs setups + mount options=(rw rbind) /var/volatile/tmp/ -> /tmp/snap.rootfs_*/var/tmp/, + mount options=(rw rslave) -> /tmp/snap.rootfs_*/var/tmp/, + + mount options=(rw rbind) /run/ -> /tmp/snap.rootfs_*/run/, + mount options=(rw rslave) -> /tmp/snap.rootfs_*/run/, + + mount options=(rw rbind) /var/lib/extrausers/ -> /tmp/snap.rootfs_*/var/lib/extrausers/, + mount options=(rw rslave) -> /tmp/snap.rootfs_*/var/lib/extrausers/, + + mount options=(rw rbind) {,/usr}/lib{,32,64,x32}/modules/ -> /tmp/snap.rootfs_*{,/usr}/lib/modules/, + mount options=(rw rslave) -> /tmp/snap.rootfs_*{,/usr}/lib/modules/, + + mount options=(rw rbind) {,/usr}/lib{,32,64,x32}/firmware/ -> /tmp/snap.rootfs_*{,/usr}/lib/firmware/, + mount options=(rw rslave) -> /tmp/snap.rootfs_*{,/usr}/lib/firmware/, + + mount options=(rw rbind) /var/log/ -> /tmp/snap.rootfs_*/var/log/, + # /var/volatile is the default volatile location on Yocto/Poky, typically used with read-only rootfs setups + mount options=(rw rbind) /var/volatile/log/ -> /tmp/snap.rootfs_*/var/log/, + mount options=(rw rslave) -> /tmp/snap.rootfs_*/var/log/, + + mount options=(rw rbind) /usr/src/ -> /tmp/snap.rootfs_*/usr/src/, + mount options=(rw rslave) -> /tmp/snap.rootfs_*/usr/src/, + + mount options=(rw rbind) /mnt/ -> /tmp/snap.rootfs_*/mnt/, + mount options=(rw rslave) -> /tmp/snap.rootfs_*/mnt/, + + # allow making host snap-exec available inside base snaps + mount options=(rw bind) @LIBEXECDIR@/ -> /tmp/snap.rootfs_*/usr/lib/snapd/, + mount options=(rw slave) -> /tmp/snap.rootfs_*/usr/lib/snapd/, + + # allow making re-execed host snap-exec available inside base snaps + mount options=(ro bind) @SNAP_MOUNT_DIR@/core/*/usr/lib/snapd/ -> /tmp/snap.rootfs_*/usr/lib/snapd/, + # allow making snapd snap tools available inside base snaps + mount options=(ro bind) @SNAP_MOUNT_DIR@/snapd/*/usr/lib/snapd/ -> /tmp/snap.rootfs_*/usr/lib/snapd/, + + mount options=(rw bind) /usr/bin/snapctl -> /tmp/snap.rootfs_*/usr/bin/snapctl, + mount options=(rw slave) -> /tmp/snap.rootfs_*/usr/bin/snapctl, + + # /etc/alternatives (classic and normal mode) + mount options=(rw bind) @SNAP_MOUNT_DIR@/*/*/etc/alternatives/ -> /tmp/snap.rootfs_*/etc/alternatives/, + mount options=(rw bind) @SNAP_MOUNT_DIR@/*/*/etc/ssl/ -> /tmp/snap.rootfs_*/etc/ssl/, + mount options=(rw bind) @SNAP_MOUNT_DIR@/*/*/etc/nsswitch.conf -> /tmp/snap.rootfs_*/etc/nsswitch.conf, + mount options=(rw bind) @SNAP_MOUNT_DIR@/*/*/etc/apparmor/ -> /tmp/snap.rootfs_*/etc/apparmor/, + mount options=(rw bind) @SNAP_MOUNT_DIR@/*/*/etc/apparmor.d/ -> /tmp/snap.rootfs_*/etc/apparmor.d/, + + # /etc/alternatives (core/legacy mode) + mount options=(rw bind) /etc/alternatives/ -> /tmp/snap.rootfs_*/etc/alternatives/, + + # making all those directories slave shared. + mount options=(rw slave) -> /tmp/snap.rootfs_*/etc/alternatives/, + mount options=(rw slave) -> /tmp/snap.rootfs_*/etc/ssl/, + mount options=(rw slave) -> /tmp/snap.rootfs_*/etc/nsswitch.conf, + mount options=(rw slave) -> /tmp/snap.rootfs_*/etc/apparmor/, + mount options=(rw slave) -> /tmp/snap.rootfs_*/etc/apparmor.d/, + + # the /snap directory + mount options=(rw rbind) @SNAP_MOUNT_DIR@/ -> /tmp/snap.rootfs_*/snap/, + mount options=(rw rslave) -> /tmp/snap.rootfs_*/snap/, + # pivot_root preparation and execution + mount options=(rw bind) /tmp/snap.rootfs_*/var/lib/snapd/hostfs/ -> /tmp/snap.rootfs_*/var/lib/snapd/hostfs/, + mount options=(rw private) -> /tmp/snap.rootfs_*/var/lib/snapd/hostfs/, + + # pivot_root mediation in AppArmor is not complete. See LP: #1791711. + # However, we can mediate the new_root and put_old to be what we expect, + # and then deny directory creation within old_root to prevent trivial + # pivoting into an allowlisted path. + pivot_root oldroot=/tmp/snap.rootfs_*/var/lib/snapd/hostfs/ /tmp/snap.rootfs_*/, + # Explicitly deny creating the old_root directory in case it is + # inadvertently added somewhere else. While this doesn't resolve + # LP: #1791711, it provides some hardening. + # For dir on dir mounts, we do need write permissions in /var though + audit deny /tmp/snap.rootfs_*/{var/lib/,var/lib/snapd/,var/lib/snapd/hostfs/} w, + + # cleanup + umount /var/lib/snapd/hostfs/tmp/snap.rootfs_*/, + umount /var/lib/snapd/hostfs/sys/, + umount /var/lib/snapd/hostfs/dev/, + umount /var/lib/snapd/hostfs/proc/, + mount options=(rw rslave) -> /var/lib/snapd/hostfs/, + + # Hide /writable from view of snaps. + mount options=(rprivate) -> /{,var/lib/snapd/hostfs/}writable/, + umount /{,var/lib/snapd/hostfs/}writable/, + + # set up user mount namespace + mount options=(rslave) -> /, + + # set up mount namespace for parallel instances of classic snaps + mount options=(rw rbind) @SNAP_MOUNT_DIR@/{,*/} -> @SNAP_MOUNT_DIR@/{,*/}, + mount options=(rslave) -> @SNAP_MOUNT_DIR@/, + mount options=(rslave) -> /var/snap/, + mount options=(rw rbind) /var/snap/{,*/} -> /var/snap/{,*/}, + mount options=(rw rshared) -> /var/snap/, + + # Allow reading the os-release file (possibly a symlink to /usr/lib). + /{etc/,usr/lib/}os-release r, + + # Allow creating /var/lib/snapd/hostfs, if missing + /var/lib/snapd/hostfs/ rw, + + # set up snap-specific private /tmp dir + capability chown, + /tmp/ rw, + /tmp/snap-private-tmp/ rw, + /tmp/snap-private-tmp/snap.*/ rw, + /tmp/snap-private-tmp/snap.*/tmp/ rw, + mount options=(rw private) -> /tmp/, + mount options=(rw bind) /tmp/snap-private-tmp/snap.*/tmp/ -> /tmp/, + mount fstype=devpts options=(rw) devpts -> /dev/pts/, + mount options=(rw bind) /dev/pts/ptmx -> /dev/ptmx, # for bind mounting + mount options=(rw bind) /dev/pts/ptmx -> /dev/pts/ptmx, # for bind mounting under LXD + # Workaround for LP: #1584456 on older kernels that mistakenly think + # /dev/pts/ptmx needs a trailing '/' + mount options=(rw bind) /dev/pts/ptmx/ -> /dev/ptmx/, + mount options=(rw bind) /dev/pts/ptmx/ -> /dev/pts/ptmx/, + + # for running snaps on classic + /snap/ r, + /snap/** r, + @SNAP_MOUNT_DIR@/ r, + @SNAP_MOUNT_DIR@/** r, + + # NOTE: at this stage the /snap directory is stable as we have called + # pivot_root already. + + # nvidia handling, glob needs /usr/** and the launcher must be + # able to bind mount the nvidia dir + /sys/module/nvidia/version r, + /sys/**/drivers/nvidia{,_*}/* r, + /sys/**/nvidia*/uevent r, + /sys/module/nvidia{,_*}/* r, + /dev/nvidia[0-9]* r, + /dev/nvidiactl r, + /dev/nvidia-uvm r, + /usr/** r, + mount options=(rw bind) /usr/lib{,32}/nvidia-*/ -> /{tmp/snap.rootfs_*/,}var/lib/snapd/lib/gl{,32}/, + mount options=(rw bind) /usr/lib{,32}/nvidia-*/ -> /{tmp/snap.rootfs_*/,}var/lib/snapd/lib/gl{,32}/, + /tmp/snap.rootfs_*/var/lib/snapd/lib/gl{,32}/{,*} w, + mount fstype=tmpfs options=(rw nodev noexec) none -> /tmp/snap.rootfs_*/var/lib/snapd/lib/gl{,32}/, + mount options=(remount ro bind) -> /tmp/snap.rootfs_*/var/lib/snapd/lib/gl{,32}/, + + # Vulkan support + /tmp/snap.rootfs_*/var/lib/snapd/lib/vulkan/{,*} w, + mount fstype=tmpfs options=(rw nodev noexec) none -> /tmp/snap.rootfs_*/var/lib/snapd/lib/vulkan/, + mount options=(remount ro bind) -> /tmp/snap.rootfs_*/var/lib/snapd/lib/vulkan/, + + # GLVND EGL vendor + /tmp/snap.rootfs_*/var/lib/snapd/lib/glvnd/{,*} w, + mount fstype=tmpfs options=(rw nodev noexec) none -> /tmp/snap.rootfs_*/var/lib/snapd/lib/glvnd/, + mount options=(remount ro bind) -> /tmp/snap.rootfs_*/var/lib/snapd/lib/glvnd/, + + # create gl dirs as needed + /tmp/snap.rootfs_*/ r, + /tmp/snap.rootfs_*/var/ r, + /tmp/snap.rootfs_*/var/lib/ r, + /tmp/snap.rootfs_*/var/lib/snapd/ r, + /tmp/snap.rootfs_*/var/lib/snapd/lib/ r, + /tmp/snap.rootfs_*/var/lib/snapd/lib/gl{,32}/ r, + /tmp/snap.rootfs_*/var/lib/snapd/lib/gl{,32}/** rw, + /tmp/snap.rootfs_*/var/lib/snapd/lib/vulkan/ r, + /tmp/snap.rootfs_*/var/lib/snapd/lib/vulkan/** rw, + /tmp/snap.rootfs_*/var/lib/snapd/lib/glvnd/ r, + /tmp/snap.rootfs_*/var/lib/snapd/lib/glvnd/** rw, + + # for chroot on steroids, we use pivot_root as a better chroot that makes + # apparmor rules behave the same on classic and outside of classic. + + # for creating the user data directories: ~/snap, ~/snap/ and + # ~/snap// + / r, + @{HOMEDIRS}/ r, + # These should both have 'owner' match but due to LP: #1466234, we can't + # yet + @{HOME}/ r, + @{HOME}/snap/{,*/,*/*/} rw, + + # experimental + @{HOME}/.snap/ rw, + @{HOME}/.snap/data/{,*/,*/*/} rw, + @{HOME}/Snap/{,*/,*/*/} rw, + + # Special case for *classic* snaps that are used by users with existing dirs + # in /var/lib/. Like jenkins, postgresql, mysql, puppet, ... + # (see https://forum.snapcraft.io/t/9717) + # TODO: this can be removed once we support home-dirs outside of /home + # better + /var/ r, + /var/lib/ r, + # These should both have 'owner' match but due to LP: #1466234, we can't + # yet + /var/lib/*/ r, + /var/lib/*/snap/{,*/,*/*/} rw, + + # for creating the user shared memory directories + /{dev,run}/{,shm/} r, + # This should both have 'owner' match but due to LP: #1466234, we can't yet + /{dev,run}/shm/{,*/,*/*/} rw, + + # for creating the user XDG_RUNTIME_DIR: /run/user, /run/user/UID and + # /run/user/UID/ + /run/user/{,[0-9]*/,[0-9]*/*/} rw, + + # Workaround https://launchpad.net/bugs/359338 until upstream handles + # stacked filesystems generally. + # encrypted ~/.Private and old-style encrypted $HOME + @{HOME}/.Private/ r, + @{HOME}/.Private/** mrwlk, + # new-style encrypted $HOME + @{HOMEDIRS}/.ecryptfs/*/.Private/ r, + @{HOMEDIRS}/.ecryptfs/*/.Private/** mrwlk, + + # Allow snap-confine to move to the void, creating it if necessary. + /var/lib/snapd/void/ rw, + + # Allow snap-confine to read snap contexts + /var/lib/snapd/context/snap.* r, + + # Allow snap-confine to unmount stale mount namespaces. + umount /run/snapd/ns/*.mnt, + /run/snapd/ns/snap.*.fstab w, + # Allow snap-confine to read and write mount namespace information files. + /run/snapd/ns/snap.*.info rw, + # Required to correctly unmount bound mount namespace. + # See LP: #1735459 for details. + umount /, + + # support for locking + /run/snapd/lock/ rw, + /run/snapd/lock/*.lock rwk, + + # support for the mount namespace sharing + capability sys_ptrace, + # allow snap-confine to read /proc/1/ns/mnt + ptrace read peer=unconfined, + # https://forum.snapcraft.io/t/custom-kernel-error-on-readlinkat-in-mount-namespace/6097/21 + ptrace trace peer=unconfined, + + mount options=(rw rbind) /run/snapd/ns/ -> /run/snapd/ns/, + mount options=(private) -> /run/snapd/ns/, + / rw, + /run/ rw, + /run/snapd/ rw, + /run/snapd/ns/ rw, + /run/snapd/ns/*.lock rwk, + /run/snapd/ns/*.mnt rw, + ptrace (read, readby, tracedby) peer=@LIBEXECDIR@/snap-confine//mount-namespace-capture-helper, + @{PROC}/*/mountinfo r, + capability sys_chroot, + capability sys_admin, + signal (send, receive) set=(abrt) peer=@LIBEXECDIR@/snap-confine, + signal (send) set=(int) peer=@LIBEXECDIR@/snap-confine//mount-namespace-capture-helper, + signal (send, receive) set=(int, alrm, exists) peer=@LIBEXECDIR@/snap-confine, + signal (receive) set=(exists) peer=@LIBEXECDIR@/snap-confine//mount-namespace-capture-helper, + + # workaround for linux 4.13/upstream, see + # https://forum.snapcraft.io/t/snapd-2-27-6-2-in-debian-sid-blocked-on-apparmor-in-kernel-4-13-0-1/2813/3 + ptrace (trace, tracedby) peer=@LIBEXECDIR@/snap-confine, + + # Allow reading snap cookies. + /var/lib/snapd/cookie/snap.* r, + + # For aa_change_hat() to go into ^mount-namespace-capture-helper + @{PROC}/[0-9]*/attr/{,apparmor/}current w, + + # As a special exception allow snap-confine to write to anything in /var/lib. + # This code should be changed to allow delegation so that snap-confine can + # inherit any file descriptor and pass it to the invoked application but + # this is not possible in apparmor yet. + # See https://bugs.launchpad.net/snapd/+bug/1815869 + /var/lib/** rw, + + ^mount-namespace-capture-helper (attach_disconnected) { + # We run privileged, so be fanatical about what we include and don't use + # any abstractions + /etc/ld.so.cache r, + /{,usr/}lib{,32,64,x32}/{,@{multiarch}/{,atomics/}}ld{-*,64}.so* mrix, + # libc, you are funny + /{,usr/}lib{,32,64,x32}/{,@{multiarch}/{,atomics/}}libc{,-[0-9]*}.so* mr, + /{,usr/}lib{,32,64,x32}/{,@{multiarch}/{,atomics/}}libpthread{,-[0-9]*}.so* mr, + /{,usr/}lib{,32,64,x32}/{,@{multiarch}/}libreadline{,-[0-9]*}.so* mr, + /{,usr/}lib{,32,64,x32}/{,@{multiarch}/{,atomics/}}librt{,-[0-9]*}.so* mr, + /{,usr/}lib{,32,64,x32}/{,@{multiarch}/}libgcc_s.so* mr, + /{,usr/}lib{,32,64,x32}/{,@{multiarch}/}libncursesw{,-[0-9]*}.so* mr, + /{,usr/}lib{,32,64,x32}/{,@{multiarch}/{,atomics/}}libresolv{,-[0-9]*}.so* mr, + /{,usr/}lib{,32,64,x32}/{,@{multiarch}/}libselinux.so* mr, + /{,usr/}lib{,32,64,x32}/{,@{multiarch}/}libpcre.so* mr, + /{,usr/}lib{,32,64,x32}/{,@{multiarch}/}libmount.so* mr, + /{,usr/}lib{,32,64,x32}/{,@{multiarch}/}libblkid.so* mr, + /{,usr/}lib{,32,64,x32}/{,@{multiarch}/}libuuid.so* mr, + # normal libs in order + /{,usr/}lib{,32,64,x32}/{,@{multiarch}/}libapparmor.so* mr, + /{,usr/}lib{,32,64,x32}/{,@{multiarch}/}libcgmanager.so* mr, + /{,usr/}lib{,32,64,x32}/{,@{multiarch}/{,atomics/}}libdl{,-[0-9]*}.so* mr, + /{,usr/}lib{,32,64,x32}/{,@{multiarch}/}libnih.so* mr, + /{,usr/}lib{,32,64,x32}/{,@{multiarch}/}libnih-dbus.so* mr, + /{,usr/}lib{,32,64,x32}/{,@{multiarch}/}libdbus-1.so* mr, + /{,usr/}lib{,32,64,x32}/{,@{multiarch}/}libudev.so* mr, + /{,usr/}lib{,32,64,x32}/{,@{multiarch}/}libseccomp.so* mr, + /{,usr/}lib{,32,64,x32}/{,@{multiarch}/}libcap.so* mr, + + @LIBEXECDIR@/snap-confine mr, + + /dev/null rw, + /dev/full rw, + /dev/zero rw, + /dev/random r, + /dev/urandom r, + + capability sys_ptrace, + capability sys_admin, + # This allows us to read and bind mount the namespace file + / r, + @{PROC}/ r, + @{PROC}/*/ r, + @{PROC}/*/ns/ r, + @{PROC}/*/ns/mnt r, + /run/ r, + /run/snapd/ r, + /run/snapd/ns/ r, + /run/snapd/ns/*.mnt rw, + # NOTE: the source name is / even though we map /proc/123/ns/mnt + mount options=(rw bind) / -> /run/snapd/ns/*.mnt, + # This is the SIGALRM that we send and receive if a timeout expires + signal (send, receive) set=(alrm) peer=@LIBEXECDIR@/snap-confine//mount-namespace-capture-helper, + # Those two rules are exactly the same but we don't know if the parent process is still alive + # and hence has the appropriate label or is already dead and hence has no label. + signal (send) set=(exists) peer=@LIBEXECDIR@/snap-confine, + signal (send) set=(exists) peer=unconfined, + # This is so that we can abort + signal (send, receive) set=(abrt) peer=@LIBEXECDIR@/snap-confine//mount-namespace-capture-helper, + # This is the signal we get if snap-confine dies (we subscribe to it with prctl) + signal (receive) set=(int) peer=@LIBEXECDIR@/snap-confine, + # This allows snap-confine to be killed from the outside. + signal (receive) peer=unconfined, + # This allows snap-confine to wait for us + ptrace (read, trace, tracedby) peer=@LIBEXECDIR@/snap-confine, + } + + # Allow snap-confine to be killed + signal (receive) peer=unconfined, + + # Allow switching to snap-update-ns with a per-snap profile. + change_profile -> snap-update-ns.*, + + # Allow executing snap-update-ns when... + + # ...snap-confine is, conceptually, re-executing and uses snap-update-ns + # from the distribution package. This is also the location used when using + # the core/base snap on all-snap systems. The variants here represent + # various locations of libexecdir across distributions. + /usr/lib{,exec,64}/snapd/snap-update-ns r, + + # ...snap-confine is not, conceptually, re-executing and uses + # snap-update-ns from the distribution package but we are already inside + # the constructed mount namespace so we must traverse "hostfs". The + # variants here represent various locations of libexecdir across + # distributions. + /var/lib/snapd/hostfs/usr/lib{,exec,64}/snapd/snap-update-ns r, + + # ..snap-confine is, conceptually, re-executing and uses snap-update-ns + # from the core or snapd snaps. Note that the location of the actual snap + # varies from distribution to distribution. The variants here represent + # different locations of snap mount directory across distributions. + /{,var/lib/snapd/}snap/{core,snapd}/*/usr/lib/snapd/snap-update-ns r, + + # ...snap-confine is, conceptually, re-executing and uses snap-update-ns + # from the core snap or snapd snap, but we are already inside the + # constructed mount namespace. Here the apparmor kernel module + # re-constructs the path to snap-update-ns using the "hostfs" mount entry + # rather than the more "natural" /snap mount entry but we have no control + # over that. This is reported as (LP: #1716339). The variants here + # represent different locations of snap mount directory across + # distributions. + /var/lib/snapd/hostfs/{,var/lib/snapd/}snap/{core,snapd}/*/usr/lib/snapd/snap-update-ns r, + + # Allow executing snap-discard-ns, just like the set for snap-update-ns + # above but with the key difference that snap-discard-ns does not + # have a dedicated profile so we need to inherit snap-confine's profile. + + /usr/lib{,exec,64}/snapd/snap-discard-ns rix, + /var/lib/snapd/hostfs/usr/lib{,exec,64}/snapd/snap-discard-ns rix, + /{,var/lib/snapd/}snap/{core,snapd}/*/usr/lib/snapd/snap-discard-ns rix, + /var/lib/snapd/hostfs/{,var/lib/snapd/}snap/{core,snapd}/*/usr/lib/snapd/snap-discard-ns rix, + + # Allow mounting /var/lib/jenkins from the host into the snap. + mount options=(rw rbind) /var/lib/jenkins/ -> /tmp/snap.rootfs_*/var/lib/jenkins/, + mount options=(rw rslave) -> /tmp/snap.rootfs_*/var/lib/jenkins/, + + # Suppress noisy file_inherit denials (LP: #1850552) until LP: #1849753 is + # fixed. + deny /dev/shm/.org.chromium.Chromium.* rw, + + # While snap-confine itself doesn't require unix rules and therefore all + # unix rules are implicitly denied, adding an explicit deny for unix to + # silence noisy denials breaks nested lxd. Until the cause is determined, + # do not use an explicit deny for unix. (LP: #1855355) + #deny unix, + + # Explicitly deny these accesses which show up on Arch to silence the + # denials for this unneeded access. + deny /{,usr/}lib{,32,64,x32}/{,@{multiarch}/}libnss_files-[0-9]*.so* mr, + deny /{,usr/}lib{,32,64,x32}/{,@{multiarch}/}libnss_mymachines.[0-9]*.so* mr, + deny /{,usr/}lib{,32,64,x32}/{,@{multiarch}/}libnss_systemd.[0-9]*.so* mr, + deny /etc/nsswitch.conf r, + deny /etc/passwd r, +} diff --git a/cmd/snap-confine/snap-confine.c b/cmd/snap-confine/snap-confine.c new file mode 100644 index 00000000..b4accc1d --- /dev/null +++ b/cmd/snap-confine/snap-confine.c @@ -0,0 +1,882 @@ +/* + * Copyright (C) 2015-2018 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +#ifdef HAVE_CONFIG_H +#include "config.h" +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../libsnap-confine-private/apparmor-support.h" +#include "../libsnap-confine-private/cgroup-freezer-support.h" +#include "../libsnap-confine-private/cgroup-support.h" +#include "../libsnap-confine-private/classic.h" +#include "../libsnap-confine-private/cleanup-funcs.h" +#include "../libsnap-confine-private/feature.h" +#include "../libsnap-confine-private/infofile.h" +#include "../libsnap-confine-private/locking.h" +#include "../libsnap-confine-private/secure-getenv.h" +#include "../libsnap-confine-private/snap.h" +#include "../libsnap-confine-private/string-utils.h" +#include "../libsnap-confine-private/tool.h" +#include "../libsnap-confine-private/utils.h" +#include "cookie-support.h" +#include "mount-support.h" +#include "ns-support.h" +#include "seccomp-support.h" +#include "snap-confine-args.h" +#include "snap-confine-invocation.h" +#include "udev-support.h" +#include "user-support.h" +#ifdef HAVE_SELINUX +#include "selinux-support.h" +#endif + +// sc_maybe_fixup_permissions fixes incorrect permissions +// inside the mount namespace for /var/lib. Before 1ccce4 +// this directory was created with permissions 1777. +static void sc_maybe_fixup_permissions(void) +{ + int fd SC_CLEANUP(sc_cleanup_close) = -1; + struct stat buf; + fd = open("/var/lib", O_PATH | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW); + if (fd < 0) { + die("cannot open /var/lib"); + } + if (fstat(fd, &buf) < 0) { + die("cannot stat /var/lib"); + } + if ((buf.st_mode & 0777) == 0777) { + if (fchmod(fd, 0755) != 0) { + die("cannot chmod /var/lib"); + } + if (fchown(fd, 0, 0) != 0) { + die("cannot chown /var/lib"); + } + } +} + +// sc_maybe_fixup_udev will remove incorrectly created udev tags +// that cause libudev on 16.04 to fail with "udev_enumerate_scan failed". +// See also: +// https://forum.snapcraft.io/t/weird-udev-enumerate-error/2360/17 +static void sc_maybe_fixup_udev(void) +{ + glob_t glob_res SC_CLEANUP(globfree) = { + .gl_pathv = NULL,.gl_pathc = 0,.gl_offs = 0, + }; + const char *glob_pattern = "/run/udev/tags/snap_*/*nvidia*"; + int err = glob(glob_pattern, 0, NULL, &glob_res); + if (err == GLOB_NOMATCH) { + return; + } + if (err != 0) { + die("cannot search using glob pattern %s: %d", + glob_pattern, err); + } + // kill bogus udev tags for nvidia. They confuse udev, this + // undoes the damage from github.com/snapcore/snapd/pull/3671. + // + // The udev tagging of nvidia got reverted in: + // https://github.com/snapcore/snapd/pull/4022 + // but leftover files need to get removed or apps won't start + for (size_t i = 0; i < glob_res.gl_pathc; ++i) { + unlink(glob_res.gl_pathv[i]); + } +} + +/** + * sc_preserved_process_state remembers clobbered state to restore. + * + * The umask is preserved and restored to ensure consistent permissions for + * runtime system. The value is preserved and restored perfectly. +**/ +typedef struct sc_preserved_process_state { + mode_t orig_umask; + int orig_cwd_fd; + struct stat file_info_orig_cwd; +} sc_preserved_process_state; + +/** + * sc_preserve_and_sanitize_process_state sanitizes process state. + * + * The following process state is sanitized: + * - the umask is set to 0 + * - the current working directory is set to / + * + * The original values are stored to be restored later. Currently only the + * umask is altered. It is set to zero to make the ownership of created files + * and directories more predictable. +**/ +static void sc_preserve_and_sanitize_process_state(sc_preserved_process_state + *proc_state) +{ + /* Reset umask to zero, storing the old value. */ + proc_state->orig_umask = umask(0); + debug("umask reset, old umask was %#4o", proc_state->orig_umask); + /* Remember a file descriptor corresponding to the original working + * directory. This is an O_PATH file descriptor. The descriptor is + * used as explained below. */ + proc_state->orig_cwd_fd = + openat(AT_FDCWD, ".", + O_PATH | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW); + if (proc_state->orig_cwd_fd < 0) { + die("cannot open path of the current working directory"); + } + if (fstat(proc_state->orig_cwd_fd, &proc_state->file_info_orig_cwd) < 0) { + die("cannot stat path of the current working directory"); + } + /* Move to the root directory. */ + if (chdir("/") < 0) { + die("cannot move to /"); + } +} + +/** + * sc_restore_process_state restores values stored earlier. +**/ +static void sc_restore_process_state(const sc_preserved_process_state + *proc_state) +{ + /* Restore original umask */ + umask(proc_state->orig_umask); + debug("umask restored to %#4o", proc_state->orig_umask); + + /* Restore original current working directory. + * + * This part is more involved for the following reasons. While we hold an + * O_PATH file descriptor that still points to the original working + * directory, that directory may not be representable in the target mount + * namespace. A quick example may be /custom that exists on the host but + * not in the base snap of the application. + * + * Also consider when the path of the original working directory now + * maps to a different inode we cannot use fchdir(2). One example of + * that is the /tmp directory, which exists in both the host mount + * namespace and the per-snap mount namespace but actually represents a + * different directory. + **/ + + /* Read the target of symlink at /proc/self/fd/ */ + char fd_path[PATH_MAX] = { 0 }; + char orig_cwd[PATH_MAX] = { 0 }; + ssize_t nread; + /* If the original working directory cannot be used for whatever reason then + * move the process to a special void directory. */ + const char *sc_void_dir = "/var/lib/snapd/void"; + int void_dir_fd SC_CLEANUP(sc_cleanup_close) = -1; + + sc_must_snprintf(fd_path, sizeof fd_path, "/proc/self/fd/%d", + proc_state->orig_cwd_fd); + nread = readlink(fd_path, orig_cwd, sizeof orig_cwd); + if (nread < 0) { + die("cannot read symbolic link target %s", fd_path); + } + if (nread == sizeof orig_cwd) { + die("cannot fit symbolic link target %s", fd_path); + } + orig_cwd[nread] = 0; + + /* Open path corresponding to the original working directory in the + * execution environment. This may normally fail if the path no longer + * exists here, this is not a fatal error. It may also fail if we don't + * have permissions to view that path, that is not a fatal error either. */ + int inner_cwd_fd SC_CLEANUP(sc_cleanup_close) = -1; + inner_cwd_fd = + open(orig_cwd, O_PATH | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW); + if (inner_cwd_fd < 0) { + if (errno == EPERM || errno == EACCES || errno == ENOENT) { + debug + ("cannot open path of the original working directory %s", + orig_cwd); + goto the_void; + } + /* Any error other than the three above is unexpected. */ + die("cannot open path of the original working directory %s", + orig_cwd); + } + + /* The original working directory exists in the execution environment + * which lets us check if it points to the same inode as before. */ + struct stat file_info_inner; + if (fstat(inner_cwd_fd, &file_info_inner) < 0) { + die("cannot stat path of working directory in the execution environment"); + } + + /* Note that we cannot use proc_state->orig_cwd_fd as that points to the + * directory but in another mount namespace and using that causes + * weird and undesired effects. + * + * By the time this code runs we are already running as the + * designated user so UNIX permissions are in effect. */ + if (fchdir(inner_cwd_fd) < 0) { + if (errno == EPERM || errno == EACCES) { + debug("cannot access original working directory %s", + orig_cwd); + goto the_void; + } + die("cannot restore original working directory via path"); + } + /* The distinction below is only logged and not acted upon. Perhaps someday + * this will be somehow communicated to cooperating applications that can + * instruct the user and avoid potential confusion. This mostly applies to + * tools that are invoked from /tmp. */ + if (proc_state->file_info_orig_cwd.st_dev == + file_info_inner.st_dev + && proc_state->file_info_orig_cwd.st_ino == + file_info_inner.st_ino) { + /* The path of the original working directory points to the same + * inode as before. */ + debug("working directory restored to %s", orig_cwd); + } else { + /* The path of the original working directory points to a different + * inode inside inside the execution environment than the host + * environment. */ + debug("working directory re-interpreted to %s", orig_cwd); + } + return; + the_void: + /* The void directory may be absent. On core18 system, and other + * systems using bootable base snap coupled with snapd snap, the + * /var/lib/snapd directory structure is not provided with packages but + * created on demand. */ + void_dir_fd = open(sc_void_dir, + O_DIRECTORY | O_PATH | O_NOFOLLOW | O_CLOEXEC); + if (void_dir_fd < 0 && errno == ENOENT) { + if (mkdir(sc_void_dir, 0111) < 0) { + die("cannot create void directory: %s", sc_void_dir); + } + if (lchown(sc_void_dir, 0, 0) < 0) { + die("cannot change ownership of void directory %s", + sc_void_dir); + } + void_dir_fd = open(sc_void_dir, + O_DIRECTORY | O_PATH | O_NOFOLLOW | + O_CLOEXEC); + } + if (void_dir_fd < 0) { + die("cannot open the void directory %s", sc_void_dir); + } + if (fchdir(void_dir_fd) < 0) { + die("cannot move to void directory %s", sc_void_dir); + } + debug("the process has been placed in the special void directory"); +} + +static void log_startup_stage(const char *stage) +{ + if (!sc_is_debug_enabled()) { + return; + } + struct timeval tv; + gettimeofday(&tv, NULL); + debug("-- snap startup {\"stage\":\"%s\", \"time\":\"%lu.%06lu\"}", + stage, tv.tv_sec, tv.tv_usec); +} + +/** + * sc_cleanup_preserved_process_state releases system resources. +**/ +static void sc_cleanup_preserved_process_state(sc_preserved_process_state + *proc_state) +{ + sc_cleanup_close(&proc_state->orig_cwd_fd); +} + +static void enter_classic_execution_environment(const sc_invocation * inv, + gid_t real_gid, + gid_t saved_gid); +static void enter_non_classic_execution_environment(sc_invocation * inv, + struct sc_apparmor *aa, + uid_t real_uid, + gid_t real_gid, + gid_t saved_gid); + +int main(int argc, char **argv) +{ + log_startup_stage("snap-confine enter"); + // Use our super-defensive parser to figure out what we've been asked to do. + sc_error *err = NULL; + struct sc_args *args SC_CLEANUP(sc_cleanup_args) = NULL; + sc_preserved_process_state proc_state + SC_CLEANUP(sc_cleanup_preserved_process_state) = { + .orig_umask = 0,.orig_cwd_fd = -1 + }; + args = sc_nonfatal_parse_args(&argc, &argv, &err); + sc_die_on_error(err); + + // Remember certain properties of the process that are clobbered by + // snap-confine during execution. Those are restored just before calling + // execv. + sc_preserve_and_sanitize_process_state(&proc_state); + + // We've been asked to print the version string so let's just do that. + if (sc_args_is_version_query(args)) { + printf("%s %s\n", PACKAGE, PACKAGE_VERSION); + return 0; + } + + /* Collect all invocation parameters. This gives us authoritative + * information about what needs to be invoked and how. The data comes + * from either the environment or from command line arguments */ + sc_invocation SC_CLEANUP(sc_cleanup_invocation) invocation; + const char *snap_instance_name_env = getenv("SNAP_INSTANCE_NAME"); + if (snap_instance_name_env == NULL) { + die("SNAP_INSTANCE_NAME is not set"); + } + sc_init_invocation(&invocation, args, snap_instance_name_env); + + // Who are we? + uid_t real_uid, effective_uid, saved_uid; + gid_t real_gid, effective_gid, saved_gid; + if (getresuid(&real_uid, &effective_uid, &saved_uid) != 0) { + die("getresuid failed"); + } + if (getresgid(&real_gid, &effective_gid, &saved_gid) != 0) { + die("getresgid failed"); + } + debug("ruid: %d, euid: %d, suid: %d", + real_uid, effective_uid, saved_uid); + debug("rgid: %d, egid: %d, sgid: %d", + real_gid, effective_gid, saved_gid); + + // snap-confine needs to run as root for cgroup/udev/mount/apparmor/etc setup. + if (effective_uid != 0) { + die("need to run as root or suid"); + } + + char *snap_context SC_CLEANUP(sc_cleanup_string) = NULL; + // Do no get snap context value if running a hook (we don't want to overwrite hook's SNAP_COOKIE) + if (!sc_is_hook_security_tag(invocation.security_tag)) { + sc_error *err SC_CLEANUP(sc_cleanup_error) = NULL; + snap_context = + sc_cookie_get_from_snapd(invocation.snap_instance, &err); + /* While the cookie is normally present due to various protection + * mechanisms ensuring its creation from snapd, we are not considering + * it a critical error for snap-confine in the case it is absent. When + * absent snaps attempting to utilize snapctl to interact with snapd + * will fail but it is more important to run a little than break + * entirely in case snapd-side code is incorrect. Therefore error + * information is collected but discarded. */ + } + + struct sc_apparmor apparmor; + sc_init_apparmor_support(&apparmor); + if (!apparmor.is_confined && apparmor.mode != SC_AA_NOT_APPLICABLE + && getuid() != 0 && geteuid() == 0) { + // Refuse to run when this process is running unconfined on a system + // that supports AppArmor when the effective uid is root and the real + // id is non-root. This protects against, for example, unprivileged + // users trying to leverage the snap-confine in the core snap to + // escalate privileges. + errno = 0; // errno is insignificant here + die("snap-confine has elevated permissions and is not confined" + " but should be. Refusing to continue to avoid" + " permission escalation attacks\n" + "Please make sure that the snapd.apparmor service is enabled and started."); + } + + log_startup_stage("snap-confine mount namespace start"); + + /* perform global initialization of mount namespace support for non-classic + * snaps or both classic and non-classic when parallel-instances feature is + * enabled */ + if (!invocation.classic_confinement || + sc_feature_enabled(SC_FEATURE_PARALLEL_INSTANCES)) { + + /* snap-confine uses privately-shared /run/snapd/ns to store bind-mounted + * mount namespaces of each snap. In the case that snap-confine is invoked + * from the mount namespace it typically constructs, the said directory + * does not contain mount entries for preserved namespaces as those are + * only visible in the main, outer namespace. + * + * In order to operate in such an environment snap-confine must first + * re-associate its own process with another namespace in which the + * /run/snapd/ns directory is visible. The most obvious candidate is pid + * one, which definitely doesn't run in a snap-specific namespace, has a + * predictable PID and is long lived. + */ + sc_reassociate_with_pid1_mount_ns(); + // Do global initialization: + int global_lock_fd = sc_lock_global(); + // Ensure that "/" or "/snap" is mounted with the + // "shared" option on legacy systems, see LP:#1668659 + debug("ensuring that snap mount directory is shared"); + sc_ensure_shared_snap_mount(); + unsigned int experimental_features = 0; + if (sc_feature_enabled(SC_FEATURE_PARALLEL_INSTANCES)) { + experimental_features |= SC_FEATURE_PARALLEL_INSTANCES; + } + sc_initialize_mount_ns(experimental_features); + sc_unlock(global_lock_fd); + } + + if (invocation.classic_confinement) { + enter_classic_execution_environment(&invocation, real_gid, + saved_gid); + } else { + enter_non_classic_execution_environment(&invocation, + &apparmor, + real_uid, + real_gid, saved_gid); + } + + log_startup_stage("snap-confine mount namespace finish"); + + // Temporarily drop privileges back to the calling user until we can + // permanently drop (which we can't do just yet due to seccomp, see + // below). + sc_identity real_user_identity = { + .uid = real_uid, + .gid = real_gid, + .change_uid = 1, + .change_gid = 1, + }; + sc_set_effective_identity(real_user_identity); + // Ensure that the user data path exists. When creating it use the identity + // of the calling user (by using real user and group identifiers). This + // allows the creation of directories inside ~/ on NFS with root_squash + // attribute. + setup_user_data(); +#if 0 + setup_user_xdg_runtime_dir(); +#endif + // https://wiki.ubuntu.com/SecurityTeam/Specifications/SnappyConfinement + sc_maybe_aa_change_onexec(&apparmor, invocation.security_tag); +#ifdef HAVE_SELINUX + // For classic and confined snaps + sc_selinux_set_snap_execcon(); +#endif + if (snap_context != NULL) { + setenv("SNAP_COOKIE", snap_context, 1); + // for compatibility, if facing older snapd. + setenv("SNAP_CONTEXT", snap_context, 1); + } + // Normally setuid/setgid not only permanently drops the UID/GID, but + // also clears the capabilities bounding sets (see "Effect of user ID + // changes on capabilities" in 'man capabilities'). To load a seccomp + // profile, we need either CAP_SYS_ADMIN or PR_SET_NO_NEW_PRIVS. Since + // NNP causes issues with AppArmor and exec transitions in certain + // snapd interfaces, keep CAP_SYS_ADMIN temporarily when we are + // permanently dropping privileges. + if (getresuid(&real_uid, &effective_uid, &saved_uid) != 0) { + die("getresuid failed"); + } + debug("ruid: %d, euid: %d, suid: %d", + real_uid, effective_uid, saved_uid); + struct __user_cap_header_struct hdr = + { _LINUX_CAPABILITY_VERSION_3, 0 }; + struct __user_cap_data_struct cap_data[2] = { {0} }; + + // At this point in time, if we are going to permanently drop our + // effective_uid will not be '0' but our saved_uid will be '0'. Detect + // and save when we are in the this state so know when to setup the + // capabilities bounding set, regain CAP_SYS_ADMIN and later drop it. + bool keep_sys_admin = effective_uid != 0 && saved_uid == 0; + if (keep_sys_admin) { + debug("setting capabilities bounding set"); + // clear all 32 bit caps but SYS_ADMIN, with none inheritable + cap_data[0].effective = CAP_TO_MASK(CAP_SYS_ADMIN); + cap_data[0].permitted = cap_data[0].effective; + cap_data[0].inheritable = 0; + // clear all 64 bit caps + cap_data[1].effective = 0; + cap_data[1].permitted = 0; + cap_data[1].inheritable = 0; + if (capset(&hdr, cap_data) != 0) { + die("capset failed"); + } + } + // Permanently drop if not root + if (effective_uid == 0) { + // Note that we do not call setgroups() here because its ok + // that the user keeps the groups he already belongs to + if (setgid(real_gid) != 0) + die("setgid failed"); + if (setuid(real_uid) != 0) + die("setuid failed"); + + if (real_gid != 0 && (getuid() == 0 || geteuid() == 0)) + die("permanently dropping privs did not work"); + if (real_uid != 0 && (getgid() == 0 || getegid() == 0)) + die("permanently dropping privs did not work"); + } + // Now that we've permanently dropped, regain SYS_ADMIN + if (keep_sys_admin) { + debug("regaining SYS_ADMIN"); + cap_data[0].effective = CAP_TO_MASK(CAP_SYS_ADMIN); + cap_data[0].permitted = cap_data[0].effective; + if (capset(&hdr, cap_data) != 0) { + die("capset regain failed"); + } + } + // Now that we've dropped and regained SYS_ADMIN, we can load the + // seccomp profiles. + if (sc_apply_seccomp_profile_for_security_tag(invocation.security_tag)) { + // If the process is not explicitly unconfined then load the + // global profile as well. + sc_apply_global_seccomp_profile(); + } + // Even though we set inheritable to 0, let's clear SYS_ADMIN + // explicitly + if (keep_sys_admin) { + debug("clearing SYS_ADMIN"); + cap_data[0].effective = 0; + cap_data[0].permitted = cap_data[0].effective; + if (capset(&hdr, cap_data) != 0) { + die("capset clear failed"); + } + } + // and exec the new executable + argv[0] = (char *)invocation.executable; + debug("execv(%s, %s...)", invocation.executable, argv[0]); + for (int i = 1; i < argc; ++i) { + debug(" argv[%i] = %s", i, argv[i]); + } + // Restore process state that was recorded earlier. + sc_restore_process_state(&proc_state); + log_startup_stage("snap-confine to snap-exec"); + execv(invocation.executable, (char *const *)&argv[0]); + perror("execv failed"); + return 1; +} + +static void enter_classic_execution_environment(const sc_invocation *inv, + gid_t real_gid, gid_t saved_gid) +{ + /* with parallel-instances enabled, main() reassociated with the mount ns of + * PID 1 to make /run/snapd/ns visible */ + + /* 'classic confinement' is designed to run without the sandbox inside the + * shared namespace. Specifically: + * - snap-confine skips using the snap-specific, private, mount namespace + * - snap-confine skips using device cgroups + * - snapd sets up a lenient AppArmor profile for snap-confine to use + * - snapd sets up a lenient seccomp profile for snap-confine to use + */ + debug("preparing classic execution environment"); + + if (!sc_feature_enabled(SC_FEATURE_PARALLEL_INSTANCES)) { + return; + } + + /* all of the following code is experimental and part of parallel instances + * of classic snaps support */ + + debug + ("(experimental) unsharing the mount namespace (per-classic-snap)"); + + /* Construct a mount namespace where the snap instance directories are + * visible under the regular snap name. In order to do that we will: + * + * - convert SNAP_MOUNT_DIR into a mount point (global init) + * - convert /var/snap into a mount point (global init) + * - always create a new mount namespace + * - for snaps with non empty instance key: + * - set slave propagation recursively on SNAP_MOUNT_DIR and /var/snap + * - recursively bind mount SNAP_MOUNT_DIR/_ on top of SNAP_MOUNT_DIR/ + * - recursively bind mount /var/snap/_ on top of /var/snap/ + * + * The destination directories /var/snap/ and SNAP_MOUNT_DIR/ + * are guaranteed to exist and were created during installation of a given + * instance. + */ + + if (unshare(CLONE_NEWNS) < 0) { + die("cannot unshare the mount namespace for parallel installed classic snap"); + } + + /* Parallel installed classic snap get special handling */ + if (!sc_streq(inv->snap_instance, inv->snap_name)) { + debug + ("(experimental) setting up environment for classic snap instance %s", + inv->snap_instance); + + /* set up mappings for snap and data directories */ + sc_setup_parallel_instance_classic_mounts(inv->snap_name, + inv->snap_instance); + } +} + +/* max wait time for /var/lib/snapd/cgroup/.devices to appear */ +static const size_t DEVICES_FILE_MAX_WAIT = 120; + +struct sc_device_cgroup_options { + bool self_managed; + bool non_strict; +}; + +static void sc_get_device_cgroup_setup(const sc_invocation *inv, struct sc_device_cgroup_options + *devsetup) +{ + if (devsetup == NULL) { + die("internal error: devsetup is NULL"); + } + + char info_path[PATH_MAX] = { 0 }; + sc_must_snprintf(info_path, + sizeof info_path, + "/var/lib/snapd/cgroup/snap.%s.device", + inv->snap_instance); + + /* TODO allow overriding timeout through env? */ + if (!sc_wait_for_file(info_path, DEVICES_FILE_MAX_WAIT)) { + /* don't die explicitly here, we'll die when trying to open the file + * (unless it shows up) */ + debug("timeout waiting for devices file at %s", info_path); + } + + FILE *stream SC_CLEANUP(sc_cleanup_file) = NULL; + stream = fopen(info_path, "r"); + if (stream == NULL) { + die("cannot open %s", info_path); + } + + sc_error *err SC_CLEANUP(sc_cleanup_error) = NULL; + char *self_managed_value SC_CLEANUP(sc_cleanup_string) = NULL; + if (sc_infofile_get_key + (stream, "self-managed", &self_managed_value, &err) < 0) { + sc_die_on_error(err); + } + rewind(stream); + + char *non_strict_value SC_CLEANUP(sc_cleanup_string) = NULL; + if (sc_infofile_get_key(stream, "non-strict", &non_strict_value, &err) < + 0) { + sc_die_on_error(err); + } + + devsetup->self_managed = sc_streq(self_managed_value, "true"); + devsetup->non_strict = sc_streq(non_strict_value, "true"); +} + +static sc_device_cgroup_mode device_cgroup_mode_for_snap(sc_invocation *inv) +{ + /** Conditionally create, populate and join the device cgroup. */ + sc_device_cgroup_mode mode = SC_DEVICE_CGROUP_MODE_REQUIRED; + + /* Preserve the legacy behavior of no default device cgroup for snaps + * using one of the following bases. Snaps using core24 and later bases + * will be placed within a device cgroup. Note that 'bare' base is also + * subject to the new behavior. */ + const char *non_required_cgroup_bases[] = { + "core", "core16", "core18", "core20", "core22", + NULL, + }; + for (const char **non_required_on_base = + non_required_cgroup_bases; *non_required_on_base != NULL; + non_required_on_base++) { + if (sc_streq(inv->base_snap_name, *non_required_on_base)) { + debug + ("device cgroup not required due to base %s", + *non_required_on_base); + mode = SC_DEVICE_CGROUP_MODE_OPTIONAL; + break; + } + } + + return mode; +} + +static void enter_non_classic_execution_environment(sc_invocation *inv, + struct sc_apparmor *aa, + uid_t real_uid, + gid_t real_gid, + gid_t saved_gid) +{ + // main() reassociated with the mount ns of PID 1 to make /run/snapd/ns + // visible + + // Find and open snap-update-ns and snap-discard-ns from the same + // path as where we (snap-confine) were called. + int snap_update_ns_fd SC_CLEANUP(sc_cleanup_close) = -1; + snap_update_ns_fd = sc_open_snap_update_ns(); + int snap_discard_ns_fd SC_CLEANUP(sc_cleanup_close) = -1; + snap_discard_ns_fd = sc_open_snap_discard_ns(); + + // Do per-snap initialization. + int snap_lock_fd = sc_lock_snap(inv->snap_instance); + debug("initializing mount namespace: %s", inv->snap_instance); + struct sc_mount_ns *group = NULL; + group = sc_open_mount_ns(inv->snap_instance); + + // Init and check rootfs_dir, apply any fallback behaviors. + sc_check_rootfs_dir(inv); + + // Set up a device cgroup, unless the snap has been allowed to manage the + // device cgroup by itself. + struct sc_device_cgroup_options cgdevopts = { false, false }; + sc_get_device_cgroup_setup(inv, &cgdevopts); + bool in_container = sc_is_in_container(); + if (cgdevopts.self_managed) { + debug("device cgroup is self-managed by the snap"); + } else if (cgdevopts.non_strict) { + debug("device cgroup skipped, snap in non-strict confinement"); + } else if (in_container) { + debug("device cgroup skipped, executing inside a container"); + } else { + sc_device_cgroup_mode mode = device_cgroup_mode_for_snap(inv); + sc_setup_device_cgroup(inv->security_tag, mode); + } + + /** + * is_normal_mode controls if we should pivot into the base snap. + * + * There are two modes of execution for snaps that are not using classic + * confinement: normal and legacy. The normal mode is where snap-confine + * sets up a rootfs and then pivots into it using pivot_root(2). The legacy + * mode is when snap-confine just unshares the initial mount namespace, + * makes some extra changes but largely runs with what was presented to it + * initially. + * + * Historically the ubuntu-core distribution used the now-legacy mode. This + * was sensible then since snaps already (kind of) have the right root + * file-system and just need some privacy and isolation features applied. + * With the introduction of snaps to classic distributions as well as the + * introduction of bases, where each snap can use a different root + * filesystem, this lost sensibility and thus became legacy. + * + * For compatibility with current installations of ubuntu-core + * distributions the legacy mode is used when: the distribution is + * SC_DISTRO_CORE16 or when the base snap name is not "core" or + * "ubuntu-core". + * + * The SC_DISTRO_CORE16 is applied to systems that boot with the "core", + * "ubuntu-core" or "core16" snap. Systems using the "core18" base snap do + * not qualify for that classification. + **/ + sc_distro distro = sc_classify_distro(); + inv->is_normal_mode = distro != SC_DISTRO_CORE16 || + !sc_streq(inv->orig_base_snap_name, "core"); + + /* Read the homedirs configuration: this information is needed both by our + * namespace helper (in order to detect if the homedirs are mounted) and by + * snap-confine itself to mount the homedirs. + */ + sc_invocation_init_homedirs(inv); + + /* Stale mount namespace discarded or no mount namespace to + join. We need to construct a new mount namespace ourselves. + To capture it we will need a helper process so make one. */ + sc_fork_helper(group, aa); + int retval = sc_join_preserved_ns(group, aa, inv, snap_discard_ns_fd); + if (retval == ESRCH) { + /* Create and populate the mount namespace. This performs all + of the bootstrapping mounts, pivots into the new root filesystem and + applies the per-snap mount profile using snap-update-ns. */ + debug("unsharing the mount namespace (per-snap)"); + if (unshare(CLONE_NEWNS) < 0) { + die("cannot unshare the mount namespace"); + } + sc_populate_mount_ns(aa, snap_update_ns_fd, inv, real_gid, + saved_gid); + sc_store_ns_info(inv); + + /* Preserve the mount namespace. */ + sc_preserve_populated_mount_ns(group); + } + + /* Older versions of snap-confine created incorrect 777 permissions + for /var/lib and we need to fixup for systems that had their NS created + with an old version. */ + sc_maybe_fixup_permissions(); + sc_maybe_fixup_udev(); + + /* User mount profiles only apply to non-root users. */ + if (real_uid != 0) { + debug("joining preserved per-user mount namespace"); + retval = + sc_join_preserved_per_user_ns(group, inv->snap_instance); + if (retval == ESRCH) { + debug("unsharing the mount namespace (per-user)"); + if (unshare(CLONE_NEWNS) < 0) { + die("cannot unshare the mount namespace"); + } + sc_setup_user_mounts(aa, snap_update_ns_fd, + inv->snap_instance); + /* Preserve the mount per-user namespace. But only if the + * experimental feature is enabled. This way if the feature is + * disabled user mount namespaces will still exist but will be + * entirely ephemeral. In addition the call + * sc_join_preserved_user_ns() will never find a preserved mount + * namespace and will always enter this code branch. */ + if (sc_feature_enabled + (SC_FEATURE_PER_USER_MOUNT_NAMESPACE)) { + sc_preserve_populated_per_user_mount_ns(group); + } else { + debug + ("NOT preserving per-user mount namespace"); + } + } + } + // With cgroups v1, associate each snap process with a dedicated + // snap freezer cgroup and snap pids cgroup. All snap processes + // belonging to one snap share the freezer cgroup. All snap + // processes belonging to one app or one hook share the pids cgroup. + // + // This simplifies testing if any processes belonging to a given snap are + // still alive as well as to properly account for each application and + // service. + // + // Note that with cgroups v2 there is no separate freeezer controller, + // but the freezer is associated with each group. The call chain when + // starting the snap application has already ensure that the process has + // been put in a dedicated group. + if (!sc_cgroup_is_v2()) { + sc_cgroup_freezer_join(inv->snap_instance, getpid()); + } + + sc_unlock(snap_lock_fd); + + sc_close_mount_ns(group); + + // Reset path as we cannot rely on the path from the host OS to make sense. + // The classic distribution may use any PATH that makes sense but we cannot + // assume it makes sense for the core snap layout. Note that the /usr/local + // directories are explicitly left out as they are not part of the core + // snap. + debug("resetting PATH to values in sync with core snap"); + setenv("PATH", + "/usr/local/sbin:" + "/usr/local/bin:" + "/usr/sbin:" + "/usr/bin:" + "/sbin:" "/bin:" "/usr/games:" "/usr/local/games", 1); + // Ensure we set the various TMPDIRs to /tmp. One of the parts of setting + // up the mount namespace is to create a private /tmp directory (this is + // done in sc_populate_mount_ns() above). The host environment may point to + // a directory not accessible by snaps so we need to reset it here. + const char *tmpd[] = { "TMPDIR", "TEMPDIR", NULL }; + int i; + for (i = 0; tmpd[i] != NULL; i++) { + if (setenv(tmpd[i], "/tmp", 1) != 0) { + die("cannot set environment variable '%s'", tmpd[i]); + } + } +} diff --git a/cmd/snap-confine/snap-confine.rst b/cmd/snap-confine/snap-confine.rst new file mode 100644 index 00000000..e870a1de --- /dev/null +++ b/cmd/snap-confine/snap-confine.rst @@ -0,0 +1,185 @@ +============== + snap-confine +============== + +----------------------------------------------- +internal tool for confining snappy applications +----------------------------------------------- + +:Author: zygmunt.krynicki@canonical.com +:Date: 2017-09-18 +:Copyright: Canonical Ltd. +:Version: 2.28 +:Manual section: 8 +:Manual group: snappy + +SYNOPSIS +======== + + snap-confine [--classic] [--base BASE] SECURITY_TAG COMMAND [...ARGUMENTS] + +DESCRIPTION +=========== + +The `snap-confine` is a program used internally by `snapd` to construct the +execution environment for snap applications. + +OPTIONS +======= + +The `snap-confine` program accepts two options: + + `--classic` requests the so-called _classic_ _confinement_ in which + applications are not confined at all (like in classic systems, hence the + name). This disables the use of a dedicated, per-snap mount namespace. The + `snapd` service generates permissive apparmor and seccomp profiles that + allow everything. + + `--base BASE` directs snap-confine to use the given base snap as the root + filesystem. If omitted it defaults to the `core` snap. This is derived from + snap meta-data by `snapd` when starting the application process. + +FEATURES +======== + +Apparmor profiles +----------------- + +`snap-confine` switches to the apparmor profile `$SECURITY_TAG`. The profile is +**mandatory** and `snap-confine` will refuse to run without it. + +The profile has to be loaded into the kernel prior to using `snap-confine`. +Typically this is arranged for by `snapd`. The profile contains rich +description of what the application process is allowed to do, this includes +system calls, file paths, access patterns, linux capabilities, etc. The +apparmor profile can also do extensive dbus mediation. Refer to apparmor +documentation for more details. + +Seccomp profiles +---------------- + +`snap-confine` looks for the +`/var/lib/snapd/seccomp/bpf/$SECURITY_TAG.bin` file. This file is +**mandatory** and `snap-confine` will refuse to run without it. This +file contains the seccomp bpf binary program that is loaded into the +kernel by snap-confine. + +The file is generated with the `/usr/lib/snapd/snap-seccomp` compiler from the +`$SECURITY_TAG.src` file that uses a custom syntax that describes the set of +allowed system calls and optionally their arguments. The profile is then used +to confine the started application. + +As a security precaution disallowed system calls cause the started application +executable to be killed by the kernel. In the future this restriction may be +lifted to return `EPERM` instead. + +Mount profiles +-------------- + +`snap-confine` uses a helper process, `snap-update-ns`, to apply the mount +namespace profile to freshly constructed mount namespace. That tool looks for +the `/var/lib/snapd/mount/snap.$SNAP_NAME.fstab` file. If present it is read, +parsed and treated like a mostly-typical `fstab(5)` file. The mount directives +listed there are executed in order. All directives must succeed as any failure +will abort execution. + +By default all mount entries start with the following flags: `bind`, `ro`, +`nodev`, `nosuid`. Some of those flags can be reversed by an appropriate +option (e.g. `rw` can cause the mount point to be writable). + +Certain additional features are enabled and conveyed through the use of mount +options prefixed with `x-snapd-`. + +As a security precaution only `bind` mounts are supported at this time. + +Sharing of the mount namespace +------------------------------ + +As of version 1.0.41 all the applications from the same snap will share the +same mount namespace. Applications from different snaps continue to use +separate mount namespaces. + +ENVIRONMENT +=========== + +`snap-confine` responds to the following environment variables + +`SNAP_CONFINE_DEBUG`: + When defined the program will print additional diagnostic information about + the actions being performed. All the output goes to stderr. + +The following variables are only used when `snap-confine` is not setuid root. +This is only applicable when testing the program itself. + +`SNAPPY_LAUNCHER_INSIDE_TESTS`: + Internal variable that should not be relied upon. + +`SNAPPY_LAUNCHER_SECCOMP_PROFILE_DIR`: + Internal variable that should not be relied upon. + +`SNAP_USER_DATA`: + Full path to the directory like /home/$LOGNAME/snap/$SNAP_NAME/$SNAP_REVISION. + + This directory is created by snap-confine on startup. This is a temporary + feature that will be merged into snapd's snap-run command. The set of directories + that can be created is confined with apparmor. + +FILES +===== + +`snap-confine` and `snap-update-ns` use the following files: + +`/var/lib/snapd/mount/snap.*.fstab`: + + Description of the mount profile. + +`/var/lib/snapd/seccomp/bpf/*.src`: + + Input for the /usr/lib/snapd/snap-seccomp profile compiler. + +`/var/lib/snapd/seccomp/bpf/*.bin`: + + Compiled seccomp bpf profile programs. + +`/run/snapd/ns/`: + + Directory used to keep shared mount namespaces. + + `snap-confine` internally converts this directory to a private bind mount. + Semantically the behavior is identical to the following mount commands: + + mount --bind /run/snapd/ns /run/snapd/ns + mount --make-private /run/snapd/ns + +`/run/snapd/ns/.lock`: + + A `flock(2)`-based lock file acquired to create and convert + `/run/snapd/ns/` to a private bind mount. + +`/run/snapd/ns/$SNAP_NAME.lock`: + + A `flock(2)`-based lock file acquired to create or join the mount namespace + represented as `/run/snaps/ns/$SNAP_NAME.mnt`. + +`/run/snapd/ns/$SNAP_NAME.mnt`: + + This file can be either: + + - An empty file that may be seen before the mount namespace is preserved or + when the mount namespace is unmounted. + - A file belonging to the `nsfs` file system, representing a fully + populated mount namespace of a given snap. The file is bind mounted from + `/proc/self/ns/mnt` from the first process in any snap. + +`/proc/self/mountinfo`: + + This file is read to decide if `/run/snapd/ns/` needs to be created and + converted to a private bind mount, as described above. + +Note that the apparmor profile is external to `snap-confine` and is loaded +directly into the kernel. The actual apparmor profile is managed by `snapd`. + +BUGS +==== + +Please report all bugs with https://bugs.launchpad.net/snapd/+filebug diff --git a/cmd/snap-confine/spread-tests/main/cgroup-used/task.yaml b/cmd/snap-confine/spread-tests/main/cgroup-used/task.yaml new file mode 100644 index 00000000..1bec96aa --- /dev/null +++ b/cmd/snap-confine/spread-tests/main/cgroup-used/task.yaml @@ -0,0 +1,37 @@ +summary: Check that launcher cgroup functionality works +# This is blacklisted on debian because we first have to get the dpkg-vendor patches +systems: [-debian-8] +prepare: | + echo "Install snapd-hacker-toolbelt" + snap install snapd-hacker-toolbelt +execute: | + cd / + echo "Clear udev tags and cgroups with non-test device and running snapd-hacker-toolbelt.busybox" + echo 'KERNEL=="uinput", TAG+="snap_snapd-hacker-toolbelt_busybox"' > /etc/udev/rules.d/70-spread-test.rules + udevadm control --reload-rules + udevadm settle + udevadm trigger + udevadm settle + snapd-hacker-toolbelt.busybox echo "Hello World" | grep Hello + echo "Verify no tags for snapd-hacker-toolbelt.busybox for kmsg" + if udevadm info /sys/devices/virtual/mem/kmsg | grep snap_snapd-hacker-toolbelt_busybox ; then exit 1; fi + echo "Manually add udev tags for snapd-hacker-toolbelt.busybox for kmsg" + echo 'KERNEL=="kmsg", TAG+="snap_snapd-hacker-toolbelt_busybox"' > /etc/udev/rules.d/70-spread-test.rules + echo "Simulate snapd udev triggers" + udevadm control --reload-rules + udevadm settle + udevadm trigger + udevadm settle + echo "Verify udev has tag for kmsg" + if ! udevadm info /sys/devices/virtual/mem/kmsg | grep snap_snapd-hacker-toolbelt_busybox; then exit 1; fi + echo "Run snapd-hacker-toolbelt.busybox echo and see if kmsg added to cgroup" + snapd-hacker-toolbelt.busybox echo "Hello World" | grep Hello + if ! grep 'c 1:11 rwm' /sys/fs/cgroup/devices/snap.snapd-hacker-toolbelt.busybox/devices.list ; then exit 1; fi +restore: | + snap remove --purge snapd-hacker-toolbelt + rm -f /etc/udev/rules.d/70-spread-test.rules + udevadm control --reload-rules + udevadm settle + udevadm trigger + udevadm settle + # no way to clear cgroup for snapd-hacker-toolbelt atm diff --git a/cmd/snap-confine/spread-tests/main/core-is-preferred/task.yaml b/cmd/snap-confine/spread-tests/main/core-is-preferred/task.yaml new file mode 100644 index 00000000..b6670687 --- /dev/null +++ b/cmd/snap-confine/spread-tests/main/core-is-preferred/task.yaml @@ -0,0 +1,10 @@ +summary: The snap named 'core' is preferred to the snap 'ubuntu-core' +prepare: | + snap install --devmode snapd-hacker-toolbelt + snap install core +execute: | + snapd-hacker-toolbelt.busybox cat /meta/snap.yaml | grep -q -F 'name: core' +restore: | + snap remove --purge snapd-hacker-toolbelt + # XXX: the core snap cannot be removed, we should use a trick to remove it + # in some other way but this can wait. diff --git a/cmd/snap-confine/spread-tests/main/debug-flags/task.yaml b/cmd/snap-confine/spread-tests/main/debug-flags/task.yaml new file mode 100644 index 00000000..a95b86be --- /dev/null +++ b/cmd/snap-confine/spread-tests/main/debug-flags/task.yaml @@ -0,0 +1,12 @@ +summary: snap-confine honors SNAP_CONFINE_DEBUG environment variable +execute: | + for value in yes no 0 1 unicorn; do + SNAP_CONFINE_DEBUG=$value ubuntu-core-launcher blah 2>debug.$value || : + done + grep -F -q 'DEBUG: shifting arguments by one' debug.yes + grep -F -q 'DEBUG: shifting arguments by one' debug.1 + grep -F -v -q 'DEBUG: shifting arguments by one' debug.no + grep -F -v -q 'DEBUG: shifting arguments by one' debug.0 + grep -F -q 'WARNING: unrecognized value of environment variable SNAP_CONFINE_DEBUG (expected yes/no or 1/0)' debug.unicorn +restore: | + rm -f debug.* diff --git a/cmd/snap-confine/spread-tests/main/hostfs-created-on-demand/task.yaml b/cmd/snap-confine/spread-tests/main/hostfs-created-on-demand/task.yaml new file mode 100644 index 00000000..3b8893eb --- /dev/null +++ b/cmd/snap-confine/spread-tests/main/hostfs-created-on-demand/task.yaml @@ -0,0 +1,24 @@ +summary: Check that /var/lib/snapd/hostfs is created on demand +# This is blacklisted on debian because debian doesn't use apparmor yet +systems: [-debian-8] +details: | + The /var/lib/snapd/hostfs directory is created by snap-confine + if the host packaging of snapd doesn't already provide it. +prepare: | + echo "Having installed the snapd-hacker-toolbelt snap" + snap install snapd-hacker-toolbelt + echo "We can move the packaged hostfs directory aside" + if [ -d /var/lib/snapd/hostfs ]; then + mv /var/lib/snapd/hostfs /var/lib/snapd/hostfs.orig + fi +execute: | + cd / + echo "We can now run a busybox true just to ensure it started correctly" + /snap/bin/snapd-hacker-toolbelt.busybox true + echo "We can now check that the directory was created on the system" + test -d /var/lib/snapd/hostfs +restore: | + snap remove --purge snapd-hacker-toolbelt + if [ -d /var/lib/snapd/hostfs.orig ]; then + mv /var/lib/snapd/hostfs.orig /var/lib/snapd/hostfs + fi diff --git a/cmd/snap-confine/spread-tests/main/media-visible-in-devmode/task.yaml b/cmd/snap-confine/spread-tests/main/media-visible-in-devmode/task.yaml new file mode 100644 index 00000000..c8a10035 --- /dev/null +++ b/cmd/snap-confine/spread-tests/main/media-visible-in-devmode/task.yaml @@ -0,0 +1,15 @@ +summary: Check that /media is available to snaps installed in --devmode +# This is blacklisted on debian because we first have to get the dpkg-vendor patches +systems: [-debian-8] +prepare: | + echo "Having installed the snapd-hacker-toolbelt snap in devmode" + snap install --devmode snapd-hacker-toolbelt + echo "Having added a canary file in /media" + echo "test" > /media/canary +execute: | + cd / + echo "We can see the canary file in /media" + [ "$(snapd-hacker-toolbelt.busybox cat /media/canary)" = "test" ] +restore: | + snap remove --purge snapd-hacker-toolbelt + rm -f /media/canary diff --git a/cmd/snap-confine/spread-tests/main/mount-ns-sharing/task.yaml b/cmd/snap-confine/spread-tests/main/mount-ns-sharing/task.yaml new file mode 100644 index 00000000..d5da4636 --- /dev/null +++ b/cmd/snap-confine/spread-tests/main/mount-ns-sharing/task.yaml @@ -0,0 +1,20 @@ +summary: mount namespace is shared among processes +details: | + The mount namespace is automatically shared amongst processes belonging to + a given snap. The namespace is preserved until the machine reboots or until + it is discarded with snap-discard-ns. +prepare: | + # NOTE: devmode is required because otherwise we cannot read /proc/self/ns/mnt + snap install --devmode snapd-hacker-toolbelt +execute: | + export PATH=/snap/bin:$PATH + echo "The mount namespace inside a snap is different" + outer_mnt_ns=$(readlink /proc/self/ns/mnt) + inner_mnt_ns=$(snapd-hacker-toolbelt.busybox readlink /proc/self/ns/mnt) + [ "$outer_mnt_ns" != "$inner_mnt_ns" ] + echo "The mount namespace is stable across invocations" + for i in $(seq 100); do + [ "$inner_mnt_ns" = "$(snapd-hacker-toolbelt.busybox readlink /proc/self/ns/mnt)" ] + done +restore: | + snap remove --purge snapd-hacker-toolbelt diff --git a/cmd/snap-confine/spread-tests/main/mount-profiles-bin-snap-destination/task.yaml b/cmd/snap-confine/spread-tests/main/mount-profiles-bin-snap-destination/task.yaml new file mode 100644 index 00000000..27ea0c7a --- /dev/null +++ b/cmd/snap-confine/spread-tests/main/mount-profiles-bin-snap-destination/task.yaml @@ -0,0 +1,26 @@ +summary: Apparmor profile prevents bind-mounting to /snap/bin +# This is blacklisted on debian because it relies on apparmor mount mediation +systems: [-debian-8] +prepare: | + echo "Having installed the snapd-hacker-toolbelt snap" + snap install snapd-hacker-toolbelt + echo "We can change its mount profile externally to create bind mount /snap/bin somewhere" + echo "/snap/snapd-hacker-toolbelt/mnt -> /snap/bin" + mkdir -p /var/lib/snapd/mount + echo "/snap/snapd-hacker-toolbelt/current/mnt /snap/bin none bind,ro 0 0" > /var/lib/snapd/mount/snap.snapd-hacker-toolbelt.busybox.fstab +execute: | + cd / + echo "Let's clear the kernel ring buffer" + dmesg -c + echo "We can now run busybox true and expect it to fail" + orig_ratelimit=$(sysctl -n kernel.printk_ratelimit) + sysctl -w kernel.printk_ratelimit=0 + not /snap/bin/snapd-hacker-toolbelt.busybox true + sysctl -w kernel.printk_ratelimit=$orig_ratelimit + echo "Not only the command failed because snap-confine failed, we see why!" + dmesg --ctime | grep 'apparmor="DENIED" operation="mount" info="failed srcname match" error=-13 profile="/usr/lib/snapd/snap-confine" name="/snap/bin/" pid=[0-9]\+ comm="ubuntu-core-lau" srcname="/snap/snapd-hacker-toolbelt/[0-9]\+/mnt/" flags="rw, bind"' +restore: | + snap remove --purge snapd-hacker-toolbelt + rm -rf /var/snap/snapd-hacker-toolbelt + rm -f /var/lib/snapd/mount/snap.snapd-hacker-toolbelt.busybox.fstab + dmesg -c diff --git a/cmd/snap-confine/spread-tests/main/mount-profiles-bin-snap-source/task.yaml b/cmd/snap-confine/spread-tests/main/mount-profiles-bin-snap-source/task.yaml new file mode 100644 index 00000000..42e16ad0 --- /dev/null +++ b/cmd/snap-confine/spread-tests/main/mount-profiles-bin-snap-source/task.yaml @@ -0,0 +1,26 @@ +summary: Apparmor profile prevents bind-mounting from /snap/bin +# This is blacklisted on debian because it relies on apparmor mount mediation +systems: [-debian-8] +prepare: | + echo "Having installed the snapd-hacker-toolbelt snap" + snap install snapd-hacker-toolbelt + echo "We can change its mount profile externally to create bind mount /snap/bin somewhere" + echo "/snap/bin -> /snap/snapd-hacker-toolbelt/mnt" + mkdir -p /var/lib/snapd/mount + echo "/snap/bin /snap/snapd-hacker-toolbelt/current/mnt none bind,ro 0 0" > /var/lib/snapd/mount/snap.snapd-hacker-toolbelt.busybox.fstab +execute: | + cd / + echo "Let's clear the kernel ring buffer" + dmesg -c + echo "We can now run busybox true and expect it to fail" + orig_ratelimit=$(sysctl -n kernel.printk_ratelimit) + sysctl -w kernel.printk_ratelimit=0 + not /snap/bin/snapd-hacker-toolbelt.busybox true + sysctl -w kernel.printk_ratelimit=$orig_ratelimit + echo "Not only the command failed because snap-confine failed, we see why!" + dmesg --ctime | grep 'apparmor="DENIED" operation="mount" info="failed srcname match" error=-13 profile="/usr/lib/snapd/snap-confine" name="/snap/snapd-hacker-toolbelt/[0-9]\+/mnt/" pid=[0-9]\+ comm="ubuntu-core-lau" srcname="/snap/bin/" flags="rw, bind"' +restore: | + snap remove --purge snapd-hacker-toolbelt + rm -rf /var/snap/snapd-hacker-toolbelt + rm -f /var/lib/snapd/mount/snap.snapd-hacker-toolbelt.busybox.fstab + dmesg -c diff --git a/cmd/snap-confine/spread-tests/main/mount-profiles-missing-dst/task.yaml b/cmd/snap-confine/spread-tests/main/mount-profiles-missing-dst/task.yaml new file mode 100644 index 00000000..530aad20 --- /dev/null +++ b/cmd/snap-confine/spread-tests/main/mount-profiles-missing-dst/task.yaml @@ -0,0 +1,19 @@ +summary: Check that missing destination directory aborts mount processing +# This is blacklisted on debian because we first have to get the dpkg-vendor patches +systems: [-debian-8] +prepare: | + echo "Having installed the snapd-hacker-toolbelt snap" + snap install snapd-hacker-toolbelt + echo "We can change its mount profile externally to create a read-only bind-mount" + echo "/var/snap/snapd-hacker-toolbelt/common/src -> /var/snap/snapd-hacker-toolbelt/common/dst" + mkdir -p /var/lib/snapd/mount + echo "/var/snap/snapd-hacker-toolbelt/common/src /var/snap/snapd-hacker-toolbelt/common/dst none bind,ro 0 0" > /var/lib/snapd/mount/snap.snapd-hacker-toolbelt.busybox.fstab + echo "We can now create the source directory, missing the destination directory" + mkdir -p /var/snap/snapd-hacker-toolbelt/common/src +execute: | + echo "We can now run busybox.true and expect it to fail" + ( cd / && ! /snap/bin/snapd-hacker-toolbelt.busybox true ) +restore: | + snap remove --purge snapd-hacker-toolbelt + rm -rf /var/snap/snapd-hacker-toolbelt + rm -f /var/lib/snapd/mount/snap.snapd-hacker-toolbelt.busybox.fstab diff --git a/cmd/snap-confine/spread-tests/main/mount-profiles-missing-src/task.yaml b/cmd/snap-confine/spread-tests/main/mount-profiles-missing-src/task.yaml new file mode 100644 index 00000000..e9202206 --- /dev/null +++ b/cmd/snap-confine/spread-tests/main/mount-profiles-missing-src/task.yaml @@ -0,0 +1,19 @@ +summary: Check that missing source directory aborts mount processing +# This is blacklisted on debian because we first have to get the dpkg-vendor patches +systems: [-debian-8] +prepare: | + echo "Having installed the snapd-hacker-toolbelt snap" + snap install snapd-hacker-toolbelt + echo "We can change its mount profile externally to create a read-only bind-mount" + echo "/var/snap/snapd-hacker-toolbelt/common/src -> /var/snap/snapd-hacker-toolbelt/common/dst" + mkdir -p /var/lib/snapd/mount + echo "/var/snap/snapd-hacker-toolbelt/common/src /var/snap/snapd-hacker-toolbelt/common/dst none bind,ro 0 0" > /var/lib/snapd/mount/snap.snapd-hacker-toolbelt.busybox.fstab + echo "We can now create the destination directory, missing the source directory" + mkdir -p /var/snap/snapd-hacker-toolbelt/common/dst +execute: | + echo "We can now run busybox.true and expect it to fail" + ( cd / && ! /snap/bin/snapd-hacker-toolbelt.busybox true ) +restore: | + snap remove --purge snapd-hacker-toolbelt + rm -rf /var/snap/snapd-hacker-toolbelt + rm -f /var/lib/snapd/mount/snap.snapd-hacker-toolbelt.busybox.fstab diff --git a/cmd/snap-confine/spread-tests/main/mount-profiles-mount-tmpfs/task.yaml b/cmd/snap-confine/spread-tests/main/mount-profiles-mount-tmpfs/task.yaml new file mode 100644 index 00000000..5957c87d --- /dev/null +++ b/cmd/snap-confine/spread-tests/main/mount-profiles-mount-tmpfs/task.yaml @@ -0,0 +1,20 @@ +summary: Check that mount profiles cannot be used to mount tmpfs +# This is blacklisted on debian because we first have to get the dpkg-vendor patches +systems: [-debian-8] +restore: | + snap remove --purge snapd-hacker-toolbelt + rm -rf /var/snap/snapd-hacker-toolbelt + rm -f /var/lib/snapd/mount/snap.snapd-hacker-toolbelt.busybox.fstab +execute: | + echo "Having installed the snapd-hacker-toolbelt snap" + snap list | grep -q snapd-hacker-toolbelt || snap install snapd-hacker-toolbelt + + echo "We can change its mount profile externally to mount tmpfs at /var/snap/snapd-hacker-toolbelt/mnt" + mkdir -p /var/lib/snapd/mount + echo "none /var/snap/snapd-hacker-toolbelt/common/mnt tmpfs rw 0 0" > /var/lib/snapd/mount/snap.snapd-hacker-toolbelt.busybox.fstab + + echo "We can now create the test mount directory" + mkdir -p /var/snap/snapd-hacker-toolbelt/common/mnt + + echo "We can now run busybox.true and expect it to fail" + ( cd / && ! /snap/bin/snapd-hacker-toolbelt.busybox true ) diff --git a/cmd/snap-confine/spread-tests/main/mount-profiles-ro-mount/task.yaml b/cmd/snap-confine/spread-tests/main/mount-profiles-ro-mount/task.yaml new file mode 100644 index 00000000..0a81be69 --- /dev/null +++ b/cmd/snap-confine/spread-tests/main/mount-profiles-ro-mount/task.yaml @@ -0,0 +1,18 @@ +summary: Check that read-only bind mounts can be created +# This is blacklisted on debian because we first have to get the dpkg-vendor patches +systems: [-debian-8] +prepare: | + echo "Having installed the snapd-hacker-toolbelt snap" + snap install snapd-hacker-toolbelt + echo "We can change its mount profile externally to create a read-only bind-mount" + echo "/snap/snapd-hacker-toolbelt/current/src -> /snap/snapd-hacker-toolbelt/current/dst" + mkdir -p /var/lib/snapd/mount + echo "/snap/snapd-hacker-toolbelt/current/src /snap/snapd-hacker-toolbelt/current/dst none bind,ro 0 0" > /var/lib/snapd/mount/snap.snapd-hacker-toolbelt.busybox.fstab +execute: | + cd / + echo "We can now look at the .id file in the destination directory" + [ "$(/snap/bin/snapd-hacker-toolbelt.busybox cat /snap/snapd-hacker-toolbelt/current/dst/.id)" = "source" ] +restore: | + snap remove --purge snapd-hacker-toolbelt + rm -rf /var/snap/snapd-hacker-toolbelt + rm -f /var/lib/snapd/mount/snap.snapd-hacker-toolbelt.busybox.fstab diff --git a/cmd/snap-confine/spread-tests/main/mount-profiles-rw-mount/task.yaml b/cmd/snap-confine/spread-tests/main/mount-profiles-rw-mount/task.yaml new file mode 100644 index 00000000..d82030f7 --- /dev/null +++ b/cmd/snap-confine/spread-tests/main/mount-profiles-rw-mount/task.yaml @@ -0,0 +1,24 @@ +summary: Check that read-write bind mounts can be created +# This is blacklisted on debian because we first have to get the dpkg-vendor patches +systems: [-debian-8] +prepare: | + echo "Having installed the snapd-hacker-toolbelt snap" + snap install snapd-hacker-toolbelt + echo "We can connect it to the mount-observe slot from the core" + snap connect snapd-hacker-toolbelt:mount-observe ubuntu-core:mount-observe + echo "We can change its mount profile externally to create a read-only bind-mount" + echo "/snap/snapd-hacker-toolbelt/current/src -> /snap/snapd-hacker-toolbelt/current/dst" + mkdir -p /var/lib/snapd/mount + echo "/snap/snapd-hacker-toolbelt/current/src /snap/snapd-hacker-toolbelt/current/dst none bind,rw 0 0" > /var/lib/snapd/mount/snap.snapd-hacker-toolbelt.busybox.fstab +execute: | + cd / + echo "We can now look at the .id file in the destination directory" + [ "$(/snap/bin/snapd-hacker-toolbelt.busybox cat /snap/snapd-hacker-toolbelt/current/dst/.id)" = "source" ] + echo "As well as the current mount points" + # FIXME: this doesn't show 'rw', bind mounts confuse most tools and it + # seems that busybox is not any different here. + /snap/bin/snapd-hacker-toolbelt.busybox mount | grep snapd-hacker-toolbelt +restore: | + snap remove --purge snapd-hacker-toolbelt + rm -rf /var/snap/snapd-hacker-toolbelt + rm -f /var/lib/snapd/mount/snap.snapd-hacker-toolbelt.busybox.fstab diff --git a/cmd/snap-confine/spread-tests/main/mount-usr-src/task.yaml b/cmd/snap-confine/spread-tests/main/mount-usr-src/task.yaml new file mode 100644 index 00000000..b5f6a98f --- /dev/null +++ b/cmd/snap-confine/spread-tests/main/mount-usr-src/task.yaml @@ -0,0 +1,17 @@ +summary: Check for https://bugs.launchpad.net/snap-confine/+bug/1597842 +# This is blacklisted on debian because debian doesn't use apparmor yet +systems: [-debian-8] +details: | + The snappy execution environment should contain the /usr/src directory + from the host filesystem when running on a classic distribution. +prepare: | + echo "Having installed the snapd-hacker-toolbelt snap" + snap install snapd-hacker-toolbelt + echo "and having connected the mount-observe interface" + snap connect snapd-hacker-toolbelt:mount-observe ubuntu-core:mount-observe +execute: | + cd / + echo "We can ensure that /usr/src is mounted" + /snap/bin/snapd-hacker-toolbelt.busybox cat /proc/self/mounts | grep ' /usr/src ' +restore: | + snap remove --purge snapd-hacker-toolbelt diff --git a/cmd/snap-confine/spread-tests/main/test-seccomp-compat/task.yaml b/cmd/snap-confine/spread-tests/main/test-seccomp-compat/task.yaml new file mode 100644 index 00000000..26e8501e --- /dev/null +++ b/cmd/snap-confine/spread-tests/main/test-seccomp-compat/task.yaml @@ -0,0 +1,16 @@ +summary: Check that basic install works +# This is blacklisted on debian because we first have to get the dpkg-vendor patches +# +# This test only makes sense on x86_64 as it can execute i386 code in addition +# to native x86_64 code). +systems: [-debian-8] +prepare: | + snap install --edge test-seccomp-compat +execute: | + cd / + echo Run the 64 bit binary + test-seccomp-compat.true64 + echo Run the 32 bit binary + test-seccomp-compat.true32 +restore: | + snap remove --purge test-seccomp-compat diff --git a/cmd/snap-confine/spread-tests/main/test-snap-runs/task.yaml b/cmd/snap-confine/spread-tests/main/test-snap-runs/task.yaml new file mode 100644 index 00000000..77cb55c6 --- /dev/null +++ b/cmd/snap-confine/spread-tests/main/test-snap-runs/task.yaml @@ -0,0 +1,15 @@ +summary: Check that basic install works +# This is blacklisted on debian because we first have to get the dpkg-vendor patches +systems: [-debian-8] +prepare: | + snap install snapd-hacker-toolbelt +execute: | + cd / + echo Run some hello-world stuff + snapd-hacker-toolbelt.busybox echo "Hello World" | grep Hello + snapd-hacker-toolbelt.busybox env | grep SNAP_NAME=snapd-hacker-toolbelt + echo Ensure that we get an error if we try to abuse the sandbox + if snapd-hacker-toolbelt.busybox touch /var/tmp/evil; then exit 1; fi + dmesg -c +restore: | + snap remove --purge snapd-hacker-toolbelt diff --git a/cmd/snap-confine/spread-tests/main/user-data-dir-created/task.yaml b/cmd/snap-confine/spread-tests/main/user-data-dir-created/task.yaml new file mode 100644 index 00000000..689d526f --- /dev/null +++ b/cmd/snap-confine/spread-tests/main/user-data-dir-created/task.yaml @@ -0,0 +1,23 @@ +summary: Ensure that SNAP_USER_DATA directory is created by snap-confine +# This is blacklisted on debian because debian doesn't use apparmor yet +systems: [-debian-8] +details: | + A regression was found in snap-confine where the new code path in snapd was + not active yet but the corresponding code path in snap-confine was already + removed. This resulted in the $SNAP_USER_DATA directory not being created + at runtime. + This test checks that it is actually created +prepare: | + echo "Having installed the snapd-hacker-toolbelt snap" + snap install snapd-hacker-toolbelt + echo "Having removed the SNAP_USER_DATA directory" + rm -rf "$HOME/snap/snapd-hacker-toolbelt/" +execute: | + cd / + echo "We can now run snapd-hacker-toolbelt.busybox true" + /snap/bin/snapd-hacker-toolbelt.busybox true + echo "And see that the SNAP_USER_DATA directory was created" + test -d $HOME/snap/snapd-hacker-toolbelt +restore: | + snap remove --purge snapd-hacker-toolbelt + rm -rf "$HOME/snap/snapd-hacker-toolbelt/" diff --git a/cmd/snap-confine/spread-tests/main/user-xdg-runtime-dir-created/task.yaml b/cmd/snap-confine/spread-tests/main/user-xdg-runtime-dir-created/task.yaml new file mode 100644 index 00000000..7b9bbb32 --- /dev/null +++ b/cmd/snap-confine/spread-tests/main/user-xdg-runtime-dir-created/task.yaml @@ -0,0 +1,21 @@ +summary: Ensure that XDG_RUNTIME_DIR directory is created by snap-confine +# This is blacklisted on debian because debian doesn't use apparmor yet +systems: [-debian-8] +details: | + This test checks that XDG_RUNTIME_DIR is actually created +prepare: | + echo "Having installed the snapd-hacker-toolbelt snap" + snap install snapd-hacker-toolbelt + echo "Having removed the XDG_RUNTIME_DIR directory" + rm -rf "/run/user/`id -u`/snapd-hacker-toolbelt" +execute: | + cd / + echo "FIXME: export XDG_RUNTIME_DIR for now until snapd does it" + export XDG_RUNTIME_DIR="/run/user/`id -u`/snapd-hacker-toolbelt" + echo "We can now run snapd-hacker-toolbelt.busybox true" + /snap/bin/snapd-hacker-toolbelt.busybox true + echo "And see that the XDG_RUNTIME_DIR directory was created" + test -d /run/user/`id -u`/snapd-hacker-toolbelt +restore: | + snap remove --purge snapd-hacker-toolbelt + rm -rf "/run/user/`id -u`/snapd-hacker-toolbelt" diff --git a/cmd/snap-confine/spread-tests/regression/lp-1599608/task.yaml b/cmd/snap-confine/spread-tests/regression/lp-1599608/task.yaml new file mode 100644 index 00000000..81382777 --- /dev/null +++ b/cmd/snap-confine/spread-tests/regression/lp-1599608/task.yaml @@ -0,0 +1,69 @@ +summary: Check that execle doesn't regress +# This is blacklisted on debian because we first have to get the dpkg-vendor patches +systems: [-debian-8] +details: | + The setup for this test is unorthodox because by the time the cgroup code is + executed, the mounts are in place and /lib/udev/snap-device-helper from the core + snap is used. Unfortunately, simple bind mounts over + /snap/ubuntu-core/current/lib/udev don't work and the core snap must be + unpacked, lib/udev/snap-device-helper modified to be tested, repacked and mounted. + We unmount the core snap and move it aside to avoid both the original and the + updated core snap from being mounted on the same mount point, which confuses + the kernel. +prepare: | + echo "This test is disabled because it causes failures for subsequent tests" + echo "it seems to unmount ubuntu-core snap and not re-mount the original one correctly" + exit 0 + cd / + echo "Install hello-world" + snap install hello-world + systemctl stop snapd.service snapd.socket + # all of this ls madness can go away when we have remote environment + # variables + echo "Unmount original core snap" + umount $(ls -1d /snap/ubuntu-core/* | grep -v current | tail -1) + mv $(ls -1 /var/lib/snapd/snaps/ubuntu-core_*.snap | tail -1) $(ls -1 /var/lib/snapd/snaps/ubuntu-core_*.snap | tail -1).orig + echo "Create modified core snap for snap-device-helper" + unsquashfs $(ls -1 /var/lib/snapd/snaps/ubuntu-core_*.snap.orig | tail -1) + echo 'echo PATH=$PATH > /run/udev/spread-test.out' >> ./squashfs-root/lib/udev/snap-device-helper + echo 'echo TESTVAR=$TESTVAR >> /run/udev/spread-test.out' >> ./squashfs-root/lib/udev/snap-device-helper + mksquashfs ./squashfs-root $(ls -1 /var/lib/snapd/snaps/ubuntu-core_*.snap.orig | tail -1 | sed 's/.orig//') -comp xz -no-fragments + if [ ! -e $(ls -1 /var/lib/snapd/snaps/ubuntu-core_*.snap | tail -1) ]; then exit 1; fi + echo "Mount modified core snap" + mount $(ls -1 /var/lib/snapd/snaps/ubuntu-core_*.snap | tail -1) $(ls -1d /snap/ubuntu-core/* | grep -v current | tail -1) + systemctl start snapd.service snapd.socket +execute: | + exit 0 + cd / + echo "Add a udev tag so affected code branch is exercised" + echo 'KERNEL=="uinput", TAG+="snap_hello-world_env"' > /etc/udev/rules.d/70-spread-test.rules + udevadm control --reload-rules + udevadm settle + udevadm trigger + udevadm settle + PATH=/foo:$PATH TESTVAR=bar hello-world.env | grep PATH + cat /run/udev/spread-test.out + echo "Ensure user-specified PATH is not used" + not grep 'PATH=/foo' /run/udev/spread-test.out + echo "Ensure environment is clean" + not grep 'TESTVAR=bar' /run/udev/spread-test.out +restore: | + exit 0 + echo "Remove hello-world" + snap remove --purge hello-world + systemctl stop snapd.service snapd.socket + echo "Unmount the modified core snap" + # all of this ls madness can go away when we have remote environment + # variables + umount $(ls -1d /snap/ubuntu-core/* | grep -v current | tail -1) + if [ "x"$(ls -1 /var/lib/snapd/snaps/ubuntu-core_*.snap.orig | tail -1) != "x" ]; then mv -f $(ls -1 /var/lib/snapd/snaps/ubuntu-core_*.snap.orig | tail -1) $(ls -1 /var/lib/snapd/snaps/ubuntu-core_*.snap.orig | tail -1 | sed 's/.orig//') ; fi + echo "Mount the original core snap" + mount $(ls -1 /var/lib/snapd/snaps/ubuntu-core_*.snap | tail -1) $(ls -1d /snap/ubuntu-core/* | grep -v current | tail -1) + rm -rf /squashfs-root + rm -f /run/udev/spread-test.out + rm -f /etc/udev/rules.d/70-spread-test.rules + udevadm control --reload-rules + udevadm settle + udevadm trigger + udevadm settle + systemctl start snapd.service snapd.socket diff --git a/cmd/snap-confine/udev-support.c b/cmd/snap-confine/udev-support.c new file mode 100644 index 00000000..27582d79 --- /dev/null +++ b/cmd/snap-confine/udev-support.c @@ -0,0 +1,393 @@ +/* + * Copyright (C) 2015-2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +#include "config.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "../libsnap-confine-private/cgroup-support.h" +#include "../libsnap-confine-private/cleanup-funcs.h" +#include "../libsnap-confine-private/device-cgroup-support.h" +#include "../libsnap-confine-private/snap.h" +#include "../libsnap-confine-private/string-utils.h" +#include "../libsnap-confine-private/utils.h" +#include "udev-support.h" + +/* Allow access to common devices. */ +static void sc_udev_allow_common(sc_device_cgroup *cgroup) +{ + /* The devices we add here have static number allocation. + * https://www.kernel.org/doc/html/v4.11/admin-guide/devices.html */ + sc_device_cgroup_allow(cgroup, S_IFCHR, 1, 3); // /dev/null + sc_device_cgroup_allow(cgroup, S_IFCHR, 1, 5); // /dev/zero + sc_device_cgroup_allow(cgroup, S_IFCHR, 1, 7); // /dev/full + sc_device_cgroup_allow(cgroup, S_IFCHR, 1, 8); // /dev/random + sc_device_cgroup_allow(cgroup, S_IFCHR, 1, 9); // /dev/urandom + sc_device_cgroup_allow(cgroup, S_IFCHR, 5, 0); // /dev/tty + sc_device_cgroup_allow(cgroup, S_IFCHR, 5, 1); // /dev/console + sc_device_cgroup_allow(cgroup, S_IFCHR, 5, 2); // /dev/ptmx +} + +/** Allow access to current and future PTY slaves. + * + * We unconditionally add them since we use a devpts newinstance. Unix98 PTY + * slaves major are 136-143. + * + * See also: + * https://www.kernel.org/doc/Documentation/admin-guide/devices.txt + **/ +static void sc_udev_allow_pty_slaves(sc_device_cgroup *cgroup) +{ + for (unsigned pty_major = 136; pty_major <= 143; pty_major++) { + sc_device_cgroup_allow(cgroup, S_IFCHR, pty_major, + SC_DEVICE_MINOR_ANY); + } +} + +/** Allow access to Nvidia devices. + * + * Nvidia modules are proprietary and therefore aren't in sysfs and can't be + * udev tagged. For now, just add existing nvidia devices to the cgroup + * unconditionally (AppArmor will still mediate the access). We'll want to + * rethink this if snapd needs to mediate access to other proprietary devices. + * + * Device major and minor numbers are described in (though nvidia-uvm currently + * isn't listed): + * + * https://www.kernel.org/doc/Documentation/admin-guide/devices.txt + **/ +static void sc_udev_allow_nvidia(sc_device_cgroup *cgroup) +{ + struct stat sbuf; + + /* Allow access to /dev/nvidia0 through /dev/nvidia254 */ + for (unsigned nv_minor = 0; nv_minor < 255; nv_minor++) { + char nv_path[15] = { 0 }; // /dev/nvidiaXXX + sc_must_snprintf(nv_path, sizeof(nv_path), "/dev/nvidia%u", + nv_minor); + + /* Stop trying to find devices after one is not found. In this manner, + * we'll add /dev/nvidia0 and /dev/nvidia1 but stop trying to find + * nvidia3 - nvidia254 if nvidia2 is not found. */ + if (stat(nv_path, &sbuf) < 0) { + break; + } + sc_device_cgroup_allow(cgroup, S_IFCHR, major(sbuf.st_rdev), + minor(sbuf.st_rdev)); + } + + if (stat("/dev/nvidiactl", &sbuf) == 0) { + sc_device_cgroup_allow(cgroup, S_IFCHR, major(sbuf.st_rdev), + minor(sbuf.st_rdev)); + } + if (stat("/dev/nvidia-uvm", &sbuf) == 0) { + sc_device_cgroup_allow(cgroup, S_IFCHR, major(sbuf.st_rdev), + minor(sbuf.st_rdev)); + } + if (stat("/dev/nvidia-modeset", &sbuf) == 0) { + sc_device_cgroup_allow(cgroup, S_IFCHR, major(sbuf.st_rdev), + minor(sbuf.st_rdev)); + } +} + +/** + * Allow access to /dev/uhid. + * + * Currently /dev/uhid isn't represented in sysfs, so add it to the device + * cgroup if it exists and let AppArmor handle the mediation. + **/ +static void sc_udev_allow_uhid(sc_device_cgroup *cgroup) +{ + struct stat sbuf; + + if (stat("/dev/uhid", &sbuf) == 0) { + sc_device_cgroup_allow(cgroup, S_IFCHR, major(sbuf.st_rdev), + minor(sbuf.st_rdev)); + } +} + +/** + * Allow access to /dev/net/tun + * + * When CONFIG_TUN=m, /dev/net/tun will exist but using it doesn't + * autoload the tun module but also /dev/net/tun isn't udev tagged + * until it is loaded. To work around this, if /dev/net/tun exists, add + * it unconditionally to the cgroup and rely on AppArmor to mediate the + * access. LP: #1859084 + **/ +static void sc_udev_allow_dev_net_tun(sc_device_cgroup *cgroup) +{ + struct stat sbuf; + + if (stat("/dev/net/tun", &sbuf) == 0) { + sc_device_cgroup_allow(cgroup, S_IFCHR, major(sbuf.st_rdev), + minor(sbuf.st_rdev)); + } +} + +/** + * Allow access to assigned devices. + * + * The snapd udev security backend uses udev rules to tag matching devices with + * tags corresponding to snap applications. Here we interrogate udev and allow + * access to all assigned devices. + **/ +static void sc_udev_allow_assigned_device(sc_device_cgroup *cgroup, + struct udev_device *device) +{ + const char *path = udev_device_get_syspath(device); + dev_t devnum = udev_device_get_devnum(device); + unsigned int major = major(devnum); + unsigned int minor = minor(devnum); + /* The manual page of udev_device_get_devnum says: + * > On success, udev_device_get_devnum() returns the device type of + * > the passed device. On failure, a device type with minor and major + * > number set to 0 is returned. */ + if (major == 0 && minor == 0) { + debug("cannot get major/minor numbers for syspath %s", path); + return; + } + /* devnode is bound to the lifetime of the device and we cannot release + * it separately. */ + const char *devnode = udev_device_get_devnode(device); + if (devnode == NULL) { + debug("cannot find /dev node from udev device"); + return; + } + debug("inspecting type of device: %s", devnode); + struct stat file_info; + if (stat(devnode, &file_info) < 0) { + debug("cannot stat %s", devnode); + return; + } + int devtype = file_info.st_mode & S_IFMT; + if (devtype == S_IFBLK || devtype == S_IFCHR) { + sc_device_cgroup_allow(cgroup, devtype, major, minor); + } +} + +static void sc_udev_setup_acls_common(sc_device_cgroup *cgroup) +{ + + /* Allow access to various devices. */ + sc_udev_allow_common(cgroup); + sc_udev_allow_pty_slaves(cgroup); + sc_udev_allow_nvidia(cgroup); + sc_udev_allow_uhid(cgroup); + sc_udev_allow_dev_net_tun(cgroup); +} + +static char *sc_security_to_udev_tag(const char *security_tag) +{ + char *udev_tag = sc_strdup(security_tag); + for (char *c = strchr(udev_tag, '.'); c != NULL; c = strchr(c, '.')) { + *c = '_'; + } + return udev_tag; +} + +static void sc_cleanup_udev(struct udev **udev) +{ + if (udev != NULL && *udev != NULL) { + udev_unref(*udev); + *udev = NULL; + } +} + +static void sc_cleanup_udev_enumerate(struct udev_enumerate **enumerate) +{ + if (enumerate != NULL && *enumerate != NULL) { + udev_enumerate_unref(*enumerate); + *enumerate = NULL; + } +} + +/* __sc_udev_device_has_current_tag will be filled at runtime if the libudev has + * this symbol. + * + * Note that we could try to define udev_device_has_current_tag with a weak + * attribute, which should in the normal case be the filled by ld.so when + * loading snap-confined. However this was observed to work in practice only + * when the binary itself is build with recent enough toolchain (eg. gcc & + * binutils on Ubuntu 20.04) + */ +static int (*__sc_udev_device_has_current_tag)(struct udev_device * udev_device, + const char *tag) = NULL; +static void setup_current_tags_support(void) +{ + void *lib = dlopen("libudev.so.1", RTLD_NOW); + if (lib == NULL) { + debug("cannot load libudev.so.1: %s", dlerror()); + /* bit unexpected as we use the library from the host and it's stable */ + return; + } + /* check whether we have the symbol introduced in systemd v247 to inspect + * the CURRENT_TAGS property */ + void *sym = dlsym(lib, "udev_device_has_current_tag"); + if (sym == NULL) { + debug("cannot find current tags symbol: %s", dlerror()); + /* symbol is not found in the library version */ + (void)dlclose(lib); + return; + } + debug("libudev has current tags support"); + __sc_udev_device_has_current_tag = sym; + /* lib goes out of scope and is leaked but we need sym and hence + * lib to be valid for the entire lifetime of the application + * lifecycle so this is fine. */ + /* coverity[leaked_storage] */ +} + +void sc_setup_device_cgroup(const char *security_tag, + sc_device_cgroup_mode mode) +{ + debug("setting up device cgroup, mode \"%s\"", + mode == SC_DEVICE_CGROUP_MODE_REQUIRED ? "required" : "optional"); + + setup_current_tags_support(); + if (__sc_udev_device_has_current_tag == NULL) { + debug("no current tags support present"); + } + + /* Derive the udev tag from the snap security tag. + * + * Because udev does not allow for dots in tag names, those are replaced by + * underscores in snapd. We just match that behavior. */ + char *udev_tag SC_CLEANUP(sc_cleanup_string) = NULL; + udev_tag = sc_security_to_udev_tag(security_tag); + + /* Use udev APIs to talk to udev-the-daemon to determine the list of + * "devices" with that tag assigned. The list may be empty, in which case + * there's no udev tagging in effect and we must refrain from constructing + * the cgroup as it would interfere with the execution of a program. */ + struct udev SC_CLEANUP(sc_cleanup_udev) * udev = NULL; + udev = udev_new(); + if (udev == NULL) { + die("cannot connect to udev"); + } + struct udev_enumerate SC_CLEANUP(sc_cleanup_udev_enumerate) * devices = + NULL; + devices = udev_enumerate_new(udev); + if (devices == NULL) { + die("cannot create udev device enumeration"); + } + if (udev_enumerate_add_match_tag(devices, udev_tag) < 0) { + die("cannot add tag match to udev device enumeration"); + } + if (udev_enumerate_scan_devices(devices) < 0) { + die("cannot enumerate udev devices"); + } + /* NOTE: udev_list_entry is bound to life-cycle of the used udev_enumerate */ + struct udev_list_entry *assigned; + assigned = udev_enumerate_get_list_entry(devices); + if (assigned == NULL) { + if (mode == SC_DEVICE_CGROUP_MODE_OPTIONAL) { + /* NOTE: Nothing is assigned, don't create or use the device cgroup. */ + debug + ("no devices tagged with %s, skipping device cgroup setup", + udev_tag); + return; + } else { + /* the device cgroup was requested to be set up despite of no + * devices being assigned to this snap */ + debug + ("no devices tagged with %s, but device cgroup is required, proceeding with setup", + udev_tag); + } + } + + /* cgroup wrapper is lazily initialized when devices are actually + * assigned */ + sc_device_cgroup *cgroup SC_CLEANUP(sc_device_cgroup_cleanup) = NULL; + + if (mode == SC_DEVICE_CGROUP_MODE_REQUIRED) { + /* Normally the cgroup setup is done lazily, but since device cgroup is + * required, prepare for mediation of device access regardless of + * devices being properly tagged. */ + cgroup = sc_device_cgroup_new(security_tag, 0); + /* Setup the device group access control list */ + sc_udev_setup_acls_common(cgroup); + } + + for (struct udev_list_entry * entry = assigned; entry != NULL; + entry = udev_list_entry_get_next(entry)) { + const char *path = udev_list_entry_get_name(entry); + if (path == NULL) { + die("udev_list_entry_get_name failed"); + } + struct udev_device *device = + udev_device_new_from_syspath(udev, path); + /** This is a non-fatal error as devices can disappear asynchronously + * and on slow devices we may indeed observe a device that no longer + * exists. + * + * Similar debug + continue pattern repeats in all the udev calls in + * this function. Related to LP: #1881209 */ + if (device == NULL) { + debug("cannot find device from syspath %s", path); + continue; + } + /* If we are able to query if the device has a current tag, + * do so and if there are no current tags, continue to prevent + * allowing assigned devices to the cgroup - this has the net + * desired effect of not re-creating device cgroups that were + * previously created/setup but should no longer be setup due + * to interface disconnection, etc. */ + if (__sc_udev_device_has_current_tag != NULL) { + if (__sc_udev_device_has_current_tag(device, udev_tag) + <= 0) { + debug("device %s has no matching current tag", + path); + udev_device_unref(device); + continue; + } + debug("device %s has matching current tag", path); + } + + if (cgroup == NULL) { + /* Lazy initialization of cgroup wrapper only when we are sure that + * there are devices assigned to this snap */ + cgroup = sc_device_cgroup_new(security_tag, 0); + /* Setup the device group access control list */ + sc_udev_setup_acls_common(cgroup); + } + + sc_udev_allow_assigned_device(cgroup, device); + udev_device_unref(device); + } + if (cgroup != NULL) { + /* Move ourselves to the device cgroup */ + sc_device_cgroup_attach_pid(cgroup, getpid()); + debug + ("associated snap application process %i with device cgroup %s", + getpid(), security_tag); + } else { + debug("device cgroup not set up for %s", udev_tag); + } +} diff --git a/cmd/snap-confine/udev-support.h b/cmd/snap-confine/udev-support.h new file mode 100644 index 00000000..726d4b83 --- /dev/null +++ b/cmd/snap-confine/udev-support.h @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2015-2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#ifndef SNAP_CONFINE_UDEV_SUPPORT_H +#define SNAP_CONFINE_UDEV_SUPPORT_H + +typedef enum { + /* Require device cgroup, even if no devices are assigned to the snap */ + SC_DEVICE_CGROUP_MODE_REQUIRED = 0x0, + /* Device cgroup is optional if no devices are assigned to the snap. This is + * to comply with the legacy behavior */ + SC_DEVICE_CGROUP_MODE_OPTIONAL = 0x1, +} sc_device_cgroup_mode; + +void sc_setup_device_cgroup(const char *security_tag, + sc_device_cgroup_mode mode); + +#endif diff --git a/cmd/snap-confine/user-support.c b/cmd/snap-confine/user-support.c new file mode 100644 index 00000000..e3414aa9 --- /dev/null +++ b/cmd/snap-confine/user-support.c @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2015 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +#include "config.h" +#include "user-support.h" + +#include +#include +#include + +#include "../libsnap-confine-private/string-utils.h" +#include "../libsnap-confine-private/utils.h" + +void setup_user_data(void) +{ + const char *user_data = getenv("SNAP_USER_DATA"); + + if (user_data == NULL) + return; + + // Only support absolute paths. + if (user_data[0] != '/') { + die("user data directory must be an absolute path"); + } + + debug("creating user data directory: %s", user_data); + if (sc_nonfatal_mkpath(user_data, 0755) < 0) { + if ((errno == EROFS || errno == EACCES) + && !sc_startswith(user_data, "/home/")) { + // clear errno or it will be displayed in die() + errno = 0; + // XXX: may point to the right config option here? + die("Sorry, home directories outside of /home needs configuration.\nSee https://forum.snapcraft.io/t/11209 for details."); + } + die("cannot create user data directory: %s", user_data); + }; +} + +void setup_user_xdg_runtime_dir(void) +{ + const char *xdg_runtime_dir = getenv("XDG_RUNTIME_DIR"); + + if (xdg_runtime_dir == NULL) + return; + // Only support absolute paths. + if (xdg_runtime_dir[0] != '/') { + die("XDG_RUNTIME_DIR must be an absolute path"); + } + + errno = 0; + debug("creating user XDG_RUNTIME_DIR directory: %s", xdg_runtime_dir); + if (sc_nonfatal_mkpath(xdg_runtime_dir, 0755) < 0) { + die("cannot create user XDG_RUNTIME_DIR directory: %s", + xdg_runtime_dir); + } + // if successfully created the directory (ie, not EEXIST), then chmod it. + if (errno == 0 && chmod(xdg_runtime_dir, 0700) != 0) { + die("cannot change permissions of user XDG_RUNTIME_DIR directory to 0700"); + } +} diff --git a/cmd/snap-confine/user-support.h b/cmd/snap-confine/user-support.h new file mode 100644 index 00000000..859d04dd --- /dev/null +++ b/cmd/snap-confine/user-support.h @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2015 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#ifndef SNAP_CONFINE_USER_SUPPORT_H +#define SNAP_CONFINE_USER_SUPPORT_H + +void setup_user_data(void); +void setup_user_xdg_runtime_dir(void); +void mkpath(const char *const path); + +#endif diff --git a/cmd/snap-device-helper/main.c b/cmd/snap-device-helper/main.c new file mode 100644 index 00000000..349b7afa --- /dev/null +++ b/cmd/snap-device-helper/main.c @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2021 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "../libsnap-confine-private/utils.h" + +#include "snap-device-helper.h" + +int main(int argc, char *argv[]) { + int old_invocation_detected = (argc >= 5); + + if ((argc != 2) && !old_invocation_detected) { + die("incorrect number of arguments"); + } + + struct sdh_invocation inv = { + .action = getenv("ACTION"), + .tagname = old_invocation_detected ? argv[2] : argv[1], + .major = getenv("MAJOR"), + .minor = getenv("MINOR"), + .subsystem = getenv("SUBSYSTEM"), + }; + + return snap_device_helper_run(&inv); +} diff --git a/cmd/snap-device-helper/snap-device-helper-test.c b/cmd/snap-device-helper/snap-device-helper-test.c new file mode 100644 index 00000000..32958c52 --- /dev/null +++ b/cmd/snap-device-helper/snap-device-helper-test.c @@ -0,0 +1,482 @@ +/* + * Copyright (C) 2021 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "../libsnap-confine-private/test-utils.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "snap-device-helper.c" + +#include "../libsnap-confine-private/device-cgroup-support.h" + +typedef struct _sdh_test_fixture { +} sdh_test_fixture; + +static void sdh_test_set_up(sdh_test_fixture *fixture, gconstpointer user_data) {} + +static void mocks_reset(void); + +static void sdh_test_tear_down(sdh_test_fixture *fixture, gconstpointer user_data) { mocks_reset(); } + +static struct mocks { + size_t cgroup_new_calls; + void *new_ret; + char *new_tag; + int new_flags; + + size_t cgroup_allow_calls; + size_t cgroup_deny_calls; + int device_type; + int device_major; + int device_minor; + int device_ret; + +} mocks; + +static void mocks_reset(void) { + if (mocks.new_tag != NULL) { + g_free(mocks.new_tag); + } + memset(&mocks, 0, sizeof(mocks)); +} + +/* mocked in test */ +sc_device_cgroup *sc_device_cgroup_new(const char *security_tag, int flags) { + g_debug("cgroup new called"); + mocks.cgroup_new_calls++; + mocks.new_tag = g_strdup(security_tag); + mocks.new_flags = flags; + return (sc_device_cgroup *)mocks.new_ret; +} + +int sc_device_cgroup_allow(sc_device_cgroup *self, int kind, int major, int minor) { + mocks.cgroup_allow_calls++; + mocks.device_type = kind; + mocks.device_major = major; + mocks.device_minor = minor; + return 0; +} + +int sc_device_cgroup_deny(sc_device_cgroup *self, int kind, int major, int minor) { + mocks.cgroup_deny_calls++; + mocks.device_type = kind; + mocks.device_major = major; + mocks.device_minor = minor; + return 0; +} + +struct sdh_test_data { + char *action; + // snap.foo.bar + char *app; + // snap_foo_bar + char *mangled_appname; +}; + +static void test_sdh_action(sdh_test_fixture *fixture, gconstpointer test_data) { + struct sdh_test_data *td = (struct sdh_test_data *)test_data; + + struct sdh_invocation inv_block = { + .action = td->action, + .tagname = td->mangled_appname, + .major = "8", + .minor = "4", + .subsystem = "block", + }; + + int bogus = 0; + /* make cgroup_device_new return a non-NULL */ + mocks.new_ret = &bogus; + + int ret = snap_device_helper_run(&inv_block); + g_assert_cmpint(ret, ==, 0); + if (g_strcmp0(td->action, "add") == 0 || g_strcmp0(td->action, "change") == 0 || + g_strcmp0(td->action, "bind") == 0) { + g_assert_cmpint(mocks.cgroup_new_calls, ==, 1); + g_assert_cmpint(mocks.cgroup_allow_calls, ==, 1); + g_assert_cmpint(mocks.cgroup_deny_calls, ==, 0); + } else if (g_strcmp0(td->action, "remove") == 0) { + g_assert_cmpint(mocks.cgroup_new_calls, ==, 1); + g_assert_cmpint(mocks.cgroup_allow_calls, ==, 0); + g_assert_cmpint(mocks.cgroup_deny_calls, ==, 1); + } else if (g_strcmp0(td->action, "unbind") == 0) { + g_assert_cmpint(mocks.cgroup_new_calls, ==, 0); + g_assert_cmpint(mocks.cgroup_allow_calls, ==, 0); + g_assert_cmpint(mocks.cgroup_deny_calls, ==, 0); + } + if (g_strcmp0(td->action, "unbind") != 0) { + g_assert_cmpint(mocks.device_major, ==, 8); + g_assert_cmpint(mocks.device_minor, ==, 4); + g_assert_cmpint(mocks.device_type, ==, S_IFBLK); + g_assert_nonnull(mocks.new_tag); + g_assert_nonnull(td->app); + g_assert_cmpstr(mocks.new_tag, ==, td->app); + g_assert_cmpint(mocks.new_flags, !=, 0); + g_assert_cmpint(mocks.new_flags, ==, SC_DEVICE_CGROUP_FROM_EXISTING); + } + + g_debug("reset"); + mocks_reset(); + mocks.new_ret = &bogus; + + struct sdh_invocation inv_serial = { + .action = td->action, + .tagname = td->mangled_appname, + .major = "6", + .minor = "64", + .subsystem = "other", + }; + ret = snap_device_helper_run(&inv_serial); + g_assert_cmpint(ret, ==, 0); + if (g_strcmp0(td->action, "add") == 0 || g_strcmp0(td->action, "change") == 0 || + g_strcmp0(td->action, "bind") == 0) { + g_assert_cmpint(mocks.cgroup_new_calls, ==, 1); + g_assert_cmpint(mocks.cgroup_allow_calls, ==, 1); + g_assert_cmpint(mocks.cgroup_deny_calls, ==, 0); + } else if (g_strcmp0(td->action, "remove") == 0) { + g_assert_cmpint(mocks.cgroup_new_calls, ==, 1); + g_assert_cmpint(mocks.cgroup_allow_calls, ==, 0); + g_assert_cmpint(mocks.cgroup_deny_calls, ==, 1); + } else if (g_strcmp0(td->action, "unbind") == 0) { + g_assert_cmpint(mocks.cgroup_new_calls, ==, 0); + g_assert_cmpint(mocks.cgroup_allow_calls, ==, 0); + g_assert_cmpint(mocks.cgroup_deny_calls, ==, 0); + } + if (g_strcmp0(td->action, "unbind") != 0) { + g_assert_cmpint(mocks.device_major, ==, 6); + g_assert_cmpint(mocks.device_minor, ==, 64); + g_assert_cmpint(mocks.device_type, ==, S_IFCHR); + g_assert_nonnull(mocks.new_tag); + g_assert_nonnull(td->app); + g_assert_cmpstr(mocks.new_tag, ==, td->app); + g_assert_cmpint(mocks.new_flags, !=, 0); + g_assert_cmpint(mocks.new_flags, ==, SC_DEVICE_CGROUP_FROM_EXISTING); + } +} + +static void test_sdh_action_nvme(sdh_test_fixture *fixture, gconstpointer test_data) { + struct { + const char *major; + const char *minor; + const char *subsystem; + int expected_maj; + int expected_min; + int expected_type; + } tcs[] = { + { + .major = "259", + .minor = "0", + .subsystem = "block", + .expected_maj = 259, + .expected_min = 0, + .expected_type = S_IFBLK, + }, + { + .major = "259", + .minor = "1", + .subsystem = "block", + .expected_maj = 259, + .expected_min = 1, + .expected_type = S_IFBLK, + }, + { + .major = "242", + .minor = "0", + .subsystem = "nvme", + .expected_maj = 242, + .expected_min = 0, + .expected_type = S_IFCHR, + }, + { + .major = "241", + .minor = "0", + .subsystem = "hwmon", + .expected_maj = 241, + .expected_min = 0, + .expected_type = S_IFCHR, + }, + }; + + int bogus = 0; + + for (size_t i = 0; i < sizeof(tcs) / sizeof(tcs[0]); i++) { + mocks_reset(); + /* make cgroup_device_new return a non-NULL */ + mocks.new_ret = &bogus; + + struct sdh_invocation inv_block = { + .action = "add", + .tagname = "snap_foo_bar", + .major = tcs[i].major, + .minor = tcs[i].minor, + .subsystem = tcs[i].subsystem, + }; + int ret = snap_device_helper_run(&inv_block); + g_assert_cmpint(ret, ==, 0); + g_assert_cmpint(mocks.cgroup_new_calls, ==, 1); + g_assert_cmpint(mocks.cgroup_allow_calls, ==, 1); + g_assert_cmpint(mocks.cgroup_deny_calls, ==, 0); + g_assert_cmpint(mocks.device_major, ==, tcs[i].expected_maj); + g_assert_cmpint(mocks.device_minor, ==, tcs[i].expected_min); + g_assert_cmpint(mocks.device_type, ==, tcs[i].expected_type); + g_assert_cmpint(mocks.new_flags, !=, 0); + g_assert_cmpint(mocks.new_flags, ==, SC_DEVICE_CGROUP_FROM_EXISTING); + } +} + +static void test_sdh_action_remove_fallback_devtype(sdh_test_fixture *fixture, gconstpointer test_data) { + struct { + const char *major; + const char *minor; + const char *subsystem; + int expected_maj; + int expected_min; + int expected_type; + } tcs[] = { + /* these device paths match the fallback pattern of block devices */ + { + .major = "259", + .minor = "0", + .subsystem = "block", + .expected_maj = 259, + .expected_min = 0, + .expected_type = S_IFBLK, + }, + { + .major = "259", + .minor = "1", + .subsystem = "block", + .expected_maj = 259, + .expected_min = 1, + .expected_type = S_IFBLK, + }, + { + .major = "8", + .minor = "0", + .subsystem = "block", + .expected_maj = 8, + .expected_min = 0, + .expected_type = S_IFBLK, + }, + /* these are treated as char devices */ + { + .major = "242", + .minor = "0", + .subsystem = "nvme", + .expected_maj = 242, + .expected_min = 0, + .expected_type = S_IFCHR, + }, + { + .major = "241", + .minor = "0", + .subsystem = "hwmon", + .expected_maj = 241, + .expected_min = 0, + .expected_type = S_IFCHR, + }, + { + .major = "4", + .minor = "64", + .subsystem = "tty", + .expected_maj = 4, + .expected_min = 64, + .expected_type = S_IFCHR, + }, + }; + + int bogus = 0; + + for (size_t i = 0; i < sizeof(tcs) / sizeof(tcs[0]); i++) { + mocks_reset(); + /* make cgroup_device_new return a non-NULL */ + mocks.new_ret = &bogus; + + struct sdh_invocation inv_block = { + .action = "remove", + .tagname = "snap_foo_bar", + .major = tcs[i].major, + .minor = tcs[i].minor, + .subsystem = tcs[i].subsystem, + }; + int ret = snap_device_helper_run(&inv_block); + g_assert_cmpint(ret, ==, 0); + g_assert_cmpint(mocks.cgroup_new_calls, ==, 1); + g_assert_cmpint(mocks.cgroup_allow_calls, ==, 0); + g_assert_cmpint(mocks.cgroup_deny_calls, ==, 1); + g_assert_cmpint(mocks.device_major, ==, tcs[i].expected_maj); + g_assert_cmpint(mocks.device_minor, ==, tcs[i].expected_min); + g_assert_cmpint(mocks.device_type, ==, tcs[i].expected_type); + g_assert_cmpint(mocks.new_flags, !=, 0); + g_assert_cmpint(mocks.new_flags, ==, SC_DEVICE_CGROUP_FROM_EXISTING); + } +} + +static void run_sdh_die(const char *action, const char *tagname, const char *major, const char *minor, + const char *subsystem, const char *msg) { + struct sdh_invocation inv = { + .action = action, + .tagname = tagname, + .major = major, + .minor = minor, + .subsystem = subsystem, + }; + if (g_test_subprocess()) { + errno = 0; + snap_device_helper_run(&inv); + } + g_test_trap_subprocess(NULL, 0, 0); + g_test_trap_assert_failed(); + g_test_trap_assert_stderr(msg); +} + +static void test_sdh_err_noappname(sdh_test_fixture *fixture, gconstpointer test_data) { + // missing appname + run_sdh_die("add", "", "8", "4", "block", "malformed tag \"\"\n"); +} + +static void test_sdh_err_badappname(sdh_test_fixture *fixture, gconstpointer test_data) { + // malformed appname + run_sdh_die("add", "foo_bar", "8", "4", "block", "malformed tag \"foo_bar\"\n"); +} + +static void test_sdh_err_wrongdevmajorminor1(sdh_test_fixture *fixture, gconstpointer test_data) { + // missing device major:minor numbers + run_sdh_die("add", "snap_foo_bar", "8", NULL, "block", "incomplete major/minor\n"); +} + +static void test_sdh_err_wrongdevmajorminor2(sdh_test_fixture *fixture, gconstpointer test_data) { + // missing device major:minor numbers + run_sdh_die("add", "snap_foo_bar", NULL, "4", "block", "incomplete major/minor\n"); +} + +static void test_sdh_err_badaction(sdh_test_fixture *fixture, gconstpointer test_data) { + // bogus action + run_sdh_die("badaction", "snap_foo_bar", "8", "4", "block", "ERROR: unknown action \"badaction\"\n"); +} + +static void test_sdh_err_noaction(sdh_test_fixture *fixture, gconstpointer test_data) { + // bogus action + run_sdh_die(NULL, "snap_foo_bar", "8", "4", "block", "ERROR: no action given\n"); +} + +static void test_sdh_err_funtag1(sdh_test_fixture *fixture, gconstpointer test_data) { + run_sdh_die("add", "snap___bar", "8", "4", "block", "security tag \"snap._.bar\" for snap \"_\" is not valid\n"); +} + +static void test_sdh_err_funtag2(sdh_test_fixture *fixture, gconstpointer test_data) { + run_sdh_die("add", "snap_foobar", "8", "4", "block", "missing app name in tag \"snap_foobar\"\n"); +} + +static void test_sdh_err_funtag3(sdh_test_fixture *fixture, gconstpointer test_data) { + run_sdh_die("add", "snap_", "8", "4", "block", "tag \"snap_\" length 5 is incorrect\n"); +} + +static void test_sdh_err_funtag4(sdh_test_fixture *fixture, gconstpointer test_data) { + run_sdh_die("add", "snap_foo_", "8", "4", "block", "security tag \"snap.foo.\" for snap \"foo\" is not valid\n"); +} + +static void test_sdh_err_funtag5(sdh_test_fixture *fixture, gconstpointer test_data) { + run_sdh_die( + "add", "snap_thisisverylonginstancenameabovelengthlimit_instancekey_bar", "8", "4", "block", + "snap instance of tag \"snap_thisisverylonginstancenameabovelengthlimit_instancekey_bar\" is too long\n"); +} + +static void test_sdh_err_funtag6(sdh_test_fixture *fixture, gconstpointer test_data) { + run_sdh_die("add", "snap__barbar", "8", "4", "block", "missing snap name in tag \"snap__barbar\"\n"); +} + +static void test_sdh_err_funtag7(sdh_test_fixture *fixture, gconstpointer test_data) { + run_sdh_die("add", "snap_barbarbarbar", "8", "4", "block", "missing app name in tag \"snap_barbarbarbar\"\n"); +} + +static void test_sdh_err_funtag8(sdh_test_fixture *fixture, gconstpointer test_data) { + run_sdh_die("add", "snap_#_barbar", "8", "4", "block", + "security tag \"snap.#.barbar\" for snap \"#\" is not valid\n"); +} + +static struct sdh_test_data add_data = {"add", "snap.foo.bar", "snap_foo_bar"}; +static struct sdh_test_data change_data = {"change", "snap.foo.bar", "snap_foo_bar"}; + +static struct sdh_test_data bind_data = {"bind", "snap.foo.bar", "snap_foo_bar"}; +static struct sdh_test_data unbind_data = {"unbind", "snap.foo.bar", "snap_foo_bar"}; + +static struct sdh_test_data remove_data = {"remove", "snap.foo.bar", "snap_foo_bar"}; + +static struct sdh_test_data instance_add_data = {"add", "snap.foo_bar.baz", "snap_foo_bar_baz"}; + +static struct sdh_test_data instance_change_data = {"change", "snap.foo_bar.baz", "snap_foo_bar_baz"}; + +static struct sdh_test_data instance_bind_data = {"bind", "snap.foo_bar.baz", "snap_foo_bar_baz"}; +static struct sdh_test_data instance_unbind_data = {"unbind", "snap.foo_bar.baz", "snap_foo_bar_baz"}; + +static struct sdh_test_data instance_remove_data = {"remove", "snap.foo_bar.baz", "snap_foo_bar_baz"}; + +static struct sdh_test_data add_hook_data = {"add", "snap.foo.hook.configure", "snap_foo_hook_configure"}; + +static struct sdh_test_data instance_add_hook_data = {"add", "snap.foo_bar.hook.configure", + "snap_foo_bar_hook_configure"}; + +static struct sdh_test_data instance_add_instance_name_is_hook_data = {"add", "snap.foo_hook.hook.configure", + "snap_foo_hook_hook_configure"}; + +static void __attribute__((constructor)) init(void) { +#define _test_add(_name, _data, _func) \ + g_test_add(_name, sdh_test_fixture, _data, sdh_test_set_up, _func, sdh_test_tear_down) + + _test_add("/snap-device-helper/add", &add_data, test_sdh_action); + _test_add("/snap-device-helper/change", &change_data, test_sdh_action); + _test_add("/snap-device-helper/bind", &bind_data, test_sdh_action); + _test_add("/snap-device-helper/unbind", &unbind_data, test_sdh_action); + _test_add("/snap-device-helper/remove", &remove_data, test_sdh_action); + _test_add("/snap-device-helper/remove_fallback", NULL, test_sdh_action_remove_fallback_devtype); + + _test_add("/snap-device-helper/err/no-appname", NULL, test_sdh_err_noappname); + _test_add("/snap-device-helper/err/bad-appname", NULL, test_sdh_err_badappname); + _test_add("/snap-device-helper/err/wrong-devmajorminor1", NULL, test_sdh_err_wrongdevmajorminor1); + _test_add("/snap-device-helper/err/wrong-devmajorminor2", NULL, test_sdh_err_wrongdevmajorminor2); + _test_add("/snap-device-helper/err/bad-action", NULL, test_sdh_err_badaction); + _test_add("/snap-device-helper/err/no-action", NULL, test_sdh_err_noaction); + _test_add("/snap-device-helper/err/funtag1", NULL, test_sdh_err_funtag1); + _test_add("/snap-device-helper/err/funtag2", NULL, test_sdh_err_funtag2); + _test_add("/snap-device-helper/err/funtag3", NULL, test_sdh_err_funtag3); + _test_add("/snap-device-helper/err/funtag4", NULL, test_sdh_err_funtag4); + _test_add("/snap-device-helper/err/funtag5", NULL, test_sdh_err_funtag5); + _test_add("/snap-device-helper/err/funtag6", NULL, test_sdh_err_funtag6); + _test_add("/snap-device-helper/err/funtag7", NULL, test_sdh_err_funtag7); + _test_add("/snap-device-helper/err/funtag8", NULL, test_sdh_err_funtag8); + // parallel instances + _test_add("/snap-device-helper/parallel/add", &instance_add_data, test_sdh_action); + _test_add("/snap-device-helper/parallel/change", &instance_change_data, test_sdh_action); + _test_add("/snap-device-helper/parallel/bind", &instance_bind_data, test_sdh_action); + _test_add("/snap-device-helper/parallel/unbind", &instance_unbind_data, test_sdh_action); + _test_add("/snap-device-helper/parallel/remove", &instance_remove_data, test_sdh_action); + // hooks + _test_add("/snap-device-helper/hook/add", &add_hook_data, test_sdh_action); + _test_add("/snap-device-helper/hook/parallel/add", &instance_add_hook_data, test_sdh_action); + _test_add("/snap-device-helper/hook-name-hook/parallel/add", &instance_add_instance_name_is_hook_data, + test_sdh_action); + + _test_add("/snap-device-helper/nvme", NULL, test_sdh_action_nvme); +} diff --git a/cmd/snap-device-helper/snap-device-helper.c b/cmd/snap-device-helper/snap-device-helper.c new file mode 100644 index 00000000..9ca3f023 --- /dev/null +++ b/cmd/snap-device-helper/snap-device-helper.c @@ -0,0 +1,216 @@ +/* + * Copyright (C) 2021 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../libsnap-confine-private/cleanup-funcs.h" +#include "../libsnap-confine-private/device-cgroup-support.h" +#include "../libsnap-confine-private/snap.h" +#include "../libsnap-confine-private/string-utils.h" +#include "../libsnap-confine-private/utils.h" + +#include "snap-device-helper.h" + +static unsigned long must_strtoul(const char *str) { + char *end = NULL; + unsigned long val = strtoul(str, &end, 10); + if (*end != '\0') { + die("malformed number \"%s\"", str); + } + return val; +} + +/* udev_to_security_tag converts a udev tag (snap_foo_bar) to security tag + * (snap.foo.bar) */ +static char *udev_to_security_tag(const char *udev_tag) { + if (!sc_startswith(udev_tag, "snap_")) { + die("malformed tag \"%s\"", udev_tag); + } + char *tag = sc_strdup(udev_tag); + /* possible udev tags are: + * snap_foo_bar + * snap_foo_instance_bar + * snap_foo_hook_hookname + * snap_foo_instance_hook_hookname + * convert those to: + * snap.foo.bar + * snap.foo_instance.bar + * snap.foo.hook.hookname + * snap.foo_instance.hook.hookname + */ + size_t tag_len = strlen(tag); + if (tag_len < strlen("snap_a_b") || tag_len > SNAP_SECURITY_TAG_MAX_LEN) { + die("tag \"%s\" length %zu is incorrect", udev_tag, tag_len); + } + const size_t snap_prefix_len = strlen("snap_"); + /* we know that the tag at least has a snap_ prefix because it was checked + * before */ + tag[snap_prefix_len - 1] = '.'; + char *snap_name_start = tag + snap_prefix_len; + char *snap_name_end = NULL; + + /* find the last separator */ + char *last_sep = strrchr(tag, '_'); + if (last_sep == NULL) { + die("missing app name in tag \"%s\"", udev_tag); + } + *last_sep = '.'; + /* we are left with the following possibilities: + * snap.foo.bar + * snap.foo_instance.bar + * snap.foo_instance_hook.hookname + * snap.foo_hook.hookname + */ + char *more_sep = strchr(tag, '_'); + if (more_sep == NULL) { + /* no more separators, we have snap.foo.bar */ + snap_name_end = last_sep; + } else { + /* we are left with the following possibilities: + * snap.foo_instance.bar + * snap.foo_instance_hook.hookname + * snap.foo_hook.hookname + */ + + /* do we have another separator? */ + char *another_sep = strchr(more_sep + 1, '_'); + if (another_sep == NULL) { + /* no, so we are left with the following possibilities: + * snap.foo_instance.bar + * snap.foo_hook.hookname + * + * there is ambiguity and we cannot correctly handle an instance named + * 'hook' as snap.foo_hook.bar could be snap.foo.hook.bar or + * snap.foo_hook.bar, for simplicity assume snap.foo.hook.bar more likely. + */ + if (sc_startswith(more_sep, "_hook.")) { + /* snap.foo_hook.bar -> snap.foo.hook.bar */ + *more_sep = '.'; + snap_name_end = more_sep; + } else { + snap_name_end = last_sep; + } + } else { + /* we have found 2 separators, so this is the only possibility: + * snap.foo_instance_hook.hookname + * which should be converted to: + * snap.foo_instance.hook.hookname + */ + *another_sep = '.'; + snap_name_end = another_sep; + } + } + if (snap_name_end <= snap_name_start) { + die("missing snap name in tag \"%s\"", udev_tag); + } + + /* let's validate the tag, but we need to extract the snap name first */ + char snap_instance[SNAP_INSTANCE_LEN + 1] = {0}; + size_t snap_instance_len = (size_t)(snap_name_end - snap_name_start); + if (snap_instance_len >= sizeof(snap_instance)) { + die("snap instance of tag \"%s\" is too long", udev_tag); + } + memcpy(snap_instance, snap_name_start, snap_instance_len); + debug("snap instance \"%s\"", snap_instance); + + if (!sc_security_tag_validate(tag, snap_instance)) { + die("security tag \"%s\" for snap \"%s\" is not valid", tag, snap_instance); + } + + return tag; +} + +int snap_device_helper_run(const struct sdh_invocation *inv) { + const char *action = inv->action; + const char *udev_tagname = inv->tagname; + const char *major = inv->major; + const char *minor = inv->minor; + const char *subsystem = inv->subsystem; + + bool allow = false; + + if ((major == NULL) && (minor == NULL)) { + /* no device node */ + return 0; + } + if ((major == NULL) || (minor == NULL)) { + die("incomplete major/minor"); + } + if (subsystem != NULL) { + /* ignore kobjects that are not devices */ + if (strcmp(subsystem, "subsystem") == 0) { + return 0; + } + if (strcmp(subsystem, "module") == 0) { + return 0; + } + if (strcmp(subsystem, "drivers") == 0) { + return 0; + } + } + + if (action == NULL) { + die("ERROR: no action given"); + } + if (sc_streq(action, "bind") || sc_streq(action, "add") || sc_streq(action, "change")) { + allow = true; + } else if (sc_streq(action, "remove")) { + allow = false; + } else if (sc_streq(action, "unbind")) { + /* "unbind" does not mean removal of the device, the device node can still exist. + * Usually "unbind" will happen before a "remove" if a removed device is bound to a driver. + * We will disable access to the device once we get "remove". For "unbind", we + * simply ignore it. + */ + return 0; + } else { + die("ERROR: unknown action \"%s\"", action); + } + + char *security_tag SC_CLEANUP(sc_cleanup_string) = udev_to_security_tag(udev_tagname); + + int devtype = ((subsystem != NULL) && (strcmp(subsystem, "block") == 0)) ? S_IFBLK : S_IFCHR; + + sc_device_cgroup *cgroup = sc_device_cgroup_new(security_tag, SC_DEVICE_CGROUP_FROM_EXISTING); + if (!cgroup) { + if (errno == ENOENT) { + debug("device cgroup does not exist"); + return 0; + } + die("cannot create device cgroup wrapper"); + } + + int devmajor = must_strtoul(major); + int devminor = must_strtoul(minor); + debug("%s device type is %s, %d:%d", inv->action, (devtype == S_IFCHR) ? "char" : "block", devmajor, devminor); + if (allow) { + sc_device_cgroup_allow(cgroup, devtype, devmajor, devminor); + } else { + sc_device_cgroup_deny(cgroup, devtype, devmajor, devminor); + } + + return 0; +} diff --git a/cmd/snap-device-helper/snap-device-helper.h b/cmd/snap-device-helper/snap-device-helper.h new file mode 100644 index 00000000..db72cad9 --- /dev/null +++ b/cmd/snap-device-helper/snap-device-helper.h @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2021 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#ifndef SNAP_DEVICE_HELPER_H +#define SNAP_DEVICE_HELPER_H + +struct sdh_invocation { + const char *action; + const char *tagname; + const char *major; + const char *minor; + const char *subsystem; +}; + +int snap_device_helper_run(const struct sdh_invocation *inv); + +#endif /* SNAP_DEVICE_HELPER_H */ diff --git a/cmd/snap-discard-ns/snap-discard-ns.c b/cmd/snap-discard-ns/snap-discard-ns.c new file mode 100644 index 00000000..0779d74a --- /dev/null +++ b/cmd/snap-discard-ns/snap-discard-ns.c @@ -0,0 +1,228 @@ +/* + * Copyright (C) 2015-2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#define _GNU_SOURCE + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../libsnap-confine-private/error.h" +#include "../libsnap-confine-private/locking.h" +#include "../libsnap-confine-private/snap.h" +#include "../libsnap-confine-private/string-utils.h" +#include "../libsnap-confine-private/utils.h" + +#ifndef NSFS_MAGIC +#define NSFS_MAGIC 0x6e736673 +#endif + +int main(int argc, char** argv) { + if (argc != 2 && argc != 3) { + printf("Usage: snap-discard-ns [--from-snap-confine] \n"); + return 0; + } + const char* snap_instance_name; + bool from_snap_confine; + + if (argc == 3) { + if (!sc_streq(argv[1], "--from-snap-confine")) { + die("unexpected argument %s", argv[1]); + } + from_snap_confine = true; + snap_instance_name = argv[2]; + } else { + from_snap_confine = false; + snap_instance_name = argv[1]; + } + + sc_error* err = NULL; + sc_instance_name_validate(snap_instance_name, &err); + sc_die_on_error(err); + + int snap_lock_fd = -1; + if (from_snap_confine) { + sc_verify_snap_lock(snap_instance_name); + } else { + /* Grab the lock holding the snap instance. This prevents races from + * concurrently executing snap-confine. The lock is explicitly released + * during normal operation but it is not preserved across the life-cycle of + * the process anyway so no attempt is made to unlock it ahead of any call + * to die() */ + snap_lock_fd = sc_lock_snap(snap_instance_name); + } + debug("discarding mount namespaces of snap %s", snap_instance_name); + + const char* ns_dir_path = "/run/snapd/ns"; + int ns_dir_fd = open(ns_dir_path, O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW); + if (ns_dir_fd < 0) { + /* The directory may legitimately not exist if no snap has started to + * prepare it. This is not an error condition. */ + if (errno == ENOENT) { + return 0; + } + die("cannot open path %s", ns_dir_path); + } + + /* Move to the namespace directory. This is used so that we don't need to + * traverse the path over and over in our upcoming umount2(2) calls. */ + if (fchdir(ns_dir_fd) < 0) { + die("cannot move to directory %s", ns_dir_path); + } + + /* Create shell patterns that describe the things we are interested in: + * + * Preserved mount namespaces to unmount and unlink: + * - "$SNAP_INSTANCE_NAME.mnt" + * - "$SNAP_INSTANCE_NAME.[0-9]+.mnt" + * + * Applied mount profiles to unlink: + * - "snap.$SNAP_INSTANCE_NAME.fstab" + * - "snap.$SNAP_INSTANCE_NAME.[0-9]+.user-fstab" + * + * Mount namespace information files: + * - "snap.$SNAP_INSTANCE_NAME.info" + * + * Use PATH_MAX as the size of each buffer since those can store any file + * name. */ + char sys_fstab_pattern[PATH_MAX]; + char usr_fstab_pattern[PATH_MAX]; + char sys_mnt_pattern[PATH_MAX]; + char usr_mnt_pattern[PATH_MAX]; + char sys_info_pattern[PATH_MAX]; + sc_must_snprintf(sys_fstab_pattern, sizeof sys_fstab_pattern, "snap\\.%s\\.fstab", snap_instance_name); + sc_must_snprintf(usr_fstab_pattern, sizeof usr_fstab_pattern, "snap\\.%s\\.*\\.user-fstab", snap_instance_name); + sc_must_snprintf(sys_mnt_pattern, sizeof sys_mnt_pattern, "%s\\.mnt", snap_instance_name); + sc_must_snprintf(usr_mnt_pattern, sizeof usr_mnt_pattern, "%s\\.*\\.mnt", snap_instance_name); + sc_must_snprintf(sys_info_pattern, sizeof sys_info_pattern, "snap\\.%s\\.info", snap_instance_name); + + DIR* ns_dir = fdopendir(ns_dir_fd); + if (ns_dir == NULL) { + die("cannot fdopendir"); + } + /* ns_dir_fd is now owned by ns_dir and will not be closed. */ + + while (true) { + /* Reset errno ahead of any call to readdir to differentiate errors + * from legitimate end of directory. */ + errno = 0; + struct dirent* dent = readdir(ns_dir); + if (dent == NULL) { + if (errno != 0) { + die("cannot read next directory entry"); + } + /* We've seen the whole directory. */ + break; + } + + /* We use dnet->d_name a lot so let's shorten it. */ + const char* dname = dent->d_name; + + /* Check the four patterns that we have against the name and set the + * two should flags to decide further actions. Note that we always + * unlink matching files so that is not reflected in the structure. */ + bool should_unmount = false; + bool should_unlink = false; + struct variant { + const char* pattern; + bool unmount; + }; + struct variant variants[] = { + {.pattern = sys_mnt_pattern, .unmount = true}, + {.pattern = usr_mnt_pattern, .unmount = true}, + {.pattern = sys_fstab_pattern}, + {.pattern = usr_fstab_pattern}, + {.pattern = sys_info_pattern}, + }; + for (size_t i = 0; i < sizeof variants / sizeof *variants; ++i) { + struct variant* v = &variants[i]; + debug("checking if %s matches %s", dname, v->pattern); + int match_result = fnmatch(v->pattern, dname, 0); + if (match_result == FNM_NOMATCH) { + continue; + } else if (match_result == 0) { + should_unmount |= v->unmount; + should_unlink = true; + debug("file %s matches pattern %s", dname, v->pattern); + /* One match is enough. */ + break; + } else if (match_result < 0) { + die("cannot execute match against pattern %s", v->pattern); + } + } + + /* Stat the candidate directory entry to know what we are dealing with. + */ + struct stat file_info; + if (fstatat(ns_dir_fd, dname, &file_info, AT_SYMLINK_NOFOLLOW) < 0) { + die("cannot inspect file %s", dname); + } + + /* We are only interested in regular files. The .mnt files, even if + * bind-mounted, appear as regular files and not as symbolic links due + * to the peculiarities of the Linux kernel. */ + if (!S_ISREG(file_info.st_mode)) { + continue; + } + + if (should_unmount) { + /* If we are asked to unmount the file double check that it is + * really a preserved mount namespace since the error code from + * umount2(2) is inconclusive. */ + int path_fd = openat(ns_dir_fd, dname, O_PATH | O_CLOEXEC | O_NOFOLLOW); + if (path_fd < 0) { + die("cannot open path %s", dname); + } + struct statfs fs_info; + if (fstatfs(path_fd, &fs_info) < 0) { + die("cannot inspect file-system at %s", dname); + } + close(path_fd); + if (fs_info.f_type == NSFS_MAGIC || fs_info.f_type == PROC_SUPER_MAGIC) { + debug("unmounting %s", dname); + if (umount2(dname, MNT_DETACH | UMOUNT_NOFOLLOW) < 0) { + die("cannot unmount %s", dname); + } + } + } + + if (should_unlink) { + debug("unlinking %s", dname); + if (unlinkat(ns_dir_fd, dname, 0) < 0) { + die("cannot unlink %s", dname); + } + } + } + + /* Close the directory and release the lock, we're done. */ + if (closedir(ns_dir) < 0) { + die("cannot close directory"); + } + if (snap_lock_fd != -1) { + sc_unlock(snap_lock_fd); + } + return 0; +} diff --git a/cmd/snap-discard-ns/snap-discard-ns.rst b/cmd/snap-discard-ns/snap-discard-ns.rst new file mode 100644 index 00000000..25a74c52 --- /dev/null +++ b/cmd/snap-discard-ns/snap-discard-ns.rst @@ -0,0 +1,62 @@ +================ + snap-discard-ns +================ + +------------------------------------------------------------------------ +internal tool for discarding preserved namespaces of snappy applications +------------------------------------------------------------------------ + +:Author: zygmunt.krynicki@canonical.com +:Date: 2018-10-17 +:Copyright: Canonical Ltd. +:Version: 2.36 +:Manual section: 5 +:Manual group: snappy + +SYNOPSIS +======== + + snap-discard-ns [--from-snap-confine] SNAP_INSTANCE_NAME + +DESCRIPTION +=========== + +The `snap-discard-ns` is a program used internally by `snapd` to discard a preserved +mount namespace of a particular snap. + +OPTIONS +======= + +The --from-snap-confine option is used internally by snap-confine to tell +snap-discard-ns that it is invoked from snap-confine and can disable locking. + +ENVIRONMENT +=========== + +`snap-discard-ns` responds to the following environment variables + +`SNAP_CONFINE_DEBUG`: + When defined the program will print additional diagnostic information about + the actions being performed. All the output goes to stderr. + +FILES +===== + +`snap-discard-ns` uses the following files: + +`/run/snapd/ns/$SNAP_INSTNACE_NAME.mnt`: +`/run/snapd/ns/$SNAP_INSTNACE_NAME.*.mnt`: + + The preserved mount namespace that is unmounted and removed by + `snap-discard-ns`. The second form is for the per-user mount namespace. + +`/run/snapd/ns/snap.$SNAP_INSTNACE_NAME.fstab`: +`/run/snapd/ns/snap.$SNAP_INSTNACE_NAME.*.user-fstab`: + + The current mount profile of a preserved mount namespace that is removed + by `snap-discard-ns`. + +BUGS +==== + +Please report all bugs with https://bugs.launchpad.net/snapd/+filebug diff --git a/cmd/snap-exec/export_test.go b/cmd/snap-exec/export_test.go new file mode 100644 index 00000000..b187587c --- /dev/null +++ b/cmd/snap-exec/export_test.go @@ -0,0 +1,72 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2014-2015 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "syscall" + + "github.com/snapcore/snapd/testutil" +) + +var ( + ExpandEnvCmdArgs = expandEnvCmdArgs + FindCommand = findCommand + ParseArgs = parseArgs + Run = run + ExecApp = execApp + ExecHook = execHook +) + +func MockSyscallExec(f func(argv0 string, argv []string, envv []string) (err error)) func() { + origSyscallExec := syscallExec + syscallExec = f + return func() { + syscallExec = origSyscallExec + } +} + +func SetOptsCommand(s string) { + opts.Command = s +} +func GetOptsCommand() string { + return opts.Command +} + +func SetOptsHook(s string) { + opts.Hook = s +} +func GetOptsHook() string { + return opts.Hook +} + +// MockOsReadlink is for use in tests +func MockOsReadlink(f func(string) (string, error)) func() { + realOsReadlink := osReadlink + osReadlink = f + return func() { + osReadlink = realOsReadlink + } +} + +func MockSyscallStat(f func(string, *syscall.Stat_t) (err error)) func() { + r := testutil.Backup(&syscallStat) + syscallStat = f + return r +} diff --git a/cmd/snap-exec/main.go b/cmd/snap-exec/main.go new file mode 100644 index 00000000..8f077586 --- /dev/null +++ b/cmd/snap-exec/main.go @@ -0,0 +1,291 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2014-2015 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "syscall" + + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/logger" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/snap/snapenv" +) + +// for the tests +var syscallExec = syscall.Exec +var syscallStat = syscall.Stat +var osReadlink = os.Readlink + +// commandline args +var opts struct { + Command string `long:"command" description:"use a different command like {stop,post-stop} from the app"` + Hook string `long:"hook" description:"hook to run" hidden:"yes"` +} + +func init() { + // plug/slot sanitization not used nor possible from snap-exec, make it no-op + snap.SanitizePlugsSlots = func(snapInfo *snap.Info) {} + logger.SimpleSetup() +} + +func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "cannot snap-exec: %s\n", err) + os.Exit(1) + } +} + +func parseArgs(args []string) (app string, appArgs []string, err error) { + parser := flags.NewParser(&opts, flags.HelpFlag|flags.PassDoubleDash|flags.PassAfterNonOption) + rest, err := parser.ParseArgs(args) + if err != nil { + return "", nil, err + } + if len(rest) == 0 { + return "", nil, fmt.Errorf("need the application to run as argument") + } + + // Catch some invalid parameter combinations, provide helpful errors + if opts.Hook != "" && opts.Command != "" { + return "", nil, fmt.Errorf("cannot use --hook and --command together") + } + if opts.Hook != "" && len(rest) > 1 { + return "", nil, fmt.Errorf("too many arguments for hook %q: %s", opts.Hook, strings.Join(rest, " ")) + } + + return rest[0], rest[1:], nil +} + +func run() error { + snapApp, extraArgs, err := parseArgs(os.Args[1:]) + if err != nil { + return err + } + + // the SNAP_REVISION is set by `snap run` - we can not (easily) + // find it in `snap-exec` because `snap-exec` is run inside the + // confinement and (generally) can not talk to snapd + revision := os.Getenv("SNAP_REVISION") + + // Now actually handle the dispatching + if opts.Hook != "" { + return execHook(snapApp, revision, opts.Hook) + } + + return execApp(snapApp, revision, opts.Command, extraArgs) +} + +const defaultShell = "/bin/bash" + +func findCommand(app *snap.AppInfo, command string) (string, error) { + var cmd string + switch command { + case "shell": + cmd = defaultShell + case "complete": + if app.Completer != "" { + cmd = defaultShell + } + case "stop": + cmd = app.StopCommand + case "reload": + cmd = app.ReloadCommand + case "post-stop": + cmd = app.PostStopCommand + case "", "gdb", "gdbserver": + cmd = app.Command + default: + return "", fmt.Errorf("cannot use %q command", command) + } + + if cmd == "" { + return "", fmt.Errorf("no %q command found for %q", command, app.Name) + } + return cmd, nil +} + +func absoluteCommandChain(snapInfo *snap.Info, commandChain []string) []string { + chain := make([]string, 0, len(commandChain)) + snapMountDir := snapInfo.MountDir() + + for _, element := range commandChain { + chain = append(chain, filepath.Join(snapMountDir, element)) + } + + return chain +} + +// expandEnvCmdArgs takes the string list of commandline arguments +// and expands any $VAR with the given var from the env argument. +func expandEnvCmdArgs(args []string, env osutil.Environment) []string { + cmdArgs := make([]string, 0, len(args)) + for _, arg := range args { + maybeExpanded := os.Expand(arg, func(varName string) string { + return env[varName] + }) + if maybeExpanded != "" { + cmdArgs = append(cmdArgs, maybeExpanded) + } + } + return cmdArgs +} + +func completionHelper() (string, error) { + exe, err := osReadlink("/proc/self/exe") + if err != nil { + return "", err + } + return filepath.Join(filepath.Dir(exe), "etelpmoc.sh"), nil +} + +func execApp(snapApp, revision, command string, args []string) error { + rev, err := snap.ParseRevision(revision) + if err != nil { + return fmt.Errorf("cannot parse revision %q: %s", revision, err) + } + + snapName, appName := snap.SplitSnapApp(snapApp) + info, err := snap.ReadInfo(snapName, &snap.SideInfo{ + Revision: rev, + }) + if err != nil { + return fmt.Errorf("cannot read info for %q: %s", snapName, err) + } + + app := info.Apps[appName] + if app == nil { + return fmt.Errorf("cannot find app %q in %q", appName, snapName) + } + + cmdAndArgs, err := findCommand(app, command) + if err != nil { + return err + } + + // build the environment from the yaml, translating TMPDIR and + // similar variables back from where they were hidden when + // invoking the setuid snap-confine. + env, err := osutil.OSEnvironmentUnescapeUnsafe(snapenv.PreservedUnsafePrefix) + if err != nil { + return err + } + for _, eenv := range app.EnvChain() { + env.ExtendWithExpanded(eenv) + } + + // this is a workaround for the lack of an environment backend in interfaces + // where we want certain interfaces when connected to add environment + // variables to plugging snap apps, but this is a lot simpler as a + // work-around + // we currently only handle the CUPS_SERVER environment variable, setting it + // to /var/cups/ if that dir is a bind-mount - it should not be one + // except in a strictly confined snap where we setup the bind mount from the + // source cups slot snap to the plugging snap. + var stVar, stVarCups syscall.Stat_t + err1 := syscallStat(dirs.GlobalRootDir+"/var/", &stVar) + err2 := syscallStat(dirs.GlobalRootDir+"/var/cups/", &stVarCups) + if err1 == nil && err2 == nil && stVar.Dev != stVarCups.Dev { + env["CUPS_SERVER"] = "/var/cups/cups.sock" + } + + // strings.Split() is ok here because we validate all app fields and the + // whitelist is pretty strict (see snap/validate.go:appContentWhitelist) + // (see also overlord/snapstate/check_snap.go's normPath) + tmpArgv := strings.Split(cmdAndArgs, " ") + cmd := tmpArgv[0] + cmdArgs := expandEnvCmdArgs(tmpArgv[1:], env) + + // run the command + fullCmd := []string{filepath.Join(app.Snap.MountDir(), cmd)} + switch command { + case "shell": + fullCmd[0] = defaultShell + cmdArgs = nil + case "complete": + fullCmd[0] = defaultShell + helper, err := completionHelper() + if err != nil { + return fmt.Errorf("cannot find completion helper: %v", err) + } + cmdArgs = []string{ + helper, + filepath.Join(app.Snap.MountDir(), app.Completer), + } + case "gdb": + fullCmd = append(fullCmd, fullCmd[0]) + fullCmd[0] = filepath.Join(dirs.CoreLibExecDir, "snap-gdb-shim") + case "gdbserver": + fullCmd = append(fullCmd, fullCmd[0]) + fullCmd[0] = filepath.Join(dirs.CoreLibExecDir, "snap-gdbserver-shim") + } + fullCmd = append(fullCmd, cmdArgs...) + fullCmd = append(fullCmd, args...) + + fullCmd = append(absoluteCommandChain(app.Snap, app.CommandChain), fullCmd...) + + logger.StartupStageTimestamp("snap-exec to app") + if err := syscallExec(fullCmd[0], fullCmd, env.ForExec()); err != nil { + return fmt.Errorf("cannot exec %q: %s", fullCmd[0], err) + } + // this is never reached except in tests + return nil +} + +func execHook(snapName, revision, hookName string) error { + rev, err := snap.ParseRevision(revision) + if err != nil { + return err + } + + info, err := snap.ReadInfo(snapName, &snap.SideInfo{ + Revision: rev, + }) + if err != nil { + return err + } + + hook := info.Hooks[hookName] + if hook == nil { + return fmt.Errorf("cannot find hook %q in %q", hookName, snapName) + } + + // build the environment + // NOTE: we do not use OSEnvironmentUnescapeUnsafe, we do not + // particurly want to transmit snapd exec environment details + // to the hooks + env, err := osutil.OSEnvironment() + if err != nil { + return err + } + for _, eenv := range hook.EnvChain() { + env.ExtendWithExpanded(eenv) + } + + // run the hook + cmd := append(absoluteCommandChain(hook.Snap, hook.CommandChain), filepath.Join(hook.Snap.HooksDir(), hook.Name)) + return syscallExec(cmd[0], cmd, env.ForExec()) +} diff --git a/cmd/snap-exec/main_test.go b/cmd/snap-exec/main_test.go new file mode 100644 index 00000000..b5b638ca --- /dev/null +++ b/cmd/snap-exec/main_test.go @@ -0,0 +1,665 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "testing" + + . "gopkg.in/check.v1" + + snapExec "github.com/snapcore/snapd/cmd/snap-exec" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/release" + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/snap/snaptest" + "github.com/snapcore/snapd/testutil" +) + +// Hook up check.v1 into the "go test" runner +func Test(t *testing.T) { TestingT(t) } + +type snapExecSuite struct{} + +var _ = Suite(&snapExecSuite{}) + +func (s *snapExecSuite) SetUpTest(c *C) { + // clean previous parse runs + snapExec.SetOptsCommand("") + snapExec.SetOptsHook("") +} + +func (s *snapExecSuite) TearDown(c *C) { + dirs.SetRootDir("/") +} + +var mockYaml = []byte(`name: snapname +version: 1.0 +apps: + app: + command: run-app cmd-arg1 $SNAP_DATA + stop-command: stop-app + post-stop-command: post-stop-app + completer: you/complete/me + environment: + BASE_PATH: /some/path + LD_LIBRARY_PATH: ${BASE_PATH}/lib + MY_PATH: $PATH + TEST_PATH: /custom + app2: + command: run-app2 + stop-command: stop-app2 + post-stop-command: post-stop-app2 + command-chain: [chain1, chain2] + nostop: + command: nostop +`) + +var mockClassicYaml = append([]byte("confinement: classic\n"), mockYaml...) + +var mockHookYaml = []byte(`name: snapname +version: 1.0 +hooks: + configure: +`) + +var mockHookCommandChainYaml = []byte(`name: snapname +version: 1.0 +hooks: + configure: + command-chain: [chain1, chain2] +`) + +var binaryTemplate = `#!/bin/sh +echo "$(basename $0)" >> %[1]q +for arg in "$@"; do +echo "$arg" >> %[1]q +done +printf "\n" >> %[1]q` + +func (s *snapExecSuite) TestInvalidCombinedParameters(c *C) { + invalidParameters := []string{"--hook=hook-name", "--command=command-name", "snap-name"} + _, _, err := snapExec.ParseArgs(invalidParameters) + c.Check(err, ErrorMatches, ".*cannot use --hook and --command together.*") +} + +func (s *snapExecSuite) TestInvalidExtraParameters(c *C) { + invalidParameters := []string{"--hook=hook-name", "snap-name", "foo", "bar"} + _, _, err := snapExec.ParseArgs(invalidParameters) + c.Check(err, ErrorMatches, ".*too many arguments for hook \"hook-name\": snap-name foo bar.*") +} + +func (s *snapExecSuite) TestFindCommand(c *C) { + info, err := snap.InfoFromSnapYaml(mockYaml) + c.Assert(err, IsNil) + + for _, t := range []struct { + cmd string + expected string + }{ + {cmd: "", expected: `run-app cmd-arg1 $SNAP_DATA`}, + {cmd: "stop", expected: "stop-app"}, + {cmd: "post-stop", expected: "post-stop-app"}, + } { + cmd, err := snapExec.FindCommand(info.Apps["app"], t.cmd) + c.Check(err, IsNil) + c.Check(cmd, Equals, t.expected) + } +} + +func (s *snapExecSuite) TestFindCommandInvalidCommand(c *C) { + info, err := snap.InfoFromSnapYaml(mockYaml) + c.Assert(err, IsNil) + + _, err = snapExec.FindCommand(info.Apps["app"], "xxx") + c.Check(err, ErrorMatches, `cannot use "xxx" command`) +} + +func (s *snapExecSuite) TestFindCommandNoCommand(c *C) { + info, err := snap.InfoFromSnapYaml(mockYaml) + c.Assert(err, IsNil) + + _, err = snapExec.FindCommand(info.Apps["nostop"], "stop") + c.Check(err, ErrorMatches, `no "stop" command found for "nostop"`) +} + +func (s *snapExecSuite) TestSnapExecAppIntegration(c *C) { + dirs.SetRootDir(c.MkDir()) + snaptest.MockSnap(c, string(mockYaml), &snap.SideInfo{ + Revision: snap.R("42"), + }) + + execArgv0 := "" + execArgs := []string{} + execEnv := []string{} + restore := snapExec.MockSyscallExec(func(argv0 string, argv []string, env []string) error { + execArgv0 = argv0 + execArgs = argv + execEnv = env + return nil + }) + defer restore() + + // FIXME: TEST_PATH was meant to be just PATH but this uncovers another + // bug in the test suite where mocking binaries misbehaves. + oldPath := os.Getenv("TEST_PATH") + os.Setenv("TEST_PATH", "/vanilla") + defer os.Setenv("TEST_PATH", oldPath) + + // launch and verify its run the right way + err := snapExec.ExecApp("snapname.app", "42", "stop", []string{"arg1", "arg2"}) + c.Assert(err, IsNil) + c.Check(execArgv0, Equals, fmt.Sprintf("%s/snapname/42/stop-app", dirs.SnapMountDir)) + c.Check(execArgs, DeepEquals, []string{execArgv0, "arg1", "arg2"}) + c.Check(execEnv, testutil.Contains, "BASE_PATH=/some/path") + c.Check(execEnv, testutil.Contains, "LD_LIBRARY_PATH=/some/path/lib") + c.Check(execEnv, testutil.Contains, fmt.Sprintf("MY_PATH=%s", os.Getenv("PATH"))) + // TEST_PATH is properly handled and we only see one value, /custom, defined + // as an app-specific override. + // See also https://bugs.launchpad.net/snapd/+bug/1860369 + c.Check(execEnv, Not(testutil.Contains), "TEST_PATH=/vanilla") + c.Check(execEnv, testutil.Contains, "TEST_PATH=/custom") + + // ensure that CUPS_SERVER is absent since we didn't mock the /var/cups dir + c.Check(execEnv, Not(testutil.Contains), "CUPS_SERVER=/var/cups") +} + +func (s *snapExecSuite) TestSnapExecAppIntegrationCupsServerWorkaround(c *C) { + dir := c.MkDir() + dirs.SetRootDir(dir) + snaptest.MockSnap(c, string(mockYaml), &snap.SideInfo{ + Revision: snap.R("42"), + }) + + // mock the /var/cups dir is a bind-mount + restore := snapExec.MockSyscallStat(func(p string, st *syscall.Stat_t) error { + if strings.HasSuffix(p, "/var/cups/") { + st.Dev = 2 + } else { + st.Dev = 1 + } + return nil + }) + defer restore() + + execArgv0 := "" + execArgs := []string{} + execEnv := []string{} + restore = snapExec.MockSyscallExec(func(argv0 string, argv []string, env []string) error { + execArgv0 = argv0 + execArgs = argv + execEnv = env + return nil + }) + defer restore() + + // launch and verify its run the right way + err := snapExec.ExecApp("snapname.app", "42", "stop", []string{"arg1", "arg2"}) + c.Assert(err, IsNil) + c.Check(execArgv0, Equals, fmt.Sprintf("%s/snapname/42/stop-app", dirs.SnapMountDir)) + c.Check(execArgs, DeepEquals, []string{execArgv0, "arg1", "arg2"}) + + // ensure that CUPS_SERVER is now set since we did mock the /var/cups dir + c.Check(execEnv, testutil.Contains, "CUPS_SERVER=/var/cups/cups.sock") +} + +func (s *snapExecSuite) TestSnapExecAppCommandChainIntegration(c *C) { + dirs.SetRootDir(c.MkDir()) + snaptest.MockSnap(c, string(mockYaml), &snap.SideInfo{ + Revision: snap.R("42"), + }) + + execArgv0 := "" + execArgs := []string{} + restore := snapExec.MockSyscallExec(func(argv0 string, argv []string, env []string) error { + execArgv0 = argv0 + execArgs = argv + return nil + }) + defer restore() + + chain1_path := fmt.Sprintf("%s/snapname/42/chain1", dirs.SnapMountDir) + chain2_path := fmt.Sprintf("%s/snapname/42/chain2", dirs.SnapMountDir) + app_path := fmt.Sprintf("%s/snapname/42/run-app2", dirs.SnapMountDir) + stop_path := fmt.Sprintf("%s/snapname/42/stop-app2", dirs.SnapMountDir) + post_stop_path := fmt.Sprintf("%s/snapname/42/post-stop-app2", dirs.SnapMountDir) + + for _, t := range []struct { + cmd string + args []string + expected []string + }{ + // Normal command + {expected: []string{chain1_path, chain2_path, app_path}}, + {args: []string{"arg1", "arg2"}, expected: []string{chain1_path, chain2_path, app_path, "arg1", "arg2"}}, + + // Stop command + {cmd: "stop", expected: []string{chain1_path, chain2_path, stop_path}}, + {cmd: "stop", args: []string{"arg1", "arg2"}, expected: []string{chain1_path, chain2_path, stop_path, "arg1", "arg2"}}, + + // Post-stop command + {cmd: "post-stop", expected: []string{chain1_path, chain2_path, post_stop_path}}, + {cmd: "post-stop", args: []string{"arg1", "arg2"}, expected: []string{chain1_path, chain2_path, post_stop_path, "arg1", "arg2"}}, + } { + err := snapExec.ExecApp("snapname.app2", "42", t.cmd, t.args) + c.Assert(err, IsNil) + c.Check(execArgv0, Equals, t.expected[0]) + c.Check(execArgs, DeepEquals, t.expected) + } +} + +func (s *snapExecSuite) TestSnapExecHookIntegration(c *C) { + dirs.SetRootDir(c.MkDir()) + snaptest.MockSnap(c, string(mockHookYaml), &snap.SideInfo{ + Revision: snap.R("42"), + }) + + execArgv0 := "" + execArgs := []string{} + restore := snapExec.MockSyscallExec(func(argv0 string, argv []string, env []string) error { + execArgv0 = argv0 + execArgs = argv + return nil + }) + defer restore() + + // launch and verify it ran correctly + err := snapExec.ExecHook("snapname", "42", "configure") + c.Assert(err, IsNil) + c.Check(execArgv0, Equals, fmt.Sprintf("%s/snapname/42/meta/hooks/configure", dirs.SnapMountDir)) + c.Check(execArgs, DeepEquals, []string{execArgv0}) +} + +func (s *snapExecSuite) TestSnapExecHookCommandChainIntegration(c *C) { + dirs.SetRootDir(c.MkDir()) + snaptest.MockSnap(c, string(mockHookCommandChainYaml), &snap.SideInfo{ + Revision: snap.R("42"), + }) + + execArgv0 := "" + execArgs := []string{} + restore := snapExec.MockSyscallExec(func(argv0 string, argv []string, env []string) error { + execArgv0 = argv0 + execArgs = argv + return nil + }) + defer restore() + + chain1_path := fmt.Sprintf("%s/snapname/42/chain1", dirs.SnapMountDir) + chain2_path := fmt.Sprintf("%s/snapname/42/chain2", dirs.SnapMountDir) + hook_path := fmt.Sprintf("%s/snapname/42/meta/hooks/configure", dirs.SnapMountDir) + + err := snapExec.ExecHook("snapname", "42", "configure") + c.Assert(err, IsNil) + c.Check(execArgv0, Equals, chain1_path) + c.Check(execArgs, DeepEquals, []string{chain1_path, chain2_path, hook_path}) +} + +func (s *snapExecSuite) TestSnapExecHookMissingHookIntegration(c *C) { + dirs.SetRootDir(c.MkDir()) + snaptest.MockSnap(c, string(mockHookYaml), &snap.SideInfo{ + Revision: snap.R("42"), + }) + + err := snapExec.ExecHook("snapname", "42", "missing-hook") + c.Assert(err, NotNil) + c.Assert(err, ErrorMatches, "cannot find hook \"missing-hook\" in \"snapname\"") +} + +func (s *snapExecSuite) TestSnapExecIgnoresUnknownArgs(c *C) { + snapApp, rest, err := snapExec.ParseArgs([]string{"--command=shell", "snapname.app", "--arg1", "arg2"}) + c.Assert(err, IsNil) + c.Assert(snapExec.GetOptsCommand(), Equals, "shell") + c.Assert(snapApp, DeepEquals, "snapname.app") + c.Assert(rest, DeepEquals, []string{"--arg1", "arg2"}) +} + +func (s *snapExecSuite) TestSnapExecErrorsOnUnknown(c *C) { + _, _, err := snapExec.ParseArgs([]string{"--command=shell", "--unknown", "snapname.app", "--arg1", "arg2"}) + c.Check(err, ErrorMatches, "unknown flag `unknown'") +} + +func (s *snapExecSuite) TestSnapExecErrorsOnMissingSnapApp(c *C) { + _, _, err := snapExec.ParseArgs([]string{"--command=shell"}) + c.Check(err, ErrorMatches, "need the application to run as argument") +} + +func (s *snapExecSuite) TestSnapExecAppRealIntegration(c *C) { + // we need a lot of mocks + dirs.SetRootDir(c.MkDir()) + + oldOsArgs := os.Args + defer func() { os.Args = oldOsArgs }() + + os.Setenv("SNAP_REVISION", "42") + defer os.Unsetenv("SNAP_REVISION") + + snaptest.MockSnap(c, string(mockYaml), &snap.SideInfo{ + Revision: snap.R("42"), + }) + + canaryFile := filepath.Join(c.MkDir(), "canary.txt") + script := fmt.Sprintf("%s/snapname/42/run-app", dirs.SnapMountDir) + err := os.WriteFile(script, []byte(fmt.Sprintf(binaryTemplate, canaryFile)), 0755) + c.Assert(err, IsNil) + + // we can not use the real syscall.execv here because it would + // replace the entire test :) + restore := snapExec.MockSyscallExec(actuallyExec) + defer restore() + + // run it + os.Args = []string{"snap-exec", "snapname.app", "foo", "--bar=baz", "foobar"} + err = snapExec.Run() + c.Assert(err, IsNil) + + c.Assert(canaryFile, testutil.FileEquals, `run-app +cmd-arg1 +foo +--bar=baz +foobar + +`) +} + +func (s *snapExecSuite) TestSnapExecHookRealIntegration(c *C) { + // we need a lot of mocks + dirs.SetRootDir(c.MkDir()) + + oldOsArgs := os.Args + defer func() { os.Args = oldOsArgs }() + + os.Setenv("SNAP_REVISION", "42") + defer os.Unsetenv("SNAP_REVISION") + + canaryFile := filepath.Join(c.MkDir(), "canary.txt") + + testSnap := snaptest.MockSnap(c, string(mockHookYaml), &snap.SideInfo{ + Revision: snap.R("42"), + }) + hookPath := filepath.Join("meta", "hooks", "configure") + hookPathAndContents := []string{hookPath, fmt.Sprintf(binaryTemplate, canaryFile)} + snaptest.PopulateDir(testSnap.MountDir(), [][]string{hookPathAndContents}) + hookPath = filepath.Join(testSnap.MountDir(), hookPath) + c.Assert(os.Chmod(hookPath, 0755), IsNil) + + // we can not use the real syscall.execv here because it would + // replace the entire test :) + restore := snapExec.MockSyscallExec(actuallyExec) + defer restore() + + // run it + os.Args = []string{"snap-exec", "--hook=configure", "snapname"} + err := snapExec.Run() + c.Assert(err, IsNil) + + c.Assert(canaryFile, testutil.FileEquals, "configure\n\n") +} + +func actuallyExec(argv0 string, argv []string, env []string) error { + cmd := exec.Command(argv[0], argv[1:]...) + cmd.Env = env + output, err := cmd.CombinedOutput() + if len(output) > 0 { + return fmt.Errorf("Expected output length to be 0, it was %d", len(output)) + } + return err +} + +func (s *snapExecSuite) TestSnapExecShellIntegration(c *C) { + dirs.SetRootDir(c.MkDir()) + snaptest.MockSnap(c, string(mockYaml), &snap.SideInfo{ + Revision: snap.R("42"), + }) + + execArgv0 := "" + execArgs := []string{} + execEnv := []string{} + restore := snapExec.MockSyscallExec(func(argv0 string, argv []string, env []string) error { + execArgv0 = argv0 + execArgs = argv + execEnv = env + return nil + }) + defer restore() + + // launch and verify its run the right way + err := snapExec.ExecApp("snapname.app", "42", "shell", []string{"-c", "echo foo"}) + c.Assert(err, IsNil) + c.Check(execArgv0, Equals, "/bin/bash") + c.Check(execArgs, DeepEquals, []string{execArgv0, "-c", "echo foo"}) + c.Check(execEnv, testutil.Contains, "LD_LIBRARY_PATH=/some/path/lib") + + // launch and verify shell still runs the command chain + err = snapExec.ExecApp("snapname.app2", "42", "shell", []string{"-c", "echo foo"}) + c.Assert(err, IsNil) + chain1 := fmt.Sprintf("%s/snapname/42/chain1", dirs.SnapMountDir) + chain2 := fmt.Sprintf("%s/snapname/42/chain2", dirs.SnapMountDir) + c.Check(execArgv0, Equals, chain1) + c.Check(execArgs, DeepEquals, []string{chain1, chain2, "/bin/bash", "-c", "echo foo"}) +} + +func (s *snapExecSuite) TestSnapExecAppIntegrationWithVars(c *C) { + dirs.SetRootDir(c.MkDir()) + snaptest.MockSnap(c, string(mockYaml), &snap.SideInfo{ + Revision: snap.R("42"), + }) + + execArgv0 := "" + execArgs := []string{} + execEnv := []string{} + restore := snapExec.MockSyscallExec(func(argv0 string, argv []string, env []string) error { + execArgv0 = argv0 + execArgs = argv + execEnv = env + return nil + }) + defer restore() + + // setup env + os.Setenv("SNAP_DATA", "/var/snap/snapname/42") + defer os.Unsetenv("SNAP_DATA") + + // launch and verify its run the right way + err := snapExec.ExecApp("snapname.app", "42", "", []string{"user-arg1"}) + c.Assert(err, IsNil) + c.Check(execArgv0, Equals, fmt.Sprintf("%s/snapname/42/run-app", dirs.SnapMountDir)) + c.Check(execArgs, DeepEquals, []string{execArgv0, "cmd-arg1", "/var/snap/snapname/42", "user-arg1"}) + c.Check(execEnv, testutil.Contains, "BASE_PATH=/some/path") + c.Check(execEnv, testutil.Contains, "LD_LIBRARY_PATH=/some/path/lib") + c.Check(execEnv, testutil.Contains, fmt.Sprintf("MY_PATH=%s", os.Getenv("PATH"))) +} + +func (s *snapExecSuite) TestSnapExecExpandEnvCmdArgs(c *C) { + for _, t := range []struct { + args []string + env map[string]string + expected []string + }{ + { + args: []string{"foo"}, + env: nil, + expected: []string{"foo"}, + }, + { + args: []string{"$var"}, + env: map[string]string{"var": "value"}, + expected: []string{"value"}, + }, + { + args: []string{"foo", "$not_existing"}, + env: nil, + expected: []string{"foo"}, + }, + { + args: []string{"foo", "$var", "baz"}, + env: map[string]string{"var": "bar", "unrelated": "env"}, + expected: []string{"foo", "bar", "baz"}, + }, + } { + env := osutil.Environment(t.env) + c.Check(snapExec.ExpandEnvCmdArgs(t.args, env), DeepEquals, t.expected) + } +} + +func (s *snapExecSuite) TestSnapExecCompleteError(c *C) { + dirs.SetRootDir(c.MkDir()) + snaptest.MockSnap(c, string(mockYaml), &snap.SideInfo{ + Revision: snap.R("42"), + }) + + restore := snapExec.MockOsReadlink(func(p string) (string, error) { + c.Assert(p, Equals, "/proc/self/exe") + return "", fmt.Errorf("fail") + }) + defer restore() + + // setup env + os.Setenv("SNAP_DATA", "/var/snap/snapname/42") + defer os.Unsetenv("SNAP_DATA") + + // launch and verify its run the right way + err := snapExec.ExecApp("snapname.app", "42", "complete", []string{"foo"}) + c.Assert(err, ErrorMatches, "cannot find completion helper: fail") +} + +func (s *snapExecSuite) TestSnapExecCompleteConfined(c *C) { + dirs.SetRootDir(c.MkDir()) + snaptest.MockSnap(c, string(mockYaml), &snap.SideInfo{ + Revision: snap.R("42"), + }) + + execArgv0 := "" + execArgs := []string{} + restore := snapExec.MockSyscallExec(func(argv0 string, argv []string, env []string) error { + execArgv0 = argv0 + execArgs = argv + return nil + }) + defer restore() + + restore = snapExec.MockOsReadlink(func(p string) (string, error) { + c.Assert(p, Equals, "/proc/self/exe") + // as if running inside the snap mount namespace + return "/usr/lib/snapd/snap-exec", nil + }) + defer restore() + + // setup env + os.Setenv("SNAP_DATA", "/var/snap/snapname/42") + defer os.Unsetenv("SNAP_DATA") + + // launch and verify its run the right way + err := snapExec.ExecApp("snapname.app", "42", "complete", []string{"foo"}) + c.Assert(err, IsNil) + c.Check(execArgv0, Equals, "/bin/bash") + c.Check(execArgs, DeepEquals, []string{execArgv0, + dirs.CompletionHelperInCore, + filepath.Join(dirs.SnapMountDir, "snapname/42/you/complete/me"), + "foo"}) +} + +func (s *snapExecSuite) TestSnapExecCompleteClassicReexec(c *C) { + restore := release.MockReleaseInfo(&release.OS{ID: "ubuntu"}) + defer restore() + dirs.SetRootDir(c.MkDir()) + snaptest.MockSnap(c, string(mockClassicYaml), &snap.SideInfo{ + Revision: snap.R("42"), + }) + + execArgv0 := "" + execArgs := []string{} + restore = snapExec.MockSyscallExec(func(argv0 string, argv []string, env []string) error { + execArgv0 = argv0 + execArgs = argv + return nil + }) + defer restore() + + restore = snapExec.MockOsReadlink(func(p string) (string, error) { + c.Assert(p, Equals, "/proc/self/exe") + // as if it's reexeced from the snap + return filepath.Join(dirs.SnapMountDir, "core/current", dirs.CoreLibExecDir, "snap-exec"), nil + }) + defer restore() + + // setup env + os.Setenv("SNAP_DATA", "/var/snap/snapname/42") + defer os.Unsetenv("SNAP_DATA") + + // launch and verify its run the right way + err := snapExec.ExecApp("snapname.app", "42", "complete", []string{"foo"}) + c.Assert(err, IsNil) + c.Check(execArgv0, Equals, "/bin/bash") + c.Check(execArgs, DeepEquals, []string{execArgv0, + filepath.Join(dirs.SnapMountDir, "core/current", dirs.CompletionHelperInCore), + filepath.Join(dirs.SnapMountDir, "snapname/42/you/complete/me"), + "foo"}) +} + +func (s *snapExecSuite) TestSnapExecCompleteClassicNoReexec(c *C) { + restore := release.MockReleaseInfo(&release.OS{ID: "centos"}) + defer restore() + dirs.SetRootDir(c.MkDir()) + snaptest.MockSnap(c, string(mockClassicYaml), &snap.SideInfo{ + Revision: snap.R("42"), + }) + + execArgv0 := "" + execArgs := []string{} + execEnv := []string{} + restore = snapExec.MockSyscallExec(func(argv0 string, argv []string, env []string) error { + execArgv0 = argv0 + execArgs = argv + execEnv = env + return nil + }) + defer restore() + + restore = snapExec.MockOsReadlink(func(p string) (string, error) { + c.Assert(p, Equals, "/proc/self/exe") + // running from distro libexecdir + return filepath.Join(dirs.DistroLibExecDir, "snap-exec"), nil + }) + defer restore() + + // setup env + os.Setenv("SNAP_DATA", "/var/snap/snapname/42") + defer os.Unsetenv("SNAP_DATA") + os.Setenv("SNAP_SAVED_TMPDIR", "/var/tmp99") + defer os.Unsetenv("SNAP_SAVED_TMPDIR") + + // launch and verify its run the right way + err := snapExec.ExecApp("snapname.app", "42", "complete", []string{"foo"}) + c.Assert(err, IsNil) + c.Check(execArgv0, Equals, "/bin/bash") + c.Check(execArgs, DeepEquals, []string{execArgv0, + filepath.Join(dirs.DistroLibExecDir, "etelpmoc.sh"), + filepath.Join(dirs.SnapMountDir, "snapname/42/you/complete/me"), + "foo"}) + c.Check(execEnv, testutil.Contains, "SNAP_DATA=/var/snap/snapname/42") + c.Check(execEnv, testutil.Contains, "TMPDIR=/var/tmp99") +} diff --git a/cmd/snap-failure/cmd_snapd.go b/cmd/snap-failure/cmd_snapd.go new file mode 100644 index 00000000..1892e728 --- /dev/null +++ b/cmd/snap-failure/cmd_snapd.go @@ -0,0 +1,224 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2018 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "time" + + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/logger" + "github.com/snapcore/snapd/osutil" +) + +func init() { + const ( + short = "Run snapd failure handling" + long = "" + ) + + if _, err := parser.AddCommand("snapd", short, long, &cmdSnapd{}); err != nil { + panic(err) + } + +} + +// We do not import anything from snapd here for safety reasons so make a +// copy of the relevant struct data we care about. +type sideInfo struct { + Revision string `json:"revision"` +} + +type snapSeq struct { + Current string `json:"current"` + Sequence []sideInfo `json:"sequence"` +} + +type cmdSnapd struct{} + +var errNoSnapd = errors.New("no snapd sequence file found") +var errNoPrevious = errors.New("no revision to go back to") + +func prevRevision(snapName string) (string, error) { + seqFile := filepath.Join(dirs.SnapSeqDir, snapName+".json") + content, err := os.ReadFile(seqFile) + if os.IsNotExist(err) { + return "", errNoSnapd + } + if err != nil { + return "", err + } + + var seq snapSeq + if err := json.Unmarshal(content, &seq); err != nil { + return "", fmt.Errorf("cannot parse %q sequence file: %v", filepath.Base(seqFile), err) + } + + var prev string + for i, si := range seq.Sequence { + if seq.Current == si.Revision { + if i == 0 { + return "", errNoPrevious + } + prev = seq.Sequence[i-1].Revision + break + } + } + if prev == "" { + return "", fmt.Errorf("internal error: current %v not found in sequence: %+v", seq.Current, seq.Sequence) + } + + return prev, nil +} + +func runCmd(prog string, args []string, env []string) *exec.Cmd { + cmd := exec.Command(prog, args...) + cmd.Env = os.Environ() + for _, envVar := range env { + cmd.Env = append(cmd.Env, envVar) + } + + cmd.Stdout = Stdout + cmd.Stderr = Stderr + + return cmd +} + +var ( + sampleForActiveInterval = 5 * time.Second + restartSnapdCoolOffWait = 12500 * time.Millisecond +) + +func (c *cmdSnapd) Execute(args []string) error { + var snapdPath string + // find previous the snapd snap + prevRev, err := prevRevision("snapd") + switch err { + case errNoSnapd: + // the snapd snap is not installed + return nil + case errNoPrevious: + // this is the first revision of snapd to be installed on the + // system, either a remodel or a plain snapd installation, call + // the snapd from the core snap + snapdPath = filepath.Join(dirs.SnapMountDir, "core", "current", "/usr/lib/snapd/snapd") + if !osutil.FileExists(snapdPath) { + // it is possible that the core snap is not installed at + // all, in which case we should try the snapd snap + snapdPath = filepath.Join(dirs.SnapMountDir, "snapd", "current", "/usr/lib/snapd/snapd") + } + prevRev = "0" + case nil: + // the snapd snap was installed before, use the previous revision + snapdPath = filepath.Join(dirs.SnapMountDir, "snapd", prevRev, "/usr/lib/snapd/snapd") + default: + return err + } + logger.Noticef("stopping snapd socket") + // stop the socket unit so that we can start snapd on its own + stdout, stderr, err := osutil.RunSplitOutput("systemctl", "stop", "snapd.socket") + if err != nil { + return osutil.OutputErrCombine(stdout, stderr, err) + } + + logger.Noticef("restoring invoking snapd from: %v", snapdPath) + if prevRev != "0" { + // if prevRev was "0" it means we did *not* find a + // previous revision and we would obey the current + // symlink. So we overwrite the symlink only if + // prevRev != "0". + currentSymlink := filepath.Join(dirs.SnapMountDir, "snapd", "current") + if err := osutil.AtomicSymlink(prevRev, currentSymlink); err != nil { + return fmt.Errorf("cannot create symlink %s: %v", currentSymlink, err) + } + } + // start previous snapd + cmd := runCmd(snapdPath, nil, []string{"SNAPD_REVERT_TO_REV=" + prevRev, "SNAPD_DEBUG=1"}) + if err = cmd.Run(); err != nil { + return fmt.Errorf("snapd failed: %v", err) + } + + isFailedCmd := runCmd("systemctl", []string{"is-failed", "snapd.socket", "snapd.service"}, nil) + if err := isFailedCmd.Run(); err != nil { + // the ephemeral snapd we invoked seems to have fixed + // snapd.service and snapd.socket, check whether they get + // reported as active for 5 * 5s + for i := 0; i < 5; i++ { + if i != 0 { + time.Sleep(sampleForActiveInterval) + } + isActiveCmd := runCmd("systemctl", []string{"is-active", "snapd.socket", "snapd.service"}, nil) + err := isActiveCmd.Run() + if err == nil && osutil.FileExists(dirs.SnapdSocket) && osutil.FileExists(dirs.SnapSocket) { + logger.Noticef("snapd is active again, sockets are available, nothing more to do") + return nil + } + } + } + + logger.Noticef("restarting snapd socket") + // we need to reset the failure state to be able to restart again + resetCmd := runCmd("systemctl", []string{"reset-failed", "snapd.socket", "snapd.service"}, nil) + if err = resetCmd.Run(); err != nil { + // don't die if we fail to reset the failed state of snapd.socket, as + // the restart itself could still work + logger.Noticef("failed to reset-failed snapd.socket: %v", err) + } + // at this point our manually started snapd stopped and + // should have removed the /run/snap* sockets (this is a feature of + // golang) - we need to restart snapd.socket to make them + // available again. + + // be extra robust and if the socket file still somehow exists delete it + // before restarting, otherwise the restart command will fail because the + // systemd can't create the file + // always remove to avoid TOCTOU issues but don't complain about ENOENT + for _, fn := range []string{dirs.SnapdSocket, dirs.SnapSocket} { + err = os.Remove(fn) + if err != nil && !os.IsNotExist(err) { + logger.Noticef("snapd socket %s still exists before restarting socket service, but unable to remove: %v", fn, err) + } + } + + restartCmd := runCmd("systemctl", []string{"restart", "snapd.socket"}, nil) + if err := restartCmd.Run(); err != nil { + logger.Noticef("failed to restart snapd.socket: %v", err) + // fallback to try snapd itself + // wait more than DefaultStartLimitIntervalSec + // + // TODO: consider parsing + // systemctl show snapd -p StartLimitIntervalUSec + // might need system-analyze timespan which is relatively new + // for the general case + time.Sleep(restartSnapdCoolOffWait) + logger.Noticef("fallback, restarting snapd itself") + restartCmd := runCmd("systemctl", []string{"restart", "snapd.service"}, nil) + if err := restartCmd.Run(); err != nil { + logger.Noticef("failed to restart snapd: %v", err) + } + } + + return nil +} diff --git a/cmd/snap-failure/cmd_snapd_test.go b/cmd/snap-failure/cmd_snapd_test.go new file mode 100644 index 00000000..58372467 --- /dev/null +++ b/cmd/snap-failure/cmd_snapd_test.go @@ -0,0 +1,458 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2018 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + . "gopkg.in/check.v1" + + failure "github.com/snapcore/snapd/cmd/snap-failure" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/testutil" +) + +func (r *failureSuite) TestRun(c *C) { + origArgs := os.Args + defer func() { os.Args = origArgs }() + os.Args = []string{"snap-failure", "snapd"} + err := failure.Run() + c.Check(err, IsNil) + c.Check(r.Stderr(), HasLen, 0) +} + +func writeSeqFile(c *C, name string, current snap.Revision, seq []*snap.SideInfo) { + seqPath := filepath.Join(dirs.SnapSeqDir, name+".json") + + err := os.MkdirAll(dirs.SnapSeqDir, 0755) + c.Assert(err, IsNil) + + b, err := json.Marshal(&struct { + Sequence []*snap.SideInfo `json:"sequence"` + Current string `json:"current"` + }{ + Sequence: seq, + Current: current.String(), + }) + c.Assert(err, IsNil) + + err = os.WriteFile(seqPath, b, 0644) + c.Assert(err, IsNil) +} + +func (r *failureSuite) TestCallPrevSnapdFromSnap(c *C) { + origArgs := os.Args + defer func() { os.Args = origArgs }() + + writeSeqFile(c, "snapd", snap.R(123), []*snap.SideInfo{ + {Revision: snap.R(99)}, + {Revision: snap.R(100)}, + {Revision: snap.R(123)}, + }) + + mockScript := ` +set -eu + +[ -L '%[1]s/snapd/current' ] +[ "$(readlink '%[1]s/snapd/current')" = 100 ] +[ "${SNAPD_REVERT_TO_REV}" = 100 ] +` + // mock snapd command from 'previous' revision + snapdCmd := testutil.MockCommand(c, filepath.Join(dirs.SnapMountDir, "snapd", "100", "/usr/lib/snapd/snapd"), fmt.Sprintf(mockScript, dirs.SnapMountDir)) + defer snapdCmd.Restore() + + systemctlCmd := testutil.MockCommand(c, "systemctl", "") + defer systemctlCmd.Restore() + + os.Args = []string{"snap-failure", "snapd"} + err := failure.Run() + c.Check(err, IsNil) + c.Check(r.Stderr(), HasLen, 0) + + c.Check(snapdCmd.Calls(), DeepEquals, [][]string{ + {"snapd"}, + }) + c.Check(systemctlCmd.Calls(), DeepEquals, [][]string{ + {"systemctl", "stop", "snapd.socket"}, + {"systemctl", "is-failed", "snapd.socket", "snapd.service"}, + {"systemctl", "reset-failed", "snapd.socket", "snapd.service"}, + {"systemctl", "restart", "snapd.socket"}, + }) +} + +func (r *failureSuite) TestCallPrevSnapdFromSnapRestartSnapdFallback(c *C) { + defer failure.MockWaitTimes(1*time.Millisecond, 1*time.Millisecond)() + + origArgs := os.Args + defer func() { os.Args = origArgs }() + + writeSeqFile(c, "snapd", snap.R(123), []*snap.SideInfo{ + {Revision: snap.R(99)}, + {Revision: snap.R(100)}, + {Revision: snap.R(123)}, + }) + + // mock snapd command from 'previous' revision + snapdCmd := testutil.MockCommand(c, filepath.Join(dirs.SnapMountDir, "snapd", "100", "/usr/lib/snapd/snapd"), + `test "$SNAPD_REVERT_TO_REV" = "100"`) + defer snapdCmd.Restore() + + systemctlCmd := testutil.MockCommand(c, "systemctl", ` +if [ "$1" = restart ] && [ "$2" == snapd.socket ] ; then + exit 1 +fi +`) + defer systemctlCmd.Restore() + + os.Args = []string{"snap-failure", "snapd"} + err := failure.Run() + c.Check(err, IsNil) + c.Check(r.Stderr(), HasLen, 0) + + c.Check(snapdCmd.Calls(), DeepEquals, [][]string{ + {"snapd"}, + }) + c.Check(systemctlCmd.Calls(), DeepEquals, [][]string{ + {"systemctl", "stop", "snapd.socket"}, + {"systemctl", "is-failed", "snapd.socket", "snapd.service"}, + {"systemctl", "reset-failed", "snapd.socket", "snapd.service"}, + {"systemctl", "restart", "snapd.socket"}, + {"systemctl", "restart", "snapd.service"}, + }) +} + +func (r *failureSuite) TestCallPrevSnapdFromSnapBackToFullyActive(c *C) { + defer failure.MockWaitTimes(1*time.Millisecond, 0)() + + origArgs := os.Args + defer func() { os.Args = origArgs }() + + writeSeqFile(c, "snapd", snap.R(123), []*snap.SideInfo{ + {Revision: snap.R(99)}, + {Revision: snap.R(100)}, + {Revision: snap.R(123)}, + }) + + // mock snapd command from 'previous' revision + snapdCmd := testutil.MockCommand(c, filepath.Join(dirs.SnapMountDir, "snapd", "100", "/usr/lib/snapd/snapd"), + `test "$SNAPD_REVERT_TO_REV" = "100"`) + defer snapdCmd.Restore() + + systemctlCmd := testutil.MockCommand(c, "systemctl", ` +if [ "$1" = is-failed ] ; then + exit 1 +fi +`) + defer systemctlCmd.Restore() + + // mock the sockets re-appearing + err := os.MkdirAll(filepath.Dir(dirs.SnapdSocket), 0755) + c.Assert(err, IsNil) + err = os.WriteFile(dirs.SnapdSocket, nil, 0755) + c.Assert(err, IsNil) + err = os.WriteFile(dirs.SnapSocket, nil, 0755) + c.Assert(err, IsNil) + + os.Args = []string{"snap-failure", "snapd"} + err = failure.Run() + c.Check(err, IsNil) + c.Check(r.Stderr(), HasLen, 0) + + c.Check(snapdCmd.Calls(), DeepEquals, [][]string{ + {"snapd"}, + }) + c.Check(systemctlCmd.Calls(), DeepEquals, [][]string{ + {"systemctl", "stop", "snapd.socket"}, + {"systemctl", "is-failed", "snapd.socket", "snapd.service"}, + {"systemctl", "is-active", "snapd.socket", "snapd.service"}, + }) +} + +func (r *failureSuite) TestCallPrevSnapdFromSnapBackActiveNoSockets(c *C) { + defer failure.MockWaitTimes(1*time.Millisecond, 0)() + + origArgs := os.Args + defer func() { os.Args = origArgs }() + + writeSeqFile(c, "snapd", snap.R(123), []*snap.SideInfo{ + {Revision: snap.R(99)}, + {Revision: snap.R(100)}, + {Revision: snap.R(123)}, + }) + + // mock snapd command from 'previous' revision + snapdCmd := testutil.MockCommand(c, filepath.Join(dirs.SnapMountDir, "snapd", "100", "/usr/lib/snapd/snapd"), + `test "$SNAPD_REVERT_TO_REV" = "100"`) + defer snapdCmd.Restore() + + systemctlCmd := testutil.MockCommand(c, "systemctl", ` +if [ "$1" = is-failed ] ; then + exit 1 +fi +`) + defer systemctlCmd.Restore() + + // no sockets + + os.Args = []string{"snap-failure", "snapd"} + err := failure.Run() + c.Check(err, IsNil) + c.Check(r.Stderr(), HasLen, 0) + + c.Check(snapdCmd.Calls(), DeepEquals, [][]string{ + {"snapd"}, + }) + c.Check(systemctlCmd.Calls(), DeepEquals, [][]string{ + {"systemctl", "stop", "snapd.socket"}, + {"systemctl", "is-failed", "snapd.socket", "snapd.service"}, + {"systemctl", "is-active", "snapd.socket", "snapd.service"}, + {"systemctl", "is-active", "snapd.socket", "snapd.service"}, + {"systemctl", "is-active", "snapd.socket", "snapd.service"}, + {"systemctl", "is-active", "snapd.socket", "snapd.service"}, + {"systemctl", "is-active", "snapd.socket", "snapd.service"}, + {"systemctl", "reset-failed", "snapd.socket", "snapd.service"}, + {"systemctl", "restart", "snapd.socket"}, + }) +} + +func (r *failureSuite) TestCallPrevSnapdFromCore(c *C) { + origArgs := os.Args + defer func() { os.Args = origArgs }() + + // only one entry in sequence + writeSeqFile(c, "snapd", snap.R(123), []*snap.SideInfo{ + {Revision: snap.R(123)}, + }) + + // mock snapd in the core snap + snapdCmd := testutil.MockCommand(c, filepath.Join(dirs.SnapMountDir, "core", "current", "/usr/lib/snapd/snapd"), + `test "$SNAPD_REVERT_TO_REV" = "0"`) + defer snapdCmd.Restore() + + systemctlCmd := testutil.MockCommand(c, "systemctl", "") + defer systemctlCmd.Restore() + + os.Args = []string{"snap-failure", "snapd"} + err := failure.Run() + c.Check(err, IsNil) + c.Check(r.Stderr(), HasLen, 0) + + c.Check(snapdCmd.Calls(), DeepEquals, [][]string{ + {"snapd"}, + }) + c.Check(systemctlCmd.Calls(), DeepEquals, [][]string{ + {"systemctl", "stop", "snapd.socket"}, + {"systemctl", "is-failed", "snapd.socket", "snapd.service"}, + {"systemctl", "reset-failed", "snapd.socket", "snapd.service"}, + {"systemctl", "restart", "snapd.socket"}, + }) +} + +func (r *failureSuite) TestCallPrevSnapdFromSnapdWhenNoCore(c *C) { + origArgs := os.Args + defer func() { os.Args = origArgs }() + + // only one entry in sequence + writeSeqFile(c, "snapd", snap.R(123), []*snap.SideInfo{ + {Revision: snap.R(123)}, + }) + + // validity + c.Assert(filepath.Join(dirs.SnapMountDir, "core", "current", "/usr/lib/snapd/snapd"), testutil.FileAbsent) + // mock snapd in the core snap + snapdCmd := testutil.MockCommand(c, filepath.Join(dirs.SnapMountDir, "snapd", "current", "/usr/lib/snapd/snapd"), + `test "$SNAPD_REVERT_TO_REV" = "0"`) + defer snapdCmd.Restore() + + systemctlCmd := testutil.MockCommand(c, "systemctl", "") + defer systemctlCmd.Restore() + + os.Args = []string{"snap-failure", "snapd"} + err := failure.Run() + c.Check(err, IsNil) + c.Check(r.Stderr(), HasLen, 0) + + c.Check(snapdCmd.Calls(), DeepEquals, [][]string{ + {"snapd"}, + }) + c.Check(systemctlCmd.Calls(), DeepEquals, [][]string{ + {"systemctl", "stop", "snapd.socket"}, + {"systemctl", "is-failed", "snapd.socket", "snapd.service"}, + {"systemctl", "reset-failed", "snapd.socket", "snapd.service"}, + {"systemctl", "restart", "snapd.socket"}, + }) +} + +func (r *failureSuite) TestCallPrevSnapdFail(c *C) { + origArgs := os.Args + defer func() { os.Args = origArgs }() + + writeSeqFile(c, "snapd", snap.R(123), []*snap.SideInfo{ + {Revision: snap.R(100)}, + {Revision: snap.R(123)}, + }) + + // mock snapd in the core snap + snapdCmd := testutil.MockCommand(c, filepath.Join(dirs.SnapMountDir, "snapd", "100", "/usr/lib/snapd/snapd"), + `exit 2`) + defer snapdCmd.Restore() + + systemctlCmd := testutil.MockCommand(c, "systemctl", "") + defer systemctlCmd.Restore() + + os.Args = []string{"snap-failure", "snapd"} + err := failure.Run() + c.Check(err, ErrorMatches, "snapd failed: exit status 2") + c.Check(r.Stderr(), HasLen, 0) + + c.Check(snapdCmd.Calls(), DeepEquals, [][]string{ + {"snapd"}, + }) + c.Check(systemctlCmd.Calls(), DeepEquals, [][]string{ + {"systemctl", "stop", "snapd.socket"}, + }) +} + +func (r *failureSuite) TestGarbageSeq(c *C) { + origArgs := os.Args + defer func() { os.Args = origArgs }() + + seqPath := filepath.Join(dirs.SnapSeqDir, "snapd.json") + err := os.MkdirAll(dirs.SnapSeqDir, 0755) + c.Assert(err, IsNil) + + err = os.WriteFile(seqPath, []byte("this is garbage"), 0644) + c.Assert(err, IsNil) + + snapdCmd := testutil.MockCommand(c, filepath.Join(dirs.SnapMountDir, "snapd", "100", "/usr/lib/snapd/snapd"), + `exit 99`) + defer snapdCmd.Restore() + + systemctlCmd := testutil.MockCommand(c, "systemctl", "exit 98") + defer systemctlCmd.Restore() + + os.Args = []string{"snap-failure", "snapd"} + err = failure.Run() + c.Check(err, ErrorMatches, `cannot parse "snapd.json" sequence file: invalid .*`) + c.Check(r.Stderr(), HasLen, 0) + + c.Check(snapdCmd.Calls(), HasLen, 0) + c.Check(systemctlCmd.Calls(), HasLen, 0) +} + +func (r *failureSuite) TestBadSeq(c *C) { + origArgs := os.Args + defer func() { os.Args = origArgs }() + + writeSeqFile(c, "snapd", snap.R(123), []*snap.SideInfo{ + {Revision: snap.R(100)}, + // current not in sequence + }) + + snapdCmd := testutil.MockCommand(c, filepath.Join(dirs.SnapMountDir, "snapd", "100", "/usr/lib/snapd/snapd"), "") + defer snapdCmd.Restore() + systemctlCmd := testutil.MockCommand(c, "systemctl", "") + defer systemctlCmd.Restore() + + os.Args = []string{"snap-failure", "snapd"} + err := failure.Run() + c.Check(err, ErrorMatches, "internal error: current 123 not found in sequence: .*Revision:100.*") + c.Check(r.Stderr(), HasLen, 0) + + c.Check(snapdCmd.Calls(), HasLen, 0) + c.Check(systemctlCmd.Calls(), HasLen, 0) +} + +func (r *failureSuite) TestSnapdOutputPassthrough(c *C) { + origArgs := os.Args + defer func() { os.Args = origArgs }() + + writeSeqFile(c, "snapd", snap.R(123), []*snap.SideInfo{ + {Revision: snap.R(100)}, + {Revision: snap.R(123)}, + }) + + snapdCmd := testutil.MockCommand(c, filepath.Join(dirs.SnapMountDir, "snapd", "100", "/usr/lib/snapd/snapd"), ` +echo 'stderr: hello from snapd' >&2 +echo 'stdout: hello from snapd' +exit 123 +`) + defer snapdCmd.Restore() + systemctlCmd := testutil.MockCommand(c, "systemctl", "") + defer systemctlCmd.Restore() + + os.Args = []string{"snap-failure", "snapd"} + err := failure.Run() + c.Check(err, ErrorMatches, "snapd failed: exit status 123") + c.Check(r.Stderr(), Equals, "stderr: hello from snapd\n") + c.Check(r.Stdout(), Equals, "stdout: hello from snapd\n") + + c.Check(snapdCmd.Calls(), HasLen, 1) + c.Check(systemctlCmd.Calls(), DeepEquals, [][]string{ + {"systemctl", "stop", "snapd.socket"}, + }) +} + +func (r *failureSuite) TestStickySnapdSocket(c *C) { + origArgs := os.Args + defer func() { os.Args = origArgs }() + + writeSeqFile(c, "snapd", snap.R(123), []*snap.SideInfo{ + {Revision: snap.R(100)}, + {Revision: snap.R(123)}, + }) + + err := os.MkdirAll(filepath.Dir(dirs.SnapdSocket), 0755) + c.Assert(err, IsNil) + err = os.WriteFile(dirs.SnapdSocket, []byte{}, 0755) + c.Assert(err, IsNil) + + // mock snapd in the core snap + snapdCmd := testutil.MockCommand(c, filepath.Join(dirs.SnapMountDir, "snapd", "100", "/usr/lib/snapd/snapd"), + `test "$SNAPD_REVERT_TO_REV" = "100"`) + defer snapdCmd.Restore() + + systemctlCmd := testutil.MockCommand(c, "systemctl", "") + defer systemctlCmd.Restore() + + os.Args = []string{"snap-failure", "snapd"} + err = failure.Run() + c.Check(err, IsNil) + c.Check(r.Stderr(), HasLen, 0) + + c.Check(snapdCmd.Calls(), DeepEquals, [][]string{ + {"snapd"}, + }) + c.Check(systemctlCmd.Calls(), DeepEquals, [][]string{ + {"systemctl", "stop", "snapd.socket"}, + {"systemctl", "is-failed", "snapd.socket", "snapd.service"}, + {"systemctl", "reset-failed", "snapd.socket", "snapd.service"}, + {"systemctl", "restart", "snapd.socket"}, + }) + + // make sure the socket file was deleted + c.Assert(osutil.FileExists(dirs.SnapdSocket), Equals, false) +} diff --git a/cmd/snap-failure/export_test.go b/cmd/snap-failure/export_test.go new file mode 100644 index 00000000..bfa2352c --- /dev/null +++ b/cmd/snap-failure/export_test.go @@ -0,0 +1,38 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2018 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import "time" + +var ( + Run = run + ParseArgs = parseArgs +) + +func MockWaitTimes(sampleForActive, restartSnapdCoolOff time.Duration) (restore func()) { + oldSampleForActive := sampleForActiveInterval + oldRestartSnapdCoolOff := restartSnapdCoolOffWait + sampleForActiveInterval = sampleForActive + restartSnapdCoolOffWait = restartSnapdCoolOff + return func() { + sampleForActiveInterval = oldSampleForActive + restartSnapdCoolOffWait = oldRestartSnapdCoolOff + } +} diff --git a/cmd/snap-failure/main.go b/cmd/snap-failure/main.go new file mode 100644 index 00000000..9d6f60b0 --- /dev/null +++ b/cmd/snap-failure/main.go @@ -0,0 +1,77 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2018 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "fmt" + "io" + "os" + + // TODO: consider not using go-flags at all + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/logger" +) + +var ( + Stderr io.Writer = os.Stderr + Stdout io.Writer = os.Stdout + + opts struct{} + parser *flags.Parser = flags.NewParser(&opts, flags.HelpFlag|flags.PassDoubleDash|flags.PassAfterNonOption) +) + +const ( + shortHelp = "Handle snapd daemon failures" + longHelp = ` +snap-failure is a tool that handles failures of the snapd daemon and +reverts if appropriate. +` +) + +func init() { + err := logger.SimpleSetup() + if err != nil { + fmt.Fprintf(Stderr, "WARNING: failed to activate logging: %v\n", err) + } +} + +func main() { + if err := run(); err != nil { + fmt.Fprintf(Stderr, "error: %v\n", err) + os.Exit(1) + } +} + +func run() error { + if err := parseArgs(os.Args[1:]); err != nil { + return err + } + + return nil +} + +func parseArgs(args []string) error { + parser.ShortDescription = shortHelp + parser.LongDescription = longHelp + + _, err := parser.ParseArgs(args) + return err +} diff --git a/cmd/snap-failure/main_test.go b/cmd/snap-failure/main_test.go new file mode 100644 index 00000000..e88e6a73 --- /dev/null +++ b/cmd/snap-failure/main_test.go @@ -0,0 +1,82 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2018 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "bytes" + "testing" + + . "gopkg.in/check.v1" + + failure "github.com/snapcore/snapd/cmd/snap-failure" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/logger" + "github.com/snapcore/snapd/testutil" +) + +// Hook up check.v1 into the "go test" runner +func Test(t *testing.T) { TestingT(t) } + +type failureSuite struct { + testutil.BaseTest + + rootdir string + + stderr *bytes.Buffer + stdout *bytes.Buffer + log *bytes.Buffer +} + +func (r *failureSuite) SetUpTest(c *C) { + r.stderr = bytes.NewBuffer(nil) + r.stdout = bytes.NewBuffer(nil) + + oldStderr := failure.Stderr + oldStdout := failure.Stdout + r.AddCleanup(func() { + failure.Stderr = oldStderr + failure.Stdout = oldStdout + }) + failure.Stderr = r.stderr + failure.Stdout = r.stdout + + r.rootdir = c.MkDir() + dirs.SetRootDir(r.rootdir) + r.AddCleanup(func() { dirs.SetRootDir("/") }) + + log, restore := logger.MockLogger() + r.log = log + r.AddCleanup(restore) +} + +func (r *failureSuite) Stderr() string { + return r.stderr.String() +} + +func (r *failureSuite) Stdout() string { + return r.stdout.String() +} + +var _ = Suite(&failureSuite{}) + +func (r *failureSuite) TestUnknownArg(c *C) { + err := failure.ParseArgs([]string{}) + c.Check(err, ErrorMatches, "Please specify the snapd command") +} diff --git a/cmd/snap-fde-keymgr/export_test.go b/cmd/snap-fde-keymgr/export_test.go new file mode 100644 index 00000000..e8dc63bc --- /dev/null +++ b/cmd/snap-fde-keymgr/export_test.go @@ -0,0 +1,70 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package main + +import ( + "io" + + "github.com/snapcore/snapd/secboot/keys" + "github.com/snapcore/snapd/testutil" +) + +var Run = run + +func MockAddRecoveryKeyToLUKS(f func(recoveryKey keys.RecoveryKey, dev string) error) (restore func()) { + restore = testutil.Backup(&keymgrAddRecoveryKeyToLUKSDevice) + keymgrAddRecoveryKeyToLUKSDevice = f + return restore +} + +func MockAddRecoveryKeyToLUKSUsingKey(f func(recoveryKey keys.RecoveryKey, key keys.EncryptionKey, dev string) error) (restore func()) { + restore = testutil.Backup(&keymgrAddRecoveryKeyToLUKSDeviceUsingKey) + keymgrAddRecoveryKeyToLUKSDeviceUsingKey = f + return restore +} + +func MockRemoveRecoveryKeyFromLUKS(f func(dev string) error) (restore func()) { + restore = testutil.Backup(&keymgrRemoveRecoveryKeyFromLUKSDevice) + keymgrRemoveRecoveryKeyFromLUKSDevice = f + return restore +} + +func MockRemoveRecoveryKeyFromLUKSUsingKey(f func(key keys.EncryptionKey, dev string) error) (restore func()) { + restore = testutil.Backup(&keymgrRemoveRecoveryKeyFromLUKSDeviceUsingKey) + keymgrRemoveRecoveryKeyFromLUKSDeviceUsingKey = f + return restore +} + +func MockStageLUKSEncryptionKeyChange(f func(newKey keys.EncryptionKey, dev string) error) (restore func()) { + restore = testutil.Backup(&keymgrStageLUKSDeviceEncryptionKeyChange) + keymgrStageLUKSDeviceEncryptionKeyChange = f + return restore +} + +func MockTransitionLUKSEncryptionKeyChange(f func(newKey keys.EncryptionKey, dev string) error) (restore func()) { + restore = testutil.Backup(&keymgrTransitionLUKSDeviceEncryptionKeyChange) + keymgrTransitionLUKSDeviceEncryptionKeyChange = f + return restore +} + +func MockOsStdin(r io.Reader) (restore func()) { + restore = testutil.Backup(&osStdin) + osStdin = r + return restore +} diff --git a/cmd/snap-fde-keymgr/main.go b/cmd/snap-fde-keymgr/main.go new file mode 100644 index 00000000..9f2223a6 --- /dev/null +++ b/cmd/snap-fde-keymgr/main.go @@ -0,0 +1,250 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" + "strings" + + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/secboot/keymgr" + "github.com/snapcore/snapd/secboot/keys" +) + +var osStdin io.Reader = os.Stdin + +type commonMultiDeviceMixin struct { + Devices []string `long:"devices" description:"encrypted devices (can be more than one)" required:"yes"` + Authorizations []string `long:"authorizations" description:"authorization sources (one for each device, either 'keyring' or 'file:')" required:"yes"` +} + +type cmdAddRecoveryKey struct { + commonMultiDeviceMixin + KeyFile string `long:"key-file" description:"path for generated recovery key file" required:"yes"` +} + +type cmdRemoveRecoveryKey struct { + commonMultiDeviceMixin + KeyFiles []string `long:"key-files" description:"path to recovery key files to be removed" required:"yes"` +} + +type cmdChangeEncryptionKey struct { + Device string `long:"device" description:"encrypted device" required:"yes"` + Stage bool `long:"stage" description:"stage the new key"` + Transition bool `long:"transition" description:"replace the old key, unstage the new"` +} + +type options struct { + CmdAddRecoveryKey cmdAddRecoveryKey `command:"add-recovery-key"` + CmdRemoveRecoveryKey cmdRemoveRecoveryKey `command:"remove-recovery-key"` + CmdChangeEncryptionKey cmdChangeEncryptionKey `command:"change-encryption-key"` +} + +var ( + keymgrAddRecoveryKeyToLUKSDevice = keymgr.AddRecoveryKeyToLUKSDevice + keymgrAddRecoveryKeyToLUKSDeviceUsingKey = keymgr.AddRecoveryKeyToLUKSDeviceUsingKey + keymgrRemoveRecoveryKeyFromLUKSDevice = keymgr.RemoveRecoveryKeyFromLUKSDevice + keymgrRemoveRecoveryKeyFromLUKSDeviceUsingKey = keymgr.RemoveRecoveryKeyFromLUKSDeviceUsingKey + keymgrStageLUKSDeviceEncryptionKeyChange = keymgr.StageLUKSDeviceEncryptionKeyChange + keymgrTransitionLUKSDeviceEncryptionKeyChange = keymgr.TransitionLUKSDeviceEncryptionKeyChange +) + +func validateAuthorizations(authorizations []string) error { + for _, authz := range authorizations { + switch { + case authz == "keyring": + // happy + case strings.HasPrefix(authz, "file:"): + // file must exist + kf := authz[len("file:"):] + if !osutil.FileExists(kf) { + return fmt.Errorf("authorization file %v does not exist", kf) + } + default: + return fmt.Errorf("unknown authorization method %q", authz) + } + } + return nil +} + +func writeIfNotExists(p string, data []byte) (alreadyExists bool, err error) { + f, err := os.OpenFile(p, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if err != nil { + if os.IsExist(err) { + return true, nil + } + return false, err + } + if _, err := f.Write(data); err != nil { + f.Close() + return false, err + } + return false, f.Close() +} + +func (c *cmdAddRecoveryKey) Execute(args []string) error { + recoveryKey, err := keys.NewRecoveryKey() + if err != nil { + return fmt.Errorf("cannot create recovery key: %v", err) + } + if len(c.Authorizations) != len(c.Devices) { + return fmt.Errorf("cannot add recovery keys: mismatch in the number of devices and authorizations") + } + if err := validateAuthorizations(c.Authorizations); err != nil { + return fmt.Errorf("cannot add recovery keys with invalid authorizations: %v", err) + } + // write the key to the file, if the file already exists it is possible + // that we are being called again after an unexpected reboot or a + // similar event + alreadyExists, err := writeIfNotExists(c.KeyFile, recoveryKey[:]) + if err != nil { + return fmt.Errorf("cannot write recovery key to file: %v", err) + } + if alreadyExists { + // we already have the recovery key, read it back + maybeKey, err := os.ReadFile(c.KeyFile) + if err != nil { + return fmt.Errorf("cannot read existing recovery key file: %v", err) + } + // TODO: verify that the size if non 0 and try again otherwise? + if len(maybeKey) != len(recoveryKey) { + return fmt.Errorf("cannot use existing recovery key of size %v", len(maybeKey)) + } + copy(recoveryKey[:], maybeKey[:]) + } + // add the recovery key to each device; keys are always added to the + // same keyslot, so when the key existed on disk, assume that the key + // was already added to the device in case we hit an error with keyslot + // being already used + for i, dev := range c.Devices { + authz := c.Authorizations[i] + switch { + case authz == "keyring": + if err := keymgrAddRecoveryKeyToLUKSDevice(recoveryKey, dev); err != nil { + if !alreadyExists || !keymgr.IsKeyslotAlreadyUsed(err) { + return fmt.Errorf("cannot add recovery key to LUKS device: %v", err) + } + } + case strings.HasPrefix(authz, "file:"): + authzKey, err := os.ReadFile(authz[len("file:"):]) + if err != nil { + return fmt.Errorf("cannot load authorization key: %v", err) + } + if err := keymgrAddRecoveryKeyToLUKSDeviceUsingKey(recoveryKey, authzKey, dev); err != nil { + if !alreadyExists || !keymgr.IsKeyslotAlreadyUsed(err) { + return fmt.Errorf("cannot add recovery key to LUKS device using authorization key: %v", err) + } + } + } + } + return nil +} + +func (c *cmdRemoveRecoveryKey) Execute(args []string) error { + if len(c.Authorizations) != len(c.Devices) { + return fmt.Errorf("cannot remove recovery keys: mismatch in the number of devices and authorizations") + } + if err := validateAuthorizations(c.Authorizations); err != nil { + return fmt.Errorf("cannot remove recovery keys with invalid authorizations: %v", err) + } + for i, dev := range c.Devices { + authz := c.Authorizations[i] + switch { + case authz == "keyring": + if err := keymgrRemoveRecoveryKeyFromLUKSDevice(dev); err != nil { + return fmt.Errorf("cannot remove recovery key from LUKS device: %v", err) + } + case strings.HasPrefix(authz, "file:"): + authzKey, err := os.ReadFile(authz[len("file:"):]) + if err != nil { + return fmt.Errorf("cannot load authorization key: %v", err) + } + if err := keymgrRemoveRecoveryKeyFromLUKSDeviceUsingKey(authzKey, dev); err != nil { + return fmt.Errorf("cannot remove recovery key from device using authorization key: %v", err) + } + } + } + var rmErrors []string + for _, kf := range c.KeyFiles { + if err := os.Remove(kf); err != nil && !os.IsNotExist(err) { + rmErrors = append(rmErrors, err.Error()) + } + } + if len(rmErrors) != 0 { + return fmt.Errorf("cannot remove key files:\n%s", strings.Join(rmErrors, "\n")) + } + return nil +} + +type newKey struct { + Key []byte `json:"key"` +} + +func (c *cmdChangeEncryptionKey) Execute(args []string) error { + if c.Stage && c.Transition { + return fmt.Errorf("cannot both stage and transition the encryption key change") + } + if !c.Stage && !c.Transition { + return fmt.Errorf("cannot change encryption key without stage or transition request") + } + + var newEncryptionKeyData newKey + dec := json.NewDecoder(osStdin) + if err := dec.Decode(&newEncryptionKeyData); err != nil { + return fmt.Errorf("cannot obtain new encryption key: %v", err) + } + switch { + case c.Stage: + // staging the key change authorizes the operation using a key + // from the keyring + if err := keymgrStageLUKSDeviceEncryptionKeyChange(newEncryptionKeyData.Key, c.Device); err != nil { + return fmt.Errorf("cannot stage LUKS device encryption key change: %v", err) + } + case c.Transition: + // transitioning the key change authorizes the operation using + // the currently provided key (which must have been staged + // before hence the op will be authorized successfully) + if err := keymgrTransitionLUKSDeviceEncryptionKeyChange(newEncryptionKeyData.Key, c.Device); err != nil { + return fmt.Errorf("cannot transition LUKS device encryption key change: %v", err) + } + } + return nil +} + +func run(osArgs1 []string) error { + var opts options + p := flags.NewParser(&opts, flags.HelpFlag|flags.PassDoubleDash) + if _, err := p.ParseArgs(osArgs1); err != nil { + return err + } + return nil +} + +func main() { + if err := run(os.Args[1:]); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } +} diff --git a/cmd/snap-fde-keymgr/main_test.go b/cmd/snap-fde-keymgr/main_test.go new file mode 100644 index 00000000..821c356f --- /dev/null +++ b/cmd/snap-fde-keymgr/main_test.go @@ -0,0 +1,452 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package main_test + +import ( + "bytes" + "errors" + "fmt" + "os" + "path/filepath" + "testing" + + . "gopkg.in/check.v1" + + main "github.com/snapcore/snapd/cmd/snap-fde-keymgr" + "github.com/snapcore/snapd/secboot/keys" + "github.com/snapcore/snapd/testutil" +) + +type mainSuite struct{} + +var _ = Suite(&mainSuite{}) + +func TestT(t *testing.T) { + TestingT(t) +} + +func (s *mainSuite) TestAddKey(c *C) { + d := c.MkDir() + dev := "" + rkey := keys.RecoveryKey{} + addCalls := 0 + restore := main.MockAddRecoveryKeyToLUKS(func(recoveryKey keys.RecoveryKey, luksDev string) error { + addCalls++ + dev = luksDev + rkey = recoveryKey + // recovery key is already written to a file + c.Assert(filepath.Join(d, "recovery.key"), testutil.FileEquals, rkey[:]) + return nil + }) + defer restore() + devUsingKey := "" + addUsingKeyCalls := 0 + var authzKey keys.EncryptionKey + restore = main.MockAddRecoveryKeyToLUKSUsingKey(func(recoveryKey keys.RecoveryKey, key keys.EncryptionKey, luksDev string) error { + addUsingKeyCalls++ + devUsingKey = luksDev + authzKey = key + // recovery key is already written to a file + c.Assert(filepath.Join(d, "recovery.key"), testutil.FileEquals, rkey[:]) + return nil + }) + defer restore() + c.Assert(os.WriteFile(filepath.Join(d, "authz.key"), []byte{1, 1, 1}, 0644), IsNil) + err := main.Run([]string{ + "add-recovery-key", + "--devices", "/dev/vda4", + "--authorizations", "keyring", + "--devices", "/dev/vda5", + "--authorizations", "file:" + filepath.Join(d, "authz.key"), + "--key-file", filepath.Join(d, "recovery.key"), + }) + c.Assert(err, IsNil) + c.Check(addCalls, Equals, 1) + c.Check(dev, Equals, "/dev/vda4") + c.Check(addUsingKeyCalls, Equals, 1) + c.Check(devUsingKey, Equals, "/dev/vda5") + c.Check(rkey, Not(DeepEquals), keys.RecoveryKey{}) + c.Assert(filepath.Join(d, "recovery.key"), testutil.FileEquals, rkey[:]) + + oldKey := rkey + // add again, in which case already existing key is read back + err = main.Run([]string{ + "add-recovery-key", + "--devices", "/dev/vda4", + "--authorizations", "keyring", + "--devices", "/dev/vda5", + "--authorizations", "file:" + filepath.Join(d, "authz.key"), + "--key-file", filepath.Join(d, "recovery.key"), + }) + c.Assert(err, IsNil) + c.Check(addCalls, Equals, 2) + c.Check(dev, Equals, "/dev/vda4") + c.Check(addUsingKeyCalls, Equals, 2) + c.Check(devUsingKey, Equals, "/dev/vda5") + c.Assert(authzKey, DeepEquals, keys.EncryptionKey([]byte{1, 1, 1})) + c.Check(rkey, DeepEquals, oldKey) + // file was overwritten + c.Assert(filepath.Join(d, "recovery.key"), testutil.FileEquals, rkey[:]) +} + +func (s *mainSuite) TestAddKeyRequiresAuthz(c *C) { + restore := main.MockAddRecoveryKeyToLUKS(func(recoveryKey keys.RecoveryKey, luksDev string) error { + c.Fail() + return fmt.Errorf("unexpected call") + }) + defer restore() + restore = main.MockAddRecoveryKeyToLUKSUsingKey(func(recoveryKey keys.RecoveryKey, key keys.EncryptionKey, luksDev string) error { + c.Fail() + return fmt.Errorf("unexpected call") + }) + defer restore() + d := c.MkDir() + err := main.Run([]string{ + "add-recovery-key", + "--devices", "/dev/vda4", + "--authorizations", "keyring", + "--devices", "/dev/vda5", + "--key-file", filepath.Join(d, "recovery.key"), + }) + c.Assert(err, ErrorMatches, "cannot add recovery keys: mismatch in the number of devices and authorizations") + + // --authorization=invalid + err = main.Run([]string{ + "add-recovery-key", + "--devices", "/dev/vda4", + "--authorizations", "invalid", + "--devices", "/dev/vda5", + "--authorizations", "file:" + filepath.Join(d, "authz.key"), + "--key-file", filepath.Join(d, "recovery.key"), + }) + c.Assert(err, ErrorMatches, `cannot add recovery keys with invalid authorizations: unknown authorization method "invalid"`) + + // authorization key file does not exist + err = main.Run([]string{ + "add-recovery-key", + "--devices", "/dev/vda4", + "--authorizations", "keyring", + "--devices", "/dev/vda5", + "--authorizations", "file:" + filepath.Join(d, "authz.key"), + "--key-file", filepath.Join(d, "recovery.key"), + }) + c.Assert(err, ErrorMatches, `cannot add recovery keys with invalid authorizations: authorization file .*/authz.key does not exist`) +} + +type addKeyTestCase struct { + errAddToLUKS error + addCalls int + errAddToLUKSUsingKey error + addUsingKeyCalls int + expErr string +} + +func (s *mainSuite) testAddKeyIdempotent(c *C, tc addKeyTestCase) { + d := c.MkDir() + c.Assert(os.WriteFile(filepath.Join(d, "authz.key"), []byte{1, 1, 1}, 0644), IsNil) + rkey := keys.RecoveryKey{'r', 'e', 'c', 'o', 'v', 'e', 'r', 'y'} + c.Assert(os.WriteFile(filepath.Join(d, "recovery.key"), rkey[:], 0600), IsNil) + + addCalls := 0 + restore := main.MockAddRecoveryKeyToLUKS(func(recoveryKey keys.RecoveryKey, luksDev string) error { + addCalls++ + c.Check(luksDev, Equals, "/dev/vda4") + c.Check(recoveryKey, DeepEquals, rkey) + return tc.errAddToLUKS + }) + defer restore() + addUsingKeyCalls := 0 + restore = main.MockAddRecoveryKeyToLUKSUsingKey(func(recoveryKey keys.RecoveryKey, key keys.EncryptionKey, luksDev string) error { + addUsingKeyCalls++ + c.Check(luksDev, Equals, "/dev/vda5") + c.Check(recoveryKey, DeepEquals, rkey) + return tc.errAddToLUKSUsingKey + }) + defer restore() + + err := main.Run([]string{ + "add-recovery-key", + "--devices", "/dev/vda4", + "--authorizations", "keyring", + "--devices", "/dev/vda5", + "--authorizations", "file:" + filepath.Join(d, "authz.key"), + "--key-file", filepath.Join(d, "recovery.key"), + }) + if tc.expErr != "" { + c.Assert(err, ErrorMatches, tc.expErr) + } else { + c.Assert(err, IsNil) + } + c.Check(addCalls, Equals, tc.addCalls) + c.Check(addUsingKeyCalls, Equals, tc.addUsingKeyCalls) + // file was not overwritten + c.Assert(filepath.Join(d, "recovery.key"), testutil.FileEquals, rkey[:]) +} + +func (s *mainSuite) TestAddKeyIdempotentBothEmpty(c *C) { + s.testAddKeyIdempotent(c, addKeyTestCase{ + addCalls: 1, + addUsingKeyCalls: 1, + }) +} + +func (s *mainSuite) TestAddKeyIdempotentOneErr(c *C) { + s.testAddKeyIdempotent(c, addKeyTestCase{ + addCalls: 1, + errAddToLUKS: errors.New("mock error"), + expErr: "cannot add recovery key to LUKS device: mock error", + }) +} + +func (s *mainSuite) TestAddKeyIdempotentOtherErr(c *C) { + s.testAddKeyIdempotent(c, addKeyTestCase{ + addCalls: 1, + addUsingKeyCalls: 1, + errAddToLUKSUsingKey: errors.New("mock error"), + expErr: "cannot add recovery key to LUKS device using authorization key: mock error", + }) +} + +func (s *mainSuite) TestAddKeyIdempotentBothPresent(c *C) { + s.testAddKeyIdempotent(c, addKeyTestCase{ + addCalls: 1, + addUsingKeyCalls: 1, + errAddToLUKS: errors.New("mock error: cryptsetup failed with: Key slot 1 is full, please select another one."), + errAddToLUKSUsingKey: errors.New("mock error: cryptsetup failed with: Key slot 1 is full, please select another one."), + }) +} + +func (s *mainSuite) TestAddKeyIdempotentOnePresent(c *C) { + s.testAddKeyIdempotent(c, addKeyTestCase{ + addCalls: 1, + addUsingKeyCalls: 1, + errAddToLUKS: errors.New("mock error: cryptsetup failed with: Key slot 1 is full, please select another one."), + }) +} + +func (s *mainSuite) TestRemoveKey(c *C) { + dev := "" + removeCalls := 0 + restore := main.MockRemoveRecoveryKeyFromLUKS(func(luksDev string) error { + removeCalls++ + dev = luksDev + return nil + }) + defer restore() + removeUsingKeyCalls := 0 + devUsingKey := "" + var authzKey keys.EncryptionKey + restore = main.MockRemoveRecoveryKeyFromLUKSUsingKey(func(key keys.EncryptionKey, luksDev string) error { + authzKey = key + removeUsingKeyCalls++ + devUsingKey = luksDev + return nil + }) + defer restore() + d := c.MkDir() + // key which will be removed + c.Assert(os.WriteFile(filepath.Join(d, "recovery.key"), []byte{0, 0, 0}, 0644), IsNil) + + c.Assert(os.WriteFile(filepath.Join(d, "authz.key"), []byte{1, 1, 1}, 0644), IsNil) + err := main.Run([]string{ + "remove-recovery-key", + "--devices", "/dev/vda4", + "--authorizations", "keyring", + "--devices", "/dev/vda5", + "--authorizations", "file:" + filepath.Join(d, "authz.key"), + "--key-files", filepath.Join(d, "recovery.key"), + }) + c.Assert(err, IsNil) + c.Check(removeCalls, Equals, 1) + c.Check(dev, Equals, "/dev/vda4") + c.Check(removeUsingKeyCalls, Equals, 1) + c.Check(devUsingKey, Equals, "/dev/vda5") + c.Assert(authzKey, DeepEquals, keys.EncryptionKey([]byte{1, 1, 1})) + c.Assert(filepath.Join(d, "recovery.key"), testutil.FileAbsent) + // again when the recover key file is gone already + err = main.Run([]string{ + "remove-recovery-key", + "--devices", "/dev/vda4", + "--authorizations", "keyring", + "--devices", "/dev/vda5", + "--authorizations", "file:" + filepath.Join(d, "authz.key"), + "--key-files", filepath.Join(d, "recovery.key"), + }) + c.Check(removeCalls, Equals, 2) + c.Check(removeUsingKeyCalls, Equals, 2) + c.Assert(err, IsNil) +} + +func (s *mainSuite) TestRemoveKeyRequiresAuthz(c *C) { + restore := main.MockRemoveRecoveryKeyFromLUKS(func(luksDev string) error { + c.Fail() + return fmt.Errorf("unexpected call") + }) + defer restore() + restore = main.MockRemoveRecoveryKeyFromLUKSUsingKey(func(key keys.EncryptionKey, luksDev string) error { + c.Fail() + return fmt.Errorf("unexpected call") + }) + defer restore() + d := c.MkDir() + + err := main.Run([]string{ + "remove-recovery-key", + "--devices", "/dev/vda4", + "--authorizations", "keyring", + "--devices", "/dev/vda5", + "--key-files", filepath.Join(d, "recovery.key"), + }) + c.Assert(err, ErrorMatches, "cannot remove recovery keys: mismatch in the number of devices and authorizations") + + // --authorization=invalid + err = main.Run([]string{ + "remove-recovery-key", + "--devices", "/dev/vda4", + "--authorizations", "invalid", + "--devices", "/dev/vda5", + "--authorizations", "file:" + filepath.Join(d, "authz.key"), + "--key-files", filepath.Join(d, "recovery.key"), + }) + c.Assert(err, ErrorMatches, `cannot remove recovery keys with invalid authorizations: unknown authorization method "invalid"`) + + // authorization key file does not exist + err = main.Run([]string{ + "remove-recovery-key", + "--devices", "/dev/vda4", + "--authorizations", "keyring", + "--devices", "/dev/vda5", + "--authorizations", "file:" + filepath.Join(d, "authz.key"), + "--key-files", filepath.Join(d, "recovery.key"), + }) + c.Assert(err, ErrorMatches, `cannot remove recovery keys with invalid authorizations: authorization file .*/authz.key does not exist`) +} + +// 1 in ASCII repeated 32 times +const all1sKey = `{"key":"MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTE="}` + +func (s *mainSuite) TestChangeEncryptionKey(c *C) { + b := bytes.NewBufferString(all1sKey) + restore := main.MockOsStdin(b) + defer restore() + unexpectedCall := func(newKey keys.EncryptionKey, luksDev string) error { + c.Errorf("unexpected call") + return fmt.Errorf("unexpected call") + } + defer main.MockStageLUKSEncryptionKeyChange(unexpectedCall) + defer main.MockTransitionLUKSEncryptionKeyChange(unexpectedCall) + + err := main.Run([]string{ + "change-encryption-key", + "--device", "/dev/vda4", + }) + c.Assert(err, ErrorMatches, "cannot change encryption key without stage or transition request") + + err = main.Run([]string{ + "change-encryption-key", + "--device", "/dev/vda4", + "--stage", "--transition", + }) + c.Assert(err, ErrorMatches, "cannot both stage and transition the encryption key change") +} + +func (s *mainSuite) TestStageEncryptionKey(c *C) { + b := bytes.NewBufferString(all1sKey) + restore := main.MockOsStdin(b) + defer restore() + dev := "" + stageCalls := 0 + var key []byte + var stageErr error + restore = main.MockStageLUKSEncryptionKeyChange(func(newKey keys.EncryptionKey, luksDev string) error { + stageCalls++ + dev = luksDev + key = newKey + return stageErr + }) + defer restore() + restore = main.MockTransitionLUKSEncryptionKeyChange(func(newKey keys.EncryptionKey, luksDev string) error { + c.Errorf("unexpected call") + return fmt.Errorf("unexpected call") + }) + defer restore() + err := main.Run([]string{ + "change-encryption-key", + "--device", "/dev/vda4", + "--stage", + }) + c.Assert(err, IsNil) + c.Check(stageCalls, Equals, 1) + c.Check(dev, Equals, "/dev/vda4") + // secboot encryption key size + c.Check(key, DeepEquals, bytes.Repeat([]byte("1"), 32)) + + restore = main.MockOsStdin(bytes.NewBufferString(all1sKey)) + defer restore() + stageErr = fmt.Errorf("mock stage error") + err = main.Run([]string{ + "change-encryption-key", + "--device", "/dev/vda4", + "--stage", + }) + c.Assert(err, ErrorMatches, "cannot stage LUKS device encryption key change: mock stage error") +} + +func (s *mainSuite) TestTransitionEncryptionKey(c *C) { + b := bytes.NewBufferString(all1sKey) + restore := main.MockOsStdin(b) + defer restore() + dev := "" + transitionCalls := 0 + var key []byte + var transitionErr error + restore = main.MockStageLUKSEncryptionKeyChange(func(newKey keys.EncryptionKey, luksDev string) error { + c.Errorf("unexpected call") + return fmt.Errorf("unexpected call") + }) + defer restore() + restore = main.MockTransitionLUKSEncryptionKeyChange(func(newKey keys.EncryptionKey, luksDev string) error { + transitionCalls++ + dev = luksDev + key = newKey + return transitionErr + }) + defer restore() + defer restore() + err := main.Run([]string{ + "change-encryption-key", + "--device", "/dev/vda4", + "--transition", + }) + c.Assert(err, IsNil) + c.Check(transitionCalls, Equals, 1) + c.Check(dev, Equals, "/dev/vda4") + // secboot encryption key size + c.Check(key, DeepEquals, bytes.Repeat([]byte("1"), 32)) + + restore = main.MockOsStdin(bytes.NewBufferString(all1sKey)) + defer restore() + transitionErr = fmt.Errorf("mock transition error") + err = main.Run([]string{ + "change-encryption-key", + "--device", "/dev/vda4", + "--transition", + }) + c.Assert(err, ErrorMatches, "cannot transition LUKS device encryption key change: mock transition error") +} diff --git a/cmd/snap-gdb-shim/snap-gdb-shim.c b/cmd/snap-gdb-shim/snap-gdb-shim.c new file mode 100644 index 00000000..a8b7fb87 --- /dev/null +++ b/cmd/snap-gdb-shim/snap-gdb-shim.c @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2018 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include +#include +#include +#include + +#include "../libsnap-confine-private/utils.h" + +int main(int argc, char **argv) { + if (sc_is_debug_enabled()) { + for (int i = 0; i < argc; i++) { + fprintf(stderr, "-%s-\n", argv[i]); + } + } + if (argc < 2) { + fprintf(stderr, "missing a command to execute"); + abort(); + } + // signal gdb to stop here + printf("\n\n"); + printf("DEPRECATED: Please consider using --gdbserver instead.\n"); + printf("\n"); + printf("Welcome to `snap run --gdb`.\n"); + printf("You are right before your application is execed():\n"); + printf("- set any options you may need\n"); + printf("- use 'cont' to start\n"); + printf("\n\n"); + raise(SIGTRAP); + + const char *executable = argv[1]; + execv(executable, (char *const *)&argv[1]); + perror("execv failed"); + // very different exit code to make an execve failure easy to distinguish + return 101; +} diff --git a/cmd/snap-gdb-shim/snap-gdbserver-shim.c b/cmd/snap-gdb-shim/snap-gdbserver-shim.c new file mode 100644 index 00000000..b02609f0 --- /dev/null +++ b/cmd/snap-gdb-shim/snap-gdbserver-shim.c @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include +#include +#include +#include + +#include "../libsnap-confine-private/utils.h" + +int main(int argc, char **argv) { + if (sc_is_debug_enabled()) { + for (int i = 0; i < argc; i++) { + fprintf(stderr, "-%s-\n", argv[i]); + } + } + if (argc < 2) { + fprintf(stderr, "missing a command to execute"); + abort(); + } + // Signal to "snap run" that we are ready to get a debugger attached. When a + // debugger gets attached it will stop the binary at whatever point the + // binary is executing. So we cannot have clever code here that e.g. waits + // for a debugger to get attached because that code would also get + // stoppped/debugged by that debugger and that would be confusing for the + // user. + // + // once a debugger is attached we expect it to send: + // "continue; signal SIGCONT" + raise(SIGSTOP); + + // signal gdb to stop here + printf("\n\n"); + printf("Welcome to `snap run --gdbserver`.\n"); + printf("You are right before your application is execed():\n"); + printf("- set any options you may need\n"); + printf("- (optionally) set a breakpoint in 'main'\n"); + printf("- use 'cont' to start\n"); + printf("\n\n"); + raise(SIGTRAP); + + const char *executable = argv[1]; + execv(executable, (char *const *)&argv[1]); + perror("execv failed"); + // very different exit code to make an execve failure easy to distinguish + return 101; +} diff --git a/cmd/snap-mgmt/snap-mgmt-selinux.sh.in b/cmd/snap-mgmt/snap-mgmt-selinux.sh.in new file mode 100644 index 00000000..f276a7d3 --- /dev/null +++ b/cmd/snap-mgmt/snap-mgmt-selinux.sh.in @@ -0,0 +1,112 @@ +#!/bin/bash + +set -e +set +x + +SNAP_MOUNT_DIR="@SNAP_MOUNT_DIR@" + +show_help() { + exec cat <<'EOF' +Usage: snap-mgmt-selinux.sh [OPTIONS] + +A helper script to manage SELinux contexts used by snapd + +Arguments: + --snap-mount-dir= Provide a path to be used as $SNAP_MOUNT_DIR + --patch-selinux-mount-context= Add SELinux context to mount units + --remove-selinux-mount-context= Remove SELinux context from mount units +EOF +} + +SNAP_UNIT_PREFIX="$(systemd-escape -p ${SNAP_MOUNT_DIR})" + +patch_selinux_mount_context() { + if ! command -v selinuxenabled > /dev/null; then + return + fi + if ! selinuxenabled; then + # The tools are there, but SELinux is not enabled + return + fi + + selinux_mount_context="$1" + remove="$2" + if ! echo "$selinux_mount_context" | grep -qE '[a-zA-Z0-9_]+(:[a-zA-Z0-9_]+){2,3}'; then + echo "invalid mount context '$selinux_mount_context'" + exit 1 + fi + context_opt="context=$selinux_mount_context" + + mounts=$(systemctl list-unit-files --no-legend --full "$SNAP_UNIT_PREFIX-*.mount" | cut -f1 -d ' ' || true) + changed_mounts= + for unit in $mounts; do + # Ensure its really a snap mount unit or systemd unit + if ! grep -q 'What=/var/lib/snapd/snaps/' "/etc/systemd/system/$unit" && ! grep -q 'X-Snappy=yes' "/etc/systemd/system/$unit"; then + echo "Skipping non-snapd systemd unit $unit" + continue + fi + + if [ "$remove" == "" ]; then + if grep -q "Options=.*,$context_opt" < "/etc/systemd/system/$unit"; then + # already patched + continue + fi + + if ! sed -i -e "s#^\\(Options=nodev.*\\)#\\1,$context_opt#" "/etc/systemd/system/$unit"; then + echo "Cannot patch $unit" + fi + + changed_mounts="$changed_mounts $unit" + elif [ "$remove" == "remove" ]; then + if ! grep -q "Options=.*,$context_opt" < "/etc/systemd/system/$unit"; then + # Not patched + continue + fi + + if ! sed -i -e "s#^\\(Options=nodev.*\\),$context_opt\\(,.*\\)\\?#\\1\\2#" "/etc/systemd/system/$unit"; then + echo "Cannot patch $unit" + fi + + changed_mounts="$changed_mounts $unit" + fi + done + + if [ -z "$changed_mounts" ]; then + # Nothing changed, no need to reload + return + fi + + systemctl daemon-reload + + for unit in $changed_mounts; do + if ! systemctl try-restart "$unit" ; then + echo "Cannot restart $unit" + fi + done +} + +while [ -n "$1" ]; do + case "$1" in + --help) + show_help + exit + ;; + --snap-mount-dir=*) + SNAP_MOUNT_DIR=${1#*=} + SNAP_UNIT_PREFIX=$(systemd-escape -p "$SNAP_MOUNT_DIR") + shift + ;; + --patch-selinux-mount-context=*) + patch_selinux_mount_context "${1#*=}" + shift + ;; + --remove-selinux-mount-context=*) + patch_selinux_mount_context "${1#*=}" remove + shift + ;; + *) + echo "Unknown command: $1" + exit 1 + ;; + esac +done diff --git a/cmd/snap-mgmt/snap-mgmt.sh.in b/cmd/snap-mgmt/snap-mgmt.sh.in new file mode 100755 index 00000000..c44df746 --- /dev/null +++ b/cmd/snap-mgmt/snap-mgmt.sh.in @@ -0,0 +1,235 @@ +#!/bin/bash + +# Overlord management of snapd for package manager actions. +# Implements actions that would be invoked in %pre(un) actions for snapd. +# Derived from the snapd.postrm scriptlet used in the Ubuntu packaging for +# snapd. + +set -e +set +x + +SNAP_MOUNT_DIR="@SNAP_MOUNT_DIR@" + +show_help() { + exec cat <<'EOF' +Usage: snap-mgmt.sh [OPTIONS] + +A simple script to cleanup snap installations. + +optional arguments: + --help Show this help message and exit + --snap-mount-dir= Provide a path to be used as $SNAP_MOUNT_DIR + --purge Purge all data from $SNAP_MOUNT_DIR +EOF +} + +SNAP_UNIT_PREFIX="$(systemd-escape -p ${SNAP_MOUNT_DIR})" + +systemctl_stop() { + unit="$1" + + echo "Stopping unit $unit" + systemctl stop -q "$unit" || true + + for i in $(seq 10); do + echo "Waiting until unit $unit is stopped [attempt $i]" + if ! systemctl is-active -q "$unit"; then + echo "$unit is stopped." + return + fi + sleep .5 + done +} + +purge() { + # shellcheck disable=SC1091 + distribution=$(. /etc/os-release; echo "${ID}-${VERSION_ID}") + + if [ "$distribution" = "ubuntu-14.04" ]; then + # snap.mount.service is a trusty thing + systemctl_stop snap.mount.service + fi + + units=$(systemctl list-unit-files --no-legend --full | grep -vF snap.mount.service || true) + # *.snap mount points + mounts=$(echo "$units" | grep "^${SNAP_UNIT_PREFIX}[-.].*\\.mount" | cut -f1 -d ' ') + # services from snaps + services=$(echo "$units" | grep '^snap\..*\.service' | cut -f1 -d ' ') + # slices from snaps + slices=$(echo "$units" | grep '^snap\..*\.slice' | cut -f1 -d ' ') + for unit in $services $mounts $slices; do + # ensure its really a snap mount unit or systemd unit + if ! grep -q 'What=/var/lib/snapd/snaps/' "/etc/systemd/system/$unit" && ! grep -q 'X-Snappy=yes' "/etc/systemd/system/$unit"; then + echo "Skipping non-snapd systemd unit $unit" + continue + fi + + echo "Stopping $unit" + systemctl_stop "$unit" + + if echo "$unit" | grep -q '.*\.mount' ; then + # Transform ${SNAP_MOUNT_DIR}/core/3440 -> core/3440 removing any + # extra / preceding snap name, eg: + # /var/lib/snapd/snap/core/3440 -> core/3440 + # /snap/core/3440 -> core/3440 + # /snap/core//3440 -> core/3440 + # NOTE: we could have used `systemctl show $unit -p Where --value` + # but systemd 204 shipped with Ubuntu 14.04 does not support this + snap_rev=$(systemctl show "$unit" -p Where | sed -e 's#Where=##' -e "s#$SNAP_MOUNT_DIR##" -e 's#^/*##') + snap=$(echo "$snap_rev" |cut -f1 -d/) + rev=$(echo "$snap_rev" |cut -f2 -d/) + if [ -n "$snap" ]; then + echo "Removing snap $snap" + # aliases + if [ -d "${SNAP_MOUNT_DIR}/bin" ]; then + find "${SNAP_MOUNT_DIR}/bin" -maxdepth 1 -lname "$snap" -delete + find "${SNAP_MOUNT_DIR}/bin" -maxdepth 1 -lname "$snap.*" -delete + fi + # generated binaries + rm -f "${SNAP_MOUNT_DIR}/bin/$snap" + rm -f "${SNAP_MOUNT_DIR}/bin/$snap".* + # snap mount dir + umount -l "${SNAP_MOUNT_DIR}/$snap/$rev" 2> /dev/null || true + rm -rf "${SNAP_MOUNT_DIR:?}/$snap/$rev" + rm -f "${SNAP_MOUNT_DIR}/$snap/current" + # snap data dir + rm -rf "/var/snap/$snap/$rev" + rm -rf "/var/snap/$snap/common" + rm -f "/var/snap/$snap/current" + # opportunistic remove (may fail if there are still revisions left) + for d in "${SNAP_MOUNT_DIR}/$snap" "/var/snap/$snap"; do + if [ -d "$d" ]; then + rmdir --ignore-fail-on-non-empty "$d" + fi + done + # udev rules + find /etc/udev/rules.d -name "*-snap.${snap}.rules" -execdir rm -f "{}" \; + # dbus policy files + if [ -d /etc/dbus-1/system.d ]; then + find /etc/dbus-1/system.d -name "snap.${snap}.*.conf" -execdir rm -f "{}" \; + fi + # modules + rm -f "/etc/modules-load.d/snap.${snap}.conf" + rm -f "/etc/modprobe.d/snap.${snap}.conf" + # timer and socket units + find /etc/systemd/system -name "snap.${snap}.*.timer" -o -name "snap.${snap}.*.socket" | while read -r f; do + systemctl_stop "$(basename "$f")" + rm -f "$f" + done + # user services, sockets, and timers - we make no attempt to stop any of them. + # TODO: ask snapd to ask each snapd.session-agent.service to stop snaps + # user-session services and stop itself. + find /etc/systemd/user -name "snap.${snap}.*.timer" -o -name "snap.${snap}.*.socket" -o -name "snap.${snap}.*.service" | while read -r f; do + rm -f "$f" + done + fi + fi + + echo "Removing $unit" + rm -f "/etc/systemd/system/$unit" + rm -f "/etc/systemd/system/multi-user.target.wants/$unit" + rm -f "/etc/systemd/system/snapd.mounts.target.wants/${unit}" + done + # Remove empty ".wants/" directory created by enabling mount units + rmdir "/etc/systemd/system/snapd.mounts.target.wants" || true + # Units may have been removed do a reload + systemctl -q daemon-reload || true + + # Undo any bind mounts to ${SNAP_MOUNT_DIR} or /var/snap done by parallel + # installs or LP:#1668659 + for mp in "$SNAP_MOUNT_DIR" /var/snap; do + # btrfs bind mounts actually include subvolume in the filesystem-path + # https://www.mail-archive.com/linux-btrfs@vger.kernel.org/msg51810.html + if grep -q " $mp $mp " /proc/self/mountinfo || + grep -q -e "\(/.*\)$mp $mp .* btrfs .*\(subvol=\1\)\(,.*\)\?\$" /proc/self/mountinfo ; then + echo "umounting $mp" + umount -l "$mp" || true + fi + done + + # stop snapd services + for serv in snapd.autoimport.service snapd.seeded.service snapd.apparmor.service snapd.mounts.target snapd.mounts-pre.target; do + systemctl_stop "$serv" + done + + # snapd session-agent + rm -f /etc/systemd/user/snapd.session-agent.socket + rm -f /etc/systemd/user/snapd.session-agent.service + rm -f /etc/systemd/user/sockets.target.wants/snapd.session-agent.socket + + # dbus activation configuration + rm -f /etc/dbus-1/session.d/snapd.session-services.conf + rm -f /etc/dbus-1/system.d/snapd.system-services.conf + + echo "Discarding preserved snap namespaces" + # opportunistic as those might not be actually mounted + if [ -d /run/snapd/ns ]; then + if [ "$(find /run/snapd/ns/ -name "*.mnt" | wc -l)" -gt 0 ]; then + for mnt in /run/snapd/ns/*.mnt; do + umount -l "$mnt" || true + rm -f "$mnt" + done + fi + find /run/snapd/ns/ \( -name '*.fstab' -o -name '*.user-fstab' -o -name '*.info' \) -delete + umount -l /run/snapd/ns/ || true + fi + + echo "Removing downloaded snaps" + rm -rf /var/lib/snapd/snaps/* + + echo "Removing features exported from snapd to helper tools" + rm -rf /var/lib/snapd/features + + echo "Final directory cleanup" + rm -rf "${SNAP_MOUNT_DIR}" + rm -rf /var/snap + + echo "Removing leftover snap shared state data" + rm -rf /var/lib/snapd/dbus-1/services/* + rm -rf /var/lib/snapd/dbus-1/system-services/* + rm -rf /var/lib/snapd/desktop/applications/* + rm -rf /var/lib/snapd/seccomp/bpf/* + rm -rf /var/lib/snapd/device/* + rm -rf /var/lib/snapd/assertions/* + rm -rf /var/lib/snapd/cookie/* + rm -rf /var/lib/snapd/cache/* + rm -rf /var/lib/snapd/mount/* + rm -rf /var/lib/snapd/sequence/* + rm -rf /var/lib/snapd/apparmor/* + rm -rf /var/lib/snapd/inhibit/* + rm -rf /var/lib/snapd/cgroup/* + rm -f /var/lib/snapd/state.json + rm -f /var/lib/snapd/system-key + + echo "Removing snapd catalog cache" + rm -rf /var/cache/snapd/* + + if test -d /etc/apparmor.d; then + # Remove auto-generated rules for snap-confine from the 'core' snap + echo "Removing extra snap-confine apparmor rules" + # shellcheck disable=SC2046 + rm -f /etc/apparmor.d/$(echo "$SNAP_UNIT_PREFIX" | tr '-' '.').core.*.usr.lib.snapd.snap-confine + fi +} + +while [ -n "$1" ]; do + case "$1" in + --help) + show_help + exit + ;; + --snap-mount-dir=*) + SNAP_MOUNT_DIR=${1#*=} + SNAP_UNIT_PREFIX=$(systemd-escape -p "$SNAP_MOUNT_DIR") + shift + ;; + --purge) + purge + shift + ;; + *) + echo "Unknown command: $1" + exit 1 + ;; + esac +done diff --git a/cmd/snap-preseed/export_test.go b/cmd/snap-preseed/export_test.go new file mode 100644 index 00000000..45c29728 --- /dev/null +++ b/cmd/snap-preseed/export_test.go @@ -0,0 +1,59 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "github.com/snapcore/snapd/image/preseed" + "github.com/snapcore/snapd/testutil" +) + +var ( + Run = run +) + +func MockOsGetuid(f func() int) (restore func()) { + r := testutil.Backup(&osGetuid) + osGetuid = f + return r +} + +func MockPreseedCore20(f func(opts *preseed.CoreOptions) error) (restore func()) { + r := testutil.Backup(&preseedCore20) + preseedCore20 = f + return r +} + +func MockPreseedClassic(f func(dir string) error) (restore func()) { + r := testutil.Backup(&preseedClassic) + preseedClassic = f + return r +} + +func MockPreseedClassicReset(f func(dir string) error) (restore func()) { + r := testutil.Backup(&preseedClassicReset) + preseedClassicReset = f + return r +} + +func MockResetPreseededChroot(f func(dir string) error) (restore func()) { + r := testutil.Backup(&preseedResetPreseededChroot) + preseedResetPreseededChroot = f + return r +} diff --git a/cmd/snap-preseed/main.go b/cmd/snap-preseed/main.go new file mode 100644 index 00000000..d6272e89 --- /dev/null +++ b/cmd/snap-preseed/main.go @@ -0,0 +1,146 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "fmt" + "io" + "os" + "path/filepath" + + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/image/preseed" + + // for SanitizePlugsSlots + "github.com/snapcore/snapd/interfaces/builtin" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/snap" +) + +const ( + shortHelp = "Prerun the first boot seeding of snaps in an image filesystem chroot with a snapd seed." + longHelp = ` +The snap-preseed command takes a directory containing an image, including seed +snaps (at /var/lib/snapd/seed), and runs through the snapd first-boot process +up to hook execution. No boot actions unrelated to snapd are performed. +It creates systemd units for seeded snaps, makes any connections, and generates +security profiles. The image is updated and consequently optimised to reduce +first-boot startup time` +) + +type options struct { + Reset bool `long:"reset"` + ResetChroot bool `long:"reset-chroot" hidden:"1"` + PreseedSignKey string `long:"preseed-sign-key"` + AppArmorFeaturesDir string `long:"apparmor-features-dir"` + SysfsOverlay string `long:"sysfs-overlay"` +} + +var ( + osGetuid = os.Getuid + // unused currently, left in place for consistency for when it is needed + // Stdout io.Writer = os.Stdout + Stderr io.Writer = os.Stderr + + preseedCore20 = preseed.Core20 + preseedClassic = preseed.Classic + preseedClassicReset = preseed.ClassicReset + preseedResetPreseededChroot = preseed.ResetPreseededChroot + + opts options +) + +func Parser() *flags.Parser { + opts = options{} + parser := flags.NewParser(&opts, flags.HelpFlag|flags.PassDoubleDash|flags.PassAfterNonOption) + parser.ShortDescription = shortHelp + parser.LongDescription = longHelp + return parser +} + +func probeCore20ImageDir(dir string) bool { + sysDir := filepath.Join(dir, "system-seed") + _, isDir, _ := osutil.DirExists(sysDir) + return isDir +} + +func main() { + parser := Parser() + if err := run(parser, os.Args[1:]); err != nil { + fmt.Fprintf(Stderr, "error: %v\n", err) + os.Exit(1) + } +} + +func run(parser *flags.Parser, args []string) (err error) { + // real validation of plugs and slots; needs to be set + // for processing of seeds with gadget because of readInfo(). + snap.SanitizePlugsSlots = builtin.SanitizePlugsSlots + + rest, err := parser.ParseArgs(args) + if err != nil { + return err + } + + if osGetuid() != 0 { + return fmt.Errorf("must be run as root") + } + + var chrootDir string + if opts.ResetChroot { + chrootDir = "/" + } else { + if len(rest) == 0 { + return fmt.Errorf("need chroot path as argument") + } + + chrootDir, err = filepath.Abs(rest[0]) + if err != nil { + return err + } + + // safety check + if chrootDir == "/" { + return fmt.Errorf("cannot run snap-preseed against /") + } + } + + if probeCore20ImageDir(chrootDir) { + if opts.Reset || opts.ResetChroot { + return fmt.Errorf("cannot snap-preseed --reset for Ubuntu Core") + } + + coreOpts := &preseed.CoreOptions{ + PrepareImageDir: chrootDir, + PreseedSignKey: opts.PreseedSignKey, + AppArmorKernelFeaturesDir: opts.AppArmorFeaturesDir, + SysfsOverlay: opts.SysfsOverlay, + } + return preseedCore20(coreOpts) + } + if opts.ResetChroot { + return preseedResetPreseededChroot(chrootDir) + } + if opts.Reset { + return preseedClassicReset(chrootDir) + } + return preseedClassic(chrootDir) +} diff --git a/cmd/snap-preseed/preseed_classic_test.go b/cmd/snap-preseed/preseed_classic_test.go new file mode 100644 index 00000000..85410f13 --- /dev/null +++ b/cmd/snap-preseed/preseed_classic_test.go @@ -0,0 +1,165 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2019-2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "testing" + + "github.com/jessevdk/go-flags" + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/cmd/snap-preseed" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/osutil/squashfs" + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/testutil" +) + +func Test(t *testing.T) { TestingT(t) } + +var _ = Suite(&startPreseedSuite{}) + +type startPreseedSuite struct { + testutil.BaseTest +} + +func (s *startPreseedSuite) SetUpTest(c *C) { + s.BaseTest.SetUpTest(c) + restore := squashfs.MockNeedsFuse(false) + s.BaseTest.AddCleanup(restore) +} + +func (s *startPreseedSuite) TearDownTest(c *C) { + s.BaseTest.TearDownTest(c) + dirs.SetRootDir("") +} + +func testParser(c *C) *flags.Parser { + parser := main.Parser() + _, err := parser.ParseArgs([]string{}) + c.Assert(err, IsNil) + return parser +} + +func (s *startPreseedSuite) TestRequiresRoot(c *C) { + restore := main.MockOsGetuid(func() int { + return 1000 + }) + defer restore() + + parser := testParser(c) + c.Check(main.Run(parser, []string{"/"}), ErrorMatches, `must be run as root`) +} + +func (s *startPreseedSuite) TestMissingArg(c *C) { + restore := main.MockOsGetuid(func() int { + return 0 + }) + defer restore() + + parser := testParser(c) + c.Check(main.Run(parser, nil), ErrorMatches, `need chroot path as argument`) +} + +func (s *startPreseedSuite) TestRunPreseedAgainstFilesystemRoot(c *C) { + restore := main.MockOsGetuid(func() int { return 0 }) + defer restore() + + parser := testParser(c) + c.Assert(main.Run(parser, []string{"/"}), ErrorMatches, `cannot run snap-preseed against /`) +} + +func (s *startPreseedSuite) TestRunPreseedClassicHappy(c *C) { + restore := main.MockOsGetuid(func() int { + return 0 + }) + defer restore() + + var called bool + restorePreseed := main.MockPreseedClassic(func(dir string) error { + c.Check(dir, Equals, "/a/dir") + called = true + return nil + }) + defer restorePreseed() + + parser := testParser(c) + c.Assert(main.Run(parser, []string{"/a/dir"}), IsNil) + c.Check(called, Equals, true) +} + +func (s *startPreseedSuite) TestResetReexeced(c *C) { + restore := main.MockOsGetuid(func() int { + return 0 + }) + defer restore() + + var called bool + main.MockResetPreseededChroot(func(dir string) error { + c.Check(dir, Equals, "/") + called = true + return nil + }) + + parser := testParser(c) + c.Assert(main.Run(parser, []string{"--reset-chroot"}), IsNil) + c.Check(called, Equals, true) +} + +func (s *startPreseedSuite) TestReset(c *C) { + restore := main.MockOsGetuid(func() int { + return 0 + }) + defer restore() + + var called bool + main.MockPreseedClassicReset(func(dir string) error { + c.Check(dir, Equals, "/a/dir") + called = true + return nil + }) + + parser := testParser(c) + c.Assert(main.Run(parser, []string{"--reset", "/a/dir"}), IsNil) + c.Check(called, Equals, true) +} + +func (s *startPreseedSuite) TestReadInfoValidity(c *C) { + var called bool + inf := &snap.Info{ + BadInterfaces: make(map[string]string), + Plugs: map[string]*snap.PlugInfo{ + "foo": { + Interface: "bad"}, + }, + } + + // set an empty sanitize method. + snap.SanitizePlugsSlots = func(*snap.Info) { called = true } + + parser := testParser(c) + tmpDir := c.MkDir() + _ = main.Run(parser, []string{tmpDir}) + + // real sanitize method should be set after Run() + snap.SanitizePlugsSlots(inf) + c.Assert(called, Equals, false) + c.Assert(inf.BadInterfaces, HasLen, 1) +} diff --git a/cmd/snap-preseed/preseed_uc20_test.go b/cmd/snap-preseed/preseed_uc20_test.go new file mode 100644 index 00000000..360e59f9 --- /dev/null +++ b/cmd/snap-preseed/preseed_uc20_test.go @@ -0,0 +1,118 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "os" + "path/filepath" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/cmd/snap-preseed" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/image/preseed" +) + +func (s *startPreseedSuite) TestRunPreseedUC20Happy(c *C) { + tmpDir := c.MkDir() + dirs.SetRootDir(tmpDir) + + restore := main.MockOsGetuid(func() int { + return 0 + }) + defer restore() + + // for UC20 probing + c.Assert(os.MkdirAll(filepath.Join(tmpDir, "system-seed/systems/20220203"), 0755), IsNil) + // we don't run tar, so create a fake artifact to make FileDigest happy + c.Assert(os.WriteFile(filepath.Join(tmpDir, "system-seed/systems/20220203/preseed.tgz"), nil, 0644), IsNil) + + var called bool + restorePreseed := main.MockPreseedCore20(func(opts *preseed.CoreOptions) error { + c.Check(opts.PrepareImageDir, Equals, tmpDir) + c.Check(opts.PreseedSignKey, Equals, "key") + c.Check(opts.AppArmorKernelFeaturesDir, Equals, "/custom/aa/features") + c.Check(opts.SysfsOverlay, Equals, "/sysfs-overlay") + called = true + return nil + }) + defer restorePreseed() + + parser := testParser(c) + c.Assert(main.Run(parser, []string{"--preseed-sign-key", "key", "--apparmor-features-dir", "/custom/aa/features", "--sysfs-overlay", "/sysfs-overlay", tmpDir}), IsNil) + c.Check(called, Equals, true) +} + +func (s *startPreseedSuite) TestRunPreseedUC20HappyNoArgs(c *C) { + tmpDir := c.MkDir() + dirs.SetRootDir(tmpDir) + + restore := main.MockOsGetuid(func() int { + return 0 + }) + defer restore() + + // for UC20 probing + c.Assert(os.MkdirAll(filepath.Join(tmpDir, "system-seed/systems/20220203"), 0755), IsNil) + c.Assert(os.WriteFile(filepath.Join(tmpDir, "system-seed/systems/20220203/preseed.tgz"), nil, 0644), IsNil) + + var called bool + restorePreseed := main.MockPreseedCore20(func(opts *preseed.CoreOptions) error { + c.Check(opts.PrepareImageDir, Equals, tmpDir) + c.Check(opts.PreseedSignKey, Equals, "") + c.Check(opts.AppArmorKernelFeaturesDir, Equals, "") + c.Check(opts.SysfsOverlay, Equals, "") + called = true + return nil + }) + defer restorePreseed() + + parser := testParser(c) + c.Assert(main.Run(parser, []string{tmpDir}), IsNil) + c.Check(called, Equals, true) +} + +func (s *startPreseedSuite) TestResetUC20(c *C) { + tmpDir := c.MkDir() + dirs.SetRootDir(tmpDir) + + restore := main.MockOsGetuid(func() int { + return 0 + }) + defer restore() + + // for UC20 probing + c.Assert(os.MkdirAll(filepath.Join(tmpDir, "system-seed/systems/20220203"), 0755), IsNil) + // we don't run tar, so create a fake artifact to make FileDigest happy + c.Assert(os.WriteFile(filepath.Join(tmpDir, "system-seed/systems/20220203/preseed.tgz"), nil, 0644), IsNil) + + var called bool + restorePreseed := main.MockPreseedCore20(func(opts *preseed.CoreOptions) error { + called = true + return nil + }) + defer restorePreseed() + + parser := testParser(c) + res := main.Run(parser, []string{"--reset", tmpDir}) + c.Assert(res, Not(IsNil)) + c.Check(res, ErrorMatches, "cannot snap-preseed --reset for Ubuntu Core") + c.Check(called, Equals, false) +} diff --git a/cmd/snap-recovery-chooser/export_test.go b/cmd/snap-recovery-chooser/export_test.go new file mode 100644 index 00000000..32d18451 --- /dev/null +++ b/cmd/snap-recovery-chooser/export_test.go @@ -0,0 +1,65 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "io" + "log/syslog" + "os/exec" +) + +var ( + OutputForUI = outputForUI + RunUI = runUI + Chooser = chooser + LoggerWithSyslogMaybe = loggerWithSyslogMaybe +) + +func MockStdStreams(stdout, stderr io.Writer) (restore func()) { + oldStdout, oldStderr := Stdout, Stderr + Stdout, Stderr = stdout, stderr + return func() { + Stdout, Stderr = oldStdout, oldStderr + } +} + +func MockChooserTool(f func() (*exec.Cmd, error)) (restore func()) { + oldTool := chooserTool + chooserTool = f + return func() { + chooserTool = oldTool + } +} + +func MockDefaultMarkerFile(p string) (restore func()) { + old := defaultMarkerFile + defaultMarkerFile = p + return func() { + defaultMarkerFile = old + } +} + +func MockSyslogNew(f func(syslog.Priority, string) (io.Writer, error)) (restore func()) { + oldSyslogNew := syslogNew + syslogNew = f + return func() { + syslogNew = oldSyslogNew + } +} diff --git a/cmd/snap-recovery-chooser/main.go b/cmd/snap-recovery-chooser/main.go new file mode 100644 index 00000000..4f47fc85 --- /dev/null +++ b/cmd/snap-recovery-chooser/main.go @@ -0,0 +1,236 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +// The `snap-recovery-chooser` acts as a proxy between the chooser UI process +// and the actual snapd daemon. +// +// It obtains the list of seed systems and their actions from the snapd API and +// passed that directly to the standard input of the UI process. The UI process +// is expected to present the list of options to the user and print out a JSON +// object with the choice to its standard output. +// +// The JSON object carrying the list of systems is the client.ChooserSystems +// structure. The response is defined as follows: +// +// { +// "label": ". + * + */ + +package main_test + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log/syslog" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "testing" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/client" + main "github.com/snapcore/snapd/cmd/snap-recovery-chooser" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/logger" + "github.com/snapcore/snapd/testutil" +) + +// Hook up check.v1 into the "go test" runner +func Test(t *testing.T) { TestingT(t) } + +type baseCmdSuite struct { + testutil.BaseTest + + stdout, stderr bytes.Buffer + markerFile string +} + +func (s *baseCmdSuite) SetUpTest(c *C) { + s.BaseTest.SetUpTest(c) + _, r := logger.MockLogger() + s.AddCleanup(r) + r = main.MockStdStreams(&s.stdout, &s.stderr) + s.AddCleanup(r) + + d := c.MkDir() + s.markerFile = filepath.Join(d, "marker") + err := os.WriteFile(s.markerFile, nil, 0644) + c.Assert(err, IsNil) +} + +type cmdSuite struct { + baseCmdSuite +} + +var _ = Suite(&cmdSuite{}) + +var mockSystems = &main.ChooserSystems{ + Systems: []client.System{ + { + Label: "foo", + Actions: []client.SystemAction{ + {Title: "reinstall", Mode: "install"}, + }, + }, + }, +} + +func (s *cmdSuite) TestRunUIHappy(c *C) { + mockCmd := testutil.MockCommand(c, "tool", ` +echo '{}' +`) + defer mockCmd.Restore() + + rsp, err := main.RunUI(exec.Command(mockCmd.Exe()), mockSystems) + c.Assert(err, IsNil) + c.Assert(rsp, NotNil) +} + +func (s *cmdSuite) TestRunUIBadJSON(c *C) { + mockCmd := testutil.MockCommand(c, "tool", ` +echo 'garbage' +`) + defer mockCmd.Restore() + + rsp, err := main.RunUI(exec.Command(mockCmd.Exe()), mockSystems) + c.Assert(err, ErrorMatches, "cannot decode response: .*") + c.Assert(rsp, IsNil) +} + +func (s *cmdSuite) TestRunUIToolErr(c *C) { + mockCmd := testutil.MockCommand(c, "tool", ` +echo foo +exit 22 +`) + defer mockCmd.Restore() + + _, err := main.RunUI(exec.Command(mockCmd.Exe()), mockSystems) + c.Assert(err, ErrorMatches, "cannot collect output of the UI process: exit status 22") +} + +func (s *cmdSuite) TestRunUIInputJSON(c *C) { + d := c.MkDir() + tf := filepath.Join(d, "json-input") + mockCmd := testutil.MockCommand(c, "tool", fmt.Sprintf(` +cat > %s +echo '{}' +`, tf)) + defer mockCmd.Restore() + + _, err := main.RunUI(exec.Command(mockCmd.Exe()), mockSystems) + c.Assert(err, IsNil) + + data, err := os.ReadFile(tf) + c.Assert(err, IsNil) + var input *main.ChooserSystems + err = json.Unmarshal(data, &input) + c.Assert(err, IsNil) + + c.Assert(input, DeepEquals, mockSystems) +} + +func (s *cmdSuite) TestStdoutUI(c *C) { + var buf bytes.Buffer + err := main.OutputForUI(&buf, mockSystems) + c.Assert(err, IsNil) + + var out *main.ChooserSystems + + err = json.Unmarshal(buf.Bytes(), &out) + c.Assert(err, IsNil) + c.Assert(out, DeepEquals, mockSystems) +} + +type mockedClientCmdSuite struct { + baseCmdSuite + + config client.Config +} + +var _ = Suite(&mockedClientCmdSuite{}) + +func (s *mockedClientCmdSuite) SetUpTest(c *C) { + s.baseCmdSuite.SetUpTest(c) +} + +func (s *mockedClientCmdSuite) RedirectClientToTestServer(handler func(http.ResponseWriter, *http.Request)) { + server := httptest.NewServer(http.HandlerFunc(handler)) + s.BaseTest.AddCleanup(func() { server.Close() }) + s.config.BaseURL = server.URL +} + +type mockSystemRequestResponse struct { + label string + code int + reboot bool + expect map[string]interface{} +} + +func (s *mockedClientCmdSuite) mockSuccessfulResponse(c *C, rspSystems *main.ChooserSystems, rspPostSystem *mockSystemRequestResponse) { + n := 0 + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch n { + case 0: + c.Check(r.URL.Path, Equals, "/v2/systems") + err := json.NewEncoder(w).Encode(apiResponse{ + Type: "sync", + Result: rspSystems, + StatusCode: 200, + }) + c.Assert(err, IsNil) + case 1: + if rspPostSystem == nil { + c.Fatalf("unexpected request to %q", r.URL.Path) + } + c.Check(r.URL.Path, Equals, "/v2/systems/"+rspPostSystem.label) + c.Check(r.Method, Equals, "POST") + + var data map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&data) + c.Assert(err, IsNil) + c.Check(data, DeepEquals, rspPostSystem.expect) + + rspType := "sync" + var rspData map[string]string + if rspPostSystem.code >= 400 { + rspType = "error" + rspData = map[string]string{"message": "failed in mock"} + } + var maintenance map[string]interface{} + if rspPostSystem.reboot { + maintenance = map[string]interface{}{ + "kind": client.ErrorKindSystemRestart, + "message": "system is restartring", + } + } + err = json.NewEncoder(w).Encode(apiResponse{ + Type: rspType, + Result: rspData, + StatusCode: rspPostSystem.code, + Maintenance: maintenance, + }) + c.Assert(err, IsNil) + default: + c.Fatalf("expected to get 1 requests, now on %d", n+1) + } + n++ + }) +} + +type apiResponse struct { + Type string `json:"type"` + Result interface{} `json:"result"` + StatusCode int `json:"status-code"` + Maintenance interface{} `json:"maintenance"` +} + +func (s *mockedClientCmdSuite) TestMainChooserWithTool(c *C) { + r := main.MockDefaultMarkerFile(s.markerFile) + defer r() + // validity + c.Assert(s.markerFile, testutil.FilePresent) + + capturedStdinPath := filepath.Join(c.MkDir(), "stdin") + mockCmd := testutil.MockCommand(c, "tool", fmt.Sprintf(` +cat - > %s +echo '{"label":"label","action":{"mode":"install","title":"reinstall"}}' +`, capturedStdinPath)) + defer mockCmd.Restore() + r = main.MockChooserTool(func() (*exec.Cmd, error) { + return exec.Command(mockCmd.Exe()), nil + }) + defer r() + + s.mockSuccessfulResponse(c, mockSystems, &mockSystemRequestResponse{ + code: 200, + label: "label", + expect: map[string]interface{}{ + "action": "do", + "mode": "install", + "title": "reinstall", + }, + reboot: true, + }) + + rbt, err := main.Chooser(client.New(&s.config)) + c.Assert(err, IsNil) + c.Assert(rbt, Equals, true) + c.Assert(mockCmd.Calls(), DeepEquals, [][]string{ + {"tool"}, + }) + + capturedStdin, err := os.ReadFile(capturedStdinPath) + c.Assert(err, IsNil) + var stdoutSystems main.ChooserSystems + err = json.Unmarshal(capturedStdin, &stdoutSystems) + c.Assert(err, IsNil) + c.Check(&stdoutSystems, DeepEquals, mockSystems) + + c.Assert(s.markerFile, testutil.FileAbsent) +} + +func (s *mockedClientCmdSuite) TestMainChooserToolNotFound(c *C) { + r := main.MockDefaultMarkerFile(s.markerFile) + defer r() + // validity + c.Assert(s.markerFile, testutil.FilePresent) + + s.mockSuccessfulResponse(c, mockSystems, nil) + + r = main.MockChooserTool(func() (*exec.Cmd, error) { + return nil, fmt.Errorf("tool not found") + }) + defer r() + + rbt, err := main.Chooser(client.New(&s.config)) + c.Assert(err, ErrorMatches, "cannot locate the chooser UI tool: tool not found") + c.Assert(rbt, Equals, false) + + c.Assert(s.markerFile, testutil.FileAbsent) +} + +func (s *mockedClientCmdSuite) TestMainChooserBadAPI(c *C) { + r := main.MockDefaultMarkerFile(s.markerFile) + defer r() + // validity + c.Assert(s.markerFile, testutil.FilePresent) + + n := 0 + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch n { + case 0: + c.Check(r.URL.Path, Equals, "/v2/systems") + enc := json.NewEncoder(w) + err := enc.Encode(apiResponse{ + Type: "error", + Result: map[string]string{ + "message": "no systems for you", + }, + StatusCode: 400, + }) + c.Assert(err, IsNil) + default: + c.Fatalf("expected to get 1 requests, now on %d", n+1) + } + n++ + }) + + rbt, err := main.Chooser(client.New(&s.config)) + c.Assert(err, ErrorMatches, "cannot list recovery systems: no systems for you") + c.Assert(rbt, Equals, false) + + c.Assert(s.markerFile, testutil.FileAbsent) +} + +func (s *mockedClientCmdSuite) testMainChooserConsoleConfAlternatives(c *C, setupCmd func(script string) *testutil.MockCmd) { + r := main.MockDefaultMarkerFile(s.markerFile) + defer r() + // validity + c.Assert(s.markerFile, testutil.FilePresent) + + s.mockSuccessfulResponse(c, mockSystems, &mockSystemRequestResponse{ + code: 200, + label: "label", + expect: map[string]interface{}{ + "action": "do", + "mode": "install", + "title": "reinstall", + }, + }) + + mockCmd := setupCmd(` +echo '{"label":"label","action":{"mode":"install","title":"reinstall"}}' +`) + + defer mockCmd.Restore() + + rbt, err := main.Chooser(client.New(&s.config)) + c.Assert(err, IsNil) + c.Assert(rbt, Equals, false) + + c.Check(mockCmd.Calls(), DeepEquals, [][]string{ + {"console-conf", "--recovery-chooser-mode"}, + }) + + c.Assert(s.markerFile, testutil.FileAbsent) +} + +func (s *mockedClientCmdSuite) TestMainChooserDefaultToConsoleConf(c *C) { + d := c.MkDir() + dirs.SetRootDir(d) + defer dirs.SetRootDir("/") + + s.testMainChooserConsoleConfAlternatives(c, func(script string) *testutil.MockCmd { + return testutil.MockCommand(c, filepath.Join(dirs.GlobalRootDir, "/usr/bin/console-conf"), + script) + }) +} + +func (s *mockedClientCmdSuite) TestMainChooserFallbackToSnapConsoleConf(c *C) { + d := c.MkDir() + dirs.SetRootDir(d) + defer dirs.SetRootDir("/") + + s.testMainChooserConsoleConfAlternatives(c, func(script string) *testutil.MockCmd { + // create /snap/bin/console-conf as a symlink like when a snap + // is installed + c.Assert(os.MkdirAll(dirs.SnapBinariesDir, 0755), IsNil) + err := os.Symlink(filepath.Join(d, "console-conf"), + filepath.Join(dirs.SnapBinariesDir, "console-conf")) + c.Assert(err, IsNil) + return testutil.MockCommand(c, filepath.Join(d, "console-conf"), script) + }) +} + +func (s *mockedClientCmdSuite) TestMainChooserNoConsoleConf(c *C) { + d := c.MkDir() + dirs.SetRootDir(d) + defer dirs.SetRootDir("/") + + r := main.MockDefaultMarkerFile(s.markerFile) + defer r() + // validity + c.Assert(s.markerFile, testutil.FilePresent) + + // not expecting a POST request + s.mockSuccessfulResponse(c, mockSystems, nil) + + // tries to look up the console-conf binary but fails + rbt, err := main.Chooser(client.New(&s.config)) + c.Assert(err, ErrorMatches, `cannot locate the chooser UI tool: chooser UI tools \[".*/usr/bin/console-conf" ".*snap/bin/console-conf"\] do not exist`) + c.Assert(rbt, Equals, false) + c.Assert(s.markerFile, testutil.FileAbsent) +} + +func (s *mockedClientCmdSuite) TestMainChooserGarbageNoActionRequested(c *C) { + d := c.MkDir() + dirs.SetRootDir(d) + defer dirs.SetRootDir("/") + + r := main.MockDefaultMarkerFile(s.markerFile) + defer r() + // validity + c.Assert(s.markerFile, testutil.FilePresent) + + // not expecting a POST request + s.mockSuccessfulResponse(c, mockSystems, nil) + + mockCmd := testutil.MockCommand(c, filepath.Join(dirs.GlobalRootDir, "/usr/bin/console-conf"), ` +echo 'garbage' +`) + defer mockCmd.Restore() + + rbt, err := main.Chooser(client.New(&s.config)) + c.Assert(err, ErrorMatches, "UI process failed: cannot decode response: .*") + c.Assert(rbt, Equals, false) + + c.Check(mockCmd.Calls(), DeepEquals, [][]string{ + {"console-conf", "--recovery-chooser-mode"}, + }) + + c.Assert(s.markerFile, testutil.FileAbsent) +} + +func (s *mockedClientCmdSuite) TestMainChooserNoMarkerNoCalls(c *C) { + r := main.MockDefaultMarkerFile(s.markerFile + ".notfound") + defer r() + + mockCmd := testutil.MockCommand(c, "tool", ` +exit 123 +`) + defer mockCmd.Restore() + r = main.MockChooserTool(func() (*exec.Cmd, error) { + return exec.Command(mockCmd.Exe()), nil + }) + defer r() + + rbt, err := main.Chooser(client.New(&s.config)) + c.Assert(err, ErrorMatches, "cannot run chooser without the marker file") + c.Assert(rbt, Equals, false) + + c.Assert(mockCmd.Calls(), HasLen, 0) +} + +func (s *mockedClientCmdSuite) TestMainChooserSnapdAPIBad(c *C) { + r := main.MockDefaultMarkerFile(s.markerFile) + defer r() + // validity + c.Assert(s.markerFile, testutil.FilePresent) + + mockCmd := testutil.MockCommand(c, "tool", ` +echo '{"label":"label","action":{"mode":"install","title":"reinstall"}}' +`) + defer mockCmd.Restore() + r = main.MockChooserTool(func() (*exec.Cmd, error) { + return exec.Command(mockCmd.Exe()), nil + }) + defer r() + + s.mockSuccessfulResponse(c, mockSystems, &mockSystemRequestResponse{ + code: 400, + label: "label", + expect: map[string]interface{}{ + "action": "do", + "mode": "install", + "title": "reinstall", + }, + }) + + rbt, err := main.Chooser(client.New(&s.config)) + c.Assert(err, ErrorMatches, "cannot request system action: .* failed in mock") + c.Assert(rbt, Equals, false) + c.Assert(mockCmd.Calls(), DeepEquals, [][]string{ + {"tool"}, + }) + + c.Assert(s.markerFile, testutil.FileAbsent) + +} + +type mockedSyslogCmdSuite struct { + baseCmdSuite + + term string +} + +var _ = Suite(&mockedSyslogCmdSuite{}) + +func (s *mockedSyslogCmdSuite) SetUpTest(c *C) { + s.baseCmdSuite.SetUpTest(c) + + s.term = os.Getenv("TERM") + s.AddCleanup(func() { os.Setenv("TERM", s.term) }) + + r := main.MockSyslogNew(func(p syslog.Priority, t string) (io.Writer, error) { + c.Fatal("not mocked") + return nil, fmt.Errorf("not mocked") + }) + s.AddCleanup(r) +} + +func (s *mockedSyslogCmdSuite) TestNoSyslogFallback(c *C) { + err := os.Setenv("TERM", "someterm") + c.Assert(err, IsNil) + + called := false + r := main.MockSyslogNew(func(_ syslog.Priority, _ string) (io.Writer, error) { + called = true + return nil, fmt.Errorf("no syslog") + }) + defer r() + err = main.LoggerWithSyslogMaybe() + c.Assert(err, IsNil) + c.Check(called, Equals, true) + // this likely goes to stderr + logger.Noticef("ping") +} + +func (s *mockedSyslogCmdSuite) TestWithSyslog(c *C) { + err := os.Setenv("TERM", "someterm") + c.Assert(err, IsNil) + + called := false + tag := "" + prio := syslog.Priority(0) + buf := bytes.Buffer{} + r := main.MockSyslogNew(func(p syslog.Priority, tg string) (io.Writer, error) { + tag = tg + prio = p + called = true + return &buf, nil + }) + defer r() + err = main.LoggerWithSyslogMaybe() + c.Assert(err, IsNil) + c.Check(called, Equals, true) + c.Check(tag, Equals, "snap-recovery-chooser") + c.Check(prio, Equals, syslog.LOG_INFO|syslog.LOG_DAEMON) + + logger.Noticef("ping") + c.Check(buf.String(), testutil.Contains, "ping") +} + +func (s *mockedSyslogCmdSuite) TestSimple(c *C) { + err := os.Unsetenv("TERM") + c.Assert(err, IsNil) + + r := main.MockSyslogNew(func(p syslog.Priority, tg string) (io.Writer, error) { + c.Fatalf("unexpected call") + return nil, fmt.Errorf("unexpected call") + }) + defer r() + err = main.LoggerWithSyslogMaybe() + c.Assert(err, IsNil) +} diff --git a/cmd/snap-repair/cmd_done_retry_skip.go b/cmd/snap-repair/cmd_done_retry_skip.go new file mode 100644 index 00000000..3144e5db --- /dev/null +++ b/cmd/snap-repair/cmd_done_retry_skip.go @@ -0,0 +1,82 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "fmt" + "os" + "strconv" +) + +func init() { + cmd, err := parser.AddCommand("done", "Signal repair is done", "", &cmdDone{}) + if err != nil { + + panic(err) + } + cmd.Hidden = true + + cmd, err = parser.AddCommand("skip", "Signal repair should be skipped", "", &cmdSkip{}) + if err != nil { + panic(err) + } + cmd.Hidden = true + + cmd, err = parser.AddCommand("retry", "Signal repair must be retried next time", "", &cmdRetry{}) + if err != nil { + panic(err) + } + cmd.Hidden = true +} + +func writeToStatusFD(msg string) error { + statusFdStr := os.Getenv("SNAP_REPAIR_STATUS_FD") + if statusFdStr == "" { + return fmt.Errorf("cannot find SNAP_REPAIR_STATUS_FD environment") + } + fd, err := strconv.Atoi(statusFdStr) + if err != nil { + return fmt.Errorf("cannot parse SNAP_REPAIR_STATUS_FD environment: %s", err) + } + f := os.NewFile(uintptr(fd), "") + defer f.Close() + if _, err := f.Write([]byte(msg + "\n")); err != nil { + return err + } + return nil +} + +type cmdDone struct{} + +func (c *cmdDone) Execute(args []string) error { + return writeToStatusFD("done") +} + +type cmdSkip struct{} + +func (c *cmdSkip) Execute([]string) error { + return writeToStatusFD("skip") +} + +type cmdRetry struct{} + +func (c *cmdRetry) Execute([]string) error { + return writeToStatusFD("retry") +} diff --git a/cmd/snap-repair/cmd_done_retry_skip_test.go b/cmd/snap-repair/cmd_done_retry_skip_test.go new file mode 100644 index 00000000..c34ef24a --- /dev/null +++ b/cmd/snap-repair/cmd_done_retry_skip_test.go @@ -0,0 +1,81 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "io" + "os" + "strconv" + "syscall" + + . "gopkg.in/check.v1" + + repair "github.com/snapcore/snapd/cmd/snap-repair" +) + +func (r *repairSuite) TestStatusNoStatusFdEnv(c *C) { + for _, s := range []string{"done", "skip", "retry"} { + err := repair.ParseArgs([]string{s}) + c.Check(err, ErrorMatches, "cannot find SNAP_REPAIR_STATUS_FD environment") + } +} + +func (r *repairSuite) TestStatusBadStatusFD(c *C) { + for _, s := range []string{"done", "skip", "retry"} { + os.Setenv("SNAP_REPAIR_STATUS_FD", "123456789") + defer os.Unsetenv("SNAP_REPAIR_STATUS_FD") + + err := repair.ParseArgs([]string{s}) + c.Check(err, ErrorMatches, `write : bad file descriptor`) + } +} + +func (r *repairSuite) TestStatusUnparsableStatusFD(c *C) { + for _, s := range []string{"done", "skip", "retry"} { + os.Setenv("SNAP_REPAIR_STATUS_FD", "xxx") + defer os.Unsetenv("SNAP_REPAIR_STATUS_FD") + + err := repair.ParseArgs([]string{s}) + c.Check(err, ErrorMatches, `cannot parse SNAP_REPAIR_STATUS_FD environment: strconv.*: parsing "xxx": invalid syntax`) + } +} + +func (r *repairSuite) TestStatusHappy(c *C) { + for _, s := range []string{"done", "skip", "retry"} { + rp, wp, err := os.Pipe() + c.Assert(err, IsNil) + defer rp.Close() + defer wp.Close() + + fd, e := syscall.Dup(int(wp.Fd())) + c.Assert(e, IsNil) + wp.Close() + + os.Setenv("SNAP_REPAIR_STATUS_FD", strconv.Itoa(fd)) + defer os.Unsetenv("SNAP_REPAIR_STATUS_FD") + + err = repair.ParseArgs([]string{s}) + c.Check(err, IsNil) + + status, err := io.ReadAll(rp) + c.Assert(err, IsNil) + c.Check(string(status), Equals, s+"\n") + } +} diff --git a/cmd/snap-repair/cmd_list.go b/cmd/snap-repair/cmd_list.go new file mode 100644 index 00000000..fa24106c --- /dev/null +++ b/cmd/snap-repair/cmd_list.go @@ -0,0 +1,74 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "fmt" + "text/tabwriter" +) + +func init() { + const ( + short = "Lists repairs run on this device" + long = "" + ) + + if _, err := parser.AddCommand("list", short, long, &cmdList{}); err != nil { + panic(err) + } + +} + +type cmdList struct{} + +func (c *cmdList) Execute([]string) error { + w := tabwriter.NewWriter(Stdout, 5, 3, 2, ' ', 0) + defer w.Flush() + + // FIXME: this will not currently list the repairs that are + // skipped because of e.g. wrong architecture + + // directory structure is: + // var/lib/snapd/run/repairs/ + // canonical/ + // 1/ + // r0.retry + // r0.script + // r1.done + // r1.script + // 2/ + // r3.done + // r3.script + repairTraces, err := newRepairTraces("*", "*") + if err != nil { + return err + } + if len(repairTraces) == 0 { + fmt.Fprintf(Stderr, "no repairs yet\n") + return nil + } + + fmt.Fprintf(w, "Repair\tRev\tStatus\tSummary\n") + for _, t := range repairTraces { + fmt.Fprintf(w, "%s\t%v\t%s\t%s\n", t.Repair(), t.Revision(), t.Status(), t.Summary()) + } + + return nil +} diff --git a/cmd/snap-repair/cmd_list_test.go b/cmd/snap-repair/cmd_list_test.go new file mode 100644 index 00000000..6f4e992d --- /dev/null +++ b/cmd/snap-repair/cmd_list_test.go @@ -0,0 +1,47 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + . "gopkg.in/check.v1" + + repair "github.com/snapcore/snapd/cmd/snap-repair" +) + +func (r *repairSuite) TestListNoRepairsYet(c *C) { + err := repair.ParseArgs([]string{"list"}) + c.Check(err, IsNil) + c.Check(r.Stdout(), Equals, "") + c.Check(r.Stderr(), Equals, "no repairs yet\n") +} + +func (r *repairSuite) TestListRepairsSimple(c *C) { + makeMockRepairState(c) + + err := repair.ParseArgs([]string{"list"}) + c.Check(err, IsNil) + c.Check(r.Stdout(), Equals, `Repair Rev Status Summary +canonical-1 3 retry repair one +my-brand-1 1 done my-brand repair one +my-brand-2 2 skip my-brand repair two +my-brand-3 0 running my-brand repair three +`) + c.Check(r.Stderr(), Equals, "") +} diff --git a/cmd/snap-repair/cmd_run.go b/cmd/snap-repair/cmd_run.go new file mode 100644 index 00000000..19d75dbe --- /dev/null +++ b/cmd/snap-repair/cmd_run.go @@ -0,0 +1,123 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2017-2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/logger" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/snapdenv" +) + +func init() { + const ( + short = "Fetch and run repair assertions as necessary for the device" + long = "" + ) + + if _, err := parser.AddCommand("run", short, long, &cmdRun{}); err != nil { + panic(err) + } + +} + +type cmdRun struct{} + +var baseURL *url.URL + +func init() { + var baseurl string + if snapdenv.UseStagingStore() { + baseurl = "https://api.staging.snapcraft.io/v2/" + } else { + baseurl = "https://api.snapcraft.io/v2/" + } + + // allow redirecting assertion requests under a different base url + if forcedURL := os.Getenv("SNAPPY_FORCE_SAS_URL"); forcedURL != "" { + baseurl = forcedURL + } + + var err error + baseURL, err = url.Parse(baseurl) + if err != nil { + panic(fmt.Sprintf("cannot setup base url: %v", err)) + } +} + +var rootBrandIDs = []string{"canonical"} + +func (c *cmdRun) Execute(args []string) error { + if err := os.MkdirAll(dirs.SnapRunRepairDir, 0755); err != nil { + return err + } + flock, err := osutil.NewFileLock(filepath.Join(dirs.SnapRunRepairDir, "lock")) + if err != nil { + return err + } + err = flock.TryLock() + if err == osutil.ErrAlreadyLocked { + return fmt.Errorf("cannot run, another snap-repair run already executing") + } + if err != nil { + return err + } + defer flock.Unlock() + + run := NewRunner() + run.BaseURL = baseURL + err = run.LoadState() + if err != nil { + return err + } + + for _, rootRepairBrandID := range rootBrandIDs { + for { + repair, err := run.Next(rootRepairBrandID) + if err == ErrRepairNotFound { + // no more repairs + break + } + + // if the store is offline, we want the unit to succeed and not + // report failures + if errors.Is(err, errStoreOffline) { + logger.NoGuardDebugf("running snap repair: %v", err) + return nil + } + + if err != nil { + return err + } + + if err := repair.Run(); err != nil { + return err + } + } + } + + return nil +} diff --git a/cmd/snap-repair/cmd_run_test.go b/cmd/snap-repair/cmd_run_test.go new file mode 100644 index 00000000..be345280 --- /dev/null +++ b/cmd/snap-repair/cmd_run_test.go @@ -0,0 +1,121 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "encoding/json" + "os" + "path/filepath" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/asserts/sysdb" + repair "github.com/snapcore/snapd/cmd/snap-repair" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/release" +) + +func (r *repairSuite) TestNonRoot(c *C) { + restore := repair.MockOsGetuid(func() int { return 1000 }) + defer restore() + restore = release.MockOnClassic(false) + defer restore() + + origArgs := os.Args + defer func() { os.Args = origArgs }() + os.Args = []string{"snap-repair", "run"} + err := repair.Run() + c.Assert(err, ErrorMatches, "must be run as root") +} + +func (r *repairSuite) TestOffline(c *C) { + restore := repair.MockOsGetuid(func() int { return 0 }) + defer restore() + restore = release.MockOnClassic(false) + defer restore() + + r.freshState(c) + + data, err := json.Marshal(repair.RepairConfig{ + StoreOffline: true, + }) + c.Assert(err, IsNil) + + err = os.MkdirAll(filepath.Dir(dirs.SnapRepairConfigFile), 0755) + c.Assert(err, IsNil) + + err = osutil.AtomicWriteFile(dirs.SnapRepairConfigFile, data, 0644, 0) + c.Assert(err, IsNil) + + origArgs := os.Args + defer func() { os.Args = origArgs }() + os.Args = []string{"snap-repair", "run"} + err = repair.Run() + c.Assert(err, IsNil) +} + +func (r *repairSuite) TestRun(c *C) { + restore := repair.MockOsGetuid(func() int { return 0 }) + defer restore() + restore = release.MockOnClassic(false) + defer restore() + + r1 := sysdb.InjectTrusted(r.storeSigning.Trusted) + defer r1() + r2 := repair.MockTrustedRepairRootKeys([]*asserts.AccountKey{r.repairRootAcctKey}) + defer r2() + + r.freshState(c) + + const script = `#!/bin/sh +echo "happy output" +echo "done" >&$SNAP_REPAIR_STATUS_FD +exit 0 +` + seqRepairs := r.signSeqRepairs(c, []string{makeMockRepair(script)}) + mockServer := makeMockServer(c, &seqRepairs, false) + defer mockServer.Close() + + repair.MockBaseURL(mockServer.URL) + + origArgs := os.Args + defer func() { os.Args = origArgs }() + os.Args = []string{"snap-repair", "run"} + err := repair.Run() + c.Check(err, IsNil) + c.Check(r.Stdout(), HasLen, 0) + + c.Check(osutil.FileExists(filepath.Join(dirs.SnapRepairRunDir, "canonical", "1", "r0.done")), Equals, true) +} + +func (r *repairSuite) TestRunAlreadyLocked(c *C) { + err := os.MkdirAll(dirs.SnapRunRepairDir, 0700) + c.Assert(err, IsNil) + flock, err := osutil.NewFileLock(filepath.Join(dirs.SnapRunRepairDir, "lock")) + c.Assert(err, IsNil) + err = flock.Lock() + c.Assert(err, IsNil) + defer flock.Close() // Close unlocks too + + err = repair.ParseArgs([]string{"run"}) + c.Check(err, ErrorMatches, `cannot run, another snap-repair run already executing`) +} diff --git a/cmd/snap-repair/cmd_show.go b/cmd/snap-repair/cmd_show.go new file mode 100644 index 00000000..c95d2a91 --- /dev/null +++ b/cmd/snap-repair/cmd_show.go @@ -0,0 +1,91 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "fmt" + "io" + "strings" +) + +func init() { + const ( + short = "Shows specific repairs run on this device" + long = "" + ) + + if _, err := parser.AddCommand("show", short, long, &cmdShow{}); err != nil { + panic(err) + } + +} + +type cmdShow struct { + Positional struct { + Repair []string `positional-arg-name:""` + } `positional-args:"yes"` +} + +func showRepairDetails(w io.Writer, repair string) error { + i := strings.LastIndex(repair, "-") + if i < 0 { + return fmt.Errorf("cannot parse repair %q", repair) + } + brand := repair[:i] + seq := repair[i+1:] + + repairTraces, err := newRepairTraces(brand, seq) + if err != nil { + return err + } + if len(repairTraces) == 0 { + return fmt.Errorf("cannot find repair \"%s-%s\"", brand, seq) + } + + for _, trace := range repairTraces { + fmt.Fprintf(w, "repair: %s\n", trace.Repair()) + fmt.Fprintf(w, "revision: %s\n", trace.Revision()) + fmt.Fprintf(w, "status: %s\n", trace.Status()) + fmt.Fprintf(w, "summary: %s\n", trace.Summary()) + + fmt.Fprintf(w, "script:\n") + if err := trace.WriteScriptIndented(w, 2); err != nil { + fmt.Fprintf(w, "%serror: %s\n", indentPrefix(2), err) + } + + fmt.Fprintf(w, "output:\n") + if err := trace.WriteOutputIndented(w, 2); err != nil { + fmt.Fprintf(w, "%serror: %s\n", indentPrefix(2), err) + } + } + + return nil +} + +func (c *cmdShow) Execute([]string) error { + for _, repair := range c.Positional.Repair { + if err := showRepairDetails(Stdout, repair); err != nil { + return err + } + fmt.Fprintf(Stdout, "\n") + } + + return nil +} diff --git a/cmd/snap-repair/cmd_show_test.go b/cmd/snap-repair/cmd_show_test.go new file mode 100644 index 00000000..f8095084 --- /dev/null +++ b/cmd/snap-repair/cmd_show_test.go @@ -0,0 +1,145 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "fmt" + "os" + "path/filepath" + + . "gopkg.in/check.v1" + + repair "github.com/snapcore/snapd/cmd/snap-repair" + "github.com/snapcore/snapd/dirs" +) + +func (r *repairSuite) TestShowRepairSingle(c *C) { + makeMockRepairState(c) + + // repair.ParseArgs() always appends to its internal slice: + // cmdShow.Positional.Repair. To workaround this we create a + // new cmdShow here + err := repair.NewCmdShow("canonical-1").Execute(nil) + c.Check(err, IsNil) + c.Check(r.Stdout(), Equals, `repair: canonical-1 +revision: 3 +status: retry +summary: repair one +script: + #!/bin/sh + echo retry output +output: + retry output + +`) + +} + +func (r *repairSuite) TestShowRepairMultiple(c *C) { + makeMockRepairState(c) + + // repair.ParseArgs() always appends to its internal slice: + // cmdShow.Positional.Repair. To workaround this we create a + // new cmdShow here + err := repair.NewCmdShow("canonical-1", "my-brand-1", "my-brand-2").Execute(nil) + c.Check(err, IsNil) + c.Check(r.Stdout(), Equals, `repair: canonical-1 +revision: 3 +status: retry +summary: repair one +script: + #!/bin/sh + echo retry output +output: + retry output + +repair: my-brand-1 +revision: 1 +status: done +summary: my-brand repair one +script: + #!/bin/sh + echo done output +output: + done output + +repair: my-brand-2 +revision: 2 +status: skip +summary: my-brand repair two +script: + #!/bin/sh + echo skip output +output: + skip output + +`) +} + +func (r *repairSuite) TestShowRepairErrorNoRepairDir(c *C) { + dirs.SetRootDir(c.MkDir()) + + err := repair.NewCmdShow("canonical-1").Execute(nil) + c.Check(err, ErrorMatches, `cannot find repair "canonical-1"`) +} + +func (r *repairSuite) TestShowRepairSingleWithoutScript(c *C) { + makeMockRepairState(c) + scriptPath := filepath.Join(dirs.SnapRepairRunDir, "canonical/1", "r3.script") + err := os.Remove(scriptPath) + c.Assert(err, IsNil) + + err = repair.NewCmdShow("canonical-1").Execute(nil) + c.Check(err, IsNil) + c.Check(r.Stdout(), Equals, fmt.Sprintf(`repair: canonical-1 +revision: 3 +status: retry +summary: repair one +script: + error: open %s: no such file or directory +output: + retry output + +`, scriptPath)) + +} + +func (r *repairSuite) TestShowRepairSingleUnreadableOutput(c *C) { + makeMockRepairState(c) + scriptPath := filepath.Join(dirs.SnapRepairRunDir, "canonical/1", "r3.retry") + err := os.Chmod(scriptPath, 0000) + c.Assert(err, IsNil) + defer os.Chmod(scriptPath, 0644) + + err = repair.NewCmdShow("canonical-1").Execute(nil) + c.Check(err, IsNil) + c.Check(r.Stdout(), Equals, fmt.Sprintf(`repair: canonical-1 +revision: 3 +status: retry +summary: - +script: + #!/bin/sh + echo retry output +output: + error: open %s: permission denied + +`, scriptPath)) + +} diff --git a/cmd/snap-repair/export_test.go b/cmd/snap-repair/export_test.go new file mode 100644 index 00000000..be0f9783 --- /dev/null +++ b/cmd/snap-repair/export_test.go @@ -0,0 +1,148 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "net/url" + "time" + + "gopkg.in/retry.v1" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/httputil" +) + +var ( + ParseArgs = parseArgs + Run = run + ErrStoreOffline = errStoreOffline +) + +type RepairConfig = repairConfig + +func MockBaseURL(baseurl string) (restore func()) { + orig := baseURL + u, err := url.Parse(baseurl) + if err != nil { + panic(err) + } + baseURL = u + return func() { + baseURL = orig + } +} + +func MockFetchRetryStrategy(strategy retry.Strategy) (restore func()) { + originalFetchRetryStrategy := fetchRetryStrategy + fetchRetryStrategy = strategy + return func() { + fetchRetryStrategy = originalFetchRetryStrategy + } +} + +func MockPeekRetryStrategy(strategy retry.Strategy) (restore func()) { + originalPeekRetryStrategy := peekRetryStrategy + peekRetryStrategy = strategy + return func() { + peekRetryStrategy = originalPeekRetryStrategy + } +} + +func MockMaxRepairScriptSize(maxSize int) (restore func()) { + originalMaxSize := maxRepairScriptSize + maxRepairScriptSize = maxSize + return func() { + maxRepairScriptSize = originalMaxSize + } +} + +func MockTrustedRepairRootKeys(keys []*asserts.AccountKey) (restore func()) { + original := trustedRepairRootKeys + trustedRepairRootKeys = keys + return func() { + trustedRepairRootKeys = original + } +} + +func TrustedRepairRootKeys() []*asserts.AccountKey { + return trustedRepairRootKeys +} + +func (run *Runner) BrandModel() (brand, model string) { + return run.state.Device.Brand, run.state.Device.Model +} + +func (run *Runner) BaseMode() (base, mode string) { + return run.state.Device.Base, run.state.Device.Mode +} + +func (run *Runner) SetStateModified(modified bool) { + run.stateModified = modified +} + +func (run *Runner) SetBrandModel(brand, model string) { + run.state.Device.Brand = brand + run.state.Device.Model = model +} + +func (run *Runner) TimeLowerBound() time.Time { + return run.state.TimeLowerBound +} + +func (run *Runner) TLSTime() time.Time { + return httputil.BaseTransport(run.cli).TLSClientConfig.Time() +} + +func (run *Runner) Sequence(brand string) []*RepairState { + return run.state.Sequences[brand] +} + +func (run *Runner) SetSequence(brand string, sequence []*RepairState) { + if run.state.Sequences == nil { + run.state.Sequences = make(map[string][]*RepairState) + } + run.state.Sequences[brand] = sequence +} + +func MockDefaultRepairTimeout(d time.Duration) (restore func()) { + orig := defaultRepairTimeout + defaultRepairTimeout = d + return func() { + defaultRepairTimeout = orig + } +} + +func MockTimeNow(f func() time.Time) (restore func()) { + origTimeNow := timeNow + timeNow = f + return func() { timeNow = origTimeNow } +} + +func NewCmdShow(args ...string) *cmdShow { + cmdShow := &cmdShow{} + cmdShow.Positional.Repair = args + return cmdShow +} + +func MockOsGetuid(f func() int) (restore func()) { + origOsGetuid := osGetuid + osGetuid = f + return func() { osGetuid = origOsGetuid } +} diff --git a/cmd/snap-repair/main.go b/cmd/snap-repair/main.go new file mode 100644 index 00000000..7b057101 --- /dev/null +++ b/cmd/snap-repair/main.go @@ -0,0 +1,94 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2017-2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "fmt" + "io" + "os" + + // TODO: consider not using go-flags at all + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/logger" + "github.com/snapcore/snapd/release" + "github.com/snapcore/snapd/snapdenv" + "github.com/snapcore/snapd/snapdtool" +) + +var ( + Stdout io.Writer = os.Stdout + Stderr io.Writer = os.Stderr + + opts struct{} + parser *flags.Parser = flags.NewParser(&opts, flags.HelpFlag|flags.PassDoubleDash|flags.PassAfterNonOption) +) + +const ( + shortHelp = "Repair an Ubuntu Core system" + longHelp = ` +snap-repair is a tool to fetch and run repair assertions +which are used to do emergency repairs on the device. +` +) + +func init() { + err := logger.SimpleSetup() + if err != nil { + fmt.Fprintf(Stderr, "WARNING: failed to activate logging: %v\n", err) + } +} + +var errOnClassic = fmt.Errorf("cannot use snap-repair on a classic system") + +func main() { + if err := run(); err != nil { + fmt.Fprintf(Stderr, "error: %v\n", err) + if err != errOnClassic { + os.Exit(1) + } + } +} + +var osGetuid = os.Getuid + +func run() error { + if release.OnClassic { + return errOnClassic + } + if osGetuid() != 0 { + return fmt.Errorf("must be run as root") + } + snapdenv.SetUserAgentFromVersion(snapdtool.Version, nil, "snap-repair") + + if err := parseArgs(os.Args[1:]); err != nil { + return err + } + + return nil +} + +func parseArgs(args []string) error { + parser.ShortDescription = shortHelp + parser.LongDescription = longHelp + + _, err := parser.ParseArgs(args) + return err +} diff --git a/cmd/snap-repair/main_test.go b/cmd/snap-repair/main_test.go new file mode 100644 index 00000000..17e662d9 --- /dev/null +++ b/cmd/snap-repair/main_test.go @@ -0,0 +1,104 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2017-2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "bytes" + "testing" + + . "gopkg.in/check.v1" + + repair "github.com/snapcore/snapd/cmd/snap-repair" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/release" + "github.com/snapcore/snapd/snapdenv" + "github.com/snapcore/snapd/testutil" +) + +// Hook up check.v1 into the "go test" runner +func Test(t *testing.T) { TestingT(t) } + +type repairSuite struct { + testutil.BaseTest + baseRunnerSuite + + rootdir string + + stdout *bytes.Buffer + stderr *bytes.Buffer + + restore func() +} + +func (r *repairSuite) SetUpSuite(c *C) { + r.baseRunnerSuite.SetUpSuite(c) + r.restore = snapdenv.SetUserAgentFromVersion("", nil, "") +} + +func (r *repairSuite) TearDownSuite(c *C) { + r.restore() +} + +func (r *repairSuite) SetUpTest(c *C) { + r.BaseTest.SetUpTest(c) + r.baseRunnerSuite.SetUpTest(c) + + r.stdout = bytes.NewBuffer(nil) + r.stderr = bytes.NewBuffer(nil) + + oldStdout := repair.Stdout + r.AddCleanup(func() { repair.Stdout = oldStdout }) + repair.Stdout = r.stdout + + oldStderr := repair.Stderr + r.AddCleanup(func() { repair.Stderr = oldStderr }) + repair.Stderr = r.stderr + + r.rootdir = c.MkDir() + dirs.SetRootDir(r.rootdir) + r.AddCleanup(func() { dirs.SetRootDir("/") }) +} + +func (r *repairSuite) TearDownTest(c *C) { + r.baseRunnerSuite.TearDownTest(c) + r.BaseTest.TearDownTest(c) +} + +func (r *repairSuite) Stdout() string { + return r.stdout.String() +} + +func (r *repairSuite) Stderr() string { + return r.stderr.String() +} + +var _ = Suite(&repairSuite{}) + +func (r *repairSuite) TestUnknownArg(c *C) { + err := repair.ParseArgs([]string{}) + c.Check(err, ErrorMatches, "Please specify one command of: list, run or show") +} + +func (r *repairSuite) TestRunOnClassic(c *C) { + defer release.MockOnClassic(true)() + + err := repair.Run() + c.Check(err, ErrorMatches, "cannot use snap-repair on a classic system") +} diff --git a/cmd/snap-repair/runner.go b/cmd/snap-repair/runner.go new file mode 100644 index 00000000..e14225ef --- /dev/null +++ b/cmd/snap-repair/runner.go @@ -0,0 +1,1156 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2017-2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "bufio" + "bytes" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" + + "github.com/mvo5/goconfigparser" + "gopkg.in/retry.v1" + + "github.com/snapcore/snapd/arch" + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/asserts/sysdb" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/httputil" + "github.com/snapcore/snapd/logger" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/release" + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/snapdenv" + "github.com/snapcore/snapd/strutil" +) + +var ( + // TODO: move inside the repairs themselves? + defaultRepairTimeout = 30 * time.Minute +) + +// Repair is a runnable repair. +type Repair struct { + *asserts.Repair + + run *Runner + sequence int +} + +func (r *Repair) RunDir() string { + return filepath.Join(dirs.SnapRepairRunDir, r.BrandID(), strconv.Itoa(r.RepairID())) +} + +func (r *Repair) String() string { + return fmt.Sprintf("%s-%v", r.BrandID(), r.RepairID()) +} + +// SetStatus sets the status of the repair in the state and saves the latter. +func (r *Repair) SetStatus(status RepairStatus) { + brandID := r.BrandID() + cur := *r.run.state.Sequences[brandID][r.sequence-1] + cur.Status = status + r.run.setRepairState(brandID, cur) + r.run.SaveState() +} + +// makeRepairSymlink ensures $dir/repair exists and is a symlink to +// /usr/lib/snapd/snap-repair +func makeRepairSymlink(dir string) (err error) { + // make "repair" binary available to the repair scripts via symlink + // to the real snap-repair + if err = os.MkdirAll(dir, 0755); err != nil { + return err + } + + old := filepath.Join(dirs.CoreLibExecDir, "snap-repair") + new := filepath.Join(dir, "repair") + if err := os.Symlink(old, new); err != nil && !os.IsExist(err) { + return err + } + + return nil +} + +// Run executes the repair script leaving execution trail files on disk. +func (r *Repair) Run() error { + // write the script to disk + rundir := r.RunDir() + err := os.MkdirAll(rundir, 0775) + if err != nil { + return err + } + + // ensure the script can use "repair done" + repairToolsDir := filepath.Join(dirs.SnapRunRepairDir, "tools") + if err := makeRepairSymlink(repairToolsDir); err != nil { + return err + } + + baseName := fmt.Sprintf("r%d", r.Revision()) + script := filepath.Join(rundir, baseName+".script") + err = osutil.AtomicWriteFile(script, r.Body(), 0700, 0) + if err != nil { + return err + } + + logPath := filepath.Join(rundir, baseName+".running") + logf, err := os.OpenFile(logPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return err + } + defer logf.Close() + + fmt.Fprintf(logf, "repair: %s\n", r) + fmt.Fprintf(logf, "revision: %d\n", r.Revision()) + fmt.Fprintf(logf, "summary: %s\n", r.Summary()) + fmt.Fprintf(logf, "output:\n") + + statusR, statusW, err := os.Pipe() + if err != nil { + return err + } + defer statusR.Close() + defer statusW.Close() + + logger.Debugf("executing %s", script) + + // run the script + env := os.Environ() + // we need to hardcode FD=3 because this is the FD after + // exec.Command() forked. there is no way in go currently + // to run something right after fork() in the child to + // know the fd. However because go will close all fds + // except the ones in "cmd.ExtraFiles" we are safe to set "3" + env = append(env, "SNAP_REPAIR_STATUS_FD=3") + env = append(env, "SNAP_REPAIR_RUN_DIR="+rundir) + // inject repairToolDir into PATH so that the script can use + // `repair {done,skip,retry}` + var havePath bool + for i, envStr := range env { + if strings.HasPrefix(envStr, "PATH=") { + newEnv := fmt.Sprintf("%s:%s", strings.TrimSuffix(envStr, ":"), repairToolsDir) + env[i] = newEnv + havePath = true + } + } + if !havePath { + env = append(env, "PATH=/usr/sbin:/usr/bin:/sbin:/bin:"+repairToolsDir) + } + + // TODO:UC20 what other details about recover mode should be included in the + // env for the repair assertion to read about? probably somethings related + // to degraded.json + if r.run.state.Device.Mode != "" { + env = append(env, fmt.Sprintf("SNAP_SYSTEM_MODE=%s", r.run.state.Device.Mode)) + } + + workdir := filepath.Join(rundir, "work") + if err := os.MkdirAll(workdir, 0700); err != nil { + return err + } + + cmd := exec.Command(script) + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Env = env + cmd.Dir = workdir + cmd.ExtraFiles = []*os.File{statusW} + cmd.Stdout = logf + cmd.Stderr = logf + if err = cmd.Start(); err != nil { + return err + } + statusW.Close() + + // wait for repair to finish or timeout + var scriptErr error + killTimerCh := time.After(defaultRepairTimeout) + doneCh := make(chan error, 1) + go func() { + doneCh <- cmd.Wait() + close(doneCh) + }() + select { + case scriptErr = <-doneCh: + // done + case <-killTimerCh: + if err := osutil.KillProcessGroup(cmd); err != nil { + logger.Noticef("cannot kill timed out repair %s: %s", r, err) + } + scriptErr = fmt.Errorf("repair did not finish within %s", defaultRepairTimeout) + } + // read repair status pipe, use the last value + status := readStatus(statusR) + statusPath := filepath.Join(rundir, baseName+"."+status.String()) + + // if the script had an error exit status still honor what we + // read from the status-pipe, however report the error + if scriptErr != nil { + // TODO: telemetry about errors here + scriptErr = fmt.Errorf("repair %s revision %d failed: %s", r, r.Revision(), scriptErr) + // ensure the error is present in the output log + fmt.Fprintf(logf, "\n%s", scriptErr) + } + if err := os.Rename(logPath, statusPath); err != nil { + return err + } + r.SetStatus(status) + + return nil +} + +func readStatus(r io.Reader) RepairStatus { + var status RepairStatus + scanner := bufio.NewScanner(r) + for scanner.Scan() { + switch strings.TrimSpace(scanner.Text()) { + case "done": + status = DoneStatus + // TODO: support having a script skip over many and up to a given repair-id # + case "skip": + status = SkipStatus + } + } + if scanner.Err() != nil { + return RetryStatus + } + return status +} + +// Runner implements fetching, tracking and running repairs. +type Runner struct { + BaseURL *url.URL + cli *http.Client + + state state + stateModified bool + + // sequenceNext keeps track of the next integer id in a brand sequence to considered in this run, see Next. + sequenceNext map[string]int +} + +// NewRunner returns a Runner. +func NewRunner() *Runner { + run := &Runner{ + sequenceNext: make(map[string]int), + } + opts := httputil.ClientOptions{ + MayLogBody: false, + ProxyConnectHeader: http.Header{"User-Agent": []string{snapdenv.UserAgent()}}, + TLSConfig: &tls.Config{ + Time: run.now, + }, + ExtraSSLCerts: &httputil.ExtraSSLCertsFromDir{ + Dir: dirs.SnapdStoreSSLCertsDir, + }, + } + run.cli = httputil.NewHTTPClient(&opts) + return run +} + +var ( + fetchRetryStrategy = retry.LimitCount(7, retry.LimitTime(90*time.Second, + retry.Exponential{ + Initial: 500 * time.Millisecond, + Factor: 2.5, + }, + )) + + peekRetryStrategy = retry.LimitCount(6, retry.LimitTime(44*time.Second, + retry.Exponential{ + Initial: 500 * time.Millisecond, + Factor: 2.5, + }, + )) +) + +var ( + ErrRepairNotFound = errors.New("repair not found") + ErrRepairNotModified = errors.New("repair was not modified") +) + +var ( + maxRepairScriptSize = 24 * 1024 * 1024 +) + +// repairConfig is a set of configuration data that is consumed by the +// snap-repair command. This struct is duplicated in o/c/configcore. +type repairConfig struct { + // StoreOffline is true if the store is marked as offline and should not be + // accessed. + StoreOffline bool `json:"store-offline"` +} + +func isStoreOffline(path string) bool { + f, err := os.Open(path) + if err != nil { + return false + } + defer f.Close() + + var repairConfig repairConfig + if err := json.NewDecoder(f).Decode(&repairConfig); err != nil { + return false + } + + return repairConfig.StoreOffline +} + +var errStoreOffline = errors.New("snap store is marked offline") + +// Fetch retrieves a stream with the repair with the given ids and any +// auxiliary assertions. If revision>=0 the request will include an +// If-None-Match header with an ETag for the revision, and +// ErrRepairNotModified is returned if the revision is still current. +func (run *Runner) Fetch(brandID string, repairID int, revision int) (*asserts.Repair, []asserts.Assertion, error) { + if isStoreOffline(dirs.SnapRepairConfigFile) { + return nil, nil, errStoreOffline + } + + u, err := run.BaseURL.Parse(fmt.Sprintf("repairs/%s/%d", brandID, repairID)) + if err != nil { + return nil, nil, err + } + + var r []asserts.Assertion + resp, err := httputil.RetryRequest(u.String(), func() (*http.Response, error) { + req, err := http.NewRequest("GET", u.String(), nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", snapdenv.UserAgent()) + req.Header.Set("Accept", "application/x.ubuntu.assertion") + if revision >= 0 { + req.Header.Set("If-None-Match", fmt.Sprintf(`"%d"`, revision)) + } + return run.cli.Do(req) + }, func(resp *http.Response) error { + if resp.StatusCode == 200 { + logger.Debugf("fetching repair %s-%d", brandID, repairID) + + // TODO: use something like TransferSpeedMonitoringWriter to avoid stalling here + // decode assertions + dec := asserts.NewDecoderWithTypeMaxBodySize(resp.Body, map[*asserts.AssertionType]int{ + asserts.RepairType: maxRepairScriptSize, + }) + for { + a, err := dec.Decode() + if err == io.EOF { + break + } + if err != nil { + return err + } + r = append(r, a) + } + if len(r) == 0 { + return io.ErrUnexpectedEOF + } + } + return nil + }, fetchRetryStrategy) + + if err != nil { + return nil, nil, err + } + + moveTimeLowerBound := true + defer func() { + if moveTimeLowerBound { + t, _ := http.ParseTime(resp.Header.Get("Date")) + run.moveTimeLowerBound(t) + } + }() + + switch resp.StatusCode { + case 200: + // ok + case 304: + // not modified + return nil, nil, ErrRepairNotModified + case 404: + return nil, nil, ErrRepairNotFound + default: + moveTimeLowerBound = false + return nil, nil, fmt.Errorf("cannot fetch repair, unexpected status %d", resp.StatusCode) + } + + repair, aux, err := checkStream(brandID, repairID, r) + if err != nil { + return nil, nil, fmt.Errorf("cannot fetch repair, %v", err) + } + + if repair.Revision() <= revision { + // this shouldn't happen but if it does we behave like + // all the rest of assertion infrastructure and ignore + // the now superseded revision + return nil, nil, ErrRepairNotModified + } + + return repair, aux, err +} + +func checkStream(brandID string, repairID int, r []asserts.Assertion) (repair *asserts.Repair, aux []asserts.Assertion, err error) { + if len(r) == 0 { + return nil, nil, fmt.Errorf("empty repair assertions stream") + } + var ok bool + repair, ok = r[0].(*asserts.Repair) + if !ok { + return nil, nil, fmt.Errorf("unexpected first assertion %q", r[0].Type().Name) + } + + if repair.BrandID() != brandID || repair.RepairID() != repairID { + return nil, nil, fmt.Errorf("repair id mismatch %s/%d != %s/%d", repair.BrandID(), repair.RepairID(), brandID, repairID) + } + + return repair, r[1:], nil +} + +type peekResp struct { + Headers map[string]interface{} `json:"headers"` +} + +// Peek retrieves the headers for the repair with the given ids. +func (run *Runner) Peek(brandID string, repairID int) (headers map[string]interface{}, err error) { + if isStoreOffline(dirs.SnapRepairConfigFile) { + return nil, errStoreOffline + } + + u, err := run.BaseURL.Parse(fmt.Sprintf("repairs/%s/%d", brandID, repairID)) + if err != nil { + return nil, err + } + + var rsp peekResp + + resp, err := httputil.RetryRequest(u.String(), func() (*http.Response, error) { + // TODO: setup a overall request timeout using contexts + // can be many minutes but not unlimited like now + req, err := http.NewRequest("GET", u.String(), nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", snapdenv.UserAgent()) + req.Header.Set("Accept", "application/json") + return run.cli.Do(req) + }, func(resp *http.Response) error { + rsp.Headers = nil + if resp.StatusCode == 200 { + dec := json.NewDecoder(resp.Body) + return dec.Decode(&rsp) + } + return nil + }, peekRetryStrategy) + + if err != nil { + return nil, err + } + + moveTimeLowerBound := true + defer func() { + if moveTimeLowerBound { + t, _ := http.ParseTime(resp.Header.Get("Date")) + run.moveTimeLowerBound(t) + } + }() + + switch resp.StatusCode { + case 200: + // ok + case 404: + return nil, ErrRepairNotFound + default: + moveTimeLowerBound = false + return nil, fmt.Errorf("cannot peek repair headers, unexpected status %d", resp.StatusCode) + } + + headers = rsp.Headers + if headers["brand-id"] != brandID || headers["repair-id"] != strconv.Itoa(repairID) { + return nil, fmt.Errorf("cannot peek repair headers, repair id mismatch %s/%s != %s/%d", headers["brand-id"], headers["repair-id"], brandID, repairID) + } + + return headers, nil +} + +// deviceInfo captures information about the device. +type deviceInfo struct { + Brand string `json:"brand"` + Model string `json:"model"` + Base string `json:"base"` + Mode string `json:"mode"` +} + +// RepairStatus represents the possible statuses of a repair. +type RepairStatus int + +const ( + RetryStatus RepairStatus = iota + SkipStatus + DoneStatus +) + +func (rs RepairStatus) String() string { + switch rs { + case RetryStatus: + return "retry" + case SkipStatus: + return "skip" + case DoneStatus: + return "done" + default: + return "unknown" + } +} + +// RepairState holds the current revision and status of a repair in a sequence of repairs. +type RepairState struct { + Sequence int `json:"sequence"` + Revision int `json:"revision"` + Status RepairStatus `json:"status"` +} + +// state holds the atomically updated control state of the runner with sequences of repairs and their states. +type state struct { + Device deviceInfo `json:"device"` + Sequences map[string][]*RepairState `json:"sequences,omitempty"` + TimeLowerBound time.Time `json:"time-lower-bound"` +} + +func (run *Runner) setRepairState(brandID string, state RepairState) { + if run.state.Sequences == nil { + run.state.Sequences = make(map[string][]*RepairState) + } + sequence := run.state.Sequences[brandID] + if state.Sequence > len(sequence) { + run.stateModified = true + run.state.Sequences[brandID] = append(sequence, &state) + } else if *sequence[state.Sequence-1] != state { + run.stateModified = true + sequence[state.Sequence-1] = &state + } +} + +func (run *Runner) readState() error { + r, err := os.Open(dirs.SnapRepairStateFile) + if err != nil { + return err + } + defer r.Close() + dec := json.NewDecoder(r) + return dec.Decode(&run.state) +} + +func (run *Runner) moveTimeLowerBound(t time.Time) { + if t.After(run.state.TimeLowerBound) { + run.stateModified = true + run.state.TimeLowerBound = t.UTC() + } +} + +var timeNow = time.Now + +func (run *Runner) now() time.Time { + now := timeNow().UTC() + if now.Before(run.state.TimeLowerBound) { + return run.state.TimeLowerBound + } + return now +} + +func (run *Runner) initState() error { + if err := os.MkdirAll(dirs.SnapRepairDir, 0775); err != nil { + return fmt.Errorf("cannot create repair state directory: %v", err) + } + // best-effort remove old + os.Remove(dirs.SnapRepairStateFile) + run.state = state{} + // initialize time lower bound with image built time/seed.yaml time + if err := run.findTimeLowerBound(); err != nil { + return err + } + // initialize device info + if err := run.initDeviceInfo(); err != nil { + return err + } + run.stateModified = true + return run.SaveState() +} + +func trustedBackstore(trusted []asserts.Assertion) asserts.Backstore { + trustedBS := asserts.NewMemoryBackstore() + for _, t := range trusted { + trustedBS.Put(t.Type(), t) + } + return trustedBS +} + +func checkAuthorityID(a asserts.Assertion, trusted asserts.Backstore) error { + assertType := a.Type() + if assertType != asserts.AccountKeyType && assertType != asserts.AccountType { + return nil + } + // check that account and account-key assertions are signed by + // a trusted authority + acctID := a.AuthorityID() + _, err := trusted.Get(asserts.AccountType, []string{acctID}, asserts.AccountType.MaxSupportedFormat()) + if err != nil && !errors.Is(err, &asserts.NotFoundError{}) { + return err + } + if errors.Is(err, &asserts.NotFoundError{}) { + return fmt.Errorf("%v not signed by trusted authority: %s", a.Ref(), acctID) + } + return nil +} + +func verifySignatures(a asserts.Assertion, workBS asserts.Backstore, trusted asserts.Backstore) error { + if err := checkAuthorityID(a, trusted); err != nil { + return err + } + acctKeyMaxSuppFormat := asserts.AccountKeyType.MaxSupportedFormat() + + seen := make(map[string]bool) + bottom := false + for !bottom { + u := a.Ref().Unique() + if seen[u] { + return fmt.Errorf("circular assertions") + } + seen[u] = true + signKey := []string{a.SignKeyID()} + key, err := trusted.Get(asserts.AccountKeyType, signKey, acctKeyMaxSuppFormat) + if err != nil && !errors.Is(err, &asserts.NotFoundError{}) { + return err + } + if err == nil { + bottom = true + } else { + key, err = workBS.Get(asserts.AccountKeyType, signKey, acctKeyMaxSuppFormat) + if err != nil && !errors.Is(err, &asserts.NotFoundError{}) { + return err + } + if errors.Is(err, &asserts.NotFoundError{}) { + return fmt.Errorf("cannot find public key %q", signKey[0]) + } + if err := checkAuthorityID(key, trusted); err != nil { + return err + } + } + if err := asserts.CheckSignature(a, key.(*asserts.AccountKey), nil, time.Time{}, time.Time{}); err != nil { + return err + } + a = key + } + return nil +} + +func (run *Runner) findTimeLowerBound() error { + timeLowerBoundSources := []string{ + // uc16 + filepath.Join(dirs.SnapSeedDir, "seed.yaml"), + // uc20+ + dirs.SnapModeenvFile, + } + // add all model files from uc20 seeds + allModels, err := filepath.Glob(filepath.Join(dirs.SnapSeedDir, "systems/*/model")) + if err != nil { + return err + } + timeLowerBoundSources = append(timeLowerBoundSources, allModels...) + + // use all files as potential time inputs + for _, p := range timeLowerBoundSources { + info, err := os.Stat(p) + if os.IsNotExist(err) { + continue + } + if err != nil { + return err + } + run.moveTimeLowerBound(info.ModTime()) + } + return nil +} + +func findBrandAndModel() (*deviceInfo, error) { + if osutil.FileExists(dirs.SnapModeenvFile) { + return findDevInfo20() + } + return findDevInfo16() +} + +func findDevInfo20() (*deviceInfo, error) { + cfg := goconfigparser.New() + cfg.AllowNoSectionHeader = true + if err := cfg.ReadFile(dirs.SnapModeenvFile); err != nil { + return nil, err + } + brandAndModel, err := cfg.Get("", "model") + if err != nil { + return nil, err + } + l := strings.SplitN(brandAndModel, "/", 2) + if len(l) != 2 { + return nil, fmt.Errorf("cannot find brand/model in modeenv model string %q", brandAndModel) + } + + mode, err := cfg.Get("", "mode") + if err != nil { + return nil, err + } + + baseName, err := cfg.Get("", "base") + if err != nil { + return nil, err + } + + baseSn, err := snap.ParsePlaceInfoFromSnapFileName(baseName) + if err != nil { + return nil, err + } + + return &deviceInfo{ + Brand: l[0], + Model: l[1], + Base: baseSn.SnapName(), + Mode: mode, + }, nil +} + +func findDevInfo16() (*deviceInfo, error) { + workBS := asserts.NewMemoryBackstore() + assertSeedDir := filepath.Join(dirs.SnapSeedDir, "assertions") + dc, err := os.ReadDir(assertSeedDir) + if err != nil { + return nil, err + } + var modelAs *asserts.Model + for _, fi := range dc { + fn := filepath.Join(assertSeedDir, fi.Name()) + f, err := os.Open(fn) + if err != nil { + // best effort + continue + } + dec := asserts.NewDecoder(f) + for { + a, err := dec.Decode() + if err != nil { + // best effort + break + } + switch a.Type() { + case asserts.ModelType: + if modelAs != nil { + return nil, fmt.Errorf("multiple models in seed assertions") + } + modelAs = a.(*asserts.Model) + case asserts.AccountType, asserts.AccountKeyType: + workBS.Put(a.Type(), a) + } + } + } + if modelAs == nil { + return nil, fmt.Errorf("no model assertion in seed data") + } + trustedBS := trustedBackstore(sysdb.Trusted()) + if err := verifySignatures(modelAs, workBS, trustedBS); err != nil { + return nil, err + } + acctPK := []string{modelAs.BrandID()} + acctMaxSupFormat := asserts.AccountType.MaxSupportedFormat() + acct, err := trustedBS.Get(asserts.AccountType, acctPK, acctMaxSupFormat) + if err != nil { + var err error + acct, err = workBS.Get(asserts.AccountType, acctPK, acctMaxSupFormat) + if err != nil { + return nil, fmt.Errorf("no brand account assertion in seed data") + } + } + if err := verifySignatures(acct, workBS, trustedBS); err != nil { + return nil, err + } + + // get the base snap as well, on uc16 it won't be specified in the model + // assertion and instead will be empty, so in this case we replace it with + // "core" + base := modelAs.Base() + if modelAs.Base() == "" { + base = "core" + } + + return &deviceInfo{ + Brand: modelAs.BrandID(), + Model: modelAs.Model(), + Base: base, + // Mode is unset on uc16/uc18 + }, nil +} + +func (run *Runner) initDeviceInfo() error { + dev, err := findBrandAndModel() + if err != nil { + return fmt.Errorf("cannot set device information: %v", err) + } + run.state.Device = *dev + + return nil +} + +// LoadState loads the repairs' state from disk, and (re)initializes it if it's missing or corrupted. +func (run *Runner) LoadState() error { + err := run.readState() + if err == nil { + return nil + } + // error => initialize from scratch + if !os.IsNotExist(err) { + logger.Noticef("cannor read repair state: %v", err) + } + return run.initState() +} + +// SaveState saves the repairs' state to disk. +func (run *Runner) SaveState() error { + if !run.stateModified { + return nil + } + m, err := json.Marshal(&run.state) + if err != nil { + return fmt.Errorf("cannot marshal repair state: %v", err) + } + err = osutil.AtomicWriteFile(dirs.SnapRepairStateFile, m, 0600, 0) + if err != nil { + return fmt.Errorf("cannot save repair state: %v", err) + } + run.stateModified = false + return nil +} + +func stringList(headers map[string]interface{}, name string) ([]string, error) { + v, ok := headers[name] + if !ok { + return nil, nil + } + l, ok := v.([]interface{}) + if !ok { + return nil, fmt.Errorf("header %q is not a list", name) + } + r := make([]string, len(l)) + for i, v := range l { + s, ok := v.(string) + if !ok { + return nil, fmt.Errorf("header %q contains non-string elements", name) + } + r[i] = s + } + return r, nil +} + +// Applicable returns whether a repair with the given headers is applicable to the device. +func (run *Runner) Applicable(headers map[string]interface{}) bool { + if headers["disabled"] == "true" { + return false + } + series, err := stringList(headers, "series") + if err != nil { + return false + } + if len(series) != 0 && !strutil.ListContains(series, release.Series) { + return false + } + archs, err := stringList(headers, "architectures") + if err != nil { + return false + } + if len(archs) != 0 && !strutil.ListContains(archs, arch.DpkgArchitecture()) { + return false + } + brandModel := fmt.Sprintf("%s/%s", run.state.Device.Brand, run.state.Device.Model) + models, err := stringList(headers, "models") + if err != nil { + return false + } + if len(models) != 0 && !strutil.ListContains(models, brandModel) { + // model prefix matching: brand/prefix* + hit := false + for _, patt := range models { + if strings.HasSuffix(patt, "*") && strings.ContainsRune(patt, '/') { + if strings.HasPrefix(brandModel, strings.TrimSuffix(patt, "*")) { + hit = true + break + } + } + } + if !hit { + return false + } + } + + // also filter by base snaps and modes + bases, err := stringList(headers, "bases") + if err != nil { + return false + } + + if len(bases) != 0 && !strutil.ListContains(bases, run.state.Device.Base) { + return false + } + + modes, err := stringList(headers, "modes") + if err != nil { + return false + } + + // modes is slightly more nuanced, if the modes setting in the assertion + // header is unset, then it means it runs on all uc16/uc18 devices, but only + // during run mode on uc20 devices + if run.state.Device.Mode == "" { + // uc16 / uc18 device, the assertion is only applicable to us if modes + // is unset + if len(modes) != 0 { + return false + } + // else modes is unset and still applies to us + } else { + // uc20 device + switch { + case len(modes) == 0 && run.state.Device.Mode != "run": + // if modes is unset, then it is only applicable if we are + // in run mode + return false + case len(modes) != 0 && !strutil.ListContains(modes, run.state.Device.Mode): + // modes was specified and our current mode is not in the header, so + // not applicable to us + return false + } + // other cases are either that we are in run mode and modes is unset (in + // which case it is applicable) or modes is set to something with our + // current mode in the list (also in which case it is applicable) + } + + return true +} + +var errSkip = errors.New("repair unnecessary on this system") + +func (run *Runner) fetch(brandID string, repairID int) (repair *asserts.Repair, aux []asserts.Assertion, err error) { + headers, err := run.Peek(brandID, repairID) + if err != nil { + return nil, nil, err + } + if !run.Applicable(headers) { + return nil, nil, errSkip + } + return run.Fetch(brandID, repairID, -1) +} + +func (run *Runner) refetch(brandID string, repairID, revision int) (repair *asserts.Repair, aux []asserts.Assertion, err error) { + return run.Fetch(brandID, repairID, revision) +} + +func (run *Runner) saveStream(brandID string, repairID int, repair *asserts.Repair, aux []asserts.Assertion) error { + d := filepath.Join(dirs.SnapRepairAssertsDir, brandID, strconv.Itoa(repairID)) + err := os.MkdirAll(d, 0775) + if err != nil { + return err + } + buf := &bytes.Buffer{} + enc := asserts.NewEncoder(buf) + r := append([]asserts.Assertion{repair}, aux...) + for _, a := range r { + if err := enc.Encode(a); err != nil { + return fmt.Errorf("cannot encode repair assertions %s-%d for saving: %v", brandID, repairID, err) + } + } + p := filepath.Join(d, fmt.Sprintf("r%d.repair", r[0].Revision())) + return osutil.AtomicWriteFile(p, buf.Bytes(), 0600, 0) +} + +func (run *Runner) readSavedStream(brandID string, repairID, revision int) (repair *asserts.Repair, aux []asserts.Assertion, err error) { + d := filepath.Join(dirs.SnapRepairAssertsDir, brandID, strconv.Itoa(repairID)) + p := filepath.Join(d, fmt.Sprintf("r%d.repair", revision)) + f, err := os.Open(p) + if err != nil { + return nil, nil, err + } + defer f.Close() + + dec := asserts.NewDecoder(f) + var r []asserts.Assertion + for { + a, err := dec.Decode() + if err == io.EOF { + break + } + if err != nil { + return nil, nil, fmt.Errorf("cannot decode repair assertions %s-%d from disk: %v", brandID, repairID, err) + } + r = append(r, a) + } + return checkStream(brandID, repairID, r) +} + +func (run *Runner) makeReady(brandID string, sequenceNext int) (repair *asserts.Repair, err error) { + sequence := run.state.Sequences[brandID] + var aux []asserts.Assertion + var state RepairState + if sequenceNext <= len(sequence) { + // consider retries + state = *sequence[sequenceNext-1] + if state.Status != RetryStatus { + return nil, errSkip + } + var err error + repair, aux, err = run.refetch(brandID, state.Sequence, state.Revision) + if err != nil { + if err != ErrRepairNotModified { + logger.Noticef("cannot refetch repair %s-%d, will retry what is on disk: %v", brandID, sequenceNext, err) + } + // try to use what we have already on disk + repair, aux, err = run.readSavedStream(brandID, state.Sequence, state.Revision) + if err != nil { + return nil, err + } + } + } else { + // fetch the next repair in the sequence + // assumes no gaps, each repair id is present so far, + // possibly skipped + var err error + repair, aux, err = run.fetch(brandID, sequenceNext) + if err != nil && err != errSkip { + return nil, err + } + state = RepairState{ + Sequence: sequenceNext, + } + if err == errSkip { + // TODO: store headers to justify decision + state.Status = SkipStatus + run.setRepairState(brandID, state) + return nil, errSkip + } + } + // verify with signatures + if err := run.Verify(repair, aux); err != nil { + return nil, fmt.Errorf("cannot verify repair %s-%d: %v", brandID, state.Sequence, err) + } + if err := run.saveStream(brandID, state.Sequence, repair, aux); err != nil { + return nil, err + } + state.Revision = repair.Revision() + if !run.Applicable(repair.Headers()) { + state.Status = SkipStatus + run.setRepairState(brandID, state) + return nil, errSkip + } + run.setRepairState(brandID, state) + return repair, nil +} + +// Next returns the next repair for the brand id sequence to run/retry or +// ErrRepairNotFound if there is none atm. It updates the state as required. +func (run *Runner) Next(brandID string) (*Repair, error) { + sequenceNext := run.sequenceNext[brandID] + if sequenceNext == 0 { + sequenceNext = 1 + } + for { + repair, err := run.makeReady(brandID, sequenceNext) + // SaveState is a no-op unless makeReady modified the state + stateErr := run.SaveState() + if err != nil && err != errSkip && err != ErrRepairNotFound { + // err is a non trivial error, just log the SaveState error and report err + if stateErr != nil { + logger.Noticef("%v", stateErr) + } + return nil, err + } + if stateErr != nil { + return nil, stateErr + } + if err == ErrRepairNotFound { + return nil, ErrRepairNotFound + } + + sequenceNext += 1 + run.sequenceNext[brandID] = sequenceNext + if err == errSkip { + continue + } + + return &Repair{ + Repair: repair, + run: run, + sequence: sequenceNext - 1, + }, nil + } +} + +// Limit trust to specific keys while there's no delegation or limited +// keys support. The obtained assertion stream may also include +// account keys that are directly or indirectly signed by a trusted +// key. +var ( + trustedRepairRootKeys []*asserts.AccountKey +) + +// Verify verifies that the repair is properly signed by the specific +// trusted root keys or by account keys in the stream (passed via aux) +// directly or indirectly signed by a trusted key. +func (run *Runner) Verify(repair *asserts.Repair, aux []asserts.Assertion) error { + workBS := asserts.NewMemoryBackstore() + for _, a := range aux { + if a.Type() != asserts.AccountKeyType { + continue + } + err := workBS.Put(asserts.AccountKeyType, a) + if err != nil { + return err + } + } + trustedBS := asserts.NewMemoryBackstore() + for _, t := range trustedRepairRootKeys { + trustedBS.Put(asserts.AccountKeyType, t) + } + for _, t := range sysdb.Trusted() { + // we do *not* add the defalt sysdb trusted account + // keys here because the repair assertions have their + // own *dedicated* root of trust + if t.Type() == asserts.AccountType { + trustedBS.Put(asserts.AccountType, t) + } + } + + return verifySignatures(repair, workBS, trustedBS) +} diff --git a/cmd/snap-repair/runner_test.go b/cmd/snap-repair/runner_test.go new file mode 100644 index 00000000..bf958326 --- /dev/null +++ b/cmd/snap-repair/runner_test.go @@ -0,0 +1,2408 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2017-2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + . "gopkg.in/check.v1" + "gopkg.in/retry.v1" + + "github.com/snapcore/snapd/arch" + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/asserts/assertstest" + "github.com/snapcore/snapd/asserts/sysdb" + "github.com/snapcore/snapd/boot" + repair "github.com/snapcore/snapd/cmd/snap-repair" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/logger" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/snapdenv" + "github.com/snapcore/snapd/testutil" +) + +type baseRunnerSuite struct { + testutil.BaseTest + + tmpdir string + + seedTime time.Time + t0 time.Time + + storeSigning *assertstest.StoreStack + + brandSigning *assertstest.SigningDB + brandAcct *asserts.Account + brandAcctKey *asserts.AccountKey + + modelAs *asserts.Model + + seedAssertsDir string + + repairRootAcctKey *asserts.AccountKey + repairsAcctKey *asserts.AccountKey + + repairsSigning *assertstest.SigningDB +} + +func makeReadOnly(c *C, dir string) (restore func()) { + // skip tests that need this because uid==0 does not honor + // write permissions in directories (yay, unix) + if os.Getuid() == 0 { + // FIXME: we could use osutil.Chattr() here + c.Skip("too lazy to make path readonly as root") + } + err := os.Chmod(dir, 0555) + c.Assert(err, IsNil) + return func() { + err := os.Chmod(dir, 0755) + c.Assert(err, IsNil) + } +} + +func (s *baseRunnerSuite) SetUpSuite(c *C) { + s.storeSigning = assertstest.NewStoreStack("canonical", nil) + + brandPrivKey, _ := assertstest.GenerateKey(752) + + s.brandAcct = assertstest.NewAccount(s.storeSigning, "my-brand", map[string]interface{}{ + "account-id": "my-brand", + }, "") + s.brandAcctKey = assertstest.NewAccountKey(s.storeSigning, s.brandAcct, nil, brandPrivKey.PublicKey(), "") + s.brandSigning = assertstest.NewSigningDB("my-brand", brandPrivKey) + + modelAs, err := s.brandSigning.Sign(asserts.ModelType, map[string]interface{}{ + "series": "16", + "brand-id": "my-brand", + "model": "my-model-2", + "architecture": "armhf", + "gadget": "gadget", + "kernel": "kernel", + "timestamp": time.Now().UTC().Format(time.RFC3339), + }, nil, "") + c.Assert(err, IsNil) + s.modelAs = modelAs.(*asserts.Model) + + repairRootKey, _ := assertstest.GenerateKey(1024) + + s.repairRootAcctKey = assertstest.NewAccountKey(s.storeSigning.RootSigning, s.storeSigning.TrustedAccount, nil, repairRootKey.PublicKey(), "") + + repairsKey, _ := assertstest.GenerateKey(752) + + repairRootSigning := assertstest.NewSigningDB("canonical", repairRootKey) + + s.repairsAcctKey = assertstest.NewAccountKey(repairRootSigning, s.storeSigning.TrustedAccount, nil, repairsKey.PublicKey(), "") + + s.repairsSigning = assertstest.NewSigningDB("canonical", repairsKey) +} + +func (s *baseRunnerSuite) SetUpTest(c *C) { + s.BaseTest.SetUpTest(c) + + _, restoreLogger := logger.MockLogger() + s.AddCleanup(restoreLogger) + + s.tmpdir = c.MkDir() + dirs.SetRootDir(s.tmpdir) + s.AddCleanup(func() { dirs.SetRootDir("/") }) +} + +func (s *baseRunnerSuite) signSeqRepairs(c *C, repairs []string) []string { + var seq []string + for _, rpr := range repairs { + decoded, err := asserts.Decode([]byte(rpr)) + c.Assert(err, IsNil) + signed, err := s.repairsSigning.Sign(asserts.RepairType, decoded.Headers(), decoded.Body(), "") + c.Assert(err, IsNil) + buf := &bytes.Buffer{} + enc := asserts.NewEncoder(buf) + enc.Encode(signed) + enc.Encode(s.repairsAcctKey) + seq = append(seq, buf.String()) + } + return seq +} + +func checkStateJSON(c *C, file string, exp map[string]interface{}) { + stateFile := map[string]interface{}{} + b, err := os.ReadFile(file) + c.Assert(err, IsNil) + err = json.Unmarshal(b, &stateFile) + c.Assert(err, IsNil) + c.Check(stateFile, DeepEquals, exp) +} + +func (s *baseRunnerSuite) freshState(c *C) { + // assume base: core18 + s.freshStateWithBaseAndMode(c, "core18", "") +} + +func (s *baseRunnerSuite) freshStateWithBaseAndMode(c *C, base, mode string) { + err := os.MkdirAll(dirs.SnapRepairDir, 0775) + c.Assert(err, IsNil) + stateJSON := map[string]interface{}{ + "device": map[string]string{ + "brand": "my-brand", + "model": "my-model", + "base": base, + "mode": mode, + }, + "time-lower-bound": "2017-08-11T15:49:49Z", + } + b, err := json.Marshal(stateJSON) + c.Assert(err, IsNil) + + err = os.WriteFile(dirs.SnapRepairStateFile, b, 0600) + c.Assert(err, IsNil) +} + +type runnerSuite struct { + baseRunnerSuite + + restore func() +} + +func (s *runnerSuite) SetUpSuite(c *C) { + s.baseRunnerSuite.SetUpSuite(c) + s.restore = snapdenv.SetUserAgentFromVersion("1", nil, "snap-repair") +} + +func (s *runnerSuite) TearDownSuite(c *C) { + s.restore() +} + +var _ = Suite(&runnerSuite{}) + +var ( + testKey = `type: account-key +authority-id: canonical +account-id: canonical +name: repair +public-key-sha3-384: KPIl7M4vQ9d4AUjkoU41TGAwtOMLc_bWUCeW8AvdRWD4_xcP60Oo4ABsFNo6BtXj +since: 2015-11-16T15:04:00Z +body-length: 149 +sign-key-sha3-384: Jv8_JiHiIzJVcO9M55pPdqSDWUvuhfDIBJUS-3VW7F_idjix7Ffn5qMxB21ZQuij + +AcZrBFaFwYABAvCX5A8dTcdLdhdiuy2YRHO5CAfM5InQefkKOhNMUq2yfi3Sk6trUHxskhZkPnm4 +NKx2yRr332q7AJXQHLX+DrZ29ycyoQ2NQGO3eAfQ0hjAAQFYBF8SSh5SutPu5XCVABEBAAE= + +AXNpZw== +` + + testRepair = `type: repair +authority-id: canonical +brand-id: canonical +repair-id: 2 +summary: repair two +architectures: + - amd64 + - arm64 +series: + - 16 +models: + - xyz/frobinator +timestamp: 2017-03-30T12:22:16Z +body-length: 7 +sign-key-sha3-384: KPIl7M4vQ9d4AUjkoU41TGAwtOMLc_bWUCeW8AvdRWD4_xcP60Oo4ABsFNo6BtXj + +script + + +AXNpZw== +` + testHeadersResp = `{"headers": +{"architectures":["amd64","arm64"],"authority-id":"canonical","body-length":"7","brand-id":"canonical","models":["xyz/frobinator"],"repair-id":"2","series":["16"],"sign-key-sha3-384":"KPIl7M4vQ9d4AUjkoU41TGAwtOMLc_bWUCeW8AvdRWD4_xcP60Oo4ABsFNo6BtXj","timestamp":"2017-03-30T12:22:16Z","type":"repair"}}` +) + +func mustParseURL(s string) *url.URL { + u, err := url.Parse(s) + if err != nil { + panic(err) + } + return u +} + +func (s *runnerSuite) mockBrokenTimeNowSetToEpoch(c *C, runner *repair.Runner) (restore func()) { + epoch := time.Unix(0, 0) + r := repair.MockTimeNow(func() time.Time { + return epoch + }) + c.Check(runner.TLSTime().Equal(epoch), Equals, true) + return r +} + +func (s *runnerSuite) checkBrokenTimeNowMitigated(c *C, runner *repair.Runner) { + c.Check(runner.TLSTime().Before(s.t0), Equals, false) +} + +func (s *runnerSuite) TestFetchJustRepair(c *C) { + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ua := r.Header.Get("User-Agent") + c.Check(strings.Contains(ua, "snap-repair"), Equals, true) + c.Check(r.Header.Get("Accept"), Equals, "application/x.ubuntu.assertion") + c.Check(r.URL.Path, Equals, "/repairs/canonical/2") + io.WriteString(w, testRepair) + })) + + c.Assert(mockServer, NotNil) + defer mockServer.Close() + + runner := repair.NewRunner() + runner.BaseURL = mustParseURL(mockServer.URL) + + r := s.mockBrokenTimeNowSetToEpoch(c, runner) + defer r() + + repair, aux, err := runner.Fetch("canonical", 2, -1) + c.Assert(err, IsNil) + c.Check(repair, NotNil) + c.Check(aux, HasLen, 0) + c.Check(repair.BrandID(), Equals, "canonical") + c.Check(repair.RepairID(), Equals, 2) + c.Check(repair.Body(), DeepEquals, []byte("script\n")) + + s.checkBrokenTimeNowMitigated(c, runner) +} + +func (s *runnerSuite) TestFetchScriptTooBig(c *C) { + restore := repair.MockMaxRepairScriptSize(4) + defer restore() + + n := 0 + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n++ + c.Check(r.Header.Get("Accept"), Equals, "application/x.ubuntu.assertion") + c.Check(r.URL.Path, Equals, "/repairs/canonical/2") + io.WriteString(w, testRepair) + })) + + c.Assert(mockServer, NotNil) + defer mockServer.Close() + + runner := repair.NewRunner() + runner.BaseURL = mustParseURL(mockServer.URL) + + _, _, err := runner.Fetch("canonical", 2, -1) + c.Assert(err, ErrorMatches, `assertion body length 7 exceeds maximum body size 4 for "repair".*`) + c.Assert(n, Equals, 1) +} + +var ( + testRetryStrategy = retry.LimitCount(5, retry.LimitTime(1*time.Second, + retry.Exponential{ + Initial: 1 * time.Millisecond, + Factor: 1, + }, + )) +) + +func (s *runnerSuite) TestFetch500(c *C) { + restore := repair.MockFetchRetryStrategy(testRetryStrategy) + defer restore() + + n := 0 + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n++ + w.WriteHeader(500) + })) + + c.Assert(mockServer, NotNil) + defer mockServer.Close() + + runner := repair.NewRunner() + runner.BaseURL = mustParseURL(mockServer.URL) + + _, _, err := runner.Fetch("canonical", 2, -1) + c.Assert(err, ErrorMatches, "cannot fetch repair, unexpected status 500") + c.Assert(n, Equals, 5) +} + +func (s *runnerSuite) TestFetchEmpty(c *C) { + restore := repair.MockFetchRetryStrategy(testRetryStrategy) + defer restore() + + n := 0 + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n++ + w.WriteHeader(200) + })) + + c.Assert(mockServer, NotNil) + defer mockServer.Close() + + runner := repair.NewRunner() + runner.BaseURL = mustParseURL(mockServer.URL) + + _, _, err := runner.Fetch("canonical", 2, -1) + c.Assert(err, Equals, io.ErrUnexpectedEOF) + c.Assert(n, Equals, 5) +} + +func (s *runnerSuite) TestFetchBroken(c *C) { + restore := repair.MockFetchRetryStrategy(testRetryStrategy) + defer restore() + + n := 0 + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n++ + w.WriteHeader(200) + io.WriteString(w, "xyz:") + })) + + c.Assert(mockServer, NotNil) + defer mockServer.Close() + + runner := repair.NewRunner() + runner.BaseURL = mustParseURL(mockServer.URL) + + _, _, err := runner.Fetch("canonical", 2, -1) + c.Assert(err, Equals, io.ErrUnexpectedEOF) + c.Assert(n, Equals, 5) +} + +func (s *runnerSuite) TestFetchNotFound(c *C) { + restore := repair.MockFetchRetryStrategy(testRetryStrategy) + defer restore() + + n := 0 + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n++ + w.WriteHeader(404) + })) + + c.Assert(mockServer, NotNil) + defer mockServer.Close() + + runner := repair.NewRunner() + runner.BaseURL = mustParseURL(mockServer.URL) + + r := s.mockBrokenTimeNowSetToEpoch(c, runner) + defer r() + + _, _, err := runner.Fetch("canonical", 2, -1) + c.Assert(err, Equals, repair.ErrRepairNotFound) + c.Assert(n, Equals, 1) + + s.checkBrokenTimeNowMitigated(c, runner) +} + +func (s *runnerSuite) TestFetchIfNoneMatchNotModified(c *C) { + restore := repair.MockFetchRetryStrategy(testRetryStrategy) + defer restore() + + n := 0 + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n++ + c.Check(r.Header.Get("If-None-Match"), Equals, `"0"`) + w.WriteHeader(304) + })) + + c.Assert(mockServer, NotNil) + defer mockServer.Close() + + runner := repair.NewRunner() + runner.BaseURL = mustParseURL(mockServer.URL) + + r := s.mockBrokenTimeNowSetToEpoch(c, runner) + defer r() + + _, _, err := runner.Fetch("canonical", 2, 0) + c.Assert(err, Equals, repair.ErrRepairNotModified) + c.Assert(n, Equals, 1) + + s.checkBrokenTimeNowMitigated(c, runner) +} + +func (s *runnerSuite) TestFetchIgnoreSupersededRevision(c *C) { + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + io.WriteString(w, testRepair) + })) + + c.Assert(mockServer, NotNil) + defer mockServer.Close() + + runner := repair.NewRunner() + runner.BaseURL = mustParseURL(mockServer.URL) + + _, _, err := runner.Fetch("canonical", 2, 2) + c.Assert(err, Equals, repair.ErrRepairNotModified) +} + +func (s *runnerSuite) TestFetchIdMismatch(c *C) { + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Check(r.Header.Get("Accept"), Equals, "application/x.ubuntu.assertion") + io.WriteString(w, testRepair) + })) + + c.Assert(mockServer, NotNil) + defer mockServer.Close() + + runner := repair.NewRunner() + runner.BaseURL = mustParseURL(mockServer.URL) + + _, _, err := runner.Fetch("canonical", 4, -1) + c.Assert(err, ErrorMatches, `cannot fetch repair, repair id mismatch canonical/2 != canonical/4`) +} + +func (s *runnerSuite) TestFetchWrongFirstType(c *C) { + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Check(r.Header.Get("Accept"), Equals, "application/x.ubuntu.assertion") + c.Check(r.URL.Path, Equals, "/repairs/canonical/2") + io.WriteString(w, testKey) + })) + + c.Assert(mockServer, NotNil) + defer mockServer.Close() + + runner := repair.NewRunner() + runner.BaseURL = mustParseURL(mockServer.URL) + + _, _, err := runner.Fetch("canonical", 2, -1) + c.Assert(err, ErrorMatches, `cannot fetch repair, unexpected first assertion "account-key"`) +} + +func (s *runnerSuite) TestFetchRepairPlusKey(c *C) { + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Check(r.Header.Get("Accept"), Equals, "application/x.ubuntu.assertion") + c.Check(r.URL.Path, Equals, "/repairs/canonical/2") + io.WriteString(w, testRepair) + io.WriteString(w, "\n") + io.WriteString(w, testKey) + })) + + c.Assert(mockServer, NotNil) + defer mockServer.Close() + + runner := repair.NewRunner() + runner.BaseURL = mustParseURL(mockServer.URL) + + repair, aux, err := runner.Fetch("canonical", 2, -1) + c.Assert(err, IsNil) + c.Check(repair, NotNil) + c.Check(aux, HasLen, 1) + _, ok := aux[0].(*asserts.AccountKey) + c.Check(ok, Equals, true) +} + +func (s *runnerSuite) TestPeek(c *C) { + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ua := r.Header.Get("User-Agent") + c.Check(strings.Contains(ua, "snap-repair"), Equals, true) + c.Check(r.Header.Get("Accept"), Equals, "application/json") + c.Check(r.URL.Path, Equals, "/repairs/canonical/2") + io.WriteString(w, testHeadersResp) + })) + + c.Assert(mockServer, NotNil) + defer mockServer.Close() + + runner := repair.NewRunner() + runner.BaseURL = mustParseURL(mockServer.URL) + + r := s.mockBrokenTimeNowSetToEpoch(c, runner) + defer r() + + h, err := runner.Peek("canonical", 2) + c.Assert(err, IsNil) + c.Check(h["series"], DeepEquals, []interface{}{"16"}) + c.Check(h["architectures"], DeepEquals, []interface{}{"amd64", "arm64"}) + c.Check(h["models"], DeepEquals, []interface{}{"xyz/frobinator"}) + + s.checkBrokenTimeNowMitigated(c, runner) +} + +func (s *runnerSuite) TestPeek500(c *C) { + restore := repair.MockPeekRetryStrategy(testRetryStrategy) + defer restore() + + n := 0 + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n++ + w.WriteHeader(500) + })) + + c.Assert(mockServer, NotNil) + defer mockServer.Close() + + runner := repair.NewRunner() + runner.BaseURL = mustParseURL(mockServer.URL) + + _, err := runner.Peek("canonical", 2) + c.Assert(err, ErrorMatches, "cannot peek repair headers, unexpected status 500") + c.Assert(n, Equals, 5) +} + +func (s *runnerSuite) TestPeekInvalid(c *C) { + restore := repair.MockPeekRetryStrategy(testRetryStrategy) + defer restore() + + n := 0 + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n++ + w.WriteHeader(200) + io.WriteString(w, "{") + })) + + c.Assert(mockServer, NotNil) + defer mockServer.Close() + + runner := repair.NewRunner() + runner.BaseURL = mustParseURL(mockServer.URL) + + _, err := runner.Peek("canonical", 2) + c.Assert(err, Equals, io.ErrUnexpectedEOF) + c.Assert(n, Equals, 5) +} + +func (s *runnerSuite) TestPeekNotFound(c *C) { + n := 0 + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n++ + w.WriteHeader(404) + })) + + c.Assert(mockServer, NotNil) + defer mockServer.Close() + + runner := repair.NewRunner() + runner.BaseURL = mustParseURL(mockServer.URL) + + r := s.mockBrokenTimeNowSetToEpoch(c, runner) + defer r() + + _, err := runner.Peek("canonical", 2) + c.Assert(err, Equals, repair.ErrRepairNotFound) + c.Assert(n, Equals, 1) + + s.checkBrokenTimeNowMitigated(c, runner) +} + +func (s *runnerSuite) TestPeekIdMismatch(c *C) { + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Check(r.Header.Get("Accept"), Equals, "application/json") + io.WriteString(w, testHeadersResp) + })) + + c.Assert(mockServer, NotNil) + defer mockServer.Close() + + runner := repair.NewRunner() + runner.BaseURL = mustParseURL(mockServer.URL) + + _, err := runner.Peek("canonical", 4) + c.Assert(err, ErrorMatches, `cannot peek repair headers, repair id mismatch canonical/2 != canonical/4`) +} + +func (s *runnerSuite) TestLoadState(c *C) { + s.freshState(c) + + runner := repair.NewRunner() + err := runner.LoadState() + c.Assert(err, IsNil) + brand, model := runner.BrandModel() + c.Check(brand, Equals, "my-brand") + c.Check(model, Equals, "my-model") +} + +func (s *runnerSuite) TestLoadStateInitStateFail(c *C) { + err := os.MkdirAll(dirs.SnapSeedDir, 0755) + c.Assert(err, IsNil) + + restore := makeReadOnly(c, filepath.Dir(dirs.SnapSeedDir)) + defer restore() + + runner := repair.NewRunner() + err = runner.LoadState() + c.Check(err, ErrorMatches, `cannot create repair state directory:.*`) +} + +func (s *runnerSuite) TestSaveStateFail(c *C) { + s.freshState(c) + + runner := repair.NewRunner() + err := runner.LoadState() + c.Assert(err, IsNil) + + restore := makeReadOnly(c, dirs.SnapRepairDir) + defer restore() + + // no error because this is a no-op + err = runner.SaveState() + c.Check(err, IsNil) + + // mark as modified + runner.SetStateModified(true) + + err = runner.SaveState() + c.Check(err, ErrorMatches, `cannot save repair state:.*`) +} + +func (s *runnerSuite) TestSaveState(c *C) { + s.freshState(c) + + runner := repair.NewRunner() + err := runner.LoadState() + c.Assert(err, IsNil) + + runner.SetSequence("canonical", []*repair.RepairState{ + {Sequence: 1, Revision: 3}, + }) + // mark as modified + runner.SetStateModified(true) + + err = runner.SaveState() + c.Assert(err, IsNil) + + exp := map[string]interface{}{ + "device": map[string]interface{}{ + "brand": "my-brand", + "model": "my-model", + "base": "core18", + "mode": "", + }, + "sequences": map[string]interface{}{ + "canonical": []interface{}{ + map[string]interface{}{ + // all json numbers are floats + "sequence": 1.0, + "revision": 3.0, + "status": 0.0, + }, + }, + }, + "time-lower-bound": "2017-08-11T15:49:49Z", + } + + checkStateJSON(c, dirs.SnapRepairStateFile, exp) +} + +type dev struct { + base string + mode string +} + +func (s *runnerSuite) TestApplicable(c *C) { + + scenarios := []struct { + device *dev + headers map[string]interface{} + applicable bool + }{ + {nil, nil, true}, + {nil, map[string]interface{}{"series": []interface{}{"18"}}, false}, + {nil, map[string]interface{}{"series": []interface{}{"18", "16"}}, true}, + {nil, map[string]interface{}{"series": "18"}, false}, + {nil, map[string]interface{}{"series": []interface{}{18}}, false}, + {nil, map[string]interface{}{"architectures": []interface{}{arch.DpkgArchitecture()}}, true}, + {nil, map[string]interface{}{"architectures": []interface{}{"other-arch"}}, false}, + {nil, map[string]interface{}{"architectures": []interface{}{"other-arch", arch.DpkgArchitecture()}}, true}, + {nil, map[string]interface{}{"architectures": arch.DpkgArchitecture()}, false}, + {nil, map[string]interface{}{"models": []interface{}{"my-brand/my-model"}}, true}, + {nil, map[string]interface{}{"models": []interface{}{"other-brand/other-model"}}, false}, + {nil, map[string]interface{}{"models": []interface{}{"other-brand/other-model", "my-brand/my-model"}}, true}, + {nil, map[string]interface{}{"models": "my-brand/my-model"}, false}, + // modes for uc16 / uc18 devices + {nil, map[string]interface{}{"modes": []interface{}{}}, true}, + {nil, map[string]interface{}{"modes": []interface{}{"run"}}, false}, + {nil, map[string]interface{}{"modes": []interface{}{"recover"}}, false}, + {nil, map[string]interface{}{"modes": []interface{}{"run", "recover"}}, false}, + // run mode for uc20 devices + {&dev{mode: "run"}, map[string]interface{}{"modes": []interface{}{}}, true}, + {&dev{mode: "run"}, map[string]interface{}{"modes": []interface{}{"run"}}, true}, + {&dev{mode: "run"}, map[string]interface{}{"modes": []interface{}{"recover"}}, false}, + {&dev{mode: "run"}, map[string]interface{}{"modes": []interface{}{"run", "recover"}}, true}, + // recover mode for uc20 devices + {&dev{mode: "recover"}, map[string]interface{}{"modes": []interface{}{}}, false}, + {&dev{mode: "recover"}, map[string]interface{}{"modes": []interface{}{"run"}}, false}, + {&dev{mode: "recover"}, map[string]interface{}{"modes": []interface{}{"recover"}}, true}, + {&dev{mode: "recover"}, map[string]interface{}{"modes": []interface{}{"run", "recover"}}, true}, + // bases for uc16 devices + {&dev{base: "core"}, map[string]interface{}{"bases": []interface{}{"core"}}, true}, + {&dev{base: "core"}, map[string]interface{}{"bases": []interface{}{"core18"}}, false}, + {&dev{base: "core"}, map[string]interface{}{"bases": []interface{}{"core", "core18"}}, true}, + // bases for uc18 devices + {&dev{base: "core18"}, map[string]interface{}{"bases": []interface{}{"core18"}}, true}, + {&dev{base: "core18"}, map[string]interface{}{"bases": []interface{}{"core"}}, false}, + {&dev{base: "core18"}, map[string]interface{}{"bases": []interface{}{"core", "core18"}}, true}, + // bases for uc20 devices + {&dev{base: "core20"}, map[string]interface{}{"bases": []interface{}{"core20"}}, true}, + {&dev{base: "core20"}, map[string]interface{}{"bases": []interface{}{"core"}}, false}, + {&dev{base: "core20"}, map[string]interface{}{"bases": []interface{}{"core", "core20"}}, true}, + // model prefix matches + {nil, map[string]interface{}{"models": []interface{}{"my-brand/*"}}, true}, + {nil, map[string]interface{}{"models": []interface{}{"my-brand/my-mod*"}}, true}, + {nil, map[string]interface{}{"models": []interface{}{"my-brand/xxx*"}}, false}, + {nil, map[string]interface{}{"models": []interface{}{"my-brand/my-mod*", "my-brand/xxx*"}}, true}, + {nil, map[string]interface{}{"models": []interface{}{"my*"}}, false}, + {nil, map[string]interface{}{"disabled": "true"}, false}, + {nil, map[string]interface{}{"disabled": "false"}, true}, + } + + for _, scen := range scenarios { + if scen.device == nil { + s.freshState(c) + } else { + s.freshStateWithBaseAndMode(c, scen.device.base, scen.device.mode) + } + + runner := repair.NewRunner() + err := runner.LoadState() + c.Assert(err, IsNil) + + ok := runner.Applicable(scen.headers) + c.Check(ok, Equals, scen.applicable, Commentf("%v", scen)) + } +} + +var ( + nextRepairs = []string{`type: repair +authority-id: canonical +brand-id: canonical +repair-id: 1 +summary: repair one +timestamp: 2017-07-01T12:00:00Z +body-length: 8 +sign-key-sha3-384: KPIl7M4vQ9d4AUjkoU41TGAwtOMLc_bWUCeW8AvdRWD4_xcP60Oo4ABsFNo6BtXj + +scriptA + + +AXNpZw==`, + `type: repair +authority-id: canonical +brand-id: canonical +repair-id: 2 +summary: repair two +series: + - 33 +timestamp: 2017-07-02T12:00:00Z +body-length: 8 +sign-key-sha3-384: KPIl7M4vQ9d4AUjkoU41TGAwtOMLc_bWUCeW8AvdRWD4_xcP60Oo4ABsFNo6BtXj + +scriptB + + +AXNpZw==`, + `type: repair +revision: 2 +authority-id: canonical +brand-id: canonical +repair-id: 3 +summary: repair three rev2 +series: + - 16 +timestamp: 2017-07-03T12:00:00Z +body-length: 8 +sign-key-sha3-384: KPIl7M4vQ9d4AUjkoU41TGAwtOMLc_bWUCeW8AvdRWD4_xcP60Oo4ABsFNo6BtXj + +scriptC + + +AXNpZw== +`} + + repair3Rev4 = `type: repair +revision: 4 +authority-id: canonical +brand-id: canonical +repair-id: 3 +summary: repair three rev4 +series: + - 16 +timestamp: 2017-07-03T12:00:00Z +body-length: 9 +sign-key-sha3-384: KPIl7M4vQ9d4AUjkoU41TGAwtOMLc_bWUCeW8AvdRWD4_xcP60Oo4ABsFNo6BtXj + +scriptC2 + + +AXNpZw== +` + + repair4 = `type: repair +authority-id: canonical +brand-id: canonical +repair-id: 4 +summary: repair four +timestamp: 2017-07-03T12:00:00Z +body-length: 8 +sign-key-sha3-384: KPIl7M4vQ9d4AUjkoU41TGAwtOMLc_bWUCeW8AvdRWD4_xcP60Oo4ABsFNo6BtXj + +scriptD + + +AXNpZw== +` +) + +func makeMockServer(c *C, seqRepairs *[]string, redirectFirst bool) *httptest.Server { + var mockServer *httptest.Server + mockServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ua := r.Header.Get("User-Agent") + c.Check(ua, testutil.Contains, "snap-repair") + + urlPath := r.URL.Path + if redirectFirst && r.Header.Get("Accept") == asserts.MediaType { + if !strings.HasPrefix(urlPath, "/final/") { + // redirect + finalURL := mockServer.URL + "/final" + r.URL.Path + w.Header().Set("Location", finalURL) + w.WriteHeader(302) + return + } + urlPath = strings.TrimPrefix(urlPath, "/final") + } + + c.Check(strings.HasPrefix(urlPath, "/repairs/canonical/"), Equals, true) + + seq, err := strconv.Atoi(strings.TrimPrefix(urlPath, "/repairs/canonical/")) + c.Assert(err, IsNil) + + if seq > len(*seqRepairs) { + w.WriteHeader(404) + return + } + + rpr := []byte((*seqRepairs)[seq-1]) + dec := asserts.NewDecoder(bytes.NewBuffer(rpr)) + repair, err := dec.Decode() + c.Assert(err, IsNil) + + switch r.Header.Get("Accept") { + case "application/json": + b, err := json.Marshal(map[string]interface{}{ + "headers": repair.Headers(), + }) + c.Assert(err, IsNil) + w.Write(b) + case asserts.MediaType: + etag := fmt.Sprintf(`"%d"`, repair.Revision()) + if strings.Contains(r.Header.Get("If-None-Match"), etag) { + w.WriteHeader(304) + return + } + w.Write(rpr) + } + })) + + c.Assert(mockServer, NotNil) + + return mockServer +} + +func (s *runnerSuite) TestTrustedRepairRootKeys(c *C) { + acctKeys := repair.TrustedRepairRootKeys() + c.Check(acctKeys, HasLen, 1) + c.Check(acctKeys[0].AccountID(), Equals, "canonical") + c.Check(acctKeys[0].PublicKeyID(), Equals, "nttW6NfBXI_E-00u38W-KH6eiksfQNXuI7IiumoV49_zkbhM0sYTzSnFlwZC-W4t") +} + +func (s *runnerSuite) TestVerify(c *C) { + r1 := sysdb.InjectTrusted(s.storeSigning.Trusted) + defer r1() + r2 := repair.MockTrustedRepairRootKeys([]*asserts.AccountKey{s.repairRootAcctKey}) + defer r2() + + runner := repair.NewRunner() + + a, err := s.repairsSigning.Sign(asserts.RepairType, map[string]interface{}{ + "brand-id": "canonical", + "repair-id": "2", + "summary": "repair two", + "timestamp": time.Now().UTC().Format(time.RFC3339), + }, []byte("#script"), "") + c.Assert(err, IsNil) + rpr := a.(*asserts.Repair) + + err = runner.Verify(rpr, []asserts.Assertion{s.repairsAcctKey}) + c.Check(err, IsNil) +} + +func (s *runnerSuite) loadSequences(c *C) map[string][]*repair.RepairState { + data, err := os.ReadFile(dirs.SnapRepairStateFile) + c.Assert(err, IsNil) + var x struct { + Sequences map[string][]*repair.RepairState `json:"sequences"` + } + err = json.Unmarshal(data, &x) + c.Assert(err, IsNil) + return x.Sequences +} + +func (s *runnerSuite) testNext(c *C, redirectFirst bool) { + r1 := sysdb.InjectTrusted(s.storeSigning.Trusted) + defer r1() + r2 := repair.MockTrustedRepairRootKeys([]*asserts.AccountKey{s.repairRootAcctKey}) + defer r2() + + seqRepairs := s.signSeqRepairs(c, nextRepairs) + + mockServer := makeMockServer(c, &seqRepairs, redirectFirst) + defer mockServer.Close() + + runner := repair.NewRunner() + runner.BaseURL = mustParseURL(mockServer.URL) + runner.LoadState() + + rpr, err := runner.Next("canonical") + c.Assert(err, IsNil) + c.Check(rpr.RepairID(), Equals, 1) + c.Check(osutil.FileExists(filepath.Join(dirs.SnapRepairAssertsDir, "canonical", "1", "r0.repair")), Equals, true) + + rpr, err = runner.Next("canonical") + c.Assert(err, IsNil) + c.Check(rpr.RepairID(), Equals, 3) + c.Check(filepath.Join(dirs.SnapRepairAssertsDir, "canonical", "3", "r2.repair"), testutil.FileEquals, seqRepairs[2]) + + // no more + rpr, err = runner.Next("canonical") + c.Check(err, Equals, repair.ErrRepairNotFound) + + expectedSeq := []*repair.RepairState{ + {Sequence: 1}, + {Sequence: 2, Status: repair.SkipStatus}, + {Sequence: 3, Revision: 2}, + } + c.Check(runner.Sequence("canonical"), DeepEquals, expectedSeq) + // on disk + seqs := s.loadSequences(c) + c.Check(seqs["canonical"], DeepEquals, expectedSeq) + + // start fresh run with new runner + // will refetch repair 3 + signed := s.signSeqRepairs(c, []string{repair3Rev4, repair4}) + seqRepairs[2] = signed[0] + seqRepairs = append(seqRepairs, signed[1]) + + runner = repair.NewRunner() + runner.BaseURL = mustParseURL(mockServer.URL) + runner.LoadState() + + rpr, err = runner.Next("canonical") + c.Assert(err, IsNil) + c.Check(rpr.RepairID(), Equals, 1) + + rpr, err = runner.Next("canonical") + c.Assert(err, IsNil) + c.Check(rpr.RepairID(), Equals, 3) + // refetched new revision! + c.Check(rpr.Revision(), Equals, 4) + c.Check(rpr.Body(), DeepEquals, []byte("scriptC2\n")) + + // new repair + rpr, err = runner.Next("canonical") + c.Assert(err, IsNil) + c.Check(rpr.RepairID(), Equals, 4) + c.Check(rpr.Body(), DeepEquals, []byte("scriptD\n")) + + // no more + rpr, err = runner.Next("canonical") + c.Check(err, Equals, repair.ErrRepairNotFound) + + c.Check(runner.Sequence("canonical"), DeepEquals, []*repair.RepairState{ + {Sequence: 1}, + {Sequence: 2, Status: repair.SkipStatus}, + {Sequence: 3, Revision: 4}, + {Sequence: 4}, + }) +} + +func (s *runnerSuite) TestNext(c *C) { + redirectFirst := false + s.testNext(c, redirectFirst) +} + +func (s *runnerSuite) TestNextRedirect(c *C) { + redirectFirst := true + s.testNext(c, redirectFirst) +} + +func (s *runnerSuite) TestNextImmediateSkip(c *C) { + seqRepairs := []string{`type: repair +authority-id: canonical +brand-id: canonical +repair-id: 1 +summary: repair one +series: + - 33 +timestamp: 2017-07-02T12:00:00Z +body-length: 8 +sign-key-sha3-384: KPIl7M4vQ9d4AUjkoU41TGAwtOMLc_bWUCeW8AvdRWD4_xcP60Oo4ABsFNo6BtXj + +scriptB + + +AXNpZw==`} + + mockServer := makeMockServer(c, &seqRepairs, false) + defer mockServer.Close() + + runner := repair.NewRunner() + runner.BaseURL = mustParseURL(mockServer.URL) + runner.LoadState() + + // not applicable => not returned + _, err := runner.Next("canonical") + c.Check(err, Equals, repair.ErrRepairNotFound) + + expectedSeq := []*repair.RepairState{ + {Sequence: 1, Status: repair.SkipStatus}, + } + c.Check(runner.Sequence("canonical"), DeepEquals, expectedSeq) + // on disk + seqs := s.loadSequences(c) + c.Check(seqs["canonical"], DeepEquals, expectedSeq) +} + +func (s *runnerSuite) TestNextRefetchSkip(c *C) { + seqRepairs := []string{`type: repair +authority-id: canonical +brand-id: canonical +repair-id: 1 +summary: repair one +series: + - 16 +timestamp: 2017-07-02T12:00:00Z +body-length: 8 +sign-key-sha3-384: KPIl7M4vQ9d4AUjkoU41TGAwtOMLc_bWUCeW8AvdRWD4_xcP60Oo4ABsFNo6BtXj + +scriptB + + +AXNpZw==`} + + r1 := sysdb.InjectTrusted(s.storeSigning.Trusted) + defer r1() + r2 := repair.MockTrustedRepairRootKeys([]*asserts.AccountKey{s.repairRootAcctKey}) + defer r2() + + seqRepairs = s.signSeqRepairs(c, seqRepairs) + + mockServer := makeMockServer(c, &seqRepairs, false) + defer mockServer.Close() + + runner := repair.NewRunner() + runner.BaseURL = mustParseURL(mockServer.URL) + runner.LoadState() + + _, err := runner.Next("canonical") + c.Assert(err, IsNil) + + expectedSeq := []*repair.RepairState{ + {Sequence: 1}, + } + c.Check(runner.Sequence("canonical"), DeepEquals, expectedSeq) + // on disk + seqs := s.loadSequences(c) + c.Check(seqs["canonical"], DeepEquals, expectedSeq) + + // new fresh run, repair becomes now unapplicable + seqRepairs[0] = `type: repair +authority-id: canonical +revision: 1 +brand-id: canonical +repair-id: 1 +summary: repair one rev1 +series: + - 16 +disabled: true +timestamp: 2017-07-02T12:00:00Z +body-length: 7 +sign-key-sha3-384: KPIl7M4vQ9d4AUjkoU41TGAwtOMLc_bWUCeW8AvdRWD4_xcP60Oo4ABsFNo6BtXj + +scriptX + +AXNpZw==` + + seqRepairs = s.signSeqRepairs(c, seqRepairs) + + runner = repair.NewRunner() + runner.BaseURL = mustParseURL(mockServer.URL) + runner.LoadState() + + _, err = runner.Next("canonical") + c.Check(err, Equals, repair.ErrRepairNotFound) + + expectedSeq = []*repair.RepairState{ + {Sequence: 1, Revision: 1, Status: repair.SkipStatus}, + } + c.Check(runner.Sequence("canonical"), DeepEquals, expectedSeq) + // on disk + seqs = s.loadSequences(c) + c.Check(seqs["canonical"], DeepEquals, expectedSeq) +} + +func (s *runnerSuite) TestNext500(c *C) { + restore := repair.MockPeekRetryStrategy(testRetryStrategy) + defer restore() + + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(500) + })) + + c.Assert(mockServer, NotNil) + defer mockServer.Close() + + runner := repair.NewRunner() + runner.BaseURL = mustParseURL(mockServer.URL) + runner.LoadState() + + _, err := runner.Next("canonical") + c.Assert(err, ErrorMatches, "cannot peek repair headers, unexpected status 500") +} + +func (s *runnerSuite) TestNextNotFound(c *C) { + s.freshState(c) + + restore := repair.MockPeekRetryStrategy(testRetryStrategy) + defer restore() + + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(404) + })) + + c.Assert(mockServer, NotNil) + defer mockServer.Close() + + runner := repair.NewRunner() + runner.BaseURL = mustParseURL(mockServer.URL) + runner.LoadState() + + _, err := runner.Next("canonical") + c.Assert(err, Equals, repair.ErrRepairNotFound) + + // we saved new time lower bound + stateFileExp := map[string]interface{}{ + "device": map[string]interface{}{ + "brand": "my-brand", + "model": "my-model", + "base": "core18", + "mode": "", + }, + "time-lower-bound": runner.TimeLowerBound().Format(time.RFC3339), + } + + checkStateJSON(c, dirs.SnapRepairStateFile, stateFileExp) +} + +func (s *runnerSuite) TestNextSaveStateError(c *C) { + seqRepairs := []string{`type: repair +authority-id: canonical +brand-id: canonical +repair-id: 1 +summary: repair one +series: + - 33 +timestamp: 2017-07-02T12:00:00Z +body-length: 8 +sign-key-sha3-384: KPIl7M4vQ9d4AUjkoU41TGAwtOMLc_bWUCeW8AvdRWD4_xcP60Oo4ABsFNo6BtXj + +scriptB + + +AXNpZw==`} + + mockServer := makeMockServer(c, &seqRepairs, false) + defer mockServer.Close() + + runner := repair.NewRunner() + runner.BaseURL = mustParseURL(mockServer.URL) + runner.LoadState() + + // break SaveState + restore := makeReadOnly(c, dirs.SnapRepairDir) + defer restore() + + _, err := runner.Next("canonical") + c.Check(err, ErrorMatches, `cannot save repair state:.*`) +} + +func (s *runnerSuite) TestNextVerifyNoKey(c *C) { + seqRepairs := []string{`type: repair +authority-id: canonical +brand-id: canonical +repair-id: 1 +summary: repair one +timestamp: 2017-07-02T12:00:00Z +body-length: 8 +sign-key-sha3-384: KPIl7M4vQ9d4AUjkoU41TGAwtOMLc_bWUCeW8AvdRWD4_xcP60Oo4ABsFNo6BtXj + +scriptB + + +AXNpZw==`} + + mockServer := makeMockServer(c, &seqRepairs, false) + defer mockServer.Close() + + runner := repair.NewRunner() + runner.BaseURL = mustParseURL(mockServer.URL) + runner.LoadState() + + _, err := runner.Next("canonical") + c.Check(err, ErrorMatches, `cannot verify repair canonical-1: cannot find public key.*`) + + c.Check(runner.Sequence("canonical"), HasLen, 0) +} + +func (s *runnerSuite) TestNextVerifySelfSigned(c *C) { + randoKey, _ := assertstest.GenerateKey(752) + + randomSigning := assertstest.NewSigningDB("canonical", randoKey) + randoKeyEncoded, err := asserts.EncodePublicKey(randoKey.PublicKey()) + c.Assert(err, IsNil) + acctKey, err := randomSigning.Sign(asserts.AccountKeyType, map[string]interface{}{ + "authority-id": "canonical", + "account-id": "canonical", + "public-key-sha3-384": randoKey.PublicKey().ID(), + "name": "repairs", + "since": time.Now().UTC().Format(time.RFC3339), + }, randoKeyEncoded, "") + c.Assert(err, IsNil) + + rpr, err := randomSigning.Sign(asserts.RepairType, map[string]interface{}{ + "brand-id": "canonical", + "repair-id": "1", + "summary": "repair one", + "timestamp": time.Now().UTC().Format(time.RFC3339), + }, []byte("scriptB\n"), "") + c.Assert(err, IsNil) + + buf := &bytes.Buffer{} + enc := asserts.NewEncoder(buf) + enc.Encode(rpr) + enc.Encode(acctKey) + seqRepairs := []string{buf.String()} + + mockServer := makeMockServer(c, &seqRepairs, false) + defer mockServer.Close() + + runner := repair.NewRunner() + runner.BaseURL = mustParseURL(mockServer.URL) + runner.LoadState() + + _, err = runner.Next("canonical") + c.Check(err, ErrorMatches, `cannot verify repair canonical-1: circular assertions`) + + c.Check(runner.Sequence("canonical"), HasLen, 0) +} + +func (s *runnerSuite) TestNextVerifyAllKeysOK(c *C) { + r1 := sysdb.InjectTrusted(s.storeSigning.Trusted) + defer r1() + r2 := repair.MockTrustedRepairRootKeys([]*asserts.AccountKey{s.repairRootAcctKey}) + defer r2() + + decoded, err := asserts.Decode([]byte(nextRepairs[0])) + c.Assert(err, IsNil) + signed, err := s.repairsSigning.Sign(asserts.RepairType, decoded.Headers(), decoded.Body(), "") + c.Assert(err, IsNil) + + // stream with all keys (any order) works as well + buf := &bytes.Buffer{} + enc := asserts.NewEncoder(buf) + enc.Encode(signed) + enc.Encode(s.storeSigning.TrustedKey) + enc.Encode(s.repairRootAcctKey) + enc.Encode(s.repairsAcctKey) + seqRepairs := []string{buf.String()} + + mockServer := makeMockServer(c, &seqRepairs, false) + defer mockServer.Close() + + runner := repair.NewRunner() + runner.BaseURL = mustParseURL(mockServer.URL) + runner.LoadState() + + rpr, err := runner.Next("canonical") + c.Assert(err, IsNil) + c.Check(rpr.RepairID(), Equals, 1) +} + +func (s *runnerSuite) TestRepairSetStatus(c *C) { + seqRepairs := []string{`type: repair +authority-id: canonical +brand-id: canonical +repair-id: 1 +summary: repair one +timestamp: 2017-07-02T12:00:00Z +body-length: 8 +sign-key-sha3-384: KPIl7M4vQ9d4AUjkoU41TGAwtOMLc_bWUCeW8AvdRWD4_xcP60Oo4ABsFNo6BtXj + +scriptB + + +AXNpZw==`} + + r1 := sysdb.InjectTrusted(s.storeSigning.Trusted) + defer r1() + r2 := repair.MockTrustedRepairRootKeys([]*asserts.AccountKey{s.repairRootAcctKey}) + defer r2() + + seqRepairs = s.signSeqRepairs(c, seqRepairs) + + mockServer := makeMockServer(c, &seqRepairs, false) + defer mockServer.Close() + + runner := repair.NewRunner() + runner.BaseURL = mustParseURL(mockServer.URL) + runner.LoadState() + + rpr, err := runner.Next("canonical") + c.Assert(err, IsNil) + + rpr.SetStatus(repair.DoneStatus) + + expectedSeq := []*repair.RepairState{ + {Sequence: 1, Status: repair.DoneStatus}, + } + c.Check(runner.Sequence("canonical"), DeepEquals, expectedSeq) + // on disk + seqs := s.loadSequences(c) + c.Check(seqs["canonical"], DeepEquals, expectedSeq) +} + +func (s *runnerSuite) TestRepairBasicRun(c *C) { + seqRepairs := []string{`type: repair +authority-id: canonical +brand-id: canonical +repair-id: 1 +summary: repair one +series: + - 16 +timestamp: 2017-07-02T12:00:00Z +body-length: 17 +sign-key-sha3-384: KPIl7M4vQ9d4AUjkoU41TGAwtOMLc_bWUCeW8AvdRWD4_xcP60Oo4ABsFNo6BtXj + +#!/bin/sh +exit 0 + + +AXNpZw==`} + + r1 := sysdb.InjectTrusted(s.storeSigning.Trusted) + defer r1() + r2 := repair.MockTrustedRepairRootKeys([]*asserts.AccountKey{s.repairRootAcctKey}) + defer r2() + + seqRepairs = s.signSeqRepairs(c, seqRepairs) + + mockServer := makeMockServer(c, &seqRepairs, false) + defer mockServer.Close() + + runner := repair.NewRunner() + runner.BaseURL = mustParseURL(mockServer.URL) + runner.LoadState() + + rpr, err := runner.Next("canonical") + c.Assert(err, IsNil) + + err = rpr.Run() + c.Assert(err, IsNil) + c.Check(filepath.Join(dirs.SnapRepairRunDir, "canonical", "1", "r0.script"), testutil.FileEquals, "#!/bin/sh\nexit 0\n") +} + +func (s *runnerSuite) TestRepairBasicRun20RecoverEnv(c *C) { + seqRepairs := []string{`type: repair +authority-id: canonical +brand-id: canonical +repair-id: 1 +summary: repair one +series: + - 16 +bases: + - core20 +modes: + - recover + - run +timestamp: 2017-07-02T12:00:00Z +body-length: 81 +sign-key-sha3-384: KPIl7M4vQ9d4AUjkoU41TGAwtOMLc_bWUCeW8AvdRWD4_xcP60Oo4ABsFNo6BtXj + +#!/bin/sh +env | grep SNAP_SYSTEM_MODE +echo "done" >&$SNAP_REPAIR_STATUS_FD +exit 0 + +AXNpZw==`} + + seqRepairs = s.signSeqRepairs(c, seqRepairs) + + r1 := sysdb.InjectTrusted(s.storeSigning.Trusted) + defer r1() + r2 := repair.MockTrustedRepairRootKeys([]*asserts.AccountKey{s.repairRootAcctKey}) + defer r2() + + for _, mode := range []string{"recover", "run"} { + s.freshStateWithBaseAndMode(c, "core20", mode) + + mockServer := makeMockServer(c, &seqRepairs, false) + defer mockServer.Close() + + runner := repair.NewRunner() + runner.BaseURL = mustParseURL(mockServer.URL) + runner.LoadState() + + rpr, err := runner.Next("canonical") + c.Assert(err, IsNil) + + err = rpr.Run() + c.Assert(err, IsNil) + c.Check(filepath.Join(dirs.SnapRepairRunDir, "canonical", "1", "r0.script"), testutil.FileEquals, `#!/bin/sh +env | grep SNAP_SYSTEM_MODE +echo "done" >&$SNAP_REPAIR_STATUS_FD +exit 0`) + + c.Check(filepath.Join(dirs.SnapRepairRunDir, "canonical", "1", "r0.done"), testutil.FileEquals, fmt.Sprintf(`repair: canonical-1 +revision: 0 +summary: repair one +output: +SNAP_SYSTEM_MODE=%s +`, mode)) + // ensure correct permissions + fi, err := os.Stat(filepath.Join(dirs.SnapRepairRunDir, "canonical", "1", "r0.done")) + c.Assert(err, IsNil) + c.Check(fi.Mode(), Equals, os.FileMode(0600)) + } +} + +func (s *runnerSuite) TestRepairModesAndBases(c *C) { + repairTempl := `type: repair +authority-id: canonical +brand-id: canonical +repair-id: 1 +summary: uc20 recovery repair +timestamp: 2017-07-03T12:00:00Z +body-length: 17 +sign-key-sha3-384: KPIl7M4vQ9d4AUjkoU41TGAwtOMLc_bWUCeW8AvdRWD4_xcP60Oo4ABsFNo6BtXj +%[1]s +#!/bin/sh +exit 0 + + +AXNpZw== + ` + + r1 := sysdb.InjectTrusted(s.storeSigning.Trusted) + defer r1() + r2 := repair.MockTrustedRepairRootKeys([]*asserts.AccountKey{s.repairRootAcctKey}) + defer r2() + + tt := []struct { + device *dev + modes []string + bases []string + shouldRun bool + comment string + }{ + // uc20 recover mode assertion + { + &dev{"core20", "recover"}, + []string{"recover"}, + []string{"core20"}, + true, + "uc20 recover mode w/ uc20 recover mode assertion", + }, + { + &dev{"core20", "run"}, + []string{"recover"}, + []string{"core20"}, + false, + "uc20 run mode w/ uc20 recover mode assertion", + }, + { + &dev{base: "core"}, + []string{"recover"}, + []string{"core20"}, + false, + "uc16 w/ uc20 recover mode assertion", + }, + { + &dev{base: "core18"}, + []string{"recover"}, + []string{"core20"}, + false, + "uc18 w/ uc20 recover mode assertion", + }, + + // uc20 run mode assertion + { + &dev{"core20", "recover"}, + []string{"run"}, + []string{"core20"}, + false, + "uc20 recover mode w/ uc20 run mode assertion", + }, + { + &dev{"core20", "run"}, + []string{"run"}, + []string{"core20"}, + true, + "uc20 run mode w/ uc20 run mode assertion", + }, + { + &dev{base: "core"}, + []string{"run"}, + []string{"core20"}, + false, + "uc16 w/ uc20 run mode assertion", + }, + { + &dev{base: "core18"}, + []string{"run"}, + []string{"core20"}, + false, + "uc18 w/ uc20 run mode assertion", + }, + + // all uc20 modes assertion + { + &dev{"core20", "recover"}, + []string{"run", "recover"}, + []string{"core20"}, + true, + "uc20 recover mode w/ all uc20 modes assertion", + }, + { + &dev{"core20", "run"}, + []string{"run", "recover"}, + []string{"core20"}, + true, + "uc20 run mode w/ all uc20 modes assertion", + }, + { + &dev{base: "core"}, + []string{"run", "recover"}, + []string{"core20"}, + false, + "uc16 w/ all uc20 modes assertion", + }, + { + &dev{base: "core18"}, + []string{"run", "recover"}, + []string{"core20"}, + false, + "uc18 w/ all uc20 modes assertion", + }, + + // alternate uc20 run mode only assertion + { + &dev{"core20", "recover"}, + []string{}, + []string{"core20"}, + false, + "uc20 recover mode w/ alternate uc20 run mode assertion", + }, + { + &dev{"core20", "run"}, + []string{}, + []string{"core20"}, + true, + "uc20 run mode w/ alternate uc20 run mode assertion", + }, + { + &dev{base: "core"}, + []string{}, + []string{"core20"}, + false, + "uc16 w/ alternate uc20 run mode assertion", + }, + { + &dev{base: "core18"}, + []string{}, + []string{"core20"}, + false, + "uc18 w/ alternate uc20 run mode assertion", + }, + { + &dev{base: "core"}, + []string{"run"}, + []string{}, + false, + "uc16 w/ uc20 run mode assertion", + }, + { + &dev{base: "core18"}, + []string{"run"}, + []string{}, + false, + "uc16 w/ uc20 run mode assertion", + }, + + // all except uc20 recover mode assertion + { + &dev{"core20", "recover"}, + []string{}, + []string{}, + false, + "uc20 recover mode w/ all except uc20 recover mode assertion", + }, + { + &dev{"core20", "run"}, + []string{}, + []string{}, + true, + "uc20 run mode w/ all except uc20 recover mode assertion", + }, + { + &dev{base: "core"}, + []string{}, + []string{}, + true, + "uc16 w/ all except uc20 recover mode assertion", + }, + { + &dev{base: "core18"}, + []string{}, + []string{}, + true, + "uc18 w/ all except uc20 recover mode assertion", + }, + + // uc16 and uc18 assertion + { + &dev{"core20", "recover"}, + []string{}, + []string{"core", "core18"}, + false, + "uc20 recover mode w/ uc16 and uc18 assertion", + }, + { + &dev{"core20", "run"}, + []string{}, + []string{"core", "core18"}, + false, + "uc20 run mode w/ uc16 and uc18 assertion", + }, + { + &dev{base: "core"}, + []string{}, + []string{"core", "core18"}, + true, + "uc16 w/ uc16 and uc18 assertion", + }, + { + &dev{base: "core18"}, + []string{}, + []string{"core", "core18"}, + true, + "uc18 w/ uc16 and uc18 assertion", + }, + + // just uc16 assertion + { + &dev{"core20", "recover"}, + []string{}, + []string{"core"}, + false, + "uc20 recover mode w/ just uc16 assertion", + }, + { + &dev{"core20", "run"}, + []string{}, + []string{"core"}, + false, + "uc20 run mode w/ just uc16 assertion", + }, + { + &dev{base: "core"}, + []string{}, + []string{"core"}, + true, + "uc16 w/ just uc16 assertion", + }, + { + &dev{base: "core18"}, + []string{}, + []string{"core"}, + false, + "uc18 w/ just uc16 assertion", + }, + + // just uc18 assertion + { + &dev{"core20", "recover"}, + []string{}, + []string{"core18"}, + false, + "uc20 recover mode w/ just uc18 assertion", + }, + { + &dev{"core20", "run"}, + []string{}, + []string{"core18"}, + false, + "uc20 run mode w/ just uc18 assertion", + }, + { + &dev{base: "core"}, + []string{}, + []string{"core18"}, + false, + "uc16 w/ just uc18 assertion", + }, + { + &dev{base: "core18"}, + []string{}, + []string{"core18"}, + true, + "uc18 w/ just uc18 assertion", + }, + } + for _, t := range tt { + comment := Commentf(t.comment) + cleanups := []func(){} + + // generate the assertion with the bases and modes + basesStr := "" + if len(t.bases) != 0 { + basesStr = "bases:\n" + for _, base := range t.bases { + basesStr += " - " + base + "\n" + } + } + modesStr := "" + if len(t.modes) != 0 { + modesStr = "modes:\n" + for _, mode := range t.modes { + modesStr += " - " + mode + "\n" + } + } + + seqRepairs := s.signSeqRepairs(c, []string{fmt.Sprintf(repairTempl, basesStr+modesStr)}) + + mockServer := makeMockServer(c, &seqRepairs, false) + cleanups = append(cleanups, func() { mockServer.Close() }) + + if t.device == nil { + s.freshState(c) + } else { + s.freshStateWithBaseAndMode(c, t.device.base, t.device.mode) + } + + runner := repair.NewRunner() + runner.BaseURL = mustParseURL(mockServer.URL) + runner.LoadState() + + script := filepath.Join(dirs.SnapRepairRunDir, "canonical", "1", "r0.script") + + rpr, err := runner.Next("canonical") + if t.shouldRun { + c.Assert(err, IsNil, comment) + + // run the repair and make sure the script is there + err = rpr.Run() + c.Assert(err, IsNil, comment) + c.Check(script, testutil.FileEquals, "#!/bin/sh\nexit 0\n", comment) + + // remove the script for the next iteration + cleanups = append(cleanups, func() { c.Assert(os.RemoveAll(dirs.SnapRepairRunDir), IsNil) }) + } else { + c.Assert(err, Equals, repair.ErrRepairNotFound, comment) + c.Check(script, testutil.FileAbsent, comment) + } + + for _, r := range cleanups { + r() + } + } +} + +func makeMockRepair(script string) string { + return fmt.Sprintf(`type: repair +authority-id: canonical +brand-id: canonical +repair-id: 1 +summary: repair one +series: + - 16 +timestamp: 2017-07-02T12:00:00Z +body-length: %d +sign-key-sha3-384: KPIl7M4vQ9d4AUjkoU41TGAwtOMLc_bWUCeW8AvdRWD4_xcP60Oo4ABsFNo6BtXj + +%s + +AXNpZw==`, len(script), script) +} + +func verifyRepairStatus(c *C, status repair.RepairStatus) { + c.Check(dirs.SnapRepairStateFile, testutil.FileContains, fmt.Sprintf(`{"device":{"brand":"","model":"","base":"","mode":""},"sequences":{"canonical":[{"sequence":1,"revision":0,"status":%d}`, status)) +} + +// tests related to correct execution of script +type runScriptSuite struct { + baseRunnerSuite + + seqRepairs []string + + mockServer *httptest.Server + runner *repair.Runner + + runDir string +} + +var _ = Suite(&runScriptSuite{}) + +func (s *runScriptSuite) SetUpTest(c *C) { + s.baseRunnerSuite.SetUpTest(c) + s.runDir = filepath.Join(dirs.SnapRepairRunDir, "canonical", "1") + + s.AddCleanup(snapdenv.SetUserAgentFromVersion("1", nil, "snap-repair")) +} + +// setupRunner must be called from the tests so that the *C passed into contains +// the tests' state and not the SetUpTest's state (otherwise, assertion failures +// in the mock server go unreported). +func (s *runScriptSuite) setupRunner(c *C) { + s.mockServer = makeMockServer(c, &s.seqRepairs, false) + s.AddCleanup(func() { s.mockServer.Close() }) + + s.runner = repair.NewRunner() + s.runner.BaseURL = mustParseURL(s.mockServer.URL) + s.runner.LoadState() +} + +func (s *runScriptSuite) testScriptRun(c *C, mockScript string) *repair.Repair { + r1 := sysdb.InjectTrusted(s.storeSigning.Trusted) + defer r1() + r2 := repair.MockTrustedRepairRootKeys([]*asserts.AccountKey{s.repairRootAcctKey}) + defer r2() + + s.seqRepairs = s.signSeqRepairs(c, s.seqRepairs) + + rpr, err := s.runner.Next("canonical") + c.Assert(err, IsNil) + + err = rpr.Run() + c.Assert(err, IsNil) + + c.Check(filepath.Join(s.runDir, "r0.script"), testutil.FileEquals, mockScript) + + return rpr +} + +func (s *runScriptSuite) verifyRundir(c *C, names []string) { + dirents, err := os.ReadDir(s.runDir) + c.Assert(err, IsNil) + c.Assert(dirents, HasLen, len(names)) + for i := range dirents { + c.Check(dirents[i].Name(), Matches, names[i]) + } +} + +func (s *runScriptSuite) verifyOutput(c *C, name, expectedOutput string) { + c.Check(filepath.Join(s.runDir, name), testutil.FileEquals, expectedOutput) + // ensure correct permissions + fi, err := os.Stat(filepath.Join(s.runDir, name)) + c.Assert(err, IsNil) + c.Check(fi.Mode(), Equals, os.FileMode(0600)) +} + +func (s *runScriptSuite) TestRepairBasicRunHappy(c *C) { + s.setupRunner(c) + script := `#!/bin/sh +echo "happy output" +echo "done" >&$SNAP_REPAIR_STATUS_FD +exit 0 +` + s.seqRepairs = []string{makeMockRepair(script)} + s.testScriptRun(c, script) + // verify + s.verifyRundir(c, []string{ + `^r0.done$`, + `^r0.script$`, + `^work$`, + }) + s.verifyOutput(c, "r0.done", `repair: canonical-1 +revision: 0 +summary: repair one +output: +happy output +`) + verifyRepairStatus(c, repair.DoneStatus) +} + +func (s *runScriptSuite) TestRepairBasicRunUnhappy(c *C) { + s.setupRunner(c) + script := `#!/bin/sh +echo "unhappy output" +exit 1 +` + s.seqRepairs = []string{makeMockRepair(script)} + s.testScriptRun(c, script) + // verify + s.verifyRundir(c, []string{ + `^r0.retry$`, + `^r0.script$`, + `^work$`, + }) + s.verifyOutput(c, "r0.retry", `repair: canonical-1 +revision: 0 +summary: repair one +output: +unhappy output + +repair canonical-1 revision 0 failed: exit status 1`) + verifyRepairStatus(c, repair.RetryStatus) +} + +func (s *runScriptSuite) TestRepairBasicSkip(c *C) { + s.setupRunner(c) + script := `#!/bin/sh +echo "other output" +echo "skip" >&$SNAP_REPAIR_STATUS_FD +exit 0 +` + s.seqRepairs = []string{makeMockRepair(script)} + s.testScriptRun(c, script) + // verify + s.verifyRundir(c, []string{ + `^r0.script$`, + `^r0.skip$`, + `^work$`, + }) + s.verifyOutput(c, "r0.skip", `repair: canonical-1 +revision: 0 +summary: repair one +output: +other output +`) + verifyRepairStatus(c, repair.SkipStatus) +} + +func (s *runScriptSuite) TestRepairBasicRunUnhappyThenHappy(c *C) { + s.setupRunner(c) + script := `#!/bin/sh +if [ -f zzz-ran-once ]; then + echo "happy now" + echo "done" >&$SNAP_REPAIR_STATUS_FD + exit 0 +fi +echo "unhappy output" +touch zzz-ran-once +exit 1 +` + s.seqRepairs = []string{makeMockRepair(script)} + rpr := s.testScriptRun(c, script) + s.verifyRundir(c, []string{ + `^r0.retry$`, + `^r0.script$`, + `^work$`, + }) + s.verifyOutput(c, "r0.retry", `repair: canonical-1 +revision: 0 +summary: repair one +output: +unhappy output + +repair canonical-1 revision 0 failed: exit status 1`) + verifyRepairStatus(c, repair.RetryStatus) + + // run again, it will be happy this time + err := rpr.Run() + c.Assert(err, IsNil) + + s.verifyRundir(c, []string{ + `^r0.done$`, + `^r0.retry$`, + `^r0.script$`, + `^work$`, + }) + s.verifyOutput(c, "r0.done", `repair: canonical-1 +revision: 0 +summary: repair one +output: +happy now +`) + verifyRepairStatus(c, repair.DoneStatus) +} + +func (s *runScriptSuite) TestRepairHitsTimeout(c *C) { + s.setupRunner(c) + r1 := sysdb.InjectTrusted(s.storeSigning.Trusted) + defer r1() + r2 := repair.MockTrustedRepairRootKeys([]*asserts.AccountKey{s.repairRootAcctKey}) + defer r2() + + restore := repair.MockDefaultRepairTimeout(100 * time.Millisecond) + defer restore() + + script := `#!/bin/sh +echo "output before timeout" +sleep 100 +` + s.seqRepairs = []string{makeMockRepair(script)} + s.seqRepairs = s.signSeqRepairs(c, s.seqRepairs) + + rpr, err := s.runner.Next("canonical") + c.Assert(err, IsNil) + + err = rpr.Run() + c.Assert(err, IsNil) + + s.verifyRundir(c, []string{ + `^r0.retry$`, + `^r0.script$`, + `^work$`, + }) + s.verifyOutput(c, "r0.retry", `repair: canonical-1 +revision: 0 +summary: repair one +output: +output before timeout + +repair canonical-1 revision 0 failed: repair did not finish within 100ms`) + verifyRepairStatus(c, repair.RetryStatus) +} + +func (s *runScriptSuite) TestRepairHasCorrectPath(c *C) { + s.setupRunner(c) + r1 := sysdb.InjectTrusted(s.storeSigning.Trusted) + defer r1() + r2 := repair.MockTrustedRepairRootKeys([]*asserts.AccountKey{s.repairRootAcctKey}) + defer r2() + + script := `#!/bin/sh +echo PATH=$PATH +ls -l ${PATH##*:}/repair +` + s.seqRepairs = []string{makeMockRepair(script)} + s.seqRepairs = s.signSeqRepairs(c, s.seqRepairs) + + rpr, err := s.runner.Next("canonical") + c.Assert(err, IsNil) + + err = rpr.Run() + c.Assert(err, IsNil) + + c.Check(filepath.Join(s.runDir, "r0.retry"), testutil.FileMatches, `(?ms).*^PATH=.*:.*/run/snapd/repair/tools.*`) + c.Check(filepath.Join(s.runDir, "r0.retry"), testutil.FileContains, `/repair -> /usr/lib/snapd/snap-repair`) + + // run again and ensure no error happens + err = rpr.Run() + c.Assert(err, IsNil) + +} + +// shared1620RunnerSuite is embedded by runner16Suite and +// runner20Suite and the tests are run once with a simulated uc16 and +// once with a simulated uc20 environment +type shared1620RunnerSuite struct { + baseRunnerSuite + + writeSeedAssert func(c *C, fname string, a asserts.Assertion) + + // this is so we can check device details that will be different in the + // 20 version of tests from the 16 version of tests + expBase string + expMode string +} + +func (s *shared1620RunnerSuite) TestTLSTime(c *C) { + s.freshState(c) + runner := repair.NewRunner() + err := runner.LoadState() + c.Assert(err, IsNil) + epoch := time.Unix(0, 0) + r := repair.MockTimeNow(func() time.Time { + return epoch + }) + defer r() + c.Check(runner.TLSTime().Equal(s.seedTime), Equals, true) +} + +func (s *shared1620RunnerSuite) TestLoadStateInitState(c *C) { + // validity + c.Check(osutil.IsDirectory(dirs.SnapRepairDir), Equals, false) + c.Check(osutil.FileExists(dirs.SnapRepairStateFile), Equals, false) + // setup realistic seed/assertions + r := sysdb.InjectTrusted(s.storeSigning.Trusted) + defer r() + s.writeSeedAssert(c, "store.account-key", s.storeSigning.StoreAccountKey("")) + s.writeSeedAssert(c, "brand.account", s.brandAcct) + s.writeSeedAssert(c, "brand.account-key", s.brandAcctKey) + s.writeSeedAssert(c, "model", s.modelAs) + + runner := repair.NewRunner() + err := runner.LoadState() + c.Assert(err, IsNil) + c.Check(osutil.FileExists(dirs.SnapRepairStateFile), Equals, true) + + brand, model := runner.BrandModel() + c.Check(brand, Equals, "my-brand") + c.Check(model, Equals, "my-model-2") + + base, mode := runner.BaseMode() + c.Check(base, Equals, s.expBase) + c.Check(mode, Equals, s.expMode) + + c.Check(runner.TimeLowerBound().Equal(s.seedTime), Equals, true) +} + +type runner16Suite struct { + shared1620RunnerSuite +} + +var _ = Suite(&runner16Suite{}) + +func (s *runner16Suite) SetUpTest(c *C) { + s.shared1620RunnerSuite.SetUpTest(c) + + s.shared1620RunnerSuite.expBase = "core" + s.shared1620RunnerSuite.expMode = "" + + s.seedAssertsDir = filepath.Join(dirs.SnapSeedDir, "assertions") + + // sample seed yaml + err := os.MkdirAll(s.seedAssertsDir, 0755) + c.Assert(err, IsNil) + seedYamlFn := filepath.Join(dirs.SnapSeedDir, "seed.yaml") + err = os.WriteFile(seedYamlFn, nil, 0644) + c.Assert(err, IsNil) + seedTime, err := time.Parse(time.RFC3339, "2017-08-11T15:49:49Z") + c.Assert(err, IsNil) + err = os.Chtimes(filepath.Join(dirs.SnapSeedDir, "seed.yaml"), seedTime, seedTime) + c.Assert(err, IsNil) + s.seedTime = seedTime + + s.t0 = time.Now().UTC().Truncate(time.Minute) + + s.writeSeedAssert = s.writeSeedAssert16 +} + +func (s *runner16Suite) writeSeedAssert16(c *C, fname string, a asserts.Assertion) { + err := os.WriteFile(filepath.Join(s.seedAssertsDir, fname), asserts.Encode(a), 0644) + c.Assert(err, IsNil) +} + +func (s *runner16Suite) rmSeedAssert16(c *C, fname string) { + err := os.Remove(filepath.Join(s.seedAssertsDir, fname)) + c.Assert(err, IsNil) +} + +func (s *runner16Suite) TestLoadStateInitDeviceInfoFail(c *C) { + // validity + c.Check(osutil.IsDirectory(dirs.SnapRepairDir), Equals, false) + c.Check(osutil.FileExists(dirs.SnapRepairStateFile), Equals, false) + // setup realistic seed/assertions + r := sysdb.InjectTrusted(s.storeSigning.Trusted) + defer r() + + const errPrefix = "cannot set device information: " + tests := []struct { + breakFunc func() + expectedErr string + }{ + {func() { s.rmSeedAssert16(c, "model") }, errPrefix + "no model assertion in seed data"}, + {func() { s.rmSeedAssert16(c, "brand.account") }, errPrefix + "no brand account assertion in seed data"}, + {func() { s.rmSeedAssert16(c, "brand.account-key") }, errPrefix + `cannot find public key.*`}, + {func() { + // broken signature + blob := asserts.Encode(s.brandAcct) + err := os.WriteFile(filepath.Join(s.seedAssertsDir, "brand.account"), blob[:len(blob)-3], 0644) + c.Assert(err, IsNil) + }, errPrefix + "cannot decode signature:.*"}, + {func() { s.writeSeedAssert(c, "model2", s.modelAs) }, errPrefix + "multiple models in seed assertions"}, + } + + for _, test := range tests { + s.writeSeedAssert(c, "store.account-key", s.storeSigning.StoreAccountKey("")) + s.writeSeedAssert(c, "brand.account", s.brandAcct) + s.writeSeedAssert(c, "brand.account-key", s.brandAcctKey) + s.writeSeedAssert(c, "model", s.modelAs) + + test.breakFunc() + + runner := repair.NewRunner() + err := runner.LoadState() + c.Check(err, ErrorMatches, test.expectedErr) + } +} + +type runner20Suite struct { + shared1620RunnerSuite +} + +var _ = Suite(&runner20Suite{}) + +var mockModeenv = []byte(` +mode=run +model=my-brand/my-model-2 +base=core20_1.snap +`) + +func (s *runner20Suite) SetUpTest(c *C) { + s.shared1620RunnerSuite.SetUpTest(c) + + s.shared1620RunnerSuite.expBase = "core20" + s.shared1620RunnerSuite.expMode = "run" + + s.seedAssertsDir = filepath.Join(dirs.SnapSeedDir, "/systems/20201212/assertions") + err := os.MkdirAll(s.seedAssertsDir, 0755) + c.Assert(err, IsNil) + + // write sample modeenv + err = os.MkdirAll(filepath.Dir(dirs.SnapModeenvFile), 0755) + c.Assert(err, IsNil) + err = os.WriteFile(dirs.SnapModeenvFile, mockModeenv, 0644) + c.Assert(err, IsNil) + // validate that modeenv is actually valid + _, err = boot.ReadModeenv("") + c.Assert(err, IsNil) + + seedTime, err := time.Parse(time.RFC3339, "2017-08-11T15:49:49Z") + c.Assert(err, IsNil) + err = os.Chtimes(dirs.SnapModeenvFile, seedTime, seedTime) + c.Assert(err, IsNil) + s.seedTime = seedTime + s.t0 = time.Now().UTC().Truncate(time.Minute) + + s.writeSeedAssert = s.writeSeedAssert20 +} + +func (s *runner20Suite) writeSeedAssert20(c *C, fname string, a asserts.Assertion) { + var fn string + if _, ok := a.(*asserts.Model); ok { + fn = filepath.Join(s.seedAssertsDir, "../model") + } else { + fn = filepath.Join(s.seedAssertsDir, fname) + } + err := os.WriteFile(fn, asserts.Encode(a), 0644) + c.Assert(err, IsNil) + + // ensure model assertion file has the correct seed time + if _, ok := a.(*asserts.Model); ok { + err = os.Chtimes(fn, s.seedTime, s.seedTime) + c.Assert(err, IsNil) + } +} + +func (s *runner20Suite) TestLoadStateInitDeviceInfoModeenvInvalidContent(c *C) { + runner := repair.NewRunner() + + for _, tc := range []struct { + modelStr string + expectedErr string + }{ + { + `invalid-key-value`, + "cannot set device information: No option model in section ", + }, { + `model=`, + `cannot set device information: cannot find brand/model in modeenv model string ""`, + }, { + `model=brand-but-no-model`, + `cannot set device information: cannot find brand/model in modeenv model string "brand-but-no-model"`, + }, + } { + err := os.WriteFile(dirs.SnapModeenvFile, []byte(tc.modelStr), 0644) + c.Assert(err, IsNil) + err = runner.LoadState() + c.Check(err, ErrorMatches, tc.expectedErr) + } +} + +func (s *runner20Suite) TestLoadStateInitDeviceInfoModeenvIncorrectPermissions(c *C) { + runner := repair.NewRunner() + + err := os.Chmod(dirs.SnapModeenvFile, 0300) + c.Assert(err, IsNil) + s.AddCleanup(func() { + err := os.Chmod(dirs.SnapModeenvFile, 0644) + c.Assert(err, IsNil) + }) + err = runner.LoadState() + c.Check(err, ErrorMatches, "cannot set device information: open /.*/modeenv: permission denied") +} + +func (s *runnerSuite) TestStoreOffline(c *C) { + runner := repair.NewRunner() + + data, err := json.Marshal(repair.RepairConfig{ + StoreOffline: true, + }) + c.Assert(err, IsNil) + + err = os.MkdirAll(filepath.Dir(dirs.SnapRepairConfigFile), 0755) + c.Assert(err, IsNil) + + err = osutil.AtomicWriteFile(dirs.SnapRepairConfigFile, data, 0644, 0) + c.Assert(err, IsNil) + + _, _, err = runner.Fetch("canonical", 2, -1) + c.Assert(err, testutil.ErrorIs, repair.ErrStoreOffline) + + _, err = runner.Peek("brand", 0) + c.Assert(err, testutil.ErrorIs, repair.ErrStoreOffline) +} + +func (s *runnerSuite) TestStoreOnlineIfFileBroken(c *C) { + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Check(r.URL.Path, Equals, "/repairs/canonical/2") + accept := r.Header.Get("Accept") + switch accept { + case "application/x.ubuntu.assertion": + io.WriteString(w, testRepair) + io.WriteString(w, "\n") + io.WriteString(w, testKey) + case "application/json": + io.WriteString(w, testHeadersResp) + default: + c.Errorf("unexpected 'Accept' header: %s", accept) + } + })) + + c.Assert(mockServer, NotNil) + defer mockServer.Close() + + err := os.MkdirAll(filepath.Dir(dirs.SnapRepairConfigFile), 0755) + c.Assert(err, IsNil) + + runner := repair.NewRunner() + runner.BaseURL = mustParseURL(mockServer.URL) + + // file is missing + _, _, err = runner.Fetch("canonical", 2, -1) + c.Assert(err, IsNil) + + _, err = runner.Peek("canonical", 2) + c.Assert(err, IsNil) + + // file is invalid json + err = osutil.AtomicWriteFile(dirs.SnapRepairConfigFile, []byte("}{"), 0644, 0) + c.Assert(err, IsNil) + + _, _, err = runner.Fetch("canonical", 2, -1) + c.Assert(err, IsNil) + + _, err = runner.Peek("canonical", 2) + c.Assert(err, IsNil) +} diff --git a/cmd/snap-repair/staging.go b/cmd/snap-repair/staging.go new file mode 100644 index 00000000..71b2e6a4 --- /dev/null +++ b/cmd/snap-repair/staging.go @@ -0,0 +1,81 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- +//go:build withtestkeys || withstagingkeys + +/* + * Copyright (C) 2017-2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "fmt" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/snapdenv" +) + +const ( + encodedStagingRepairRootAccountKey = `type: account-key +authority-id: canonical +public-key-sha3-384: 0GgXgD-RtfU0HJFBmaaiUQaNUFATl1oOlzJ44Bi4MbQAwcu8ektQLGBkKQ4JuA_O +account-id: canonical +name: repair-root +since: 2017-07-07T00:00:00.0Z +body-length: 1406 +sign-key-sha3-384: e2r8on4LgdxQSaW5T8mBD5oC2fSTktTVxOYa5w3kIP4_nNF6L7mt6fOJShdOGkKu + +AcbDTQRWhcGAASAA0D+AqdqBtgiaABSZ++NdDKcvFtpmEphDfpEAAo4MAWucmE5yVN9mHFRXvwU0 +ZkLQwPuiF5Vp5EP/kHyKgmmF9nKUBXnuZXfuH4vzH9ZuEfdxGc0On+XK4KwyPUzOXj2n1Rsxj0P5 +06wJ6QFghi6nORx3Hz4pxZH7SRANgZudwWE53+whbkJyU/psv4RXfxPnu3YGo0qPk/wGCfV8kkkH +UFmeqJJEk14EGI+Kv9WlIVAqctryqf9mSXkgnhp4lzdGpsRCmRcw7boVjdOieFCJv+gVs7i52Yxu +YyiA09XF8j85DSMl4s/TyN4bm9649beBCpTJNyb3Ep6lydGiZck8ChJRLDXGP2XZtRKXsBMNIfwP +5qnLtX1/Sm1znag0grGbd3AUqL6ySAr42x8QIxZfqzk5DvQbF3xOiu2xzxTt1eB3069MlnFw99ui +fLwlec7imbCiX3bryutCRhKgkJz4MbNsyHiW51k1l1IDbABey+gfVHpeBq/2aHK5qSy98dRBSYSj +Ki++j8zR1ODequWy+OrF4cu6IQ35eunHQ2mRsJIE0xFGAjG3vCPJwzVoNS5m5R0ffncIUYdKxt/R +W2mLo43qX0fquW5LvHyI14d3B3LYfKz05FmASJaE4A+/GQhM7kMCnmykro0MM6MU0sd2OruOZVVo +z6GQ37Hyo/TGToyCr7qD/+tSq3dKYGyezl/Y4I589eqnc1DaMHL2ssiXDsbpSRHpnNqMUq6UNg4M +NsUiDLaGsNJj1ft6A7jz+yoqJ74m3hlaQK1Rot8FXBkuJGoRKBahHbh/bfkGWChDvzY9ZpoUExY2 +rp7tNYEP/LEAI/RQd03sBnqd3V8YhggT2n6QCC4ikLKvUTE3RY0qn5aAa7KC1wVi+7SfdeVl3RBF +Jyb9GCYfRUv/bfFH/TCZ9WVN1v/GIMcjBFGJf7H3cz/deela53XSaecYHuvFRpVfzmx28UcR8UY4 +5WqHfxnVQlY6DPv+kjzMzIEJGwgSAFc0d4wlSwS/Y1T0ednFRUyjMAxEUvE8tOLibtXw4q/srIFt +OIgpd/xErcyi5Ddgt7EQoYo+rtVZ8x5EwR0+i7VAV+a3bnGSJW2LFEjt2RZUiMjohVZ4oOVuoDd2 +VQzMFv41flbyqjgHhtJSCIOKDg9uI2FHbQ5vrX9qBooS68YkBALwCq+P7nSxDxFuS0CgrzSH35FX +VneOl68U74pxRgdlPJ0HI92oilrbTH8Ft0m5SzNsy+9ZZZtIDFQW+lx/ApixyifARFnZ3C3Gdx59 +FlFNbE75+X28joGtul2mPjJ1eI1dCwiFCF3R/rwfRmw3Wpv76re+EzVR1MJVCcTgC1lUoCJpKl1J +n3PQLcR8J0iqswARAQAB + +AcLBXAQAAQoABgUCWYM7bQAKCRAHKljtl9kuLtCFD/4miBm0HyLE8GdboeUtWw+oOlH0AgabRqYi +a1TpEJeYQIjnwDuCCPYtJxL1Rc+5dSNnbY9L+34NuaSyYMJY/FMuSS5iaNomGnj7YiAOds1+1/6h +Z1bTm3ttZnphg5DxckYZLaKoYgRaOzAbiRM8l+2bDbXlq3KRxZ7o7D1V/xpPis8SWK57gQ7VppHI +fcw5jnzWokWSowaKShimjJNCXMaeGdGJBLU1wcJC/XRf3tXSZecwMfL9CN/G8b17HvIFN/Pe3oS9 +QxYMQ0p3J3PF3F19Iow0VHi78hPKtVmJb5igwzBlGYFW7zZ3R35nJ7Iv6VW58G2HDDGMdBfZp930 +FbLb3mj8Yw3S5fcMZ09vpT7PK0tjFoVJtDFBOkrjvxVMEPRa0IJNcfl/hgPdp1/IFXWpZhfvk8a8 +qgzffxN+Ro/J4Jt9QrHM4sNwiEOjVvHY4cQ9GOfns9UqocmxYPDxElBNraCFOCSudZgXiyF7zUYF +OnYqTDR4ChiZtmUqIiZr6rXgZTm1raGlqR7nsbDlkJtru7tzkgMRw8xFRolaQIKiyAwTewF7vLho +imwYTRuYRMzft1q5EeRWR4XwtlIuqsXg3FCGTNIG4HiAFKrrNV7AOvVjIUSgpOcWv2leSiRQjgpY +I9oD82ii+5rKvebnGIa0o+sWhYNFoviP/49DnDNJWA== +` +) + +func init() { + repairRootAccountKey, err := asserts.Decode([]byte(encodedStagingRepairRootAccountKey)) + if err != nil { + panic(fmt.Sprintf("cannot decode trusted account-key: %v", err)) + } + if snapdenv.UseStagingStore() { + trustedRepairRootKeys = append(trustedRepairRootKeys, repairRootAccountKey.(*asserts.AccountKey)) + } +} diff --git a/cmd/snap-repair/testkeys.go b/cmd/snap-repair/testkeys.go new file mode 100644 index 00000000..730bb79f --- /dev/null +++ b/cmd/snap-repair/testkeys.go @@ -0,0 +1,34 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- +//go:build withtestkeys + +/* + * Copyright (C) 2021 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/asserts/systestkeys" +) + +func init() { + // when built with testkeys enabled, trust the TestRepairRootAccountKey + trustedRepairRootKeys = append(trustedRepairRootKeys, systestkeys.TestRepairRootAccountKey.(*asserts.AccountKey)) + + // also check for root brand ID + rootBrandIDs = append(rootBrandIDs, "testrootorg") +} diff --git a/cmd/snap-repair/trace.go b/cmd/snap-repair/trace.go new file mode 100644 index 00000000..9724f2ef --- /dev/null +++ b/cmd/snap-repair/trace.go @@ -0,0 +1,176 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "bufio" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + + "github.com/snapcore/snapd/dirs" +) + +// newRepairTraces returns all repairTrace about the given "brand" and "seq" +// that can be found. brand, seq can be filepath.Glob expressions. +func newRepairTraces(brand, seq string) ([]*repairTrace, error) { + matches, err := filepath.Glob(filepath.Join(dirs.SnapRepairRunDir, brand, seq, "*")) + if err != nil { + return nil, err + } + + var repairTraces []*repairTrace + for _, match := range matches { + if trace := newRepairTraceFromPath(match); trace != nil { + repairTraces = append(repairTraces, trace) + } + } + + return repairTraces, nil +} + +// repairTrace holds information about a repair that was run. +type repairTrace struct { + path string +} + +// validRepairTraceName checks that the given name looks like a valid repair +// trace +var validRepairTraceName = regexp.MustCompile(`^r[0-9]+\.(done|skip|retry|running)$`) + +// newRepairTraceFromPath takes a repair log path like +// the path /var/lib/snapd/repair/run/my-brand/1/r2.done +// and contructs a repair log from that. +func newRepairTraceFromPath(path string) *repairTrace { + rt := &repairTrace{path: path} + if !validRepairTraceName.MatchString(filepath.Base(path)) { + return nil + } + return rt +} + +// Repair returns the repair human readable string in the form $brand-$id +func (rt *repairTrace) Repair() string { + seq := filepath.Base(filepath.Dir(rt.path)) + brand := filepath.Base(filepath.Dir(filepath.Dir(rt.path))) + + return fmt.Sprintf("%s-%s", brand, seq) +} + +// Revision returns the revision of the repair +func (rt *repairTrace) Revision() string { + rev, err := revFromFilepath(rt.path) + if err != nil { + // this can never happen because we check that path starts + // with the right prefix. However handle the case just in + // case. + return "-" + } + return rev +} + +// Summary returns the summary of the repair that was run +func (rt *repairTrace) Summary() string { + f, err := os.Open(rt.path) + if err != nil { + return "-" + } + defer f.Close() + + needle := "summary: " + scanner := bufio.NewScanner(f) + for scanner.Scan() { + s := scanner.Text() + if strings.HasPrefix(s, needle) { + return s[len(needle):] + } + } + + return "-" +} + +// Status returns the status of the given repair {done,skip,retry,running} +func (rt *repairTrace) Status() string { + return filepath.Ext(rt.path)[1:] +} + +func indentPrefix(level int) string { + return strings.Repeat(" ", level) +} + +// WriteScriptIndented outputs the script that produced this repair output +// to the given writer w with the indent level given by indent. +func (rt *repairTrace) WriteScriptIndented(w io.Writer, indent int) error { + scriptPath := rt.path[:strings.LastIndex(rt.path, ".")] + ".script" + f, err := os.Open(scriptPath) + if err != nil { + return err + } + defer f.Close() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + fmt.Fprintf(w, "%s%s\n", indentPrefix(indent), scanner.Text()) + } + if scanner.Err() != nil { + return scanner.Err() + } + return nil +} + +// WriteOutputIndented outputs the repair output to the given writer w +// with the indent level given by indent. +func (rt *repairTrace) WriteOutputIndented(w io.Writer, indent int) error { + f, err := os.Open(rt.path) + if err != nil { + return err + } + defer f.Close() + + scanner := bufio.NewScanner(f) + // move forward in the log to where the actual script output starts + for scanner.Scan() { + if scanner.Text() == "output:" { + break + } + } + // write the script output to w + for scanner.Scan() { + fmt.Fprintf(w, "%s%s\n", indentPrefix(indent), scanner.Text()) + } + if scanner.Err() != nil { + return scanner.Err() + } + return nil +} + +// revFromFilepath is a helper that extracts the revision number from the +// filename of the repairTrace +func revFromFilepath(name string) (string, error) { + var rev int + if _, err := fmt.Sscanf(filepath.Base(name), "r%d.", &rev); err == nil { + return strconv.Itoa(rev), nil + } + return "", fmt.Errorf("cannot find revision in %q", name) +} diff --git a/cmd/snap-repair/trace_test.go b/cmd/snap-repair/trace_test.go new file mode 100644 index 00000000..bd4f53bd --- /dev/null +++ b/cmd/snap-repair/trace_test.go @@ -0,0 +1,65 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "os" + "path/filepath" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/dirs" +) + +func makeMockRepairState(c *C) { + // the canonical script dir content + basedir := filepath.Join(dirs.SnapRepairRunDir, "canonical/1") + err := os.MkdirAll(basedir, 0700) + c.Assert(err, IsNil) + err = os.WriteFile(filepath.Join(basedir, "r3.retry"), []byte("repair: canonical-1\nsummary: repair one\noutput:\nretry output"), 0600) + c.Assert(err, IsNil) + err = os.WriteFile(filepath.Join(basedir, "r3.script"), []byte("#!/bin/sh\necho retry output"), 0700) + c.Assert(err, IsNil) + + // my-brand + basedir = filepath.Join(dirs.SnapRepairRunDir, "my-brand/1") + err = os.MkdirAll(basedir, 0700) + c.Assert(err, IsNil) + err = os.WriteFile(filepath.Join(basedir, "r1.done"), []byte("repair: my-brand-1\nsummary: my-brand repair one\noutput:\ndone output"), 0600) + c.Assert(err, IsNil) + err = os.WriteFile(filepath.Join(basedir, "r1.script"), []byte("#!/bin/sh\necho done output"), 0700) + c.Assert(err, IsNil) + + basedir = filepath.Join(dirs.SnapRepairRunDir, "my-brand/2") + err = os.MkdirAll(basedir, 0700) + c.Assert(err, IsNil) + err = os.WriteFile(filepath.Join(basedir, "r2.skip"), []byte("repair: my-brand-2\nsummary: my-brand repair two\noutput:\nskip output"), 0600) + c.Assert(err, IsNil) + err = os.WriteFile(filepath.Join(basedir, "r2.script"), []byte("#!/bin/sh\necho skip output"), 0700) + c.Assert(err, IsNil) + + basedir = filepath.Join(dirs.SnapRepairRunDir, "my-brand/3") + err = os.MkdirAll(basedir, 0700) + c.Assert(err, IsNil) + err = os.WriteFile(filepath.Join(basedir, "r0.running"), []byte("repair: my-brand-3\nsummary: my-brand repair three\noutput:\nrunning output"), 0600) + c.Assert(err, IsNil) + err = os.WriteFile(filepath.Join(basedir, "r0.script"), []byte("#!/bin/sh\necho running output"), 0700) + c.Assert(err, IsNil) +} diff --git a/cmd/snap-repair/trusted.go b/cmd/snap-repair/trusted.go new file mode 100644 index 00000000..365962a4 --- /dev/null +++ b/cmd/snap-repair/trusted.go @@ -0,0 +1,89 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2017-2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "fmt" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/snapdenv" +) + +const ( + encodedRepairRootAccountKey = `type: account-key +authority-id: canonical +public-key-sha3-384: nttW6NfBXI_E-00u38W-KH6eiksfQNXuI7IiumoV49_zkbhM0sYTzSnFlwZC-W4t +account-id: canonical +name: repair-root +since: 2017-07-07T00:00:00.0Z +body-length: 1406 +sign-key-sha3-384: -CvQKAwRQ5h3Ffn10FILJoEZUXOv6km9FwA80-Rcj-f-6jadQ89VRswHNiEB9Lxk + +AcbDTQRWhcGAASAAtlCIEilQCQh9Ffjn9IxD+5FtWfdKdJHrQdtFPy/2Q1kOvC++ef/3bG1xMwao +tue9K0HCMtv3apHS1C32Y8JBMD8oykRpAd5H05+sgzZr3kCHIvgogKFsXfdd5+W5Q1+59Vy/81UH +tJCs99wBwboNh/pMCXBGI3jDRN1f7hOxcHUIW+KTaHCVZnrXXmCn6Oe6brR9qiUXgEB2I6rBT/Fe +cumdfvFN/zSsJ3Vvv9IbTfHYAZD82NrSqz4UZ3WJarIaxlykgLJaZN4bqSQYPYsc8lLlwjQeGloW ++r8dIypKOzPnUYurzWcNzcCnNCT1zhpY/IK2rFcZbN5/mP2/5PtjFlbX88aPGPbOTqYANmxfboCx +wo4D4aS7PD6gLC7XM8bgh8BACpmG3BnskL7F/9IHMl85SUHFIya2fDu7A7HqNUn7cpENGbHojj7G +J2s2965FSRuIvp69wEmknYD/kahjT1+Vy94D2rVB7mjtTruPueF2KTpo2jRXFM+ABq+T9ybjXD6f +UuSXu5xeg0Cv1sxOh4O4b45uaCXb8B74chEUW+cb3cV0NGE/QgBJUBeS68vUI8lqQFmPInci6Md4 +oiKFVbloL0ZmOGj73Xv2uAcexAK9bEiI+adVS2x9r4eFwtkST3XG0t/kw7eLgAVjtRcpmD6EuZ0Q +ulAJHEsl7Sazm8GRU4GtZWaCajVb4n5TS1lin2nqUXwqxRUA3smLqkQoYXQni9vmhez4dEP4MVvq +/0IdI50UGME5Fzc8hUhYzvbNS8g+VOeAK/qj3dzcdF9+n940AQI16Fcxi1/xFk8A4dw3AaDl4XnJ +piyStE+xi95ne5HJW8r/f/JQos8I6QR5w7pe2URbgUdVPgQLv3r/4dS/X3aP+oakrPR7JuAVdP62 +vsjF4jK8Bl69mcF434xpshnbnW/f7XHomPY4gp8y7kD2/DdEs5hvaTHIPp25DEYhqjt3gfmMuUXi +Mb5oy9KZp3ff8Squ+XNWSGQSyhX14xcQwM8QjNQnAisNg2hYwSM2n8q5IDWiwJQkFSriP5tMsa8E +DMGI3LXUZKRJll9dQBjs6VzApT4/Ee0ZvSni0d2cWm3wkqFQudRpqz3JSwQ7jal0W5e0UhNeHh/W +7nACD5hvcwF7UgUz0r8adlOy+nyfvWte65nbcRrIH7GS1xdgS0e9eW4znsplp7s/Z3gMhi8CN5TY +0nZW82TTl69Wvn13SGJye0yJSjiy4KS0iRE6BwAt7dGAMs5c62IlBsWEHLmCW1/lixWA9YXT9iji +G7DKSoofnsvqVP2wIQZxxt4xHMjUGXecyx8QX4BznwsV1vbzHOIG4a3Z9A1X1L3yh3ZbazFVeEE9 +7Dhz9hGYfd3PvwARAQAB + +AcLDXAQAAQoABgUCWbuO2gAKCRDUpVvql9g3IOPcIADZWObdYMKh2SblWXxUchnc3S4LDRtL8v+Q +HdnXO5+dJmsj5LWhdsB7lz373aaylFTwHpNDWcDdAu7ulP0vr7zJURo1jGOo7VojSEeuAAu3YhwL +2pR0p5Me0wuxl/pCX0x0nfDSeeTw11kproyN0GwJaErKEmyQyfOgVr2jN5sl1gBqQtKgG5gqZzC3 +oFH1HYGPl2kfAorxFw7MoPy4aRFaxUJfx4x6bEktgkkFT7AWGmawVwcpiiUbbpe9CPLEsn6yqJI9 +5XmQ3dJjp/6Y5D7x04LRH3Q5fajRcpdBrC0tDsT9UDbSRtIyo0KDNVHwQalLa2Sv51DV+Fy4eneM +Lgu+oCUOnBecXIWnX+k0uyDW8aLHYapx8etpW3pln/hMRd8JxYVYAqDn7G2AYeSGS/4lzCJzysW2 +2/4RhH9Ql8ea0nSWVTJr3pmXKlPSH/OOy9IADEDUuEdvyMcq3YOXA9E4L3g9vR31JH+++swcTQPz +rnGx0mE+TCQRWok/NZ1QNv4eNZlnLXdNS1DoV/kRqU04cblYYUoSO34mkjPEJ8ti+VzKh/PTA6/7 +1feFX276Zam/6b2rBLWCWYdblDM9oLAR4PfzntBZW4LzzOIb95IwiK4JoDoBr3x4+RxvxgVQLvHt +8990uQ0se9+8BtLVFtd07NbldHXRBlZkq22a8CeVYrU3YZEtuEBvlGDpkdegw/vcvgHUUK1f8dXJ +0+9oW2yQOLAguuPZ67GFSgdTnvR5dQYZZR2EbQJlPMOFA3loKeUxHSR9w7E3SFqXGqN1v6APDK0V +lpVFq+rYlprvbf4GB0oR8yQOGtlxf+Ag3Nnx+90nlmtTK4k7tQpLzuAXGGREDCtn0y/YvWvGt6kN +EV5Q/mAVe2/CtAUvfyX7V3xlzYCrJT9DBcCBMaUUekFrwvZi13WYJIn7YE2Qmam7ZsXdb991PoFv ++c6Pmeg6w3y7D+Vj4Yfi8IrjPrc6765DaaZxFyMia9GEQKHChZDkiEiAM6RfwlC5YXGzCroaZi0Y +Knf/UkUWoa/jKZgQNiqrZ9oGmbURLeXkkHzpcFitwjzWr6tNScCzNIqs/uxTxbFM8fJu1gSmauEY +TE1rn62SiuHNRKJqfLcCHucStK10knHkHTAJ3avS7rBz0Dy8UOa77bOjyei5n2rkyXztL2YjjGYh +8jEt00xcvwJGePBfH10gCgTFWdfhfcP9/muKgiOSErQlHPypnr4vqO0PU9XDp106FFWyyNPd95kC +l5IF9WMfl7YHpT0Ph7kBYwg9sKF/7oCVdbT5CoImxkE5DTkWB8xX6W/BhuMrp1rzTHFFGVd1ppb7 +EMUll4dd78OWonMlIgsMRuTSn93awb4X8xSJhRi9 +` +) + +func init() { + repairRootAccountKey, err := asserts.Decode([]byte(encodedRepairRootAccountKey)) + if err != nil { + panic(fmt.Sprintf("cannot decode trusted account-key: %v", err)) + } + if !snapdenv.UseStagingStore() { + trustedRepairRootKeys = append(trustedRepairRootKeys, repairRootAccountKey.(*asserts.AccountKey)) + } +} diff --git a/cmd/snap-seccomp-blacklist/.gitignore b/cmd/snap-seccomp-blacklist/.gitignore new file mode 100644 index 00000000..fdf9a385 --- /dev/null +++ b/cmd/snap-seccomp-blacklist/.gitignore @@ -0,0 +1,4 @@ +*.bpf +*.o +*.pfc +snap-seccomp-blacklist diff --git a/cmd/snap-seccomp-blacklist/BE-bpf-script b/cmd/snap-seccomp-blacklist/BE-bpf-script new file mode 100644 index 00000000..218fbc82 --- /dev/null +++ b/cmd/snap-seccomp-blacklist/BE-bpf-script @@ -0,0 +1,3 @@ +load bpf-blob BE-blacklist.bpf +disassemble +dump diff --git a/cmd/snap-seccomp-blacklist/LE-bpf-script b/cmd/snap-seccomp-blacklist/LE-bpf-script new file mode 100644 index 00000000..58fdfc9a --- /dev/null +++ b/cmd/snap-seccomp-blacklist/LE-bpf-script @@ -0,0 +1,3 @@ +load bpf-blob LE-blacklist.bpf +disassemble +dump diff --git a/cmd/snap-seccomp-blacklist/Makefile b/cmd/snap-seccomp-blacklist/Makefile new file mode 100644 index 00000000..e7076146 --- /dev/null +++ b/cmd/snap-seccomp-blacklist/Makefile @@ -0,0 +1,40 @@ +.PHONY: all +all: $(foreach v,LE BE,$v-blacklist.bpf) | analyze + +.PHONY: clean +clean: + rm -f snap-seccomp-blacklist snap-seccomp-blacklist.o *.pfc *.bpf + +.PHONY: fmt +fmt: snap-seccomp-blacklist.c + clang-format -style='{BasedOnStyle: Google, IndentWidth: 4, ColumnLimit: 120}' -i $^ + +DESTDIR ?= + +.PHONY: install +install:: blacklist.bpf | $(DESTDIR)/var/lib/snapd/seccomp/bpf + install -m 644 $^ $|/global.bin + +$(DESTDIR)/var/lib/snapd/seccomp/bpf: + install -m 755 -d $@ + +$(foreach v,LE BE,$v-blacklist.pfc $v-blacklist.bpf): snap-seccomp-blacklist + ./$< + +.PHONY: analyze +analyze: $(foreach v,LE BE,$v-blacklist.bpf $v-blacklist.pfc $v-bpf-script) + # Not everyone has bpf_dbg installed, not everyone has support for "load bpf-blob". + -bpf_dbg LE-bpf-script + cat LE-blacklist.pfc + -bpf_dbg BE-bpf-script + cat BE-blacklist.pfc + +snap-seccomp-blacklist: snap-seccomp-blacklist.o + $(CC) -o $@ $^ $(LDLIBS) + +%.o: %.c + $(CC) -o $@ -c $^ $(CFLAGS) + +CFLAGS += -Wall -Werror +CFLAGS += $(shell pkg-config libseccomp --cflags) +LDLIBS += $(shell pkg-config libseccomp --libs) diff --git a/cmd/snap-seccomp-blacklist/snap-seccomp-blacklist.c b/cmd/snap-seccomp-blacklist/snap-seccomp-blacklist.c new file mode 100644 index 00000000..b0205b6a --- /dev/null +++ b/cmd/snap-seccomp-blacklist/snap-seccomp-blacklist.c @@ -0,0 +1,226 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +__attribute__((format(printf, 1, 2))) static void showerr(const char *fmt, ...); + +static void showerr(const char *fmt, ...) { + va_list va; + va_start(va, fmt); + vfprintf(stderr, fmt, va); + fputc('\n', stderr); + va_end(va); +} + +static int populate_filter(scmp_filter_ctx ctx, const uint32_t *arch_tags, size_t num_arch_tags) { + int sc_err; + + /* If the native architecture is not one of the supported 64bit + * architectures listed in main in le_arch_tags and be_arch_tags, then + * remove it. + * + * Libseccomp automatically adds the native architecture to each new filter. + * If the native architecture is a 32bit-one then we will hit a bug in libseccomp + * and the generated BPF program is incorrect as described below. */ + uint32_t native_arch = seccomp_arch_native(); + bool remove_native_arch = true; + for (size_t i = 0; i < num_arch_tags; ++i) { + if (arch_tags[i] == native_arch) { + remove_native_arch = false; + break; + } + } + if (remove_native_arch) { + sc_err = seccomp_arch_remove(ctx, SCMP_ARCH_NATIVE); + if (sc_err < 0) { + showerr("cannot remove native architecture"); + return sc_err; + } + } + + /* Add 64-bit architectures supported by snapd into the seccomp filter. + * + * The documentation of seccomp_arch_add() is confusing. It says that after + * this call any new rules will be added to this architecture. This is + * correct. It doesn't, however, explain that the rules will be multiplied + * and re-written as explained below. */ + for (size_t i = 0; i < num_arch_tags; ++i) { + uint32_t arch_tag = arch_tags[i]; + sc_err = seccomp_arch_add(ctx, arch_tag); + if (sc_err < 0 && sc_err != -EEXIST) { + showerr("cannot add architecture %x", arch_tag); + return sc_err; + } + } + + /* When the rule set doesn't match one of the architectures above then the + * resulting action should be a "allow" rather than "kill". We don't add + * any of the 32bit architectures since there is no need for any extra + * filtering there. */ + sc_err = seccomp_attr_set(ctx, SCMP_FLTATR_ACT_BADARCH, SCMP_ACT_ALLOW); + if (sc_err < 0) { + showerr("cannot set action for unknown architectures"); + return sc_err; + } + + /* Resolve the name of "ioctl" on this architecture. We are not using the + * system call number as available through the appropriate linux-specific + * header. This allows us to use a system call number that is not defined + * for the current architecture. This does not matter here, in this + * specific program, however it is more generic. In addition this is more + * in sync with the snap-seccomp program, which does the same for every + * system call. */ + int sys_ioctl_nr; + sys_ioctl_nr = seccomp_syscall_resolve_name("ioctl"); + if (sys_ioctl_nr == __NR_SCMP_ERROR) { + showerr("cannot resolve ioctl system call number"); + return -ESRCH; + } + + /* All of the rules must be added for the native architecture (using native + * system call numbers). When the final program is generated the set of + * architectures added earlier will be used to determine the correct system + * call number for each architecture. + * + * In other words, arguments to scmp_rule_add() must always use native + * system call numbers. Translation for the correct architecture will be + * performed internally. This is not documented in libseccomp, but correct + * operation was confirmed using the pseudo-code program and the bpf_dbg + * tool from the kernel tools/bpf directory. + * + * NOTE: not using scmp_rule_add_exact as that was not doing anything + * at all (presumably due to having all the architectures defined). */ + + struct scmp_arg_cmp no_tty_inject = { + /* We learned that existing programs make legitimate requests with all + * bits set in the more significant 32bit word of the 64 bit double + * word. While this kernel behavior remains suspect and presumably + * undesired it is unlikely to change for backwards compatibility + * reasons. As such we cannot block all requests with high-bits set. + * + * When faced with ioctl(fd, request); refuse to proceed when + * request&0xffffffff == TIOCSTI. This specific way to encode the + * filter has the following important properties: + * + * - it blocks ioctl(fd, TIOCSTI, ptr). + * - it also blocks ioctl(fd, (1UL<<32) | TIOCSTI, ptr). + * - it doesn't block ioctl(fd, (1UL<<32) | (request not equal to TIOCSTI), ptr); */ + .arg = 1, + .op = SCMP_CMP_MASKED_EQ, + .datum_a = 0xffffffffUL, + .datum_b = TIOCSTI, + }; + sc_err = seccomp_rule_add(ctx, SCMP_ACT_ERRNO(EPERM), sys_ioctl_nr, 1, no_tty_inject); + + /* also block use of TIOCLINUX */ + no_tty_inject.datum_b = TIOCLINUX; + sc_err = seccomp_rule_add(ctx, SCMP_ACT_ERRNO(EPERM), sys_ioctl_nr, 1, no_tty_inject); + + if (sc_err < 0) { + showerr("cannot add rule preventing the use high bits in ioctl"); + return sc_err; + } + return 0; +} + +typedef struct arch_set { + const char *name; + const uint32_t *arch_tags; + size_t num_arch_tags; +} arch_set; + +int main(int argc, char **argv) { + const uint32_t le_arch_tags[] = { + SCMP_ARCH_X86_64, + SCMP_ARCH_AARCH64, + SCMP_ARCH_PPC64LE, + SCMP_ARCH_S390X, + }; + const uint32_t be_arch_tags[] = { + SCMP_ARCH_S390X, + }; + const arch_set arch_sets[] = { + {"LE", le_arch_tags, sizeof le_arch_tags / sizeof *le_arch_tags}, + {"BE", be_arch_tags, sizeof be_arch_tags / sizeof *be_arch_tags}, + }; + int rc = -1; + + for (size_t i = 0; i < sizeof arch_sets / sizeof *arch_sets; ++i) { + const arch_set *as = &arch_sets[i]; + int sc_err; + int fd = -1; + int fname_len; + char fname[PATH_MAX]; + + scmp_filter_ctx ctx = NULL; + ctx = seccomp_init(SCMP_ACT_ALLOW); + if (ctx == NULL) { + showerr("cannot construct seccomp context"); + return -rc; + } + sc_err = populate_filter(ctx, as->arch_tags, as->num_arch_tags); + if (sc_err < 0) { + seccomp_release(ctx); + return -rc; + } + + /* Save pseudo-code program */ + fname_len = snprintf(fname, sizeof fname, "%s-blacklist.pfc", as->name); + if (fname_len < 0 || fname_len >= sizeof fname) { + showerr("cannot format file name (%s)", as->name); + seccomp_release(ctx); + return -rc; + } + fd = open(fname, O_CREAT | O_TRUNC | O_WRONLY | O_NOFOLLOW, 0644); + if (fd < 0) { + showerr("cannot open file %s", fname); + seccomp_release(ctx); + return -rc; + } + sc_err = seccomp_export_pfc(ctx, fd); + if (sc_err < 0) { + showerr("cannot export PFC program %s", fname); + seccomp_release(ctx); + close(fd); + return -rc; + } + + close(fd); + + /* Save binary program. */ + fname_len = snprintf(fname, sizeof fname, "%s-blacklist.bpf", as->name); + if (fname_len < 0 || fname_len >= sizeof fname) { + showerr("cannot format file name (%s)", as->name); + seccomp_release(ctx); + return -rc; + } + fd = open(fname, O_CREAT | O_TRUNC | O_WRONLY | O_NOFOLLOW, 0644); + if (fd < 0) { + showerr("cannot open file %s", fname); + seccomp_release(ctx); + return -rc; + } + sc_err = seccomp_export_bpf(ctx, fd); + if (sc_err < 0) { + showerr("cannot export BPF program %s", fname); + seccomp_release(ctx); + close(fd); + return -rc; + } + + close(fd); + seccomp_release(ctx); + } + rc = 0; + return -rc; +} diff --git a/cmd/snap-seccomp/export_test.go b/cmd/snap-seccomp/export_test.go new file mode 100644 index 00000000..74d88055 --- /dev/null +++ b/cmd/snap-seccomp/export_test.go @@ -0,0 +1,80 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "os" + + "github.com/snapcore/snapd/testutil" +) + +var ( + Compile = compile + SeccompResolver = seccompResolver + VersionInfo = versionInfo + GoSeccompFeatures = goSeccompFeatures + ExportBPF = exportBPF +) + +func MockArchDpkgArchitecture(f func() string) (restore func()) { + realArchDpkgArchitecture := archDpkgArchitecture + archDpkgArchitecture = f + return func() { + archDpkgArchitecture = realArchDpkgArchitecture + } +} + +func MockArchDpkgKernelArchitecture(f func() string) (restore func()) { + realArchDpkgKernelArchitecture := archDpkgKernelArchitecture + archDpkgKernelArchitecture = f + return func() { + archDpkgKernelArchitecture = realArchDpkgKernelArchitecture + } +} + +func MockErrnoOnImplicitDenial(i int16) (retore func()) { + origErrnoOnImplicitDenial := errnoOnImplicitDenial + errnoOnImplicitDenial = i + return func() { + errnoOnImplicitDenial = origErrnoOnImplicitDenial + } +} + +func MockErrnoOnExplicitDenial(i int16) (retore func()) { + origErrnoOnExplicitDenial := errnoOnExplicitDenial + errnoOnExplicitDenial = i + return func() { + errnoOnExplicitDenial = origErrnoOnExplicitDenial + } +} + +func MockSeccompSyscalls(syscalls []string) (resture func()) { + old := seccompSyscalls + seccompSyscalls = syscalls + return func() { + seccompSyscalls = old + } +} + +func MockOsCreateTemp(f func(dir, pattern string) (*os.File, error)) (restore func()) { + restore = testutil.Backup(&osCreateTemp) + osCreateTemp = f + return restore +} diff --git a/cmd/snap-seccomp/main.go b/cmd/snap-seccomp/main.go new file mode 100644 index 00000000..49a81718 --- /dev/null +++ b/cmd/snap-seccomp/main.go @@ -0,0 +1,1045 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2017-2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +//#cgo CFLAGS: -D_FILE_OFFSET_BITS=64 +//#cgo pkg-config: libseccomp +//#cgo LDFLAGS: +// +//#include +//#include +//#include +//#include +//#include +//#include +//#include +//#include +//#include +//#include +//#include +//#include +//#include +//#include +//#include +//#include +//#include +//#include +//#include +//#include +//#include +//#include +// //The XFS interface requires a 64 bit file system interface +// //but we don't want to leak this anywhere else if not globally +// //defined. +//#ifndef _FILE_OFFSET_BITS +//#define _FILE_OFFSET_BITS 64 +//#include +//#undef _FILE_OFFSET_BITS +//#else +//#include +//#endif +//#include +//#include +//#include +//#include +// +//#ifndef AF_IB +//#define AF_IB 27 +//#define PF_IB AF_IB +//#endif // AF_IB +// +//#ifndef AF_MPLS +//#define AF_MPLS 28 +//#define PF_MPLS AF_MPLS +//#endif // AF_MPLS +// +//#ifndef AF_QIPCRTR +//#define AF_QIPCRTR 42 +//#define PF_QIPCRTR AF_QIPCRTR +//#endif // AF_QIPCRTR +// +//#ifndef AF_XDP +//#define AF_XDP 44 +//#define PF_XDP AF_XDP +//#endif // AF_XDP +// +// // https://github.com/sctplab/usrsctp/blob/master/usrsctplib/usrsctp.h +//#ifndef AF_CONN +//#define AF_CONN 123 +//#define PF_CONN AF_CONN +//#endif // AF_CONN +// +//#ifndef PR_CAP_AMBIENT +//#define PR_CAP_AMBIENT 47 +//#define PR_CAP_AMBIENT_IS_SET 1 +//#define PR_CAP_AMBIENT_RAISE 2 +//#define PR_CAP_AMBIENT_LOWER 3 +//#define PR_CAP_AMBIENT_CLEAR_ALL 4 +//#endif // PR_CAP_AMBIENT +// +//#ifndef PR_SET_THP_DISABLE +//#define PR_SET_THP_DISABLE 41 +//#endif // PR_SET_THP_DISABLE +//#ifndef PR_GET_THP_DISABLE +//#define PR_GET_THP_DISABLE 42 +//#endif // PR_GET_THP_DISABLE +// +//#ifndef PR_MPX_ENABLE_MANAGEMENT +//#define PR_MPX_ENABLE_MANAGEMENT 43 +//#endif +// +//#ifndef PR_MPX_DISABLE_MANAGEMENT +//#define PR_MPX_DISABLE_MANAGEMENT 44 +//#endif +// +// //FIXME: ARCH_BAD is defined as ~0 in libseccomp internally, however +// // this leads to a build failure on 14.04. the important part +// // is that its an invalid id for libseccomp. +// +//#define ARCH_BAD 0x7FFFFFFF +//#ifndef SCMP_ARCH_AARCH64 +//#define SCMP_ARCH_AARCH64 ARCH_BAD +//#endif +// +//#ifndef SCMP_ARCH_PPC +//#define SCMP_ARCH_PPC ARCH_BAD +//#endif +// +//#ifndef SCMP_ARCH_PPC64LE +//#define SCMP_ARCH_PPC64LE ARCH_BAD +//#endif +// +//#ifndef SCMP_ARCH_PPC64 +//#define SCMP_ARCH_PPC64 ARCH_BAD +//#endif +// +//#ifndef SCMP_ARCH_S390X +//#define SCMP_ARCH_S390X ARCH_BAD +//#endif +// +//#ifndef SCMP_ARCH_RISCV64 +//#define SCMP_ARCH_RISCV64 ARCH_BAD +//#endif +// +//#ifndef SECCOMP_RET_LOG +//#define SECCOMP_RET_LOG 0x7ffc0000U +//#endif +// +//typedef struct seccomp_data kernel_seccomp_data; +// +//__u32 htot32(__u32 arch, __u32 val) +//{ +// if (arch & __AUDIT_ARCH_LE) +// return htole32(val); +// else +// return htobe32(val); +//} +// +//__u64 htot64(__u32 arch, __u64 val) +//{ +// if (arch & __AUDIT_ARCH_LE) +// return htole64(val); +// else +// return htobe64(val); +//} +// +// /* Define missing ptrace constants. They are available on some architectures +// only but the missing values are not reused on architectures that lack them. +// As such we can simply define the missing pair and have a simpler cross-arch +// code to support. */ +// +// #ifndef PTRACE_GETREGS +// #define PTRACE_GETREGS 12 +// #endif +// #ifndef PTRACE_SETREGS +// #define PTRACE_SETREGS 13 +// #endif +// #ifndef PTRACE_GETFPREGS +// #define PTRACE_GETFPREGS 14 +// #endif +// #ifndef PTRACE_SETFPREGS +// #define PTRACE_SETFPREGS 15 +// #endif +// #ifndef PTRACE_GETFPXREGS +// #define PTRACE_GETFPXREGS 18 +// #endif +// #ifndef PTRACE_SETFPXREGS +// #define PTRACE_SETFPXREGS 19 +// #endif +// +// /* Define TIOCLINUX if needed */ +// #ifndef TIOCLINUX +// #define TIOCLINUX 0x541C +// #endif +// +//#include +//#if LINUX_VERSION_CODE >= KERNEL_VERSION(3,19,0) +// #include +//#else // LINUX_VERSION_CODE >= KERNEL_VERSION(3,19,0) +// /* Define missing kcmp constants */ +// #define KCMP_FILE 0 +// #define KCMP_VM 1 +// #define KCMP_FILES 2 +// #define KCMP_FS 3 +// #define KCMP_SIGHAND 4 +// #define KCMP_IO 5 +// #define KCMP_SYSVSEM 6 +//#endif // LINUX_VERSION_CODE >= KERNEL_VERSION(3,19,0) +//#if LINUX_VERSION_CODE < KERNEL_VERSION(4,13,0) +// #define KCMP_EPOLL_TFD 7 +//#endif // LINUX_VERSION_CODE < KERNEL_VERSION(4,13,0) +import "C" + +import ( + "bufio" + "bytes" + "encoding/binary" + "fmt" + "io" + "os" + "strconv" + "strings" + "syscall" + + seccomp "github.com/seccomp/libseccomp-golang" + + "github.com/snapcore/snapd/arch" + "github.com/snapcore/snapd/osutil" +) + +// libseccomp maximum per ARG_COUNT_MAX in src/arch.h +const ScArgsMaxlength = 6 + +var seccompResolver = map[string]uint64{ + // man 2 socket - domain and man 5 apparmor.d. AF_ and PF_ are + // synonymous in the kernel and can be used interchangeably in + // policy (ie, if use AF_UNIX, don't need a corresponding PF_UNIX + // rule). See include/linux/socket.h + "AF_UNIX": syscall.AF_UNIX, + "PF_UNIX": C.PF_UNIX, + "AF_LOCAL": syscall.AF_LOCAL, + "PF_LOCAL": C.PF_LOCAL, + "AF_INET": syscall.AF_INET, + "PF_INET": C.PF_INET, + "AF_INET6": syscall.AF_INET6, + "PF_INET6": C.PF_INET6, + "AF_IPX": syscall.AF_IPX, + "PF_IPX": C.PF_IPX, + "AF_NETLINK": syscall.AF_NETLINK, + "PF_NETLINK": C.PF_NETLINK, + "AF_X25": syscall.AF_X25, + "PF_X25": C.PF_X25, + "AF_AX25": syscall.AF_AX25, + "PF_AX25": C.PF_AX25, + "AF_ATMPVC": syscall.AF_ATMPVC, + "PF_ATMPVC": C.PF_ATMPVC, + "AF_APPLETALK": syscall.AF_APPLETALK, + "PF_APPLETALK": C.PF_APPLETALK, + "AF_PACKET": syscall.AF_PACKET, + "PF_PACKET": C.PF_PACKET, + "AF_ALG": syscall.AF_ALG, + "PF_ALG": C.PF_ALG, + "AF_BRIDGE": syscall.AF_BRIDGE, + "PF_BRIDGE": C.PF_BRIDGE, + "AF_NETROM": syscall.AF_NETROM, + "PF_NETROM": C.PF_NETROM, + "AF_ROSE": syscall.AF_ROSE, + "PF_ROSE": C.PF_ROSE, + "AF_NETBEUI": syscall.AF_NETBEUI, + "PF_NETBEUI": C.PF_NETBEUI, + "AF_SECURITY": syscall.AF_SECURITY, + "PF_SECURITY": C.PF_SECURITY, + "AF_KEY": syscall.AF_KEY, + "PF_KEY": C.PF_KEY, + "AF_ASH": syscall.AF_ASH, + "PF_ASH": C.PF_ASH, + "AF_ECONET": syscall.AF_ECONET, + "PF_ECONET": C.PF_ECONET, + "AF_SNA": syscall.AF_SNA, + "PF_SNA": C.PF_SNA, + "AF_IRDA": syscall.AF_IRDA, + "PF_IRDA": C.PF_IRDA, + "AF_PPPOX": syscall.AF_PPPOX, + "PF_PPPOX": C.PF_PPPOX, + "AF_WANPIPE": syscall.AF_WANPIPE, + "PF_WANPIPE": C.PF_WANPIPE, + "AF_BLUETOOTH": syscall.AF_BLUETOOTH, + "PF_BLUETOOTH": C.PF_BLUETOOTH, + "AF_RDS": syscall.AF_RDS, + "PF_RDS": C.PF_RDS, + "AF_LLC": syscall.AF_LLC, + "PF_LLC": C.PF_LLC, + "AF_TIPC": syscall.AF_TIPC, + "PF_TIPC": C.PF_TIPC, + "AF_IUCV": syscall.AF_IUCV, + "PF_IUCV": C.PF_IUCV, + "AF_RXRPC": syscall.AF_RXRPC, + "PF_RXRPC": C.PF_RXRPC, + "AF_ISDN": syscall.AF_ISDN, + "PF_ISDN": C.PF_ISDN, + "AF_PHONET": syscall.AF_PHONET, + "PF_PHONET": C.PF_PHONET, + "AF_IEEE802154": syscall.AF_IEEE802154, + "PF_IEEE802154": C.PF_IEEE802154, + "AF_CAIF": syscall.AF_CAIF, + "PF_CAIF": C.AF_CAIF, + "AF_NFC": C.AF_NFC, + "PF_NFC": C.PF_NFC, + "AF_VSOCK": C.AF_VSOCK, + "PF_VSOCK": C.PF_VSOCK, + // may not be defined in socket.h yet + "AF_IB": C.AF_IB, // 27 + "PF_IB": C.PF_IB, + "AF_MPLS": C.AF_MPLS, // 28 + "PF_MPLS": C.PF_MPLS, + "AF_CAN": syscall.AF_CAN, + "PF_CAN": C.PF_CAN, + "AF_CONN": C.AF_CONN, // 123 + "PF_CONN": C.PF_CONN, + "AF_QIPCRTR": C.AF_QIPCRTR, // 42 + "PF_QIPCRTR": C.PF_QIPCRTR, + "AF_XDP": C.AF_XDP, // 44 + "PF_XDP": C.PF_XDP, + + // man 2 socket - type + "SOCK_STREAM": syscall.SOCK_STREAM, + "SOCK_DGRAM": syscall.SOCK_DGRAM, + "SOCK_SEQPACKET": syscall.SOCK_SEQPACKET, + "SOCK_RAW": syscall.SOCK_RAW, + "SOCK_RDM": syscall.SOCK_RDM, + "SOCK_PACKET": syscall.SOCK_PACKET, + + // man 2 prctl + "PR_CAP_AMBIENT": C.PR_CAP_AMBIENT, + "PR_CAP_AMBIENT_RAISE": C.PR_CAP_AMBIENT_RAISE, + "PR_CAP_AMBIENT_LOWER": C.PR_CAP_AMBIENT_LOWER, + "PR_CAP_AMBIENT_IS_SET": C.PR_CAP_AMBIENT_IS_SET, + "PR_CAP_AMBIENT_CLEAR_ALL": C.PR_CAP_AMBIENT_CLEAR_ALL, + "PR_CAPBSET_READ": C.PR_CAPBSET_READ, + "PR_CAPBSET_DROP": C.PR_CAPBSET_DROP, + "PR_SET_CHILD_SUBREAPER": C.PR_SET_CHILD_SUBREAPER, + "PR_GET_CHILD_SUBREAPER": C.PR_GET_CHILD_SUBREAPER, + "PR_SET_DUMPABLE": C.PR_SET_DUMPABLE, + "PR_GET_DUMPABLE": C.PR_GET_DUMPABLE, + "PR_SET_ENDIAN": C.PR_SET_ENDIAN, + "PR_GET_ENDIAN": C.PR_GET_ENDIAN, + "PR_SET_FPEMU": C.PR_SET_FPEMU, + "PR_GET_FPEMU": C.PR_GET_FPEMU, + "PR_SET_FPEXC": C.PR_SET_FPEXC, + "PR_GET_FPEXC": C.PR_GET_FPEXC, + "PR_SET_KEEPCAPS": C.PR_SET_KEEPCAPS, + "PR_GET_KEEPCAPS": C.PR_GET_KEEPCAPS, + "PR_MCE_KILL": C.PR_MCE_KILL, + "PR_MCE_KILL_GET": C.PR_MCE_KILL_GET, + "PR_SET_MM": C.PR_SET_MM, + "PR_SET_MM_START_CODE": C.PR_SET_MM_START_CODE, + "PR_SET_MM_END_CODE": C.PR_SET_MM_END_CODE, + "PR_SET_MM_START_DATA": C.PR_SET_MM_START_DATA, + "PR_SET_MM_END_DATA": C.PR_SET_MM_END_DATA, + "PR_SET_MM_START_STACK": C.PR_SET_MM_START_STACK, + "PR_SET_MM_START_BRK": C.PR_SET_MM_START_BRK, + "PR_SET_MM_BRK": C.PR_SET_MM_BRK, + "PR_SET_MM_ARG_START": C.PR_SET_MM_ARG_START, + "PR_SET_MM_ARG_END": C.PR_SET_MM_ARG_END, + "PR_SET_MM_ENV_START": C.PR_SET_MM_ENV_START, + "PR_SET_MM_ENV_END": C.PR_SET_MM_ENV_END, + "PR_SET_MM_AUXV": C.PR_SET_MM_AUXV, + "PR_SET_MM_EXE_FILE": C.PR_SET_MM_EXE_FILE, + "PR_MPX_ENABLE_MANAGEMENT": C.PR_MPX_ENABLE_MANAGEMENT, + "PR_MPX_DISABLE_MANAGEMENT": C.PR_MPX_DISABLE_MANAGEMENT, + "PR_SET_NAME": C.PR_SET_NAME, + "PR_GET_NAME": C.PR_GET_NAME, + "PR_SET_NO_NEW_PRIVS": C.PR_SET_NO_NEW_PRIVS, + "PR_GET_NO_NEW_PRIVS": C.PR_GET_NO_NEW_PRIVS, + "PR_SET_PDEATHSIG": C.PR_SET_PDEATHSIG, + "PR_GET_PDEATHSIG": C.PR_GET_PDEATHSIG, + "PR_SET_PTRACER": C.PR_SET_PTRACER, + "PR_SET_SECCOMP": C.PR_SET_SECCOMP, + "PR_GET_SECCOMP": C.PR_GET_SECCOMP, + "PR_SET_SECUREBITS": C.PR_SET_SECUREBITS, + "PR_GET_SECUREBITS": C.PR_GET_SECUREBITS, + "PR_SET_THP_DISABLE": C.PR_SET_THP_DISABLE, + "PR_TASK_PERF_EVENTS_DISABLE": C.PR_TASK_PERF_EVENTS_DISABLE, + "PR_TASK_PERF_EVENTS_ENABLE": C.PR_TASK_PERF_EVENTS_ENABLE, + "PR_GET_THP_DISABLE": C.PR_GET_THP_DISABLE, + "PR_GET_TID_ADDRESS": C.PR_GET_TID_ADDRESS, + "PR_SET_TIMERSLACK": C.PR_SET_TIMERSLACK, + "PR_GET_TIMERSLACK": C.PR_GET_TIMERSLACK, + "PR_SET_TIMING": C.PR_SET_TIMING, + "PR_GET_TIMING": C.PR_GET_TIMING, + "PR_SET_TSC": C.PR_SET_TSC, + "PR_GET_TSC": C.PR_GET_TSC, + "PR_SET_UNALIGN": C.PR_SET_UNALIGN, + "PR_GET_UNALIGN": C.PR_GET_UNALIGN, + + // man 2 getpriority + "PRIO_PROCESS": syscall.PRIO_PROCESS, + "PRIO_PGRP": syscall.PRIO_PGRP, + "PRIO_USER": syscall.PRIO_USER, + + // man 2 setns + "CLONE_NEWIPC": syscall.CLONE_NEWIPC, + "CLONE_NEWNET": syscall.CLONE_NEWNET, + "CLONE_NEWNS": syscall.CLONE_NEWNS, + "CLONE_NEWPID": syscall.CLONE_NEWPID, + "CLONE_NEWUSER": syscall.CLONE_NEWUSER, + "CLONE_NEWUTS": syscall.CLONE_NEWUTS, + + // man 4 tty_ioctl + "TIOCSTI": syscall.TIOCSTI, + + // man 2 ioctl_console + "TIOCLINUX": C.TIOCLINUX, + + // man 2 quotactl (with what Linux supports) + "Q_SYNC": C.Q_SYNC, + "Q_QUOTAON": C.Q_QUOTAON, + "Q_QUOTAOFF": C.Q_QUOTAOFF, + "Q_GETFMT": C.Q_GETFMT, + "Q_GETINFO": C.Q_GETINFO, + "Q_SETINFO": C.Q_SETINFO, + "Q_GETQUOTA": C.Q_GETQUOTA, + "Q_SETQUOTA": C.Q_SETQUOTA, + "Q_XQUOTAON": C.Q_XQUOTAON, + "Q_XQUOTAOFF": C.Q_XQUOTAOFF, + "Q_XGETQUOTA": C.Q_XGETQUOTA, + "Q_XSETQLIM": C.Q_XSETQLIM, + "Q_XGETQSTAT": C.Q_XGETQSTAT, + "Q_XQUOTARM": C.Q_XQUOTARM, + + // man 2 mknod + "S_IFREG": syscall.S_IFREG, + "S_IFCHR": syscall.S_IFCHR, + "S_IFBLK": syscall.S_IFBLK, + "S_IFIFO": syscall.S_IFIFO, + "S_IFSOCK": syscall.S_IFSOCK, + + // man 7 netlink (uapi/linux/netlink.h) + "NETLINK_ROUTE": syscall.NETLINK_ROUTE, + "NETLINK_USERSOCK": syscall.NETLINK_USERSOCK, + "NETLINK_FIREWALL": syscall.NETLINK_FIREWALL, + "NETLINK_SOCK_DIAG": C.NETLINK_SOCK_DIAG, + "NETLINK_NFLOG": syscall.NETLINK_NFLOG, + "NETLINK_XFRM": syscall.NETLINK_XFRM, + "NETLINK_SELINUX": syscall.NETLINK_SELINUX, + "NETLINK_ISCSI": syscall.NETLINK_ISCSI, + "NETLINK_AUDIT": syscall.NETLINK_AUDIT, + "NETLINK_FIB_LOOKUP": syscall.NETLINK_FIB_LOOKUP, + "NETLINK_CONNECTOR": syscall.NETLINK_CONNECTOR, + "NETLINK_NETFILTER": syscall.NETLINK_NETFILTER, + "NETLINK_IP6_FW": syscall.NETLINK_IP6_FW, + "NETLINK_DNRTMSG": syscall.NETLINK_DNRTMSG, + "NETLINK_KOBJECT_UEVENT": syscall.NETLINK_KOBJECT_UEVENT, + "NETLINK_GENERIC": syscall.NETLINK_GENERIC, + "NETLINK_SCSITRANSPORT": syscall.NETLINK_SCSITRANSPORT, + "NETLINK_ECRYPTFS": syscall.NETLINK_ECRYPTFS, + "NETLINK_RDMA": C.NETLINK_RDMA, + "NETLINK_CRYPTO": C.NETLINK_CRYPTO, + "NETLINK_INET_DIAG": C.NETLINK_INET_DIAG, // synonymous with NETLINK_SOCK_DIAG + + // man 2 ptrace + "PTRACE_ATTACH": C.PTRACE_ATTACH, + "PTRACE_DETACH": C.PTRACE_DETACH, + "PTRACE_GETREGS": C.PTRACE_GETREGS, + "PTRACE_GETFPREGS": C.PTRACE_GETFPREGS, + "PTRACE_GETFPXREGS": C.PTRACE_GETFPXREGS, + "PTRACE_GETREGSET": C.PTRACE_GETREGSET, + "PTRACE_PEEKDATA": C.PTRACE_PEEKDATA, + // and have different spellings for PEEKUS{,E}R + "PTRACE_PEEKUSR": C.PTRACE_PEEKUSER, + "PTRACE_PEEKUSER": C.PTRACE_PEEKUSER, + "PTRACE_CONT": C.PTRACE_CONT, + + // man 2 kcmp + "KCMP_FILE": C.KCMP_FILE, + "KCMP_VM": C.KCMP_VM, + "KCMP_FILES": C.KCMP_FILES, + "KCMP_FS": C.KCMP_FS, + "KCMP_SIGHAND": C.KCMP_SIGHAND, + "KCMP_IO": C.KCMP_IO, + "KCMP_SYSVSEM": C.KCMP_SYSVSEM, + "KCMP_EPOLL_TFD": C.KCMP_EPOLL_TFD, +} + +// DpkgArchToScmpArch takes a dpkg architecture and converts it to +// the seccomp.ScmpArch as used in the libseccomp-golang library +func DpkgArchToScmpArch(dpkgArch string) seccomp.ScmpArch { + switch dpkgArch { + case "amd64": + return seccomp.ArchAMD64 + case "arm64": + return seccomp.ArchARM64 + case "armhf": + return seccomp.ArchARM + case "i386": + return seccomp.ArchX86 + case "powerpc": + return seccomp.ArchPPC + case "ppc64": + return seccomp.ArchPPC64 + case "ppc64el": + return seccomp.ArchPPC64LE + case "s390x": + return seccomp.ArchS390X + } + return extraDpkgArchToScmpArch(dpkgArch) +} + +// important for unit testing +type SeccompData C.kernel_seccomp_data + +func (sc *SeccompData) SetNr(nr seccomp.ScmpSyscall) { + sc.nr = C.int(C.htot32(C.__u32(sc.arch), C.__u32(nr))) +} +func (sc *SeccompData) SetArch(arch uint32) { + sc.arch = C.htot32(C.__u32(arch), C.__u32(arch)) +} +func (sc *SeccompData) SetArgs(args [6]uint64) { + for i := range args { + sc.args[i] = C.htot64(sc.arch, C.__u64(args[i])) + } +} + +// Only support negative args for syscalls where we understand the glibc/kernel +// prototypes and behavior. This lists all the syscalls that support negative +// arguments where we want to ignore the high 32 bits (ie, we'll mask it since +// the arg is known to be 32 bit (uid_t/gid_t) and the kernel accepts one +// or both of uint32(-1) and uint64(-1) and does its own masking). +var syscallsWithNegArgsMaskHi32 = map[string]bool{ + "chown": true, + "chown32": true, + "fchown": true, + "fchown32": true, + "fchownat": true, + "lchown": true, + "lchown32": true, + "setgid": true, + "setgid32": true, + "setregid": true, + "setregid32": true, + "setresgid": true, + "setresgid32": true, + "setreuid": true, + "setreuid32": true, + "setresuid": true, + "setresuid32": true, + "setuid": true, + "setuid32": true, + "copy_file_range": true, +} + +// The kernel uses uint32 for all syscall arguments, but seccomp takes a +// uint64. For unsigned ints in our policy, just read straight into uint32 +// since we don't need to worry about sign extending. +// +// For negative signed ints in our policy, we first read in as int32, convert +// to uint32 and then again uint64 to avoid sign extension woes (see +// https://github.com/seccomp/libseccomp/issues/69). For syscalls that take +// a 64bit arg that we want to express in our policy, we can add an exception +// for reading into a uint64. For now there are no exceptions, so don't need to +// do anything extra. +func readNumber(token string, syscallName string) (uint64, error) { + if value, ok := seccompResolver[token]; ok { + return value, nil + } + + if value, err := strconv.ParseUint(token, 10, 32); err == nil { + return value, nil + } + + // Not a positive integer, see if negative is allowed for this syscall + if !syscallsWithNegArgsMaskHi32[syscallName] { + return 0, fmt.Errorf(`negative argument not supported with "%s"`, syscallName) + } + + // It is, so try to parse as an int32 + value, err := strconv.ParseInt(token, 10, 32) + if err != nil { + return 0, err + } + + // convert the int32 to uint32 then to uint64 (see above) + return uint64(uint32(value)), nil +} + +var ( + errnoOnExplicitDenial int16 = C.EACCES + errnoOnImplicitDenial int16 = C.EPERM +) + +func parseLine(line string, secFilterAllow, secFilterDeny *seccomp.ScmpFilter) error { + // ignore comments and empty lines + if strings.HasPrefix(line, "#") || line == "" { + return nil + } + secFilter := secFilterAllow + + // regular line + tokens := strings.Fields(line) + if len(tokens[1:]) > ScArgsMaxlength { + return fmt.Errorf("too many arguments specified for syscall '%s' in line %q", tokens[0], line) + } + + // allow the listed syscall but also support explicit denials as well by + // prefixing the line with a ~ + action := seccomp.ActAllow + + // fish out syscall + syscallName := tokens[0] + if strings.HasPrefix(syscallName, "~") { + action = seccomp.ActErrno.SetReturnCode(errnoOnExplicitDenial) + syscallName = syscallName[1:] + secFilter = secFilterDeny + } + + secSyscall, err := seccomp.GetSyscallFromName(syscallName) + if err != nil { + // FIXME: use structed error in libseccomp-golang when + // https://github.com/seccomp/libseccomp-golang/pull/26 + // gets merged. For now, ignore + // unknown syscalls + return nil + } + + var conds []seccomp.ScmpCondition + for pos, arg := range tokens[1:] { + var cmpOp seccomp.ScmpCompareOp + var value uint64 + var err error + + if arg == "-" { // skip arg + continue + } + + if strings.HasPrefix(arg, ">=") { + cmpOp = seccomp.CompareGreaterEqual + value, err = readNumber(arg[2:], syscallName) + } else if strings.HasPrefix(arg, "<=") { + cmpOp = seccomp.CompareLessOrEqual + value, err = readNumber(arg[2:], syscallName) + } else if strings.HasPrefix(arg, "!") { + cmpOp = seccomp.CompareNotEqual + value, err = readNumber(arg[1:], syscallName) + } else if strings.HasPrefix(arg, "<") { + cmpOp = seccomp.CompareLess + value, err = readNumber(arg[1:], syscallName) + } else if strings.HasPrefix(arg, ">") { + cmpOp = seccomp.CompareGreater + value, err = readNumber(arg[1:], syscallName) + } else if strings.HasPrefix(arg, "|") { + cmpOp = seccomp.CompareMaskedEqual + value, err = readNumber(arg[1:], syscallName) + } else if strings.HasPrefix(arg, "u:") { + cmpOp = seccomp.CompareEqual + value, err = findUid(arg[2:]) + if err != nil { + return fmt.Errorf("cannot parse token %q (line %q): %v", arg, line, err) + } + } else if strings.HasPrefix(arg, "g:") { + cmpOp = seccomp.CompareEqual + value, err = findGid(arg[2:]) + if err != nil { + return fmt.Errorf("cannot parse token %q (line %q): %v", arg, line, err) + } + } else { + cmpOp = seccomp.CompareEqual + value, err = readNumber(arg, syscallName) + } + if err != nil { + return fmt.Errorf("cannot parse token %q (line %q)", arg, line) + } + + // For now only support EQ with negative args. If changing + // this, be sure to adjust readNumber accordingly and use + // libseccomp carefully. + if syscallsWithNegArgsMaskHi32[syscallName] { + if cmpOp != seccomp.CompareEqual { + return fmt.Errorf("cannot parse token %q (line %q): unsupported comparison", arg, line) + } + } + + var scmpCond seccomp.ScmpCondition + if cmpOp == seccomp.CompareMaskedEqual { + scmpCond, err = seccomp.MakeCondition(uint(pos), cmpOp, value, value) + } else if syscallsWithNegArgsMaskHi32[syscallName] { + scmpCond, err = seccomp.MakeCondition(uint(pos), seccomp.CompareMaskedEqual, 0xFFFFFFFF, value) + } else { + scmpCond, err = seccomp.MakeCondition(uint(pos), cmpOp, value) + } + if err != nil { + return fmt.Errorf("cannot parse line %q: %s", line, err) + } + conds = append(conds, scmpCond) + } + + // Default to adding a precise match if possible. Otherwise + // let seccomp figure out the architecture specifics. + if err = secFilter.AddRuleConditionalExact(secSyscall, action, conds); err != nil { + err = secFilter.AddRuleConditional(secSyscall, action, conds) + } + if err != nil { + return fmt.Errorf("cannot add rule for line %q: %v", line, err) + } + + return nil +} + +// used to mock in tests +var ( + archDpkgArchitecture = arch.DpkgArchitecture + archDpkgKernelArchitecture = arch.DpkgKernelArchitecture +) + +var ( + dpkgArchitecture = archDpkgArchitecture() + dpkgKernelArchitecture = archDpkgKernelArchitecture() +) + +// For architectures that support a compat architecture, when the +// kernel and userspace match, add the compat arch, otherwise add +// the kernel arch to support the kernel's arch (eg, 64bit kernels with +// 32bit userspace). +func addSecondaryArches(secFilter *seccomp.ScmpFilter) error { + // note that all architecture strings are in the dpkg + // architecture notation + var compatArch seccomp.ScmpArch + + // common case: kernel and userspace have the same arch. We + // add a compat architecture for some architectures that + // support it, e.g. on amd64 kernel and userland, we add + // compat i386 syscalls. + if dpkgArchitecture == dpkgKernelArchitecture { + switch archDpkgArchitecture() { + case "amd64": + compatArch = seccomp.ArchX86 + case "arm64": + compatArch = seccomp.ArchARM + case "ppc64": + compatArch = seccomp.ArchPPC + } + } else { + // less common case: kernel and userspace have different archs + // so add a compat architecture that matches the kernel. E.g. + // an amd64 kernel with i386 userland needs the amd64 secondary + // arch added to support specialized snaps that might + // conditionally call 64bit code when the kernel supports it. + // Note that in this case snapd requests i386 (or arch 'all') + // snaps. While unusual from a traditional Linux distribution + // perspective, certain classes of embedded devices are known + // to use this configuration. + compatArch = DpkgArchToScmpArch(archDpkgKernelArchitecture()) + } + + if compatArch != seccomp.ArchInvalid { + return secFilter.AddArch(compatArch) + } + + return nil +} + +func preprocess(content []byte) (unrestricted, complain bool) { + scanner := bufio.NewScanner(bytes.NewBuffer(content)) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + switch line { + case "@unrestricted": + unrestricted = true + case "@complain": + complain = true + } + } + return unrestricted, complain +} + +// With golang-seccomp <= 0.9.0, seccomp.ActLog is not available so guess +// at the ActLog value by adding one to ActAllow and then verify that the +// string representation is what we expect for ActLog. The value and string is +// defined in https://github.com/seccomp/libseccomp-golang/pull/29. +// +// Ultimately, the fix for this workaround is to be able to use the GetApi() +// function created in the PR above. It'll tell us if the kernel, libseccomp, +// and libseccomp-golang all support ActLog, but GetApi() is also not available +// in golang-seccomp <= 0.9.0. +const actLog seccomp.ScmpAction = seccomp.ActAllow + 1 + +func actLogSupported() bool { + return actLog.String() == "Action: Log system call" +} + +func complainAction() seccomp.ScmpAction { + // XXX: Work around some distributions not having a new enough + // libseccomp-golang that declares ActLog. + if actLogSupported() { + return actLog + } + + // Because ActLog is functionally ActAllow with logging, if we don't + // support ActLog, fallback to ActAllow. + return seccomp.ActAllow +} + +var osCreateTemp = os.CreateTemp + +func exportBPF(fout *os.File, filter *seccomp.ScmpFilter) (bpfLen int64, err error) { + // TODO: use a common way to handle prefixed errors across snapd + errPrefixFmt := "cannot export bpf filter: %w" + + oldPos, err := fout.Seek(0, io.SeekCurrent) + if err != nil { + return 0, fmt.Errorf(errPrefixFmt, err) + } + if err := filter.ExportBPF(fout); err != nil { + return 0, fmt.Errorf(errPrefixFmt, err) + } + nowPos, err := fout.Seek(0, io.SeekCurrent) + if err != nil { + return 0, fmt.Errorf(errPrefixFmt, err) + } + + return nowPos - oldPos, nil +} + +// New .bin2 seccomp files are composed by the following header, and potentially one +// allow filter and/or one deny filter (if lenAllowFilter and lenDenyFilter are greater +// than 0 respectively). When more than one filter is loaded, the kernel applies +// the most restrictive action, thus any explicit deny will take precedence. +// This struct needs to be in sync with seccomp-support.c +type scSeccompFileHeader struct { + header [2]byte + version byte + // flags + unrestricted byte + // unused + padding [4]byte + // location of allow/deny, all offsets/len in bytes + lenAllowFilter uint32 + lenDenyFilter uint32 + // reserved for future use + reserved2 [112]byte +} + +func writeUnrestrictedFilter(outFile string) error { + hdr := scSeccompFileHeader{ + header: [2]byte{'S', 'C'}, + version: 0x1, + // tell snap-confine + unrestricted: 0x1, + } + fout, err := osutil.NewAtomicFile(outFile, 0644, 0, osutil.NoChown, osutil.NoChown) + if err != nil { + return err + } + defer fout.Cancel() + + if err := binary.Write(fout, arch.Endian(), hdr); err != nil { + return err + } + return fout.Commit() +} + +func writeSeccompFilter(outFile string, filterAllow, filterDeny *seccomp.ScmpFilter) error { + fout, err := osutil.NewAtomicFile(outFile, 0644, 0, osutil.NoChown, osutil.NoChown) + if err != nil { + return err + } + defer fout.Cancel() + + // Write preliminary header because we don't know the sizes of the + // seccomp filters yet and the only way to know is to export to + // a file (until seccomp_export_bpf_mem() becomes available) + hdr := scSeccompFileHeader{ + header: [2]byte{'S', 'C'}, + version: 0x1, + } + if err := binary.Write(fout, arch.Endian(), hdr); err != nil { + return err + } + allowSize, err := exportBPF(fout.File, filterAllow) + if err != nil { + return err + } + denySize, err := exportBPF(fout.File, filterDeny) + if err != nil { + return err + } + + // now write final header + hdr.lenAllowFilter = uint32(allowSize) + hdr.lenDenyFilter = uint32(denySize) + if _, err := fout.Seek(0, io.SeekStart); err != nil { + return err + } + if err := binary.Write(fout, arch.Endian(), hdr); err != nil { + return err + } + + return fout.Commit() +} + +func compile(content []byte, out string) error { + var err error + var secFilterAllow, secFilterDeny *seccomp.ScmpFilter + + unrestricted, complain := preprocess(content) + switch { + case unrestricted: + return writeUnrestrictedFilter(out) + case complain: + var complainAct seccomp.ScmpAction = complainAction() + + secFilterAllow, err = seccomp.NewFilter(complainAct) + if err != nil { + if complainAct != seccomp.ActAllow { + // ActLog is only supported in newer versions + // of the kernel, libseccomp, and + // libseccomp-golang. Attempt to fall back to + // ActAllow before erroring out. + complainAct = seccomp.ActAllow + secFilterAllow, err = seccomp.NewFilter(complainAct) + } + } + if err != nil { + return fmt.Errorf("cannot create allow seccomp filter: %s", err) + } + secFilterDeny, err = seccomp.NewFilter(complainAct) + if err != nil { + return fmt.Errorf("cannot create deny seccomp filter: %s", err) + } + + // Set unrestricted to 'true' to fallback to the pre-ActLog + // behavior of simply setting the allow filter without adding + // any rules. + if complainAct == seccomp.ActAllow { + unrestricted = true + } + default: + secFilterAllow, err = seccomp.NewFilter(seccomp.ActErrno.SetReturnCode(errnoOnImplicitDenial)) + if err != nil { + return fmt.Errorf("cannot create seccomp filter: %s", err) + } + secFilterDeny, err = seccomp.NewFilter(seccomp.ActAllow) + if err != nil { + return fmt.Errorf("cannot create seccomp filter: %s", err) + } + } + if err := addSecondaryArches(secFilterAllow); err != nil { + return err + } + if err := addSecondaryArches(secFilterDeny); err != nil { + return err + } + + if !unrestricted { + scanner := bufio.NewScanner(bytes.NewBuffer(content)) + for scanner.Scan() { + if err := parseLine(scanner.Text(), secFilterAllow, secFilterDeny); err != nil { + return fmt.Errorf("cannot parse line: %s", err) + } + } + if scanner.Err(); err != nil { + return err + } + } + + if osutil.GetenvBool("SNAP_SECCOMP_DEBUG") { + secFilterAllow.ExportPFC(os.Stdout) + secFilterDeny.ExportPFC(os.Stdout) + } + + if err := writeSeccompFilter(out, secFilterAllow, secFilterDeny); err != nil { + return err + } + return nil +} + +// caches for uid and gid lookups +var uidCache = make(map[string]uint64) +var gidCache = make(map[string]uint64) + +// findUid returns the identifier of the given UNIX user name. +func findUid(username string) (uint64, error) { + if uid, ok := uidCache[username]; ok { + return uid, nil + } + if !osutil.IsValidSnapSystemUsername(username) { + return 0, fmt.Errorf("%q must be a valid username", username) + } + uid, err := osutil.FindUid(username) + if err == nil { + uidCache[username] = uid + } + return uid, err +} + +// findGid returns the identifier of the given UNIX group name. +func findGid(group string) (uint64, error) { + if gid, ok := gidCache[group]; ok { + return gid, nil + } + if !osutil.IsValidSnapSystemUsername(group) { + return 0, fmt.Errorf("%q must be a valid group name", group) + } + gid, err := osutil.FindGid(group) + if err == nil { + gidCache[group] = gid + } + return gid, err +} + +func showSeccompLibraryVersion() error { + major, minor, micro := seccomp.GetLibraryVersion() + fmt.Fprintf(os.Stdout, "%d.%d.%d\n", major, minor, micro) + return nil +} + +func main() { + var err error + var content []byte + + if len(os.Args) < 2 { + fmt.Printf("%s: need a command\n", os.Args[0]) + os.Exit(1) + } + + cmd := os.Args[1] + switch cmd { + case "compile": + if len(os.Args) < 4 { + fmt.Println("compile needs an input and output file") + os.Exit(1) + } + content, err = os.ReadFile(os.Args[2]) + if err != nil { + break + } + err = compile(content, os.Args[3]) + case "library-version": + err = showSeccompLibraryVersion() + case "version-info": + err = showVersionInfo() + default: + err = fmt.Errorf("unsupported argument %q", cmd) + } + + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } +} diff --git a/cmd/snap-seccomp/main_nonriscv64.go b/cmd/snap-seccomp/main_nonriscv64.go new file mode 100644 index 00000000..8ebd2924 --- /dev/null +++ b/cmd/snap-seccomp/main_nonriscv64.go @@ -0,0 +1,37 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- +// +//go:build !riscv64 + +/* + * Copyright (C) 2021 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "fmt" + + "github.com/seccomp/libseccomp-golang" +) + +// this extraDpkgArchToScmpArch does not have riscv64 constant, when +// building on non-riscv64 archtictures with an old seccomp library. +// once all distros upgrade to the new seccomp library we can drop +// this and riscv64 specific files and fold things back into +// DpkgArchToScmpArch() without this function +func extraDpkgArchToScmpArch(dpkgArch string) seccomp.ScmpArch { + panic(fmt.Sprintf("cannot map dpkg arch %q to a seccomp arch", dpkgArch)) +} diff --git a/cmd/snap-seccomp/main_ppc64le.go b/cmd/snap-seccomp/main_ppc64le.go new file mode 100644 index 00000000..441eb7a1 --- /dev/null +++ b/cmd/snap-seccomp/main_ppc64le.go @@ -0,0 +1,31 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- +// +//go:build ppc64le && go1.7 && !go1.8 + +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +/* +#cgo LDFLAGS: -no-pie + +// we need "-no-pie" for ppc64le,go1.7 to work around build failure on +// ppc64el with go1.7, see +// https://forum.snapcraft.io/t/snapd-master-fails-on-zesty-ppc64el-with-r-ppc64-addr16-ha-for-symbol-out-of-range/ +*/ +import "C" diff --git a/cmd/snap-seccomp/main_riscv64.go b/cmd/snap-seccomp/main_riscv64.go new file mode 100644 index 00000000..28370f6d --- /dev/null +++ b/cmd/snap-seccomp/main_riscv64.go @@ -0,0 +1,41 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- +// +//go:build riscv64 + +/* + * Copyright (C) 2021 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "fmt" + + "github.com/seccomp/libseccomp-golang" +) + +// this extraDpkgArchToScmpArch uses riscv64 constant, when building +// on riscv64 architecture which requires newer snapshot of libseccomp +// library. Once all distros have newer libseccomp golang library, +// this portion can be just folded into the DpkgArchToScmArch() +// function to be compiled on all architecutres. +func extraDpkgArchToScmpArch(dpkgArch string) seccomp.ScmpArch { + switch dpkgArch { + case "riscv64": + return seccomp.ArchRISCV64 + } + panic(fmt.Sprintf("cannot map dpkg arch %q to a seccomp arch", dpkgArch)) +} diff --git a/cmd/snap-seccomp/main_test.go b/cmd/snap-seccomp/main_test.go new file mode 100644 index 00000000..51a9a247 --- /dev/null +++ b/cmd/snap-seccomp/main_test.go @@ -0,0 +1,921 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "fmt" + "math/rand" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/seccomp/libseccomp-golang" + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/arch" + main "github.com/snapcore/snapd/cmd/snap-seccomp" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/release" +) + +// Hook up check.v1 into the "go test" runner +func Test(t *testing.T) { TestingT(t) } + +type snapSeccompSuite struct { + seccompBpfLoader string + seccompSyscallRunner string + canCheckCompatArch bool +} + +var _ = Suite(&snapSeccompSuite{}) + +const ( + Deny = iota + DenyExplicit + Allow +) + +var seccompBpfLoaderContent = []byte(` +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#define MAX_BPF_SIZE 32 * 1024 + +// keep in sync with: +// cmd/snap-confine/seccomp-support.c +// main.go:scSeccompFileHeader +struct sc_seccomp_file_header { + char header[2]; + char version; + char unrestricted; + char padding[4]; + uint32_t len_allow_filter; + uint32_t len_deny_filter; + char reserved2[112]; +}; + +int sc_apply_seccomp_bpf(const char* profile_path) +{ + struct sc_seccomp_file_header hdr = {{0}, 0}; + unsigned char bpf_allow[MAX_BPF_SIZE + 1]; // account for EOF + unsigned char bpf_deny[MAX_BPF_SIZE + 1]; // account for EOF + FILE* fp; + + fp = fopen(profile_path, "rb"); + if (fp == NULL) { + fprintf(stderr, "cannot read %s\n", profile_path); + return -1; + } + fread(&hdr, 1, sizeof(struct sc_seccomp_file_header), fp); + if (ferror(fp) != 0) { + perror("fread() header"); + return -1; + } + fread(bpf_allow, 1, hdr.len_allow_filter, fp); + if (ferror(fp) != 0) { + perror("fread()"); + return -1; + } + fread(bpf_deny, 1, hdr.len_deny_filter, fp); + if (ferror(fp) != 0) { + perror("fread()"); + return -1; + } + + fclose(fp); + + struct sock_fprog prog_allow = { + .len = hdr.len_allow_filter / sizeof(struct sock_filter), + .filter = (struct sock_filter*)bpf_allow, + }; + + struct sock_fprog prog_deny = { + .len = hdr.len_deny_filter / sizeof(struct sock_filter), + .filter = (struct sock_filter*)bpf_deny, + }; + + // Set NNP to allow loading seccomp policy into the kernel without + // root + if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)) { + perror("prctl(PR_NO_NEW_PRIVS, 1, 0, 0, 0)"); + return -1; + } + + if (prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog_deny)) { + perror("prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, ...) deny failed"); + return -1; + } + if (prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog_allow)) { + perror("prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, ...) allow failed"); + return -1; + } + + return 0; +} + +int main(int argc, char* argv[]) +{ + int rc = 0; + if (argc < 2) { + fprintf(stderr, "Usage: %s [prog ...]\n", argv[0]); + return 1; + } + + rc = sc_apply_seccomp_bpf(argv[1]); + if (rc != 0) + return -rc; + + execv(argv[2], (char* const*)&argv[2]); + perror("execv failed"); + return 1; +} +`) + +var seccompSyscallRunnerContent = []byte(` +#define _GNU_SOURCE +#include +#include +#include +#include +#include +int main(int argc, char** argv) +{ + uint32_t l[7]; + int syscall_ret, ret = 0; + for (int i = 0; i < 7 && argv[i+1] != NULL; i++) { + errno = 0; + l[i] = strtoll(argv[i + 1], NULL, 10); + // exit '11' let's us know strtoll failed + if (errno != 0) + syscall(SYS_exit, 11, 0, 0, 0, 0, 0); + } + // There might be architecture-specific requirements. see "man syscall" + // for details. + syscall_ret = syscall(l[0], l[1], l[2], l[3], l[4], l[5], l[6]); + // 911 is our mocked errno for implicit denials via unlisted syscalls and + // 999 is explicit denial + if (syscall_ret < 0 && errno == 911) { + ret = 10; + } + if (syscall_ret < 0 && errno == 999) { + ret = 20; + } + syscall(SYS_exit, ret, 0, 0, 0, 0, 0); + return 0; +} +`) + +func (s *snapSeccompSuite) SetUpSuite(c *C) { + main.MockErrnoOnImplicitDenial(911) + main.MockErrnoOnExplicitDenial(999) + + // build seccomp-load helper + s.seccompBpfLoader = filepath.Join(c.MkDir(), "seccomp_bpf_loader") + err := os.WriteFile(s.seccompBpfLoader+".c", seccompBpfLoaderContent, 0644) + c.Assert(err, IsNil) + cmd := exec.Command("gcc", "-Werror", "-Wall", s.seccompBpfLoader+".c", "-o", s.seccompBpfLoader) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + err = cmd.Run() + c.Assert(err, IsNil) + + // build syscall-runner helper + s.seccompSyscallRunner = filepath.Join(c.MkDir(), "seccomp_syscall_runner") + err = os.WriteFile(s.seccompSyscallRunner+".c", seccompSyscallRunnerContent, 0644) + c.Assert(err, IsNil) + + cmd = exec.Command("gcc", "-std=c99", "-Werror", "-Wall", "-static", s.seccompSyscallRunner+".c", "-o", s.seccompSyscallRunner, "-Wl,-static", "-static-libgcc") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + err = cmd.Run() + c.Assert(err, IsNil) + + // Amazon Linux 2 is 64bit only and there is no multilib support + s.canCheckCompatArch = !release.DistroLike("amzn") + + // Build 32bit runner on amd64 to test non-native syscall handling. + // Ideally we would build for ppc64el->powerpc and arm64->armhf but + // it seems tricky to find the right gcc-multilib for this. + if arch.DpkgArchitecture() == "amd64" && s.canCheckCompatArch { + cmd = exec.Command(cmd.Args[0], cmd.Args[1:]...) + cmd.Args = append(cmd.Args, "-m32") + for i, k := range cmd.Args { + if k == s.seccompSyscallRunner { + cmd.Args[i] = s.seccompSyscallRunner + ".m32" + } + } + if output, err := cmd.CombinedOutput(); err != nil { + fmt.Printf("cannot build multi-lib syscall runner: %v\n%s", err, output) + } + } +} + +// Runs the policy through the kernel: +// 1. runs main.Compile() +// 2. the program in seccompBpfLoaderContent with the output file as an +// argument +// 3. the program in seccompBpfLoaderContent loads the output file BPF into +// the kernel and executes the program in seccompBpfRunnerContent with the +// syscall and arguments specified by the test +// +// In this manner, in addition to verifying policy syntax we are able to +// unit test the resulting bpf in several ways. +// +// Full testing of applied policy is done elsewhere via spread tests. +// +// Note that we skip testing prctl(PR_SET_ENDIAN) - it causes havoc when +// it is run. We will also need to skip: fadvise64_64, ftruncate64, +// posix_fadvise, pread64, pwrite64, readahead, sync_file_range, and truncate64. +// +// Once we start using those. See `man syscall` +func (s *snapSeccompSuite) runBpf(c *C, seccompAllowlist, bpfInput string, expected int) { + // Common syscalls we need to allow for a minimal statically linked + // c program. + // + // If we compile a test program for each test we can get away with + // a even smaller set of syscalls: execve,exit essentially. But it + // means a much longer test run (30s vs 2s). Commit d288d89 contains + // the code for this. + common := ` +execve +uname +brk +arch_prctl +readlink +access +sysinfo +exit +# i386 +set_thread_area +# armhf +set_tls +# arm64 +readlinkat +faccessat +# i386 from amd64 +restart_syscall +# libc6 2.31/gcc-9.3 +mprotect +` + bpfPath := filepath.Join(c.MkDir(), "bpf") + err := main.Compile([]byte(common+seccompAllowlist), bpfPath) + c.Assert(err, IsNil) + + // default syscall runner + syscallRunner := s.seccompSyscallRunner + + // syscallName;arch;arg1,arg2... + l := strings.Split(bpfInput, ";") + syscallName := l[0] + syscallArch := "native" + if len(l) > 1 { + syscallArch = l[1] + } + + syscallNr, err := seccomp.GetSyscallFromName(syscallName) + c.Assert(err, IsNil) + + // Check if we want to test non-native architecture + // handling. Doing this via the in-kernel tests is tricky as + // we need a kernel that can run the architecture and a + // compiler that can produce the required binaries. Currently + // we only test amd64 running i386 here. + if syscallArch != "native" { + syscallNr, err = seccomp.GetSyscallFromNameByArch(syscallName, main.DpkgArchToScmpArch(syscallArch)) + c.Assert(err, IsNil) + + switch syscallArch { + case "amd64": + // default syscallRunner + case "i386": + syscallRunner = s.seccompSyscallRunner + ".m32" + default: + c.Errorf("unexpected non-native arch: %s", syscallArch) + } + } + switch { + case syscallNr == -101: + // "socket" needs to be demuxed + switch arch.DpkgArchitecture() { + case "ppc64el": + // see libseccomp: _ppc64_syscall_demux() + syscallNr = 326 + default: + // see libseccomp: _s390x_sock_demux(), + // _x86_sock_demux() the -101 is translated to + // 359 (socket) + syscallNr = 359 + } + case syscallNr == -10165: + // "mknod" on arm64 is not available at all on arm64 + // only "mknodat" but libseccomp will not generate a + // "mknodat" allowlist, it geneates a allowlist with + // syscall -10165 (!?!) so we cannot test this. + c.Skip("skipping mknod tests on arm64") + case syscallNr < 0: + c.Errorf("failed to resolve %v: %v", l[0], syscallNr) + return + } + + var syscallRunnerArgs [7]string + syscallRunnerArgs[0] = strconv.FormatInt(int64(syscallNr), 10) + if len(l) > 2 { + args := strings.Split(l[2], ",") + for i := range args { + // init with random number argument + syscallArg := (uint64)(rand.Uint32()) + // override if the test specifies a specific number; + // this must match main.go:readNumber() + if nr, ok := main.SeccompResolver[args[i]]; ok { + syscallArg = nr + } else if nr, err := strconv.ParseUint(args[i], 10, 32); err == nil { + syscallArg = nr + } else if nr, err := strconv.ParseInt(args[i], 10, 32); err == nil { + syscallArg = uint64(uint32(nr)) + } + syscallRunnerArgs[i+1] = strconv.FormatUint(syscallArg, 10) + } + } + + cmd := exec.Command(s.seccompBpfLoader, bpfPath, syscallRunner, syscallRunnerArgs[0], syscallRunnerArgs[1], syscallRunnerArgs[2], syscallRunnerArgs[3], syscallRunnerArgs[4], syscallRunnerArgs[5], syscallRunnerArgs[6]) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + err = cmd.Run() + // the exit code of the test binary is either 0 or 10, everything + // else is unexpected (segv, strtoll failure, ...) + exitCode, e := osutil.ExitCode(err) + c.Assert(e, IsNil) + c.Assert(exitCode == 0 || exitCode == 10 || exitCode == 20, Equals, true, Commentf("unexpected exit code: %v for %v - test setup broken", exitCode, seccompAllowlist)) + switch expected { + case Allow: + if err != nil { + c.Fatalf("unexpected error for %q (failed to run %q)", seccompAllowlist, err) + } + case Deny: + if exitCode != 10 { + c.Fatalf("unexpected exit code for %q %q (%v != %v)", seccompAllowlist, bpfInput, exitCode, 10) + } + if err == nil { + c.Fatalf("unexpected success for %q %q (ran but should have failed)", seccompAllowlist, bpfInput) + } + case DenyExplicit: + if exitCode != 20 { + c.Fatalf("unexpected exit code for %q %q (%v != %v)", seccompAllowlist, bpfInput, exitCode, 20) + } + if err == nil { + c.Fatalf("unexpected success for %q %q (ran but should have failed)", seccompAllowlist, bpfInput) + } + default: + c.Fatalf("unknown expected result %v", expected) + } +} + +func (s *snapSeccompSuite) TestUnrestricted(c *C) { + inp := "@unrestricted\n" + outPath := filepath.Join(c.MkDir(), "bpf") + err := main.Compile([]byte(inp), outPath) + c.Assert(err, IsNil) + + expected := [128]byte{'S', 'C', 0x1, 0x1} + fileContent, err := os.ReadFile(outPath) + c.Assert(err, IsNil) + c.Check(fileContent, DeepEquals, expected[:]) +} + +// TestCompile iterates over a range of textual seccomp allowlist rules and +// mocked kernel syscall input. For each rule, the test consists of compiling +// the rule into a bpf program and then running that program on a virtual bpf +// machine and comparing the bpf machine output to the specified expected +// output and seccomp operation. Eg: +// +// {"", "", } +// +// Eg to test that the rule 'read >=2' is allowed with 'read(2)' and 'read(3)' +// and denied with 'read(1)' and 'read(0)', add the following tests: +// +// {"read >=2", "read;native;2", Allow}, +// {"read >=2", "read;native;3", Allow}, +// {"read >=2", "read;native;1", main.SeccompRetKill}, +// {"read >=2", "read;native;0", main.SeccompRetKill}, +func (s *snapSeccompSuite) TestCompile(c *C) { + + for _, t := range []struct { + seccompAllowlist string + bpfInput string + expected int + }{ + // special + {"@complain", "execve", Allow}, + + // trivial allow + {"read", "read", Allow}, + {"read\nwrite\nexecve\n", "write", Allow}, + + // trivial denial (uses write in allow-list to ensure any + // errors printing is visible) + {"write", "ioctl", Deny}, + + // test argument filtering syntax, we currently support: + // >=, <=, !, <, >, | + // modifiers. + + // reads >= 2 are ok + {"read >=2", "read;native;2", Allow}, + {"read >=2", "read;native;3", Allow}, + // but not reads < 2, those get killed + {"read >=2", "read;native;1", Deny}, + {"read >=2", "read;native;0", Deny}, + + // reads <= 2 are ok + {"read <=2", "read;native;0", Allow}, + {"read <=2", "read;native;1", Allow}, + {"read <=2", "read;native;2", Allow}, + // but not reads >2, those get killed + {"read <=2", "read;native;3", Deny}, + {"read <=2", "read;native;4", Deny}, + + // reads that are not 2 are ok + {"read !2", "read;native;1", Allow}, + {"read !2", "read;native;3", Allow}, + // but not 2, this gets killed + {"read !2", "read;native;2", Deny}, + + // reads > 2 are ok + {"read >2", "read;native;4", Allow}, + {"read >2", "read;native;3", Allow}, + // but not reads <= 2, those get killed + {"read >2", "read;native;2", Deny}, + {"read >2", "read;native;1", Deny}, + + // reads < 2 are ok + {"read <2", "read;native;0", Allow}, + {"read <2", "read;native;1", Allow}, + // but not reads >= 2, those get killed + {"read <2", "read;native;2", Deny}, + {"read <2", "read;native;3", Deny}, + + // FIXME: test maskedEqual better + {"read |1", "read;native;1", Allow}, + {"read |1", "read;native;2", Deny}, + + // exact match, reads == 2 are ok + {"read 2", "read;native;2", Allow}, + // but not those != 2 + {"read 2", "read;native;3", Deny}, + {"read 2", "read;native;1", Deny}, + + // test actual syscalls and their expected usage + {"ioctl - TIOCSTI", "ioctl;native;-,TIOCSTI", Allow}, + {"ioctl - TIOCSTI", "ioctl;native;-,99", Deny}, + {"ioctl - !TIOCSTI", "ioctl;native;-,TIOCSTI", Deny}, + {"ioctl\n~ioctl - TIOCSTI", "ioctl;native;-,TIOCSTI", DenyExplicit}, + // also check we can deny multiple uses of ioctl but still allow + // others + {"ioctl\n~ioctl - TIOCSTI\n~ioctl - TIOCLINUX\nioctl - !TIOCSTI", "ioctl;native;-,TIOCSTI", DenyExplicit}, + {"ioctl\n~ioctl - TIOCSTI\n~ioctl - TIOCLINUX\nioctl - !TIOCSTI", "ioctl;native;-,TIOCLINUX", DenyExplicit}, + {"ioctl\n~ioctl - TIOCSTI\n~ioctl - TIOCLINUX\nioctl - !TIOCSTI", "ioctl;native;-,TIOCGWINSZ", Allow}, + + // test_bad_seccomp_filter_args_clone + {"setns - CLONE_NEWNET", "setns;native;-,99", Deny}, + {"setns - CLONE_NEWNET", "setns;native;-,CLONE_NEWNET", Allow}, + + // test_bad_seccomp_filter_args_mknod + {"mknod - |S_IFIFO", "mknod;native;-,S_IFIFO", Allow}, + {"mknod - |S_IFIFO", "mknod;native;-,99", Deny}, + + // test_bad_seccomp_filter_args_prctl + {"prctl PR_CAP_AMBIENT_RAISE", "prctl;native;PR_CAP_AMBIENT_RAISE", Allow}, + {"prctl PR_CAP_AMBIENT_RAISE", "prctl;native;99", Deny}, + + // test_bad_seccomp_filter_args_prio + {"setpriority PRIO_PROCESS 0 >=0", "setpriority;native;PRIO_PROCESS,0,19", Allow}, + {"setpriority PRIO_PROCESS 0 >=0", "setpriority;native;99", Deny}, + // negative filtering + {"setpriority\n~setpriority PRIO_PROCESS 0 >=0", "setpriority;native;PRIO_PROCESS,0,10", DenyExplicit}, + // mix negative/positiv filtering + // allow setprioty >= 5 but explicitly deny >=10 + {"setpriority PRIO_PROCESS 0 >=5\n~setpriority PRIO_PROCESS 0 >=10", "setpriority;native;PRIO_PROCESS,0,2", Deny}, + {"setpriority PRIO_PROCESS 0 >=5\n~setpriority PRIO_PROCESS 0 >=10", "setpriority;native;PRIO_PROCESS,0,5", Allow}, + {"setpriority PRIO_PROCESS 0 >=5\n~setpriority PRIO_PROCESS 0 >=10", "setpriority;native;PRIO_PROCESS,0,10", DenyExplicit}, + + // test_bad_seccomp_filter_args_quotactl + {"quotactl Q_GETQUOTA", "quotactl;native;Q_GETQUOTA", Allow}, + {"quotactl Q_GETQUOTA", "quotactl;native;99", Deny}, + + // test_bad_seccomp_filter_args_termios + {"ioctl - TIOCSTI", "ioctl;native;-,TIOCSTI", Allow}, + {"ioctl - TIOCSTI", "ioctl;native;-,99", Deny}, + + // u:root g:root + {"fchown - u:root g:root", "fchown;native;-,0,0", Allow}, + {"fchown - u:root g:root", "fchown;native;-,99,0", Deny}, + {"chown - u:root g:root", "chown;native;-,0,0", Allow}, + {"chown - u:root g:root", "chown;native;-,99,0", Deny}, + + // u:root -1 + {"chown - u:root -1", "chown;native;-,0,-1", Allow}, + {"chown - u:root -1", "chown;native;-,99,-1", Deny}, + {"chown - -1 u:root", "chown;native;-,-1,0", Allow}, + {"chown - -1 u:root", "chown;native;-,99,0", Deny}, + {"chown - -1 -1", "chown;native;-,-1,-1", Allow}, + {"chown - -1 -1", "chown;native;-,99,-1", Deny}, + } { + s.runBpf(c, t.seccompAllowlist, t.bpfInput, t.expected) + } +} + +// TestCompileSocket runs in a separate tests so that only this part +// can be skipped when "socketcall()" is used instead of "socket()". +// +// Some architectures (i386, s390x) use the "socketcall" syscall instead +// of "socket". This is the case on Ubuntu 14.04, 17.04, 17.10 +func (s *snapSeccompSuite) TestCompileSocket(c *C) { + if release.ReleaseInfo.ID == "ubuntu" && release.ReleaseInfo.VersionID == "14.04" { + c.Skip("14.04/i386 uses socketcall which cannot be tested here") + } + + for _, t := range []struct { + seccompAllowlist string + bpfInput string + expected int + }{ + + // test_bad_seccomp_filter_args_socket + {"socket AF_UNIX", "socket;native;AF_UNIX", Allow}, + {"socket AF_UNIX", "socket;native;99", Deny}, + {"socket - SOCK_STREAM", "socket;native;-,SOCK_STREAM", Allow}, + {"socket - SOCK_STREAM", "socket;native;-,99", Deny}, + {"socket AF_CONN", "socket;native;AF_CONN", Allow}, + {"socket AF_CONN", "socket;native;99", Deny}, + } { + s.runBpf(c, t.seccompAllowlist, t.bpfInput, t.expected) + } + +} + +func (s *snapSeccompSuite) TestCompileBadInput(c *C) { + for _, t := range []struct { + inp string + errMsg string + }{ + // test_bad_seccomp_filter_args_clone (various typos in input) + {"setns - CLONE_NEWNE", `cannot parse line: cannot parse token "CLONE_NEWNE" \(line "setns - CLONE_NEWNE"\)`}, + {"setns - CLONE_NEWNETT", `cannot parse line: cannot parse token "CLONE_NEWNETT" \(line "setns - CLONE_NEWNETT"\)`}, + {"setns - CL0NE_NEWNET", `cannot parse line: cannot parse token "CL0NE_NEWNET" \(line "setns - CL0NE_NEWNET"\)`}, + + // test_bad_seccomp_filter_args_mknod (various typos in input) + {"mknod - |S_IFIF", `cannot parse line: cannot parse token "S_IFIF" \(line "mknod - |S_IFIF"\)`}, + {"mknod - |S_IFIFOO", `cannot parse line: cannot parse token "S_IFIFOO" \(line "mknod - |S_IFIFOO"\)`}, + {"mknod - |S_!FIFO", `cannot parse line: cannot parse token "S_IFIFO" \(line "mknod - |S_!FIFO"\)`}, + + // test_bad_seccomp_filter_args_null + {"socket S\x00CK_STREAM", `cannot parse line: cannot parse token .*`}, + {"socket SOCK_STREAM\x00bad stuff", `cannot parse line: cannot parse token .*`}, + + // test_bad_seccomp_filter_args + {"setpriority bar", `cannot parse line: cannot parse token "bar" .*`}, + {"setpriority -1", `cannot parse line: cannot parse token "-1" .*`}, + {"setpriority 0 - -1 0", `cannot parse line: cannot parse token "-1" .*`}, + {"setpriority --10", `cannot parse line: cannot parse token "--10" .*`}, + {"setpriority 0:10", `cannot parse line: cannot parse token "0:10" .*`}, + {"setpriority 0-10", `cannot parse line: cannot parse token "0-10" .*`}, + {"setpriority 0,1", `cannot parse line: cannot parse token "0,1" .*`}, + {"setpriority 0x0", `cannot parse line: cannot parse token "0x0" .*`}, + {"setpriority a1", `cannot parse line: cannot parse token "a1" .*`}, + {"setpriority 1a", `cannot parse line: cannot parse token "1a" .*`}, + {"setpriority 1-", `cannot parse line: cannot parse token "1-" .*`}, + {"setpriority 1\\ 2", `cannot parse line: cannot parse token "1\\\\" .*`}, + {"setpriority 1\\n2", `cannot parse line: cannot parse token "1\\\\n2" .*`}, + // 1 bigger than uint32 + {"chown 0 4294967296", `cannot parse line: cannot parse token "4294967296" .*`}, + // 1 smaller than int32 + {"chown - 0 -2147483649", `cannot parse line: cannot parse token "-2147483649" .*`}, + {"setpriority 999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999", `cannot parse line: cannot parse token "999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999" .*`}, + {"mbind - - - - - - 7", `cannot parse line: too many arguments specified for syscall 'mbind' in line.*`}, + {"mbind 1 2 3 4 5 6 7", `cannot parse line: too many arguments specified for syscall 'mbind' in line.*`}, + // test_bad_seccomp_filter_args_prctl + {"prctl PR_GET_SECCOM", `cannot parse line: cannot parse token "PR_GET_SECCOM" .*`}, + {"prctl PR_GET_SECCOMPP", `cannot parse line: cannot parse token "PR_GET_SECCOMPP" .*`}, + {"prctl PR_GET_SECC0MP", `cannot parse line: cannot parse token "PR_GET_SECC0MP" .*`}, + {"prctl PR_CAP_AMBIENT_RAIS", `cannot parse line: cannot parse token "PR_CAP_AMBIENT_RAIS" .*`}, + {"prctl PR_CAP_AMBIENT_RAISEE", `cannot parse line: cannot parse token "PR_CAP_AMBIENT_RAISEE" .*`}, + // test_bad_seccomp_filter_args_prio + {"setpriority PRIO_PROCES 0 >=0", `cannot parse line: cannot parse token "PRIO_PROCES" .*`}, + {"setpriority PRIO_PROCESSS 0 >=0", `cannot parse line: cannot parse token "PRIO_PROCESSS" .*`}, + {"setpriority PRIO_PR0CESS 0 >=0", `cannot parse line: cannot parse token "PRIO_PR0CESS" .*`}, + // test_bad_seccomp_filter_args_quotactl + {"quotactl Q_GETQUOT", `cannot parse line: cannot parse token "Q_GETQUOT" .*`}, + {"quotactl Q_GETQUOTAA", `cannot parse line: cannot parse token "Q_GETQUOTAA" .*`}, + {"quotactl Q_GETQU0TA", `cannot parse line: cannot parse token "Q_GETQU0TA" .*`}, + // test_bad_seccomp_filter_args_socket + {"socket AF_UNI", `cannot parse line: cannot parse token "AF_UNI" .*`}, + {"socket AF_UNIXX", `cannot parse line: cannot parse token "AF_UNIXX" .*`}, + {"socket AF_UN!X", `cannot parse line: cannot parse token "AF_UN!X" .*`}, + {"socket - SOCK_STREA", `cannot parse line: cannot parse token "SOCK_STREA" .*`}, + {"socket - SOCK_STREAMM", `cannot parse line: cannot parse token "SOCK_STREAMM" .*`}, + {"socket - NETLINK_ROUT", `cannot parse line: cannot parse token "NETLINK_ROUT" .*`}, + {"socket - NETLINK_ROUTEE", `cannot parse line: cannot parse token "NETLINK_ROUTEE" .*`}, + {"socket - NETLINK_R0UTE", `cannot parse line: cannot parse token "NETLINK_R0UTE" .*`}, + // test_bad_seccomp_filter_args_termios + {"ioctl - TIOCST", `cannot parse line: cannot parse token "TIOCST" .*`}, + {"ioctl - TIOCSTII", `cannot parse line: cannot parse token "TIOCSTII" .*`}, + {"ioctl - TIOCST1", `cannot parse line: cannot parse token "TIOCST1" .*`}, + // ensure missing numbers are caught + {"setpriority >", `cannot parse line: cannot parse token ">" .*`}, + {"setpriority >=", `cannot parse line: cannot parse token ">=" .*`}, + {"setpriority <", `cannot parse line: cannot parse token "<" .*`}, + {"setpriority <=", `cannot parse line: cannot parse token "<=" .*`}, + {"setpriority |", `cannot parse line: cannot parse token "|" .*`}, + {"setpriority !", `cannot parse line: cannot parse token "!" .*`}, + + // u: + {"setuid :root", `cannot parse line: cannot parse token ":root" .*`}, + {"setuid u:", `cannot parse line: cannot parse token "u:" \(line "setuid u:"\): "" must be a valid username`}, + {"setuid u:!", `cannot parse line: cannot parse token "u:!" \(line "setuid u:!"\): "!" must be a valid username`}, + {"setuid u:b@d|npu+", `cannot parse line: cannot parse token "u:b@d|npu+" \(line "setuid u:b@d|npu+"\): "b@d|npu+" must be a valid username`}, + {"setuid u:snap|bad", `cannot parse line: cannot parse token "u:snap|bad" \(line "setuid u:snap|bad"\): "snap|bad" must be a valid username`}, + {"setuid U:root", `cannot parse line: cannot parse token "U:root" .*`}, + {"setuid u:nonexistent", `cannot parse line: cannot parse token "u:nonexistent" \(line "setuid u:nonexistent"\): user: unknown user nonexistent`}, + // g: + {"setgid g:", `cannot parse line: cannot parse token "g:" \(line "setgid g:"\): "" must be a valid group name`}, + {"setgid g:!", `cannot parse line: cannot parse token "g:!" \(line "setgid g:!"\): "!" must be a valid group name`}, + {"setgid g:b@d|npu+", `cannot parse line: cannot parse token "g:b@d|npu+" \(line "setgid g:b@d|npu+"\): "b@d|npu+" must be a valid group name`}, + {"setgid g:snap|bad", `cannot parse line: cannot parse token "g:snap|bad" \(line "setgid g:snap|bad"\): "snap|bad" must be a valid group name`}, + {"setgid G:root", `cannot parse line: cannot parse token "G:root" .*`}, + {"setgid g:nonexistent", `cannot parse line: cannot parse token "g:nonexistent" \(line "setgid g:nonexistent"\): group: unknown group nonexistent`}, + } { + outPath := filepath.Join(c.MkDir(), "bpf") + err := main.Compile([]byte(t.inp), outPath) + c.Check(err, ErrorMatches, t.errMsg, Commentf("%q errors in unexpected ways, got: %q expected %q", t.inp, err, t.errMsg)) + } +} + +// ported from test_restrictions_working_args_socket +func (s *snapSeccompSuite) TestRestrictionsWorkingArgsSocket(c *C) { + if release.ReleaseInfo.ID == "ubuntu" && release.ReleaseInfo.VersionID == "14.04" { + c.Skip("14.04/i386 uses socketcall which cannot be tested here") + } + + for _, pre := range []string{"AF", "PF"} { + for _, i := range []string{"UNIX", "LOCAL", "INET", "INET6", "IPX", "NETLINK", "X25", "AX25", "ATMPVC", "APPLETALK", "PACKET", "ALG", "CAN", "BRIDGE", "NETROM", "ROSE", "NETBEUI", "SECURITY", "KEY", "ASH", "ECONET", "SNA", "IRDA", "PPPOX", "WANPIPE", "BLUETOOTH", "RDS", "LLC", "TIPC", "IUCV", "RXRPC", "ISDN", "PHONET", "IEEE802154", "CAIF", "NFC", "VSOCK", "MPLS", "IB"} { + seccompAllowlist := fmt.Sprintf("socket %s_%s", pre, i) + bpfInputGood := fmt.Sprintf("socket;native;%s_%s", pre, i) + bpfInputBad := "socket;native;99999" + s.runBpf(c, seccompAllowlist, bpfInputGood, Allow) + s.runBpf(c, seccompAllowlist, bpfInputBad, Deny) + + for _, j := range []string{"SOCK_STREAM", "SOCK_DGRAM", "SOCK_SEQPACKET", "SOCK_RAW", "SOCK_RDM", "SOCK_PACKET"} { + seccompAllowlist := fmt.Sprintf("socket %s_%s %s", pre, i, j) + bpfInputGood := fmt.Sprintf("socket;native;%s_%s,%s", pre, i, j) + bpfInputBad := fmt.Sprintf("socket;native;%s_%s,9999", pre, i) + s.runBpf(c, seccompAllowlist, bpfInputGood, Allow) + s.runBpf(c, seccompAllowlist, bpfInputBad, Deny) + } + } + } + + for _, i := range []string{"NETLINK_ROUTE", "NETLINK_USERSOCK", "NETLINK_FIREWALL", "NETLINK_SOCK_DIAG", "NETLINK_NFLOG", "NETLINK_XFRM", "NETLINK_SELINUX", "NETLINK_ISCSI", "NETLINK_AUDIT", "NETLINK_FIB_LOOKUP", "NETLINK_CONNECTOR", "NETLINK_NETFILTER", "NETLINK_IP6_FW", "NETLINK_DNRTMSG", "NETLINK_KOBJECT_UEVENT", "NETLINK_GENERIC", "NETLINK_SCSITRANSPORT", "NETLINK_ECRYPTFS", "NETLINK_RDMA", "NETLINK_CRYPTO", "NETLINK_INET_DIAG"} { + for _, j := range []string{"AF_NETLINK", "PF_NETLINK"} { + seccompAllowlist := fmt.Sprintf("socket %s - %s", j, i) + bpfInputGood := fmt.Sprintf("socket;native;%s,0,%s", j, i) + bpfInputBad := fmt.Sprintf("socket;native;%s,0,99", j) + s.runBpf(c, seccompAllowlist, bpfInputGood, Allow) + s.runBpf(c, seccompAllowlist, bpfInputBad, Deny) + } + } +} + +// ported from test_restrictions_working_args_quotactl +func (s *snapSeccompSuite) TestRestrictionsWorkingArgsQuotactl(c *C) { + for _, arg := range []string{"Q_QUOTAON", "Q_QUOTAOFF", "Q_GETQUOTA", "Q_SETQUOTA", "Q_GETINFO", "Q_SETINFO", "Q_GETFMT", "Q_SYNC", "Q_XQUOTAON", "Q_XQUOTAOFF", "Q_XGETQUOTA", "Q_XSETQLIM", "Q_XGETQSTAT", "Q_XQUOTARM"} { + // good input + seccompAllowlist := fmt.Sprintf("quotactl %s", arg) + bpfInputGood := fmt.Sprintf("quotactl;native;%s", arg) + s.runBpf(c, seccompAllowlist, bpfInputGood, Allow) + // bad input + for _, bad := range []string{"quotactl;native;99999", "read;native;"} { + s.runBpf(c, seccompAllowlist, bad, Deny) + } + } +} + +// ported from test_restrictions_working_args_prctl +func (s *snapSeccompSuite) TestRestrictionsWorkingArgsPrctl(c *C) { + for _, arg := range []string{"PR_CAP_AMBIENT", "PR_CAP_AMBIENT_RAISE", "PR_CAP_AMBIENT_LOWER", "PR_CAP_AMBIENT_IS_SET", "PR_CAP_AMBIENT_CLEAR_ALL", "PR_CAPBSET_READ", "PR_CAPBSET_DROP", "PR_SET_CHILD_SUBREAPER", "PR_GET_CHILD_SUBREAPER", "PR_SET_DUMPABLE", "PR_GET_DUMPABLE", "PR_GET_ENDIAN", "PR_SET_FPEMU", "PR_GET_FPEMU", "PR_SET_FPEXC", "PR_GET_FPEXC", "PR_SET_KEEPCAPS", "PR_GET_KEEPCAPS", "PR_MCE_KILL", "PR_MCE_KILL_GET", "PR_SET_MM", "PR_SET_MM_START_CODE", "PR_SET_MM_END_CODE", "PR_SET_MM_START_DATA", "PR_SET_MM_END_DATA", "PR_SET_MM_START_STACK", "PR_SET_MM_START_BRK", "PR_SET_MM_BRK", "PR_SET_MM_ARG_START", "PR_SET_MM_ARG_END", "PR_SET_MM_ENV_START", "PR_SET_MM_ENV_END", "PR_SET_MM_AUXV", "PR_SET_MM_EXE_FILE", "PR_MPX_ENABLE_MANAGEMENT", "PR_MPX_DISABLE_MANAGEMENT", "PR_SET_NAME", "PR_GET_NAME", "PR_SET_NO_NEW_PRIVS", "PR_GET_NO_NEW_PRIVS", "PR_SET_PDEATHSIG", "PR_GET_PDEATHSIG", "PR_SET_PTRACER", "PR_SET_SECCOMP", "PR_GET_SECCOMP", "PR_SET_SECUREBITS", "PR_GET_SECUREBITS", "PR_SET_THP_DISABLE", "PR_TASK_PERF_EVENTS_DISABLE", "PR_TASK_PERF_EVENTS_ENABLE", "PR_GET_THP_DISABLE", "PR_GET_TID_ADDRESS", "PR_SET_TIMERSLACK", "PR_GET_TIMERSLACK", "PR_SET_TIMING", "PR_GET_TIMING", "PR_SET_TSC", "PR_GET_TSC", "PR_SET_UNALIGN", "PR_GET_UNALIGN"} { + // good input + seccompAllowlist := fmt.Sprintf("prctl %s", arg) + bpfInputGood := fmt.Sprintf("prctl;native;%s", arg) + s.runBpf(c, seccompAllowlist, bpfInputGood, Allow) + // bad input + for _, bad := range []string{"prctl;native;99999", "setpriority;native;"} { + s.runBpf(c, seccompAllowlist, bad, Deny) + } + + if arg == "PR_CAP_AMBIENT" { + for _, j := range []string{"PR_CAP_AMBIENT_RAISE", "PR_CAP_AMBIENT_LOWER", "PR_CAP_AMBIENT_IS_SET", "PR_CAP_AMBIENT_CLEAR_ALL"} { + seccompAllowlist := fmt.Sprintf("prctl %s %s", arg, j) + bpfInputGood := fmt.Sprintf("prctl;native;%s,%s", arg, j) + s.runBpf(c, seccompAllowlist, bpfInputGood, Allow) + for _, bad := range []string{ + fmt.Sprintf("prctl;native;%s,99999", arg), + "setpriority;native;", + } { + s.runBpf(c, seccompAllowlist, bad, Deny) + } + } + } + } +} + +// ported from test_restrictions_working_args_clone +func (s *snapSeccompSuite) TestRestrictionsWorkingArgsClone(c *C) { + for _, t := range []struct { + seccompAllowlist string + bpfInput string + expected int + }{ + // good input + {"setns - CLONE_NEWIPC", "setns;native;-,CLONE_NEWIPC", Allow}, + {"setns - CLONE_NEWNET", "setns;native;-,CLONE_NEWNET", Allow}, + {"setns - CLONE_NEWNS", "setns;native;-,CLONE_NEWNS", Allow}, + {"setns - CLONE_NEWPID", "setns;native;-,CLONE_NEWPID", Allow}, + {"setns - CLONE_NEWUSER", "setns;native;-,CLONE_NEWUSER", Allow}, + {"setns - CLONE_NEWUTS", "setns;native;-,CLONE_NEWUTS", Allow}, + // bad input + {"setns - CLONE_NEWIPC", "setns;native;-,99", Deny}, + {"setns - CLONE_NEWNET", "setns;native;-,99", Deny}, + {"setns - CLONE_NEWNS", "setns;native;-,99", Deny}, + {"setns - CLONE_NEWPID", "setns;native;-,99", Deny}, + {"setns - CLONE_NEWUSER", "setns;native;-,99", Deny}, + {"setns - CLONE_NEWUTS", "setns;native;-,99", Deny}, + } { + s.runBpf(c, t.seccompAllowlist, t.bpfInput, t.expected) + } +} + +// ported from test_restrictions_working_args_mknod +func (s *snapSeccompSuite) TestRestrictionsWorkingArgsMknod(c *C) { + for _, t := range []struct { + seccompAllowlist string + bpfInput string + expected int + }{ + // good input + {"mknod - S_IFREG", "mknod;native;-,S_IFREG", Allow}, + {"mknod - S_IFCHR", "mknod;native;-,S_IFCHR", Allow}, + {"mknod - S_IFBLK", "mknod;native;-,S_IFBLK", Allow}, + {"mknod - S_IFIFO", "mknod;native;-,S_IFIFO", Allow}, + {"mknod - S_IFSOCK", "mknod;native;-,S_IFSOCK", Allow}, + // bad input + {"mknod - S_IFREG", "mknod;native;-,999", Deny}, + {"mknod - S_IFCHR", "mknod;native;-,999", Deny}, + {"mknod - S_IFBLK", "mknod;native;-,999", Deny}, + {"mknod - S_IFIFO", "mknod;native;-,999", Deny}, + {"mknod - S_IFSOCK", "mknod;native;-,999", Deny}, + } { + s.runBpf(c, t.seccompAllowlist, t.bpfInput, t.expected) + } +} + +// ported from test_restrictions_working_args_prio +func (s *snapSeccompSuite) TestRestrictionsWorkingArgsPrio(c *C) { + for _, t := range []struct { + seccompAllowlist string + bpfInput string + expected int + }{ + // good input + {"setpriority PRIO_PROCESS", "setpriority;native;PRIO_PROCESS", Allow}, + {"setpriority PRIO_PGRP", "setpriority;native;PRIO_PGRP", Allow}, + {"setpriority PRIO_USER", "setpriority;native;PRIO_USER", Allow}, + // bad input + {"setpriority PRIO_PROCESS", "setpriority;native;99", Deny}, + {"setpriority PRIO_PGRP", "setpriority;native;99", Deny}, + {"setpriority PRIO_USER", "setpriority;native;99", Deny}, + } { + s.runBpf(c, t.seccompAllowlist, t.bpfInput, t.expected) + } +} + +// ported from test_restrictions_working_args_termios +func (s *snapSeccompSuite) TestRestrictionsWorkingArgsTermios(c *C) { + for _, t := range []struct { + seccompAllowlist string + bpfInput string + expected int + }{ + // good input + {"ioctl - TIOCSTI", "ioctl;native;-,TIOCSTI", Allow}, + // bad input + {"ioctl - TIOCSTI", "quotactl;native;-,99", Deny}, + } { + s.runBpf(c, t.seccompAllowlist, t.bpfInput, t.expected) + } +} + +func (s *snapSeccompSuite) TestRestrictionsWorkingArgsUidGid(c *C) { + // while 'root' user usually has uid 0, 'daemon' user uid may vary + // across distributions, best lookup the uid directly + daemonUid, err := osutil.FindUid("daemon") + + if err != nil { + c.Skip("daemon user not available, perhaps we are in a buildroot jail") + } + + for _, t := range []struct { + seccompAllowlist string + bpfInput string + expected int + }{ + // good input. 'root' is guaranteed to be '0' and 'daemon' uid + // was determined at runtime + {"setuid u:root", "setuid;native;0", Allow}, + {"setuid u:daemon", fmt.Sprintf("setuid;native;%v", daemonUid), Allow}, + {"setgid g:root", "setgid;native;0", Allow}, + {"setgid g:daemon", fmt.Sprintf("setgid;native;%v", daemonUid), Allow}, + // bad input + {"setuid u:root", "setuid;native;99", Deny}, + {"setuid u:daemon", "setuid;native;99", Deny}, + {"setgid g:root", "setgid;native;99", Deny}, + {"setgid g:daemon", "setgid;native;99", Deny}, + } { + s.runBpf(c, t.seccompAllowlist, t.bpfInput, t.expected) + } +} + +func (s *snapSeccompSuite) TestCompatArchWorks(c *C) { + if !s.canCheckCompatArch { + c.Skip("multi-lib syscall runner not supported by this host") + } + for _, t := range []struct { + arch string + seccompAllowlist string + bpfInput string + expected int + }{ + // on amd64 we add compat i386 + {"amd64", "read", "read;i386", Allow}, + {"amd64", "read", "read;amd64", Allow}, + {"amd64", "chown - 0 -1", "chown;i386;-,0,-1", Allow}, + {"amd64", "chown - 0 -1", "chown;amd64;-,0,-1", Allow}, + {"amd64", "chown - 0 -1", "chown;i386;-,99,-1", Deny}, + {"amd64", "chown - 0 -1", "chown;amd64;-,99,-1", Deny}, + {"amd64", "setresuid -1 -1 -1", "setresuid;i386;-1,-1,-1", Allow}, + {"amd64", "setresuid -1 -1 -1", "setresuid;amd64;-1,-1,-1", Allow}, + {"amd64", "setresuid -1 -1 -1", "setresuid;i386;-1,99,-1", Deny}, + {"amd64", "setresuid -1 -1 -1", "setresuid;amd64;-1,99,-1", Deny}, + } { + // It is tricky to mock the architecture here because + // seccomp is always adding the native arch to the seccomp + // filter and it will silently discard arches that have + // an endian mismatch: + // https://github.com/seccomp/libseccomp/issues/86 + // + // This means we can not just + // main.MockArchDpkgArchitecture(t.arch) + // here because on endian mismatch the arch will *not* be + // added + if arch.DpkgArchitecture() == t.arch { + s.runBpf(c, t.seccompAllowlist, t.bpfInput, t.expected) + } + } +} + +func (s *snapSeccompSuite) TestExportBpfErrors(c *C) { + fout, err := os.Create(filepath.Join(c.MkDir(), "filter")) + c.Assert(err, IsNil) + + // invalid filter + _, err = main.ExportBPF(fout, &seccomp.ScmpFilter{}) + c.Check(err, ErrorMatches, "cannot export bpf filter: filter is invalid or uninitialized") +} diff --git a/cmd/snap-seccomp/old_seccomp.go b/cmd/snap-seccomp/old_seccomp.go new file mode 100644 index 00000000..c41ee901 --- /dev/null +++ b/cmd/snap-seccomp/old_seccomp.go @@ -0,0 +1,30 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- +//go:build oldseccomp + +/* + * Copyright (C) 2021 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +// On 14.04 we need to use forked libseccomp-golang, as recent +// upstream libseccomp-golang does not support building against +// libseecomp 2.1.1. This is patched in via packaging patch. But to +// continue vendoring the modules in go.mod any golang file must still +// reference the old forked libseccomp-golang. Which is here. This +// file and import can be safely removed, once 14.04 build support of +// master is deemed to never be required again. +import "github.com/mvo5/libseccomp-golang" diff --git a/cmd/snap-seccomp/syscalls/syscalls.go b/cmd/snap-seccomp/syscalls/syscalls.go new file mode 100644 index 00000000..7e60e3b6 --- /dev/null +++ b/cmd/snap-seccomp/syscalls/syscalls.go @@ -0,0 +1,509 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package syscalls + +// Generated using arch-syscall-dump test tool from libseccomp tree, git +// revision aa168d49243b95f63b9825a87351a1eb323dc792. +var SeccompSyscalls = []string{ + "_llseek", + "_newselect", + "_sysctl", + "accept", + "accept4", + "access", + "acct", + "add_key", + "adjtimex", + "afs_syscall", + "alarm", + "arch_prctl", + "arm_fadvise64_64", + "arm_sync_file_range", + "atomic_barrier", + "atomic_cmpxchg_32", + "bdflush", + "bind", + "bpf", + "break", + "breakpoint", + "brk", + "cachectl", + "cacheflush", + "capget", + "capset", + "chdir", + "chmod", + "chown", + "chown32", + "chroot", + "clock_adjtime", + "clock_adjtime64", + "clock_getres", + "clock_getres_time64", + "clock_gettime", + "clock_gettime64", + "clock_nanosleep", + "clock_nanosleep_time64", + "clock_settime", + "clock_settime64", + "clone", + "clone3", + "close", + "close_range", + "connect", + "copy_file_range", + "creat", + "create_module", + "delete_module", + "dup", + "dup2", + "dup3", + "epoll_create", + "epoll_create1", + "epoll_ctl", + "epoll_ctl_old", + "epoll_pwait", + "epoll_pwait2", + "epoll_wait", + "epoll_wait_old", + "eventfd", + "eventfd2", + "execve", + "execveat", + "exit", + "exit_group", + "faccessat", + "faccessat2", + "fadvise64", + "fadvise64_64", + "fallocate", + "fanotify_init", + "fanotify_mark", + "fchdir", + "fchmod", + "fchmodat", + "fchown", + "fchown32", + "fchownat", + "fcntl", + "fcntl64", + "fdatasync", + "fgetxattr", + "finit_module", + "flistxattr", + "flock", + "fork", + "fremovexattr", + "fsconfig", + "fsetxattr", + "fsmount", + "fsopen", + "fspick", + "fstat", + "fstat64", + "fstatat64", + "fstatfs", + "fstatfs64", + "fsync", + "ftime", + "ftruncate", + "ftruncate64", + "futex", + "futex_time64", + "futex_waitv", + "futimesat", + "get_kernel_syms", + "get_mempolicy", + "get_robust_list", + "get_thread_area", + "get_tls", + "getcpu", + "getcwd", + "getdents", + "getdents64", + "getegid", + "getegid32", + "geteuid", + "geteuid32", + "getgid", + "getgid32", + "getgroups", + "getgroups32", + "getitimer", + "getpagesize", + "getpeername", + "getpgid", + "getpgrp", + "getpid", + "getpmsg", + "getppid", + "getpriority", + "getrandom", + "getresgid", + "getresgid32", + "getresuid", + "getresuid32", + "getrlimit", + "getrusage", + "getsid", + "getsockname", + "getsockopt", + "gettid", + "gettimeofday", + "getuid", + "getuid32", + "getxattr", + "gtty", + "idle", + "init_module", + "inotify_add_watch", + "inotify_init", + "inotify_init1", + "inotify_rm_watch", + "io_cancel", + "io_destroy", + "io_getevents", + "io_pgetevents", + "io_pgetevents_time64", + "io_setup", + "io_submit", + "io_uring_enter", + "io_uring_register", + "io_uring_setup", + "ioctl", + "ioperm", + "iopl", + "ioprio_get", + "ioprio_set", + "ipc", + "kcmp", + "kexec_file_load", + "kexec_load", + "keyctl", + "kill", + "landlock_add_rule", + "landlock_create_ruleset", + "landlock_restrict_self", + "lchown", + "lchown32", + "lgetxattr", + "link", + "linkat", + "listen", + "listxattr", + "llistxattr", + "lock", + "lookup_dcookie", + "lremovexattr", + "lseek", + "lsetxattr", + "lstat", + "lstat64", + "madvise", + "mbind", + "membarrier", + "memfd_create", + "memfd_secret", + "migrate_pages", + "mincore", + "mkdir", + "mkdirat", + "mknod", + "mknodat", + "mlock", + "mlock2", + "mlockall", + "mmap", + "mmap2", + "modify_ldt", + "mount", + "mount_setattr", + "move_mount", + "move_pages", + "mprotect", + "mpx", + "mq_getsetattr", + "mq_notify", + "mq_open", + "mq_timedreceive", + "mq_timedreceive_time64", + "mq_timedsend", + "mq_timedsend_time64", + "mq_unlink", + "mremap", + "msgctl", + "msgget", + "msgrcv", + "msgsnd", + "msync", + "multiplexer", + "munlock", + "munlockall", + "munmap", + "name_to_handle_at", + "nanosleep", + "newfstatat", + "nfsservctl", + "nice", + "oldfstat", + "oldlstat", + "oldolduname", + "oldstat", + "olduname", + "open", + "open_by_handle_at", + "open_tree", + "openat", + "openat2", + "pause", + "pciconfig_iobase", + "pciconfig_read", + "pciconfig_write", + "perf_event_open", + "personality", + "pidfd_getfd", + "pidfd_open", + "pidfd_send_signal", + "pipe", + "pipe2", + "pivot_root", + "pkey_alloc", + "pkey_free", + "pkey_mprotect", + "poll", + "ppoll", + "ppoll_time64", + "prctl", + "pread64", + "preadv", + "preadv2", + "prlimit64", + "process_madvise", + "process_mrelease", + "process_vm_readv", + "process_vm_writev", + "prof", + "profil", + "pselect6", + "pselect6_time64", + "ptrace", + "putpmsg", + "pwrite64", + "pwritev", + "pwritev2", + "query_module", + "quotactl", + "quotactl_fd", + "read", + "readahead", + "readdir", + "readlink", + "readlinkat", + "readv", + "reboot", + "recv", + "recvfrom", + "recvmmsg", + "recvmmsg_time64", + "recvmsg", + "remap_file_pages", + "removexattr", + "rename", + "renameat", + "renameat2", + "request_key", + "restart_syscall", + "riscv_flush_icache", + "rmdir", + "rseq", + "rt_sigaction", + "rt_sigpending", + "rt_sigprocmask", + "rt_sigqueueinfo", + "rt_sigreturn", + "rt_sigsuspend", + "rt_sigtimedwait", + "rt_sigtimedwait_time64", + "rt_tgsigqueueinfo", + "rtas", + "s390_guarded_storage", + "s390_pci_mmio_read", + "s390_pci_mmio_write", + "s390_runtime_instr", + "s390_sthyi", + "sched_get_priority_max", + "sched_get_priority_min", + "sched_getaffinity", + "sched_getattr", + "sched_getparam", + "sched_getscheduler", + "sched_rr_get_interval", + "sched_rr_get_interval_time64", + "sched_setaffinity", + "sched_setattr", + "sched_setparam", + "sched_setscheduler", + "sched_yield", + "seccomp", + "security", + "select", + "semctl", + "semget", + "semop", + "semtimedop", + "semtimedop_time64", + "send", + "sendfile", + "sendfile64", + "sendmmsg", + "sendmsg", + "sendto", + "set_mempolicy", + "set_mempolicy_home_node", + "set_robust_list", + "set_thread_area", + "set_tid_address", + "set_tls", + "setdomainname", + "setfsgid", + "setfsgid32", + "setfsuid", + "setfsuid32", + "setgid", + "setgid32", + "setgroups", + "setgroups32", + "sethostname", + "setitimer", + "setns", + "setpgid", + "setpriority", + "setregid", + "setregid32", + "setresgid", + "setresgid32", + "setresuid", + "setresuid32", + "setreuid", + "setreuid32", + "setrlimit", + "setsid", + "setsockopt", + "settimeofday", + "setuid", + "setuid32", + "setxattr", + "sgetmask", + "shmat", + "shmctl", + "shmdt", + "shmget", + "shutdown", + "sigaction", + "sigaltstack", + "signal", + "signalfd", + "signalfd4", + "sigpending", + "sigprocmask", + "sigreturn", + "sigsuspend", + "socket", + "socketcall", + "socketpair", + "splice", + "spu_create", + "spu_run", + "ssetmask", + "stat", + "stat64", + "statfs", + "statfs64", + "statx", + "stime", + "stty", + "subpage_prot", + "swapcontext", + "swapoff", + "swapon", + "switch_endian", + "symlink", + "symlinkat", + "sync", + "sync_file_range", + "sync_file_range2", + "syncfs", + "sys_debug_setcontext", + "syscall", + "sysfs", + "sysinfo", + "syslog", + "sysmips", + "tee", + "tgkill", + "time", + "timer_create", + "timer_delete", + "timer_getoverrun", + "timer_gettime", + "timer_gettime64", + "timer_settime", + "timer_settime64", + "timerfd", + "timerfd_create", + "timerfd_gettime", + "timerfd_gettime64", + "timerfd_settime", + "timerfd_settime64", + "times", + "tkill", + "truncate", + "truncate64", + "tuxcall", + "ugetrlimit", + "ulimit", + "umask", + "umount", + "umount2", + "uname", + "unlink", + "unlinkat", + "unshare", + "uselib", + "userfaultfd", + "usr26", + "usr32", + "ustat", + "utime", + "utimensat", + "utimensat_time64", + "utimes", + "vfork", + "vhangup", + "vm86", + "vm86old", + "vmsplice", + "vserver", + "wait4", + "waitid", + "waitpid", + "write", + "writev", +} diff --git a/cmd/snap-seccomp/versioninfo.go b/cmd/snap-seccomp/versioninfo.go new file mode 100644 index 00000000..2ddba5fd --- /dev/null +++ b/cmd/snap-seccomp/versioninfo.go @@ -0,0 +1,81 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "crypto/sha256" + "fmt" + "os" + "strings" + + "github.com/seccomp/libseccomp-golang" + + "github.com/snapcore/snapd/cmd/snap-seccomp/syscalls" + "github.com/snapcore/snapd/osutil" +) + +var seccompSyscalls = syscalls.SeccompSyscalls + +func versionInfo() (string, error) { + myBuildID, err := osutil.MyBuildID() + if err != nil { + return "", fmt.Errorf("cannot get build-id of snap-seccomp: %v", err) + } + // Calculate the checksum of all syscall names supported by libseccomp + // library. We add that to the version info to cover the case when + // libseccomp version does not change, but the set of supported syscalls + // does due to distro patches. + sh := sha256.New() + newline := []byte("\n") + for _, syscallName := range seccompSyscalls { + if _, err := seccomp.GetSyscallFromName(syscallName); err != nil { + // syscall is unsupported by this version of libseccomp + continue + } + sh.Write([]byte(syscallName)) + sh.Write(newline) + } + + major, minor, micro := seccomp.GetLibraryVersion() + features := goSeccompFeatures() + + return fmt.Sprintf("%s %d.%d.%d %x %s", myBuildID, major, minor, micro, sh.Sum(nil), features), nil +} + +func showVersionInfo() error { + vi, err := versionInfo() + if err != nil { + return err + } + fmt.Fprintln(os.Stdout, vi) + return nil +} + +func goSeccompFeatures() string { + var features []string + if actLogSupported() { + features = append(features, "bpf-actlog") + } + + if len(features) == 0 { + return "-" + } + return strings.Join(features, ":") +} diff --git a/cmd/snap-seccomp/versioninfo_test.go b/cmd/snap-seccomp/versioninfo_test.go new file mode 100644 index 00000000..ea20c306 --- /dev/null +++ b/cmd/snap-seccomp/versioninfo_test.go @@ -0,0 +1,74 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "fmt" + "strings" + + "github.com/seccomp/libseccomp-golang" + . "gopkg.in/check.v1" + + main "github.com/snapcore/snapd/cmd/snap-seccomp" + "github.com/snapcore/snapd/osutil" +) + +type versionInfoSuite struct{} + +var _ = Suite(&versionInfoSuite{}) + +func (s *versionInfoSuite) TestVersionInfo(c *C) { + buildID, err := osutil.MyBuildID() + c.Assert(err, IsNil) + + m, i, p := seccomp.GetLibraryVersion() + prefix := fmt.Sprintf("%s %d.%d.%d ", buildID, m, i, p) + suffix := fmt.Sprintf(" %s", main.GoSeccompFeatures()) + + defaultVi, err := main.VersionInfo() + c.Assert(err, IsNil) + + // $ echo -n 'read\nwrite\n' | sha256sum + // 88b06efcea4b5946cebd4b0674b93744de328339de5d61b75db858119054ff93 - + readWriteHash := "88b06efcea4b5946cebd4b0674b93744de328339de5d61b75db858119054ff93" + + c.Check(strings.HasPrefix(defaultVi, prefix), Equals, true) + c.Check(strings.HasSuffix(defaultVi, suffix), Equals, true) + c.Assert(len(defaultVi) > len(prefix)+len(suffix), Equals, true) + hash := defaultVi[len(prefix) : len(defaultVi)-len(suffix)] + c.Check(len(hash), Equals, len(readWriteHash)) + c.Check(hash, Not(Equals), readWriteHash) + + restore := main.MockSeccompSyscalls([]string{"read", "write"}) + defer restore() + + vi, err := main.VersionInfo() + c.Assert(err, IsNil) + c.Check(vi, Equals, prefix+readWriteHash+suffix) + + // pretend it's only 'read' now + readHash := "15fd60c6f5c6804626177d178f3dba849a41f4a1878b2e7e7e3ed38a194dc82b" + restore = main.MockSeccompSyscalls([]string{"read"}) + defer restore() + + vi, err = main.VersionInfo() + c.Assert(err, IsNil) + c.Check(vi, Equals, prefix+readHash+suffix) +} diff --git a/cmd/snap-update-ns/bootstrap.c b/cmd/snap-update-ns/bootstrap.c new file mode 100644 index 00000000..564de481 --- /dev/null +++ b/cmd/snap-update-ns/bootstrap.c @@ -0,0 +1,511 @@ +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +// IMPORTANT: all the code in this file may be run with elevated privileges +// when invoking snap-update-ns from the setuid snap-confine. +// +// This file is a preprocessor for snap-update-ns' main() function. It will +// perform input validation and clear the environment so that snap-update-ns' +// go code runs with safe inputs when called by the setuid() snap-confine. + +#include "bootstrap.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// bootstrap_errno contains a copy of errno if a system call fails. +int bootstrap_errno = 0; +// bootstrap_msg contains a static string if something fails. +const char *bootstrap_msg = NULL; + +// setns_into_snap switches mount namespace into that of a given snap. +static int setns_into_snap(const char *snap_name) +{ + // Construct the name of the .mnt file to open. + char buf[PATH_MAX] = { + 0, + }; + int n = snprintf(buf, sizeof buf, "/run/snapd/ns/%s.mnt", snap_name); + if (n >= sizeof buf || n < 0) { + bootstrap_errno = 0; + bootstrap_msg = "cannot format mount namespace file name"; + return -1; + } + // Open the mount namespace file. + int fd = open(buf, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + if (fd < 0) { + bootstrap_errno = errno; + bootstrap_msg = "cannot open mount namespace file"; + return -1; + } + // Switch to the mount namespace of the given snap. + int err = setns(fd, CLONE_NEWNS); + if (err < 0) { + bootstrap_errno = errno; + bootstrap_msg = "cannot switch mount namespace"; + }; + + close(fd); + return err; +} + +// switch_to_privileged_user drops to the real user ID while retaining +// CAP_SYS_ADMIN, for operations such as mount(). +static int switch_to_privileged_user() +{ + uid_t real_uid; + gid_t real_gid; + + real_uid = getuid(); + if (real_uid == 0) { + // We're running as root: no need to switch IDs + return 0; + } + real_gid = getgid(); + + // _LINUX_CAPABILITY_VERSION_3 valid for kernel >= 2.6.26. See + // https://github.com/torvalds/linux/blob/master/kernel/capability.c + struct __user_cap_header_struct hdr = + { _LINUX_CAPABILITY_VERSION_3, 0 }; + struct __user_cap_data_struct data[2] = { {0} }; + + data[0].effective = (CAP_TO_MASK(CAP_SYS_ADMIN) | + CAP_TO_MASK(CAP_SETUID) | CAP_TO_MASK(CAP_SETGID)); + data[0].permitted = data[0].effective; + data[0].inheritable = 0; + data[1].effective = 0; + data[1].permitted = 0; + data[1].inheritable = 0; + + if (capset(&hdr, data) != 0) { + bootstrap_errno = errno; + bootstrap_msg = "cannot set permitted capabilities mask"; + return -1; + } + + if (prctl(PR_SET_KEEPCAPS, 1, 0, 0, 0) != 0) { + bootstrap_errno = errno; + bootstrap_msg = + "cannot tell kernel to keep capabilities over setuid"; + return -1; + } + + if (setgroups(1, &real_gid) != 0) { + bootstrap_errno = errno; + bootstrap_msg = "cannot drop supplementary groups"; + return -1; + } + + if (setgid(real_gid) != 0) { + bootstrap_errno = errno; + bootstrap_msg = "cannot switch to real group ID"; + return -1; + } + + if (setuid(real_uid) != 0) { + bootstrap_errno = errno; + bootstrap_msg = "cannot switch to real user ID"; + return -1; + } + // After changing uid, our effective capabilities were dropped. + // Reacquire CAP_SYS_ADMIN, and discard CAP_SETUID/CAP_SETGID. + data[0].effective = CAP_TO_MASK(CAP_SYS_ADMIN); + data[0].permitted = data[0].effective; + if (capset(&hdr, data) != 0) { + bootstrap_errno = errno; + bootstrap_msg = + "cannot enable capabilities after switching to real user"; + return -1; + } + + return 0; +} + +// TODO: reuse the code from snap-confine, if possible. +static int skip_lowercase_letters(const char **p) +{ + int skipped = 0; + const char *c; + for (c = *p; *c >= 'a' && *c <= 'z'; ++c) { + skipped += 1; + } + *p = (*p) + skipped; + return skipped; +} + +// TODO: reuse the code from snap-confine, if possible. +static int skip_digits(const char **p) +{ + int skipped = 0; + const char *c; + for (c = *p; *c >= '0' && *c <= '9'; ++c) { + skipped += 1; + } + *p = (*p) + skipped; + return skipped; +} + +// TODO: reuse the code from snap-confine, if possible. +static int skip_one_char(const char **p, char c) +{ + if (**p == c) { + *p += 1; + return 1; + } + return 0; +} + +// validate_snap_name performs full validation of the given name. +int validate_snap_name(const char *snap_name) +{ + // NOTE: This function should be synchronized with the two other + // implementations: sc_snap_name_validate and snap.ValidateName. + + // Ensure that name is not NULL + if (snap_name == NULL) { + bootstrap_msg = "snap name cannot be NULL"; + return -1; + } + // This is a regexp-free routine hand-codes the following pattern: + // + // "^([a-z0-9]+-?)*[a-z](-?[a-z0-9])*$" + // + // The only motivation for not using regular expressions is so that we + // don't run untrusted input against a potentially complex regular + // expression engine. + const char *p = snap_name; + if (skip_one_char(&p, '-')) { + bootstrap_msg = "snap name cannot start with a dash"; + return -1; + } + bool got_letter = false; + int n = 0, m; + for (; *p != '\0';) { + if ((m = skip_lowercase_letters(&p)) > 0) { + n += m; + got_letter = true; + continue; + } + if ((m = skip_digits(&p)) > 0) { + n += m; + continue; + } + if (skip_one_char(&p, '-') > 0) { + n++; + if (*p == '\0') { + bootstrap_msg = + "snap name cannot end with a dash"; + return -1; + } + if (skip_one_char(&p, '-') > 0) { + bootstrap_msg = + "snap name cannot contain two consecutive dashes"; + return -1; + } + continue; + } + bootstrap_msg = + "snap name must use lower case letters, digits or dashes"; + return -1; + } + if (!got_letter) { + bootstrap_msg = "snap name must contain at least one letter"; + return -1; + } + if (n < 2) { + bootstrap_msg = "snap name must be longer than 1 character"; + return -1; + } + if (n > 40) { + bootstrap_msg = "snap name must be shorter than 40 characters"; + return -1; + } + + bootstrap_msg = NULL; + return 0; +} + +static int instance_key_validate(const char *instance_key) +{ + // NOTE: see snap.ValidateInstanceName for reference of a valid instance key + // format + + // Ensure that name is not NULL + if (instance_key == NULL) { + bootstrap_msg = "instance key cannot be NULL"; + return -1; + } + // This is a regexp-free routine hand-coding the following pattern: + // + // "^[a-z]{1,10}$" + // + // The only motivation for not using regular expressions is so that we don't + // run untrusted input against a potentially complex regular expression + // engine. + int i = 0; + for (i = 0; instance_key[i] != '\0'; i++) { + char c = instance_key[i]; + /* NOTE: We are reimplementing islower() and isdigit() + * here. For context see + * https://github.com/golang/go/issues/29689 */ + if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) { + continue; + } + bootstrap_msg = + "instance key must use lower case letters or digits"; + return -1; + } + + if (i == 0) { + bootstrap_msg = + "instance key must contain at least one letter or digit"; + return -1; + } else if (i > 10) { + bootstrap_msg = + "instance key must be shorter than 10 characters"; + return -1; + } + return 0; +} + +// validate_instance_name performs full validation of the given snap instance name. +int validate_instance_name(const char *instance_name) +{ + // NOTE: This function should be synchronized with the two other + // implementations: sc_instance_name_validate and snap.ValidateInstanceName. + + if (instance_name == NULL) { + bootstrap_msg = "snap instance name cannot be NULL"; + return -1; + } + // 40 char snap_name + '_' + 10 char instance_key + 1 extra overflow + 1 + // NULL + char s[53] = { 0 }; + strncpy(s, instance_name, sizeof(s) - 1); + + char *t = s; + const char *snap_name = strsep(&t, "_"); + const char *instance_key = strsep(&t, "_"); + const char *third_separator = strsep(&t, "_"); + if (third_separator != NULL) { + bootstrap_msg = + "snap instance name can contain only one underscore"; + return -1; + } + + if (validate_snap_name(snap_name) < 0) { + return -1; + } + // When the instance_name is a normal snap name, instance_key will be + // NULL, so only validate instance_key when we found one. + if (instance_key != NULL && instance_key_validate(instance_key) < 0) { + return -1; + } + + return 0; +} + +// parse the -u argument, returns -1 on failure or 0 on success. +static int parse_arg_u(int argc, char *const *argv, int *optind, + unsigned long *uid_out) +{ + if (*optind + 1 == argc || argv[*optind + 1] == NULL) { + bootstrap_msg = "-u requires an argument"; + bootstrap_errno = 0; + return -1; + } + const char *uid_text = argv[*optind + 1]; + errno = 0; + char *uid_text_end = NULL; + unsigned long parsed_uid = strtoul(uid_text, &uid_text_end, 10); + int saved_errno = errno; + char c = *uid_text; + if ( + /* Reject overflow in parsed representation */ + (parsed_uid == ULONG_MAX && errno != 0) + /* Reject leading whitespace allowed by strtoul. */ + /* NOTE: We are reimplementing isspace() here. + * For context see + * https://github.com/golang/go/issues/29689 */ + || c == ' ' || c == '\t' || c == '\v' || c == '\r' + || c == '\n' + /* Reject empty string. */ + || (*uid_text == '\0') + /* Reject partially parsed strings. */ + || (*uid_text != '\0' && uid_text_end != NULL + && *uid_text_end != '\0')) { + bootstrap_msg = "cannot parse user id"; + bootstrap_errno = saved_errno; + return -1; + } + if ((long)parsed_uid < 0) { + bootstrap_msg = "user id cannot be negative"; + bootstrap_errno = 0; + return -1; + } + if (uid_out != NULL) { + *uid_out = parsed_uid; + } + *optind += 1; // Account for the argument to -u. + return 0; +} + +// process_arguments parses given a command line +// argc and argv are defined as for the main() function +void process_arguments(int argc, char *const *argv, const char **snap_name_out, + bool *should_setns_out, bool *process_user_fstab, + unsigned long *uid_out) +{ + // Find the name of the called program. If it is ending with ".test" then do nothing. + // NOTE: This lets us use cgo/go to write tests without running the bulk + // of the code automatically. + // + if (argv == NULL || argc < 1) { + bootstrap_errno = 0; + bootstrap_msg = "argv0 is corrupted"; + return; + } + const char *argv0 = argv[0]; + const char *argv0_suffix_maybe = strstr(argv0, ".test"); + if (argv0_suffix_maybe != NULL + && argv0_suffix_maybe[strlen(".test")] == '\0') { + bootstrap_errno = 0; + bootstrap_msg = "bootstrap is not enabled while testing"; + return; + } + + bool should_setns = true; + bool user_fstab = false; + const char *snap_name = NULL; + + // Sanity check the command line arguments. The go parts will + // scan this too. + int i; + for (i = 1; i < argc; i++) { + const char *arg = argv[i]; + if (arg[0] == '-') { + /* We have an option */ + if (!strcmp(arg, "--from-snap-confine")) { + // When we are running under "--from-snap-confine" + // option skip the setns call as snap-confine has + // already placed us in the right namespace. + should_setns = false; + } else if (!strcmp(arg, "--user-mounts")) { + user_fstab = true; + // Processing the user-fstab file implies we're being + // called from snap-confine. + should_setns = false; + } else if (!strcmp(arg, "-u")) { + if (parse_arg_u(argc, argv, &i, uid_out) < 0) { + return; + } + // Providing an user identifier implies we are performing an + // update of a specific user mount namespace and that we are + // invoked from snapd and we should setns ourselves. When + // invoked from snap-confine we are only called with + // --from-snap-confine and with --user-mounts. + should_setns = true; + user_fstab = true; + } else { + bootstrap_errno = 0; + bootstrap_msg = "unsupported option"; + return; + } + } else { + // We expect a single positional argument: the snap name + if (snap_name != NULL) { + bootstrap_errno = 0; + bootstrap_msg = "too many positional arguments"; + return; + } + snap_name = arg; + } + } + + // If there's no snap name given, just bail out. + if (snap_name == NULL) { + bootstrap_errno = 0; + bootstrap_msg = "snap name not provided"; + return; + } + // Ensure that the snap instance name is valid so that we don't blindly setns into + // something that is controlled by a potential attacker. + if (validate_instance_name(snap_name) < 0) { + bootstrap_errno = 0; + // bootstap_msg is set by validate_instance_name; + return; + } + // We have a valid snap name now so let's store it. + if (snap_name_out != NULL) { + *snap_name_out = snap_name; + } + if (should_setns_out != NULL) { + *should_setns_out = should_setns; + } + if (process_user_fstab != NULL) { + *process_user_fstab = user_fstab; + } + bootstrap_errno = 0; + bootstrap_msg = NULL; +} + +// bootstrap prepares snap-update-ns to work in the namespace of the snap given +// on command line. +void bootstrap(int argc, char **argv, char **envp) +{ + // We may have been started via a setuid-root snap-confine. In order to + // prevent environment-based attacks we start by erasing all environment + // variables. + char *snapd_debug = getenv("SNAPD_DEBUG"); + if (clearenv() != 0) { + bootstrap_errno = 0; + bootstrap_msg = "bootstrap could not clear the environment"; + return; + } + if (snapd_debug != NULL) { + setenv("SNAPD_DEBUG", snapd_debug, 0); + } + // Analyze the read process cmdline to find the snap name and decide if we + // should use setns to jump into the mount namespace of a particular snap. + // This is spread out for easier testability. + const char *snap_name = NULL; + bool should_setns = false; + bool process_user_fstab = false; + unsigned long uid = 0; + process_arguments(argc, argv, &snap_name, &should_setns, + &process_user_fstab, &uid); + if (process_user_fstab) { + switch_to_privileged_user(); + // switch_to_privileged_user sets bootstrap_{errno,msg} + } else if (snap_name != NULL && should_setns) { + setns_into_snap(snap_name); + // setns_into_snap sets bootstrap_{errno,msg} + } +} diff --git a/cmd/snap-update-ns/bootstrap.go b/cmd/snap-update-ns/bootstrap.go new file mode 100644 index 00000000..afc5c465 --- /dev/null +++ b/cmd/snap-update-ns/bootstrap.go @@ -0,0 +1,134 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +// Use a pre-main helper to switch the mount namespace. This is required as +// golang creates threads at will and setns(..., CLONE_NEWNS) fails if any +// threads apart from the main thread exist. + +/* + +#include +#include "bootstrap.h" + +// The bootstrap function is called by the loader before passing +// control to main. We are using `preinit_array` rather than +// `init_array` because the Go linker adds its own initialisation +// function to `init_array`, and having ours run second would defeat +// the purpose of the C bootstrap code. +// +// The `used` attribute ensures that the compiler doesn't optimise out +// the variable on the mistaken belief that it isn't used. +__attribute__((section(".preinit_array"), used)) static typeof(&bootstrap) init = &bootstrap; + +// NOTE: do not add anything before the following `import "C"' +*/ +import "C" + +import ( + "errors" + "fmt" + "syscall" + "unsafe" +) + +var ( + // ErrNoNamespace is returned when a snap namespace does not exist. + ErrNoNamespace = errors.New("cannot update mount namespace that was not created yet") +) + +// IMPORTANT: all the code in this section may be run with elevated privileges +// when invoking snap-update-ns from the setuid snap-confine. + +// BootstrapError returns error (if any) encountered in pre-main C code. +func BootstrapError() error { + if C.bootstrap_msg == nil { + return nil + } + errno := syscall.Errno(C.bootstrap_errno) + // Translate EINVAL from setns or ENOENT from open into a dedicated error. + if errno == syscall.EINVAL || errno == syscall.ENOENT { + return ErrNoNamespace + } + if errno != 0 { + return fmt.Errorf("%s: %s", C.GoString(C.bootstrap_msg), errno) + } + return fmt.Errorf("%s", C.GoString(C.bootstrap_msg)) +} + +// This function is here to make clearing the boostrap errors accessible +// from the tests. +func clearBootstrapError() { + C.bootstrap_msg = nil + C.bootstrap_errno = 0 +} + +// END IMPORTANT + +func makeArgv(args []string) []*C.char { + // Create argv array with terminating NULL element + argv := make([]*C.char, len(args)+1) + for i, arg := range args { + argv[i] = C.CString(arg) + } + return argv +} + +func freeArgv(argv []*C.char) { + for _, arg := range argv { + C.free(unsafe.Pointer(arg)) + } +} + +// validateInstanceName checks if snap instance name is valid. +// This also sets bootstrap_msg on failure. +// +// This function is here only to make the C.validate_instance_name +// code testable from go, as cgo is not directly importable from test packages. +func validateInstanceName(instanceName string) int { + cStr := C.CString(instanceName) + defer C.free(unsafe.Pointer(cStr)) + return int(C.validate_instance_name(cStr)) +} + +// processArguments parses commnad line arguments. +// The argument cmdline is a string with embedded +// NUL bytes, separating particular arguments. +// +// This function is here only to make the C.validate_instance_name +// code testable from go, as cgo is not directly importable from test packages. +func processArguments(args []string) (snapName string, shouldSetNs bool, processUserFstab bool, uid uint) { + argv := makeArgv(args) + defer freeArgv(argv) + + var snapNameOut *C.char + var shouldSetNsOut C.bool + var processUserFstabOut C.bool + var uidOut C.ulong + C.process_arguments(C.int(len(args)), &argv[0], &snapNameOut, &shouldSetNsOut, &processUserFstabOut, &uidOut) + if snapNameOut != nil { + snapName = C.GoString(snapNameOut) + } + shouldSetNs = bool(shouldSetNsOut) + processUserFstab = bool(processUserFstabOut) + uid = uint(uidOut) + + return snapName, shouldSetNs, processUserFstab, uid +} diff --git a/cmd/snap-update-ns/bootstrap.h b/cmd/snap-update-ns/bootstrap.h new file mode 100644 index 00000000..9fcf114e --- /dev/null +++ b/cmd/snap-update-ns/bootstrap.h @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#ifndef SNAPD_CMD_SNAP_UPDATE_NS_H +#define SNAPD_CMD_SNAP_UPDATE_NS_H + +#define _GNU_SOURCE + +#include +#include + +extern int bootstrap_errno; +extern const char *bootstrap_msg; + +void bootstrap(int argc, char **argv, char **envp); +void process_arguments(int argc, char *const *argv, const char **snap_name_out, + bool *should_setns_out, bool *process_user_fstab, + unsigned long *uid_out); +int validate_instance_name(const char *instance_name); + +#endif diff --git a/cmd/snap-update-ns/bootstrap_ppc64le.go b/cmd/snap-update-ns/bootstrap_ppc64le.go new file mode 100644 index 00000000..441eb7a1 --- /dev/null +++ b/cmd/snap-update-ns/bootstrap_ppc64le.go @@ -0,0 +1,31 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- +// +//go:build ppc64le && go1.7 && !go1.8 + +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +/* +#cgo LDFLAGS: -no-pie + +// we need "-no-pie" for ppc64le,go1.7 to work around build failure on +// ppc64el with go1.7, see +// https://forum.snapcraft.io/t/snapd-master-fails-on-zesty-ppc64el-with-r-ppc64-addr16-ha-for-symbol-out-of-range/ +*/ +import "C" diff --git a/cmd/snap-update-ns/bootstrap_test.go b/cmd/snap-update-ns/bootstrap_test.go new file mode 100644 index 00000000..92eda827 --- /dev/null +++ b/cmd/snap-update-ns/bootstrap_test.go @@ -0,0 +1,159 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + . "gopkg.in/check.v1" + + update "github.com/snapcore/snapd/cmd/snap-update-ns" +) + +type bootstrapSuite struct{} + +var _ = Suite(&bootstrapSuite{}) + +// Check that ValidateSnapName rejects "/" and "..". +func (s *bootstrapSuite) TestValidateInstanceName(c *C) { + validNames := []string{ + "aa", + "aa_a", + "hello-world", + "a123456789012345678901234567890123456789", + "a123456789012345678901234567890123456789_0123456789", + "hello-world_foo", + "foo_0123456789", + "foo_1234abcd", + "a123456789012345678901234567890123456789", + "a123456789012345678901234567890123456789_0123456789", + } + for _, name := range validNames { + c.Check(update.ValidateInstanceName(name), Equals, 0, Commentf("name %q should be valid but is not", name)) + } + + invalidNames := []string{ + "", + "a", + "a_a", + "a123456789012345678901234567890123456789_01234567890", + "hello/world", + "hello..world", + "INVALID", + "-invalid", + "hello-world_", + "_foo", + "foo_01234567890", + "foo_123_456", + "foo__456", + "foo_", + "hello-world_foo_foo", + "foo01234567890012345678900123456789001234567890", + "foo01234567890012345678900123456789001234567890_foo", + "a123456789012345678901234567890123456789_0123456789_", + } + for _, name := range invalidNames { + c.Check(update.ValidateInstanceName(name), Equals, -1, Commentf("name %q should be invalid but is valid", name)) + } + +} + +// Test various cases of command line handling. +func (s *bootstrapSuite) TestProcessArguments(c *C) { + cases := []struct { + cmdline []string + snapName string + shouldSetNs bool + userFstab bool + uid uint + errPattern string + }{ + // Corrupted buffer is dealt with. + {[]string{}, "", false, false, 0, "argv0 is corrupted"}, + // When testing real bootstrap is identified and disabled. + {[]string{"argv0.test"}, "", false, false, 0, "bootstrap is not enabled while testing"}, + // Snap name is mandatory. + {[]string{"argv0"}, "", false, false, 0, "snap name not provided"}, + // Snap name is parsed correctly. + {[]string{"argv0", "snapname"}, "snapname", true, false, 0, ""}, + {[]string{"argv0", "snapname_instance"}, "snapname_instance", true, false, 0, ""}, + // Onlye one snap name is allowed. + {[]string{"argv0", "snapone", "snaptwo"}, "", false, false, 0, "too many positional arguments"}, + // Snap name is validated correctly. + {[]string{"argv0", ""}, "", false, false, 0, "snap name must contain at least one letter"}, + {[]string{"argv0", "in--valid"}, "", false, false, 0, "snap name cannot contain two consecutive dashes"}, + {[]string{"argv0", "invalid-"}, "", false, false, 0, "snap name cannot end with a dash"}, + {[]string{"argv0", "@invalid"}, "", false, false, 0, "snap name must use lower case letters, digits or dashes"}, + {[]string{"argv0", "INVALID"}, "", false, false, 0, "snap name must use lower case letters, digits or dashes"}, + {[]string{"argv0", "foo_01234567890"}, "", false, false, 0, "instance key must be shorter than 10 characters"}, + {[]string{"argv0", "foo_0123456_2"}, "", false, false, 0, "snap instance name can contain only one underscore"}, + // The option --from-snap-confine disables setns. + {[]string{"argv0", "--from-snap-confine", "snapname"}, "snapname", false, false, 0, ""}, + {[]string{"argv0", "snapname", "--from-snap-confine"}, "snapname", false, false, 0, ""}, + // The option --user-mounts switches to the real uid + {[]string{"argv0", "--user-mounts", "snapname"}, "snapname", false, true, 0, ""}, + // Unknown options are reported. + {[]string{"argv0", "-invalid"}, "", false, false, 0, "unsupported option"}, + {[]string{"argv0", "--option"}, "", false, false, 0, "unsupported option"}, + {[]string{"argv0", "--from-snap-confine", "-invalid", "snapname"}, "", false, false, 0, "unsupported option"}, + // The -u option can be used to specify the user id. + {[]string{"argv0", "snapname", "-u", "1234"}, "snapname", true, true, 1234, ""}, + {[]string{"argv0", "-u", "1234", "snapname"}, "snapname", true, true, 1234, ""}, + /* Empty user id is rejected. */ + {[]string{"argv0", "-u", "", "snapname"}, "", false, false, 0, "cannot parse user id"}, + /* Partially parsed values are rejected. */ + {[]string{"argv0", "-u", "1foo", "snapname"}, "", false, false, 0, "cannot parse user id"}, + /* Hexadecimal values are rejected. */ + {[]string{"argv0", "-u", "0x16", "snapname"}, "", false, false, 0, "cannot parse user id"}, + {[]string{"argv0", "-u", " 0x16", "snapname"}, "", false, false, 0, "cannot parse user id"}, + {[]string{"argv0", "-u", "0x16 ", "snapname"}, "", false, false, 0, "cannot parse user id"}, + {[]string{"argv0", "-u", " 0x16 ", "snapname"}, "", false, false, 0, "cannot parse user id"}, + /* Octal-looking values are parsed as decimal. */ + {[]string{"argv0", "-u", "042", "snapname"}, "snapname", true, true, 42, ""}, + /* Spaces around octal values is rejected. */ + {[]string{"argv0", "-u", " 042", "snapname"}, "", false, false, 0, "cannot parse user id"}, + {[]string{"argv0", "-u", "042 ", "snapname"}, "", false, false, 0, "cannot parse user id"}, + {[]string{"argv0", "-u", " 042 ", "snapname"}, "", false, false, 0, "cannot parse user id"}, + /* Space around the value is rejected. */ + {[]string{"argv0", "-u", "42 ", "snapname"}, "", false, false, 0, "cannot parse user id"}, + {[]string{"argv0", "-u", " 42", "snapname"}, "", false, false, 0, "cannot parse user id"}, + {[]string{"argv0", "-u", " 42 ", "snapname"}, "", false, false, 0, "cannot parse user id"}, + {[]string{"argv0", "-u", "\n42 ", "snapname"}, "", false, false, 0, "cannot parse user id"}, + {[]string{"argv0", "-u", "42\t", "snapname"}, "", false, false, 0, "cannot parse user id"}, + /* Negative values are rejected. */ + {[]string{"argv0", "-u", "-1", "snapname"}, "", false, false, 0, "user id cannot be negative"}, + /* The option -u requires an argument. */ + {[]string{"argv0", "snapname", "-u"}, "", false, false, 0, "-u requires an argument"}, + } + for _, tc := range cases { + update.ClearBootstrapError() + snapName, shouldSetNs, userFstab, uid := update.ProcessArguments(tc.cmdline) + err := update.BootstrapError() + comment := Commentf("failed with cmdline %q, expected error pattern %q, actual error %q", + tc.cmdline, tc.errPattern, err) + if tc.errPattern != "" { + c.Assert(err, ErrorMatches, tc.errPattern, comment) + } else { + c.Assert(err, IsNil, comment) + } + c.Check(snapName, Equals, tc.snapName, comment) + c.Check(shouldSetNs, Equals, tc.shouldSetNs, comment) + c.Check(userFstab, Equals, tc.userFstab, comment) + c.Check(uid, Equals, tc.uid, comment) + } +} diff --git a/cmd/snap-update-ns/change.go b/cmd/snap-update-ns/change.go new file mode 100644 index 00000000..2c700c15 --- /dev/null +++ b/cmd/snap-update-ns/change.go @@ -0,0 +1,819 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "syscall" + + "github.com/snapcore/snapd/logger" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/osutil/mount" + "github.com/snapcore/snapd/osutil/sys" +) + +// Action represents a mount action (mount, remount, unmount, etc). +type Action string + +const ( + // Keep indicates that a given mount entry should be kept as-is. + Keep Action = "keep" + // Mount represents an action that results in mounting something somewhere. + Mount Action = "mount" + // Unmount represents an action that results in unmounting something from somewhere. + Unmount Action = "unmount" + // Remount when needed +) + +var ( + // function calls for mocking + osutilIsDirectory = osutil.IsDirectory +) + +var ( + // ErrIgnoredMissingMount is returned when a mount entry has + // been marked with x-snapd.ignore-missing, and the mount + // source or target do not exist. + ErrIgnoredMissingMount = errors.New("mount source or target are missing") +) + +// Change describes a change to the mount table (action and the entry to act on). +type Change struct { + Entry osutil.MountEntry + Action Action +} + +// String formats mount change to a human-readable line. +func (c Change) String() string { + return fmt.Sprintf("%s (%s)", c.Action, c.Entry) +} + +// changePerform is Change.Perform that can be mocked for testing. +var changePerform func(*Change, *Assumptions) ([]*Change, error) + +// mimicRequired provides information if an error warrants a writable mimic. +// +// The returned path is the location where a mimic should be constructed. +func mimicRequired(err error) (needsMimic bool, path string) { + switch err := err.(type) { + case *ReadOnlyFsError: + rofsErr := err + return true, rofsErr.Path + case *TrespassingError: + tErr := err + return true, tErr.ViolatedPath + } + return false, "" +} + +func (c *Change) createPath(path string, pokeHoles bool, as *Assumptions) ([]*Change, error) { + // If we've been asked to create a missing path, and the mount + // entry uses the ignore-missing option, return an error. + if c.Entry.XSnapdIgnoreMissing() { + return nil, ErrIgnoredMissingMount + } + + var err error + var changes []*Change + + // Root until proven otherwise + var uid sys.UserID = 0 + var gid sys.GroupID = 0 + mode := as.ModeForPath(path) + + // If the element doesn't exist we can attempt to create it. We will + // create the parent directory and then the final element relative to it. + // The traversed space may be writable so we just try to create things + // first. + kind := c.Entry.XSnapdKind() + + // TODO: re-factor this, if possible, with inspection and preemptive + // creation after the current release ships. This should be possible but + // will affect tests heavily (churn, not safe before release). + rs := as.RestrictionsFor(path) + switch kind { + case "": + err = MkdirAll(path, mode, uid, gid, rs) + case "file": + err = MkfileAll(path, mode, uid, gid, rs) + case "symlink": + err = MksymlinkAll(path, mode, uid, gid, c.Entry.XSnapdSymlink(), rs) + case "ensure-dir": + uid = sysGetuid() + gid = sysGetgid() + // https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html + // Mode hints cannot be used here because it does not support specifying all + // directory paths within a given directory. + mode = 0700 + err = MkdirAllWithin(path, c.Entry.XSnapdMustExistDir(), mode, uid, gid, rs) + } + if needsMimic, mimicPath := mimicRequired(err); needsMimic && pokeHoles { + // If the error can be recovered by using a writable mimic + // then construct one and try again. + logger.Debugf("need to create writable mimic needed to create path %q (mount entry id: %q) (original error: %v)", path, c.Entry.XSnapdEntryID(), err) + changes, err = createWritableMimic(mimicPath, c.Entry.XSnapdEntryID(), as) + if err != nil { + err = fmt.Errorf("cannot create writable mimic over %q: %s", mimicPath, err) + } else { + // Try once again. Note that we care *just* about the error. We have already + // performed the hole poking and thus additional changes must be nil. + _, err = c.createPath(path, false, as) + } + } + return changes, err +} + +func (c *Change) ensureTarget(as *Assumptions) ([]*Change, error) { + var changes []*Change + + kind := c.Entry.XSnapdKind() + path := c.Entry.Dir + + // We use lstat to ensure that we don't follow a symlink in case one was + // set up by the snap. Note that at the time this is run, all the snap's + // processes are frozen but if the path is a directory controlled by the + // user (typically in /home) then we may still race with user processes + // that change it. + fi, err := osLstat(path) + + if err == nil { + // If the element already exists we just need to ensure it is of + // the correct type. The desired type depends on the kind of entry + // we are working with. + switch kind { + case "": + if !fi.Mode().IsDir() { + err = fmt.Errorf("cannot use %q as mount point: not a directory", path) + } + case "file": + if !fi.Mode().IsRegular() { + err = fmt.Errorf("cannot use %q as mount point: not a regular file", path) + } + case "symlink": + if fi.Mode()&os.ModeSymlink == os.ModeSymlink { + // Create path verifies the symlink or fails if it is not what we wanted. + _, err = c.createPath(path, false, as) + } else { + err = fmt.Errorf("cannot create symlink in %q: existing file in the way", path) + } + case "ensure-dir": + if !fi.Mode().IsDir() { + err = fmt.Errorf("cannot create ensure-dir target %q: existing file in the way", path) + } + } + } else if os.IsNotExist(err) { + pokeHoles := kind != "ensure-dir" + changes, err = c.createPath(path, pokeHoles, as) + } else { + // If we cannot inspect the element let's just bail out. + err = fmt.Errorf("cannot inspect %q: %v", path, err) + } + return changes, err +} + +func (c *Change) ensureSource(as *Assumptions) ([]*Change, error) { + var changes []*Change + + kind := c.Entry.XSnapdKind() + + // Source is not relevant to ensure-dir mounts that are intended for + // creating missing directories based on the mount target. + if kind == "ensure-dir" { + return nil, nil + } + + // We only have to do ensure bind mount source exists. + // This also rules out symlinks. + flags, _ := osutil.MountOptsToCommonFlags(c.Entry.Options) + if flags&syscall.MS_BIND == 0 { + return nil, nil + } + + path := c.Entry.Name + fi, err := osLstat(path) + + if err == nil { + // If the element already exists we just need to ensure it is of + // the correct type. The desired type depends on the kind of entry + // we are working with. + switch kind { + case "": + if !fi.Mode().IsDir() { + err = fmt.Errorf("cannot use %q as bind-mount source: not a directory", path) + } + case "file": + if !fi.Mode().IsRegular() { + err = fmt.Errorf("cannot use %q as bind-mount source: not a regular file", path) + } + } + } else if os.IsNotExist(err) { + // NOTE: This createPath is using pokeHoles, to make read-only places + // writable, but only for layouts and not for other (typically content + // sharing) mount entries. + // + // This is done because the changes made with pokeHoles=true are only + // visible in this current mount namespace and are not generally + // visible from other snaps because they inhabit different namespaces. + // + // In other words, changes made here are only observable by the single + // snap they apply to. As such they are useless for content sharing but + // very much useful to layouts. + pokeHoles := c.Entry.XSnapdOrigin() == "layout" + changes, err = c.createPath(path, pokeHoles, as) + } else { + // If we cannot inspect the element let's just bail out. + err = fmt.Errorf("cannot inspect %q: %v", path, err) + } + + return changes, err +} + +// changePerformImpl is the real implementation of Change.Perform +func changePerformImpl(c *Change, as *Assumptions) (changes []*Change, err error) { + if c.Action == Mount { + var changesSource, changesTarget []*Change + // We may be asked to bind mount a file, bind mount a directory, mount + // a filesystem over a directory, or create a symlink (which is abusing + // the "mount" concept slightly). That actual operation is performed in + // c.lowLevelPerform. Here we just set the stage to make that possible. + // + // As a result of this ensure call we may need to make the medium writable + // and that's why we may return more changes as a result of performing this + // one. + changesTarget, err = c.ensureTarget(as) + // NOTE: we are collecting changes even if things fail. This is so that + // upper layers can perform undo correctly. + changes = append(changes, changesTarget...) + if err != nil { + return changes, err + } + + // At this time we can be sure that the target element (for files and + // directories) exists and is of the right type or that it (for + // symlinks) doesn't exist but the parent directory does. + // This property holds as long as we don't interact with locations that + // are under the control of regular (non-snap) processes that are not + // suspended and may be racing with us. + changesSource, err = c.ensureSource(as) + // NOTE: we are collecting changes even if things fail. This is so that + // upper layers can perform undo correctly. + changes = append(changes, changesSource...) + if err != nil { + return changes, err + } + } + + // Perform the underlying mount / unmount / unlink call. + err = c.lowLevelPerform(as) + return changes, err +} + +func init() { + changePerform = changePerformImpl +} + +// Perform executes the desired mount or unmount change using system calls. +// Filesystems that depend on helper programs or multiple independent calls to +// the kernel (--make-shared, for example) are unsupported. +// +// Perform may synthesize *additional* changes that were necessary to perform +// this change (such as mounted tmpfs or overlayfs). +func (c *Change) Perform(as *Assumptions) ([]*Change, error) { + return changePerform(c, as) +} + +// lowLevelPerform is simple bridge from Change to mount / unmount syscall. +func (c *Change) lowLevelPerform(as *Assumptions) error { + var err error + + kind := c.Entry.XSnapdKind() + // ensure-dir mounts attempts to create a potentially missing target directory during the ensureTarget step + // and does not require any low-level actions. Directories created with ensure-dir mounts should never be removed. + if kind == "ensure-dir" { + return nil + } + + switch c.Action { + case Mount: + kind := c.Entry.XSnapdKind() + switch kind { + case "symlink": + // symlinks are handled in createInode directly, nothing to do here. + case "", "file": + flags, unparsed := osutil.MountOptsToCommonFlags(c.Entry.Options) + // Split the mount flags from the event propagation changes. + // Those have to be applied separately. + const propagationMask = syscall.MS_SHARED | syscall.MS_SLAVE | syscall.MS_PRIVATE | syscall.MS_UNBINDABLE + maskedFlagsRecursive := flags & syscall.MS_REC + maskedFlagsPropagation := flags & propagationMask + maskedFlagsNotPropagationNotRecursive := flags & ^(propagationMask | syscall.MS_REC) + + var flagsForMount uintptr + if flags&syscall.MS_BIND == syscall.MS_BIND { + // bind / rbind mount + flagsForMount = uintptr(maskedFlagsNotPropagationNotRecursive | maskedFlagsRecursive) + err = BindMount(c.Entry.Name, c.Entry.Dir, uint(flagsForMount)) + } else { + // normal mount, not bind / rbind, not propagation change + flagsForMount = uintptr(maskedFlagsNotPropagationNotRecursive) + err = sysMount(c.Entry.Name, c.Entry.Dir, c.Entry.Type, uintptr(flagsForMount), strings.Join(unparsed, ",")) + } + mountOpts, unknownFlags := mount.MountFlagsToOpts(int(flagsForMount)) + if unknownFlags != 0 { + mountOpts = append(mountOpts, fmt.Sprintf("%#x", unknownFlags)) + } + logger.Debugf("mount name:%q dir:%q type:%q opts:%s unparsed:%q (error: %v)", + c.Entry.Name, c.Entry.Dir, c.Entry.Type, strings.Join(mountOpts, "|"), strings.Join(unparsed, ","), err) + if err == nil && maskedFlagsPropagation != 0 { + // now change mount propagation (shared/rshared, private/rprivate, + // slave/rslave, unbindable/runbindable). + flagsForMount := uintptr(maskedFlagsPropagation | maskedFlagsRecursive) + mountOpts, unknownFlags := mount.MountFlagsToOpts(int(flagsForMount)) + if unknownFlags != 0 { + mountOpts = append(mountOpts, fmt.Sprintf("%#x", unknownFlags)) + } + err = sysMount("none", c.Entry.Dir, "", flagsForMount, "") + logger.Debugf("mount name:%q dir:%q type:%q opts:%s unparsed:%q (error: %v)", + "none", c.Entry.Dir, "", strings.Join(mountOpts, "|"), strings.Join(unparsed, ","), err) + } + if err == nil { + as.AddChange(c) + } + } + return err + case Unmount: + kind := c.Entry.XSnapdKind() + switch kind { + case "symlink": + err = osRemove(c.Entry.Dir) + logger.Debugf("remove %q (error: %v)", c.Entry.Dir, err) + case "", "file": + // Unmount and remount operations can fail with EINVAL if the given + // mount does not exist; since here we only care about the + // resulting configuration, let's not treat such situations as + // errors. + clearMissingMountError := func(err error) error { + if err == syscall.EINVAL { + // We attempted to unmount but got an EINVAL, one of the + // possibilities and the only one unless we provided wrong + // flags, is that the mount no longer exists. + // + // We can verify that now by scanning mountinfo: + entries, _ := osutil.LoadMountInfo() + for _, entry := range entries { + if entry.MountDir == c.Entry.Dir { + // Mount point still exists, EINVAL was unexpected. + return err + } + } + // We didn't find a mount point at the location we tried to + // unmount. The EINVAL we observed indicates that the mount + // profile no longer agrees with reality. The mount point + // no longer exists. As such, consume the error and carry on. + logger.Debugf("ignoring EINVAL from unmount, %q is not mounted", c.Entry.Dir) + err = nil + } + return err + } + // Detach the mount point instead of unmounting it if requested. + flags := umountNoFollow + if c.Entry.XSnapdDetach() { + flags |= syscall.MNT_DETACH + // If we are detaching something then before performing the actual detach + // switch the entire hierarchy to private event propagation (that is, + // none). This works around a bit of peculiar kernel behavior when the + // kernel reports EBUSY during a detach operation, because the changes + // propagate in a way that conflicts with itself. This is also documented + // in umount(2). + err = sysMount("none", c.Entry.Dir, "", syscall.MS_REC|syscall.MS_PRIVATE, "") + logger.Debugf("mount --make-rprivate %q (error: %v)", c.Entry.Dir, err) + err = clearMissingMountError(err) + } + + // Perform the raw unmount operation. + if err == nil { + err = sysUnmount(c.Entry.Dir, flags) + umountOpts, unknownFlags := mount.UnmountFlagsToOpts(flags) + if unknownFlags != 0 { + umountOpts = append(umountOpts, fmt.Sprintf("%#x", unknownFlags)) + } + logger.Debugf("umount %q %s (error: %v)", c.Entry.Dir, strings.Join(umountOpts, "|"), err) + err = clearMissingMountError(err) + if err != nil { + return err + } + } + if err == nil { + as.AddChange(c) + } + + // Open a path of the file we are considering the removal of. + path := c.Entry.Dir + var fd int + fd, err = OpenPath(path) + // If the place does not exist anymore, we are done. + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + defer sysClose(fd) + + // Don't attempt to remove anything from squashfs. + // Note that this is not a perfect check and we also handle EROFS below. + var statfsBuf syscall.Statfs_t + err = sysFstatfs(fd, &statfsBuf) + if err != nil { + return err + } + if statfsBuf.Type == SquashfsMagic { + return nil + } + + if kind == "file" { + // Don't attempt to remove non-empty files since they cannot be + // the placeholders we created. + var statBuf syscall.Stat_t + err = sysFstat(fd, &statBuf) + if err != nil { + return err + } + if statBuf.Size != 0 { + return nil + } + } + + // Remove the file or directory while using the full path. There's + // no way to avoid a race here since there's no way to unlink a + // file solely by file descriptor. + err = osRemove(path) + logger.Debugf("remove %q (error: %v)", path, err) + // Unpack the low-level error that osRemove wraps into PathError. + if packed, ok := err.(*os.PathError); ok { + err = packed.Err + } + if err == syscall.EROFS { + // If the underlying medium is read-only then ignore the error. + // Instead of checking up front we just try to remove because + // of https://bugs.launchpad.net/snapd/+bug/1867752 which showed us + // two important properties: + // 1) inside containers we cannot detect squashfs reliably and + // will always see FUSE instead. The problem is that there's no + // indication as to what is really mounted via statfs(2) and + // we would have to deduce that from mountinfo, trusting + // that fuse. is not spoofed (as in, the name is not + // spoofed). + // 2) rmdir of a bind mount (from a normal writable filesystem like ext4) + // over a read-only filesystem also yields EROFS without any indication + // that this is to be expected. + logger.Debugf("cannot remove a mount point on read-only filesystem %q", path) + return nil + } + if err == syscall.EBUSY { + // It's still unclear how this can happen. For the time being + // let the operation succeed and log the event. + logger.Noticef("cannot remove mount point, got EBUSY: %q", path) + if isMount, err := osutil.IsMounted(path); isMount { + mounts, _ := osutil.LoadMountInfo() + logger.Noticef("%q is still a mount point:\n%s", path, mounts) + } else if err != nil { + logger.Noticef("cannot read mountinfo: %v", err) + } + return nil + } + // If we were removing a directory but it was not empty then just + // ignore the error. This is the equivalent of the non-empty file + // check we do above. See rmdir(2) for explanation why we accept + // more than one errno value. + if kind == "" && (err == syscall.ENOTEMPTY || err == syscall.EEXIST) { + return nil + } + } + return err + case Keep: + as.AddChange(c) + return nil + } + return fmt.Errorf("cannot process mount change: unknown action: %q", c.Action) +} + +// Using dir is not enough to identify the mount entry, because when +// using layouts some directories could be used as mount points more +// than once. This can happen when, say, we have a layout for /dir/sd1 +// and another one for /dir/sd2/sd3, being the case that /dir/sd1 and +// /dir/sd2/sd3 do not exist (but their parent dirs do exist) - +// /dir/sd2 will be one of the bind mounted directories of the tmpfs +// that is created in /dir to have a layout on /dir/sd1, while at the +// same time a tmpfs will be mounted in /dir/sd2 so we can have a +// layout in /dir/sd2/sd3. So /dir/sd2 is used twice with different +// filesystem types (none and tmpfs). As we make sure that mimics are +// created only once per directory, we should only have one entry per +// dir+fstype, being fstype either none or tmpfs. +// +// TODO Ideally we should have only one mount per mountpoint, but we +// perform mounts as we create the changes in Change.Perform which +// makes it difficult to create the full list of changes and then +// clean-up repeated mountpoints. In any case using this is still +// needed to handle mount namespaces created by older snapd versions. +type mountEntryId struct { + dir string + fsType string +} + +// neededChanges is the real implementation of NeededChanges +func neededChanges(currentProfile, desiredProfile *osutil.MountProfile) []*Change { + // Copy both profiles as we will want to mutate them. + current := make([]osutil.MountEntry, len(currentProfile.Entries)) + copy(current, currentProfile.Entries) + desired := make([]osutil.MountEntry, len(desiredProfile.Entries)) + copy(desired, desiredProfile.Entries) + + // Clean the directory part of both profiles. This is done so that we can + // easily test if a given directory is a subdirectory with + // strings.HasPrefix coupled with an extra slash character. + for i := range current { + current[i].Dir = filepath.Clean(current[i].Dir) + } + for i := range desired { + desired[i].Dir = filepath.Clean(desired[i].Dir) + } + + // Make yet another copy of the current entries, to retain their original + // order (the "current" variable is going to be sorted soon); just using + // currentProfile.Entries is not reliable because it didn't undergo the + // cleanup of the Dir paths. + unsortedCurrent := make([]osutil.MountEntry, len(current)) + copy(unsortedCurrent, current) + + dumpMountEntries := func(entries []osutil.MountEntry, pfx string) { + logger.Debugf(pfx) + for _, en := range entries { + logger.Debugf("- %v", en) + } + } + dumpMountEntries(current, "current mount entries") + // Sort only the desired lists by directory name with implicit trailing + // slash and the mount kind. + // Note that the current profile is a log of what was applied and should + // not be sorted at all. + sort.Sort(byOriginAndMountPoint(desired)) + dumpMountEntries(desired, "desired mount entries (sorted)") + + // Construct a desired directory map. + desiredMap := make(map[string]*osutil.MountEntry) + for i := range desired { + desiredMap[desired[i].Dir] = &desired[i] + } + + // Indexed by mount point path. + reuse := make(map[mountEntryId]bool) + // Indexed by entry ID + desiredIDs := make(map[string]bool) + var skipDir string + + // Collect the IDs of desired changes. + // We need that below to keep implicit changes from the current profile. + for i := range desired { + desiredIDs[desired[i].XSnapdEntryID()] = true + } + + // Compute reusable entries: those which are equal in current and desired and which + // are not prefixed by another entry that changed. + // sort them first + sort.Sort(byOvernameAndMountPoint(current)) + for i := range current { + dir := current[i].Dir + if skipDir != "" && strings.HasPrefix(dir, skipDir) { + logger.Debugf("skipping entry %q", current[i]) + continue + } + skipDir = "" // reset skip prefix as it no longer applies + + mountId := mountEntryId{dir, current[i].Type} + if current[i].XSnapdOrigin() == "rootfs" { + // This is the rootfs setup by snap-confine, we should not touch it + logger.Debugf("reusing rootfs") + reuse[mountId] = true + continue + } + + // Reuse synthetic entries if their needed-by entry is desired. + // Synthetic entries cannot exist on their own and always couple to a + // non-synthetic entry. + + // NOTE: Synthetic changes have a special purpose. + // + // They are a "shadow" of mount events that occurred to allow one of + // the desired mount entries to be possible. The changes have only one + // goal: tell snap-update-ns how those mount events can be undone in + // case they are no longer needed. The actual changes may have been + // different and may have involved steps not represented as synthetic + // mount entires as long as those synthetic entries can be undone to + // reverse the effect. In reality each non-tmpfs synthetic entry was + // constructed using a temporary bind mount that contained the original + // mount entries of a directory that was hidden with a tmpfs, but this + // fact was lost. + if current[i].XSnapdSynthetic() && desiredIDs[current[i].XSnapdNeededBy()] { + logger.Debugf("reusing synthetic entry %q", current[i]) + reuse[mountId] = true + continue + } + + // Reuse entries that are desired and identical in the current profile. + if entry, ok := desiredMap[dir]; ok && current[i].Equal(entry) { + logger.Debugf("reusing unchanged entry %q", current[i]) + reuse[mountId] = true + continue + } + + skipDir = strings.TrimSuffix(dir, "/") + "/" + } + + logger.Debugf("desiredIDs: %v", desiredIDs) + logger.Debugf("reuse: %v", reuse) + + // We are now ready to compute the necessary mount changes. + var changes []*Change + + // Unmount entries not reused in reverse to handle children before their parent. + unmountOrder := unsortedCurrent + for i := len(unmountOrder) - 1; i >= 0; i-- { + if reuse[mountEntryId{unmountOrder[i].Dir, unmountOrder[i].Type}] { + changes = append(changes, &Change{Action: Keep, Entry: unmountOrder[i]}) + } else { + var entry osutil.MountEntry = unmountOrder[i] + entry.Options = append([]string(nil), entry.Options...) + // If the mount entry can potentially host nested mount points then detach + // rather than unmount, since detach will always succeed. + shouldDetach := entry.Type == "tmpfs" || entry.OptBool("bind") || entry.OptBool("rbind") + if shouldDetach && !entry.XSnapdDetach() { + entry.Options = append(entry.Options, osutil.XSnapdDetach()) + } + changes = append(changes, &Change{Action: Unmount, Entry: entry}) + } + } + + var desiredNotReused []osutil.MountEntry + for _, entry := range desired { + if !reuse[mountEntryId{entry.Dir, entry.Type}] { + desiredNotReused = append(desiredNotReused, entry) + } + } + + // Mount desired entries not reused, ordering by the mimic directories they + // need created + // We proceeds in three steps: + // 1. Perform the mounts for the "overname" entries + // 2. Perform the mounts for the entries which need a mimic + // 3. Perform all the remaining desired mounts + + var newDesiredEntries []osutil.MountEntry + var newIndependentDesiredEntries []osutil.MountEntry + // Indexed by mount point path. + addedDesiredEntries := make(map[string]bool) + // This function is idempotent, it won't add the same entry twice + addDesiredEntry := func(entry osutil.MountEntry) { + if !addedDesiredEntries[entry.Dir] { + logger.Debugf("adding entry: %s", entry) + newDesiredEntries = append(newDesiredEntries, entry) + addedDesiredEntries[entry.Dir] = true + } + } + addIndependentDesiredEntry := func(entry osutil.MountEntry) { + if !addedDesiredEntries[entry.Dir] { + logger.Debugf("adding independent entry: %s", entry) + newIndependentDesiredEntries = append(newIndependentDesiredEntries, entry) + addedDesiredEntries[entry.Dir] = true + } + } + + logger.Debugf("processing mount entries") + // Create a map of the target directories (mimics) needed for the visited + // entries + affectedTargetCreationDirs := map[string][]osutil.MountEntry{} + for _, entry := range desiredNotReused { + if entry.XSnapdOrigin() == "overname" { + addIndependentDesiredEntry(entry) + } + + // collect all entries, so that we know what mimics are needed + parentTargetDir := filepath.Dir(entry.Dir) + affectedTargetCreationDirs[parentTargetDir] = append(affectedTargetCreationDirs[parentTargetDir], entry) + } + + if len(affectedTargetCreationDirs) != 0 { + entriesForMimicDir := map[string][]osutil.MountEntry{} + for parentTargetDir, entriesNeedingDir := range affectedTargetCreationDirs { + // First check if any of the mount entries for the changes will potentially + // result in creating a mimic. Note that to actually know if a given mount + // entry will require a mimic when the mount target doesn't exist, we would + // have to try and create a file/directory/symlink at the desired target, + // however that would be a destructive change which is not appropriate here + // (that is done in ChangePerform() instead), but for our purposes of sorting + // mount entries it is sufficient to use this assumption. + // We check if a mount entry would result in a potential mimic by just + // checking if the file/dir/symlink that is the target of the mount exists + // already in the form we need to to bind mount on top of it. If it + // doesn't then we need to create a mimic and so we then go looking for + // where to create the mimic. + for _, entry := range entriesNeedingDir { + exists := true + switch entry.XSnapdKind() { + case "": + exists = osutilIsDirectory(entry.Dir) + case "file": + exists = osutil.FileExists(entry.Dir) + case "symlink": + exists = osutil.IsSymlink(entry.Dir) + } + + // if it doesn't exist we may need a mimic + if !exists { + neededMimicDir := findFirstRootDirectoryThatExists(parentTargetDir) + entriesForMimicDir[neededMimicDir] = append(entriesForMimicDir[neededMimicDir], entry) + logger.Debugf("entry that requires %q: %v", neededMimicDir, entry) + } else { + // entry is independent + addIndependentDesiredEntry(entry) + } + } + } + + // sort the mimic creation dirs to get the correct ordering of mimics to + // create dirs in: the sorting algorithm places parent directories + // before children. + allMimicCreationDirs := []string{} + for mimicDir := range entriesForMimicDir { + allMimicCreationDirs = append(allMimicCreationDirs, mimicDir) + } + + sort.Strings(allMimicCreationDirs) + + logger.Debugf("all mimics:") + for _, mimicDir := range allMimicCreationDirs { + logger.Debugf("- %v", mimicDir) + } + + for _, mimicDir := range allMimicCreationDirs { + // make sure to sort the entries for each mimic dir in a consistent + // order + entries := entriesForMimicDir[mimicDir] + sort.Sort(byOriginAndMountPoint(entries)) + for _, entry := range entries { + addDesiredEntry(entry) + } + } + } + + sort.Sort(byOriginAndMountPoint(newIndependentDesiredEntries)) + allEntries := append(newIndependentDesiredEntries, newDesiredEntries...) + dumpMountEntries(allEntries, "mount entries ordered as they will be applied") + for _, entry := range allEntries { + changes = append(changes, &Change{Action: Mount, Entry: entry}) + } + + return changes +} + +func findFirstRootDirectoryThatExists(desiredParentDir string) string { + // trivial case - the dir already exists + if osutilIsDirectory(desiredParentDir) { + return desiredParentDir + } + + // otherwise we need to recurse up to find the first dir that exists where + // we would place the mimic - note that this cannot recurse infinitely, + // since at some point we will reach "/" which always exists + return findFirstRootDirectoryThatExists(filepath.Dir(desiredParentDir)) +} + +// NeededChanges computes the changes required to change current to desired mount entries. +// +// A diff-like operation on the mount profile is computed. Some of the mount +// entries from the current profile may be reused. +var NeededChanges = func(current, desired *osutil.MountProfile) []*Change { + return neededChanges(current, desired) +} diff --git a/cmd/snap-update-ns/change_test.go b/cmd/snap-update-ns/change_test.go new file mode 100644 index 00000000..83beae25 --- /dev/null +++ b/cmd/snap-update-ns/change_test.go @@ -0,0 +1,3052 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "errors" + "io/fs" + "path/filepath" + "syscall" + + . "gopkg.in/check.v1" + + update "github.com/snapcore/snapd/cmd/snap-update-ns" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/osutil/sys" + "github.com/snapcore/snapd/strutil" + "github.com/snapcore/snapd/testutil" +) + +type changeSuite struct { + testutil.BaseTest + sys *testutil.SyscallRecorder + as *update.Assumptions +} + +var ( + errTesting = errors.New("testing") +) + +var _ = Suite(&changeSuite{}) + +func (s *changeSuite) SetUpTest(c *C) { + s.BaseTest.SetUpTest(c) + // This isolates us from host's experimental settings. + dirs.SetRootDir(c.MkDir()) + // Mock and record system interactions. + s.sys = &testutil.SyscallRecorder{} + s.BaseTest.AddCleanup(update.MockSystemCalls(s.sys)) + s.as = &update.Assumptions{} +} + +func (s *changeSuite) TearDownTest(c *C) { + s.BaseTest.TearDownTest(c) + s.sys.CheckForStrayDescriptors(c) + dirs.SetRootDir("") +} + +func (s *changeSuite) TestFakeFileInfo(c *C) { + c.Assert(testutil.FileInfoDir.IsDir(), Equals, true) + c.Assert(testutil.FileInfoFile.IsDir(), Equals, false) + c.Assert(testutil.FileInfoSymlink.IsDir(), Equals, false) +} + +func (s *changeSuite) TestString(c *C) { + change := update.Change{ + Entry: osutil.MountEntry{Dir: "/a/b", Name: "/dev/sda1"}, + Action: update.Mount, + } + c.Assert(change.String(), Equals, "mount (/dev/sda1 /a/b none defaults 0 0)") +} + +// When there are no profiles we don't do anything. +func (s *changeSuite) TestNeededChangesNoProfiles(c *C) { + current := &osutil.MountProfile{} + desired := &osutil.MountProfile{} + changes := update.NeededChanges(current, desired) + c.Assert(changes, IsNil) +} + +// When the profiles are the same we don't do anything. +func (s *changeSuite) TestNeededChangesNoChange(c *C) { + current := &osutil.MountProfile{Entries: []osutil.MountEntry{{Dir: "/common/stuff"}}} + desired := &osutil.MountProfile{Entries: []osutil.MountEntry{{Dir: "/common/stuff"}}} + changes := update.NeededChanges(current, desired) + c.Assert(changes, DeepEquals, []*update.Change{ + {Entry: osutil.MountEntry{Dir: "/common/stuff"}, Action: update.Keep}, + }) +} + +// When the content interface is connected we should mount the new entry. +func (s *changeSuite) TestNeededChangesTrivialMount(c *C) { + current := &osutil.MountProfile{} + desired := &osutil.MountProfile{Entries: []osutil.MountEntry{{Dir: "/common/stuff"}}} + changes := update.NeededChanges(current, desired) + c.Assert(changes, DeepEquals, []*update.Change{ + {Entry: desired.Entries[0], Action: update.Mount}, + }) +} + +// When the content interface is disconnected we should unmount the mounted entry. +func (s *changeSuite) TestNeededChangesTrivialUnmount(c *C) { + current := &osutil.MountProfile{Entries: []osutil.MountEntry{{Dir: "/common/stuff"}}} + desired := &osutil.MountProfile{} + changes := update.NeededChanges(current, desired) + c.Assert(changes, DeepEquals, []*update.Change{ + {Entry: current.Entries[0], Action: update.Unmount}, + }) +} + +// When the rootfs was setup by snap-confine, don't touch it +func (s *changeSuite) TestNeededChangesKeepRootfs(c *C) { + current := &osutil.MountProfile{Entries: []osutil.MountEntry{ + {Dir: "/", Options: []string{"x-snapd.origin=rootfs"}}, + }} + desired := &osutil.MountProfile{Entries: []osutil.MountEntry{{Dir: "/common/stuff"}}} + changes := update.NeededChanges(current, desired) + c.Assert(changes, DeepEquals, []*update.Change{ + {Entry: current.Entries[0], Action: update.Keep}, + {Entry: desired.Entries[0], Action: update.Mount}, + }) +} + +// When the rootfs was *not* setup by snap-confine, it's umounted +func (s *changeSuite) TestNeededChangesUmountRootfs(c *C) { + current := &osutil.MountProfile{Entries: []osutil.MountEntry{ + // Like the test above, but without "x-snapd.origin=rootfs" + {Dir: "/"}, + }} + desired := &osutil.MountProfile{Entries: []osutil.MountEntry{{Dir: "/common/stuff"}}} + changes := update.NeededChanges(current, desired) + c.Assert(changes, DeepEquals, []*update.Change{ + {Entry: current.Entries[0], Action: update.Unmount}, + {Entry: desired.Entries[0], Action: update.Mount}, + }) +} + +// When umounting we unmount children before parents. +func (s *changeSuite) TestNeededChangesUnmountOrder(c *C) { + current := &osutil.MountProfile{Entries: []osutil.MountEntry{ + {Dir: "/common/stuff"}, + {Dir: "/common/stuff/extra"}, + }} + desired := &osutil.MountProfile{} + changes := update.NeededChanges(current, desired) + c.Assert(changes, DeepEquals, []*update.Change{ + {Entry: osutil.MountEntry{Dir: "/common/stuff/extra"}, Action: update.Unmount}, + {Entry: osutil.MountEntry{Dir: "/common/stuff"}, Action: update.Unmount}, + }) +} + +// When mounting we mount the parents before the children. +func (s *changeSuite) TestNeededChangesMountOrder(c *C) { + existingDirectories := []string{} + + restore := update.MockIsDirectory(func(path string) bool { + return strutil.ListContains(existingDirectories, path) + }) + defer restore() + + current := &osutil.MountProfile{} + desired := &osutil.MountProfile{Entries: []osutil.MountEntry{ + {Dir: "/c/stuff/dir/symlink1"}, + {Dir: "/c/stuff/dir/file2", Options: []string{"x-snapd.kind=file"}}, + {Dir: "/c/stuff/dir"}, + {Dir: "/c/stuff"}, + {Dir: "/c/stuff/dir/file1", Options: []string{"x-snapd.kind=file"}}, + }} + + for _, testData := range []struct { + existingDirs []string + expectedOrder []string + }{ + { + existingDirs: []string{"/c"}, + expectedOrder: []string{"/c/stuff", "/c/stuff/dir", "/c/stuff/dir/file1", "/c/stuff/dir/file2", "/c/stuff/dir/symlink1"}, + }, + { + existingDirs: []string{"/c", "/c/stuff"}, + expectedOrder: []string{"/c/stuff", "/c/stuff/dir", "/c/stuff/dir/file1", "/c/stuff/dir/file2", "/c/stuff/dir/symlink1"}, + }, + { + existingDirs: []string{"/c", "/c/stuff", "/c/stuff/dir"}, + expectedOrder: []string{"/c/stuff", "/c/stuff/dir", "/c/stuff/dir/file1", "/c/stuff/dir/file2", "/c/stuff/dir/symlink1"}, + }, + } { + existingDirectories = testData.existingDirs + changes := update.NeededChanges(current, desired) + + // Check that every change is sane, and extract their path in order + actualOrder := make([]string, 0, len(changes)) + for _, change := range changes { + c.Check(change.Action, Equals, update.Mount) + actualOrder = append(actualOrder, change.Entry.Dir) + } + + c.Check(actualOrder, DeepEquals, testData.expectedOrder, + Commentf("Existing dirs: %q", existingDirectories)) + } +} + +func (s *changeSuite) TestNeededChangesMountFromReal(c *C) { + existingDirectories := []string{} + + restore := update.MockIsDirectory(func(path string) bool { + return strutil.ListContains(existingDirectories, path) + }) + defer restore() + + current := &osutil.MountProfile{} + desired := &osutil.MountProfile{Entries: []osutil.MountEntry{ + {Dir: "/snap/test-snapd-layout/x1/fooo-top", Options: []string{"x-snapd.origin=layout"}}, + {Dir: "/snap/test-snapd-layout/x1/fooo/deeper", Options: []string{"x-snapd.origin=layout"}}, + {Dir: "/usr/lib/x86_64-linux-gnu/wpe-webkit-1.0", Options: []string{"x-snapd.origin=layout"}}, + {Dir: "/usr/libexec/wpe-webkit-1.0", Options: []string{"x-snapd.origin=layout"}}, + {Dir: "/var/fooo-top", Options: []string{"x-snapd.origin=layout"}}, + {Dir: "/var/fooo/deeper", Options: []string{"x-snapd.origin=layout"}}, + }} + + for _, testData := range []struct { + existingDirs []string + expectedOrder []string + }{ + { + existingDirs: []string{"/snap/test-snapd-layout/x1", "/usr", "/usr/lib/x86_64-linux-gnu", "/var"}, + expectedOrder: []string{ + "/snap/test-snapd-layout/x1/fooo-top", "/snap/test-snapd-layout/x1/fooo/deeper", + // triggers a mimic on /usr + "/usr/libexec/wpe-webkit-1.0", + "/usr/lib/x86_64-linux-gnu/wpe-webkit-1.0", + "/var/fooo-top", "/var/fooo/deeper", + }, + }, + { + existingDirs: []string{"/snap/test-snapd-layout/x1", "/usr", "/usr/lib/x86_64-linux-gnu", "/usr/libexec", "/var"}, + expectedOrder: []string{ + // parents for all dirs exists, so entries are + // ordered lexicographically + "/snap/test-snapd-layout/x1/fooo-top", "/snap/test-snapd-layout/x1/fooo/deeper", + "/usr/lib/x86_64-linux-gnu/wpe-webkit-1.0", "/usr/libexec/wpe-webkit-1.0", + "/var/fooo-top", "/var/fooo/deeper", + }, + }, + } { + existingDirectories = testData.existingDirs + changes := update.NeededChanges(current, desired) + + // Check that every change is sane, and extract their path in order + actualOrder := make([]string, 0, len(changes)) + for _, change := range changes { + c.Check(change.Action, Equals, update.Mount) + actualOrder = append(actualOrder, change.Entry.Dir) + } + + c.Check(actualOrder, DeepEquals, testData.expectedOrder, + Commentf("Existing dirs: %q", existingDirectories)) + } +} + +func (s *changeSuite) TestNeededChangesKind(c *C) { + current := &osutil.MountProfile{} + desired := &osutil.MountProfile{Entries: []osutil.MountEntry{ + {Dir: "/common/file", Options: []string{"x-snapd.kind=file"}}, + {Dir: "/common/symlink", Options: []string{"x-snapd.kind=symlink"}}, + }} + changes := update.NeededChanges(current, desired) + c.Assert(changes, DeepEquals, []*update.Change{ + {Entry: osutil.MountEntry{Dir: "/common/file", Options: []string{"x-snapd.kind=file"}}, Action: update.Mount}, + {Entry: osutil.MountEntry{Dir: "/common/symlink", Options: []string{"x-snapd.kind=symlink"}}, Action: update.Mount}, + }) +} + +// When parent changes we don't reuse its children + +func (s *changeSuite) TestNeededChangesChangedParentSameChild(c *C) { + current := &osutil.MountProfile{Entries: []osutil.MountEntry{ + {Dir: "/common/stuff", Name: "/dev/sda1"}, + {Dir: "/common/stuff/extra"}, + {Dir: "/common/unrelated"}, + }} + desired := &osutil.MountProfile{Entries: []osutil.MountEntry{ + {Dir: "/common/stuff", Name: "/dev/sda2"}, + {Dir: "/common/stuff/extra"}, + {Dir: "/common/unrelated"}, + }} + changes := update.NeededChanges(current, desired) + c.Assert(changes, DeepEquals, []*update.Change{ + {Entry: osutil.MountEntry{Dir: "/common/unrelated"}, Action: update.Keep}, + {Entry: osutil.MountEntry{Dir: "/common/stuff/extra"}, Action: update.Unmount}, + {Entry: osutil.MountEntry{Dir: "/common/stuff", Name: "/dev/sda1"}, Action: update.Unmount}, + {Entry: osutil.MountEntry{Dir: "/common/stuff", Name: "/dev/sda2"}, Action: update.Mount}, + {Entry: osutil.MountEntry{Dir: "/common/stuff/extra"}, Action: update.Mount}, + }) +} + +// When child changes we don't touch the unchanged parent +func (s *changeSuite) TestNeededChangesSameParentChangedChild(c *C) { + current := &osutil.MountProfile{Entries: []osutil.MountEntry{ + {Dir: "/common/stuff"}, + {Dir: "/common/stuff/extra", Name: "/dev/sda1"}, + {Dir: "/common/unrelated"}, + }} + desired := &osutil.MountProfile{Entries: []osutil.MountEntry{ + {Dir: "/common/stuff"}, + {Dir: "/common/stuff/extra", Name: "/dev/sda2"}, + {Dir: "/common/unrelated"}, + }} + changes := update.NeededChanges(current, desired) + c.Assert(changes, DeepEquals, []*update.Change{ + {Entry: osutil.MountEntry{Dir: "/common/unrelated"}, Action: update.Keep}, + {Entry: osutil.MountEntry{Dir: "/common/stuff/extra", Name: "/dev/sda1"}, Action: update.Unmount}, + {Entry: osutil.MountEntry{Dir: "/common/stuff"}, Action: update.Keep}, + {Entry: osutil.MountEntry{Dir: "/common/stuff/extra", Name: "/dev/sda2"}, Action: update.Mount}, + }) +} + +// Unused bind mount farms are unmounted. +func (s *changeSuite) TestNeededChangesTmpfsBindMountFarmUnused(c *C) { + current := &osutil.MountProfile{Entries: []osutil.MountEntry{{ + // The tmpfs that lets us write into immutable squashfs. We mock + // x-snapd.needed-by to the last entry in the current profile (the bind + // mount). Mark it synthetic since it is a helper mount that is needed + // to facilitate the following mounts. + Name: "tmpfs", + Dir: "/snap/name/42/subdir", + Type: "tmpfs", + Options: []string{"x-snapd.needed-by=/snap/name/42/subdir", "x-snapd.synthetic"}, + }, { + // A bind mount to preserve a directory hidden by the tmpfs (the mount + // point is created elsewhere). We mock x-snapd.needed-by to the + // location of the bind mount below that is no longer desired. + Name: "/var/lib/snapd/hostfs/snap/name/42/subdir/existing", + Dir: "/snap/name/42/subdir/existing", + Options: []string{"bind", "ro", "x-snapd.needed-by=/snap/name/42/subdir", "x-snapd.synthetic"}, + }, { + // A bind mount to put some content from another snap. The bind mount + // is nothing special but the fact that it is possible is the reason + // the two entries above exist. The mount point (created) is created + // elsewhere. + Name: "/snap/other/123/libs", + Dir: "/snap/name/42/subdir/created", + Options: []string{"bind", "ro"}, + }}} + + desired := &osutil.MountProfile{} + + changes := update.NeededChanges(current, desired) + + c.Assert(changes, DeepEquals, []*update.Change{ + {Entry: osutil.MountEntry{ + Name: "/snap/other/123/libs", + Dir: "/snap/name/42/subdir/created", + Options: []string{"bind", "ro", "x-snapd.detach"}, + }, Action: update.Unmount}, + {Entry: osutil.MountEntry{ + Name: "/var/lib/snapd/hostfs/snap/name/42/subdir/existing", + Dir: "/snap/name/42/subdir/existing", + Options: []string{"bind", "ro", "x-snapd.needed-by=/snap/name/42/subdir", "x-snapd.synthetic", "x-snapd.detach"}, + }, Action: update.Unmount}, + {Entry: osutil.MountEntry{ + Name: "tmpfs", + Dir: "/snap/name/42/subdir", + Type: "tmpfs", + Options: []string{"x-snapd.needed-by=/snap/name/42/subdir", "x-snapd.synthetic", "x-snapd.detach"}, + }, Action: update.Unmount}, + }) +} + +func (s *changeSuite) TestNeededChangesTmpfsBindMountFarmUsed(c *C) { + + // NOTE: the current profile is the same as in the test + // TestNeededChangesTmpfsBindMountFarmUnused written above. + current := &osutil.MountProfile{Entries: []osutil.MountEntry{{ + Name: "tmpfs", + Dir: "/snap/name/42/subdir", + Type: "tmpfs", + Options: []string{"x-snapd.needed-by=/snap/name/42/subdir/created", "x-snapd.synthetic"}, + }, { + Name: "/var/lib/snapd/hostfs/snap/name/42/subdir/existing", + Dir: "/snap/name/42/subdir/existing", + Options: []string{"bind", "ro", "x-snapd.needed-by=/snap/name/42/subdir/created", "x-snapd.synthetic"}, + }, { + Name: "/snap/other/123/libs", + Dir: "/snap/name/42/subdir/created", + Options: []string{"bind", "ro"}, + }}} + + desired := &osutil.MountProfile{Entries: []osutil.MountEntry{{ + // This is the only entry that we explicitly want but in order to + // support it we need to keep the remaining implicit entries. + Name: "/snap/other/123/libs", + Dir: "/snap/name/42/subdir/created", + Options: []string{"bind", "ro"}, + }}} + + changes := update.NeededChanges(current, desired) + + c.Assert(changes, DeepEquals, []*update.Change{ + {Entry: osutil.MountEntry{ + Name: "/snap/other/123/libs", + Dir: "/snap/name/42/subdir/created", + Options: []string{"bind", "ro"}, + }, Action: update.Keep}, + {Entry: osutil.MountEntry{ + Name: "/var/lib/snapd/hostfs/snap/name/42/subdir/existing", + Dir: "/snap/name/42/subdir/existing", + Options: []string{"bind", "ro", "x-snapd.needed-by=/snap/name/42/subdir/created", "x-snapd.synthetic"}, + }, Action: update.Keep}, + {Entry: osutil.MountEntry{ + Name: "tmpfs", + Dir: "/snap/name/42/subdir", + Type: "tmpfs", + Options: []string{"x-snapd.needed-by=/snap/name/42/subdir/created", "x-snapd.synthetic"}, + }, Action: update.Keep}, + }) +} + +// cur = ['/a/b', '/a/b-1', '/a/b-1/3', '/a/b/c'] +// des = ['/a/b', '/a/b-1', '/a/b/c' +// +// We are smart about comparing entries as directories. Here even though "/a/b" +// is a prefix of "/a/b-1" it is correctly reused. +func (s *changeSuite) TestNeededChangesSmartEntryComparisonOld(c *C) { + + current := &osutil.MountProfile{Entries: []osutil.MountEntry{ + {Dir: "/a/b", Name: "/dev/sda1"}, + {Dir: "/a/b/c"}, + {Dir: "/a/b-1"}, + {Dir: "/a/b-1/3"}, + }} + desired := &osutil.MountProfile{Entries: []osutil.MountEntry{ + {Dir: "/a/b", Name: "/dev/sda2"}, + {Dir: "/a/b-1"}, + {Dir: "/a/b/c"}, + }} + changes := update.NeededChanges(current, desired) + for _, chg := range changes { + c.Logf("- %+v", chg) + } + c.Assert(changes, DeepEquals, []*update.Change{ + {Entry: osutil.MountEntry{Dir: "/a/b-1/3"}, Action: update.Unmount}, + {Entry: osutil.MountEntry{Dir: "/a/b-1"}, Action: update.Keep}, + {Entry: osutil.MountEntry{Dir: "/a/b/c"}, Action: update.Unmount}, + {Entry: osutil.MountEntry{Dir: "/a/b", Name: "/dev/sda1"}, Action: update.Unmount}, + + {Entry: osutil.MountEntry{Dir: "/a/b", Name: "/dev/sda2"}, Action: update.Mount}, + {Entry: osutil.MountEntry{Dir: "/a/b/c"}, Action: update.Mount}, + }) +} + +// Parallel instance changes are executed first +func (s *changeSuite) TestNeededChangesParallelInstancesManyComeFirst(c *C) { + + desired := &osutil.MountProfile{Entries: []osutil.MountEntry{ + {Dir: "/common/stuff", Name: "/dev/sda1"}, + {Dir: "/common/stuff/extra"}, + {Dir: "/common/unrelated"}, + {Dir: "/foo/bar", Name: "/foo/bar_bar", Options: []string{osutil.XSnapdOriginOvername()}}, + {Dir: "/snap/foo", Name: "/snap/foo_bar", Options: []string{osutil.XSnapdOriginOvername()}}, + }} + changes := update.NeededChanges(&osutil.MountProfile{}, desired) + c.Assert(changes, DeepEquals, []*update.Change{ + {Entry: osutil.MountEntry{Dir: "/foo/bar", Name: "/foo/bar_bar", Options: []string{osutil.XSnapdOriginOvername()}}, Action: update.Mount}, + {Entry: osutil.MountEntry{Dir: "/snap/foo", Name: "/snap/foo_bar", Options: []string{osutil.XSnapdOriginOvername()}}, Action: update.Mount}, + {Entry: osutil.MountEntry{Dir: "/common/stuff", Name: "/dev/sda1"}, Action: update.Mount}, + {Entry: osutil.MountEntry{Dir: "/common/stuff/extra"}, Action: update.Mount}, + {Entry: osutil.MountEntry{Dir: "/common/unrelated"}, Action: update.Mount}, + }) +} + +// Parallel instance changes are kept if already present +func (s *changeSuite) TestNeededChangesParallelInstancesKeep(c *C) { + + desired := &osutil.MountProfile{Entries: []osutil.MountEntry{ + {Dir: "/common/stuff", Name: "/dev/sda1"}, + {Dir: "/common/unrelated"}, + {Dir: "/foo/bar", Name: "/foo/bar_bar", Options: []string{osutil.XSnapdOriginOvername()}}, + {Dir: "/snap/foo", Name: "/snap/foo_bar", Options: []string{osutil.XSnapdOriginOvername()}}, + }} + current := &osutil.MountProfile{Entries: []osutil.MountEntry{ + {Dir: "/foo/bar", Name: "/foo/bar_bar", Options: []string{osutil.XSnapdOriginOvername()}}, + {Dir: "/snap/foo", Name: "/snap/foo_bar", Options: []string{osutil.XSnapdOriginOvername()}}, + }} + changes := update.NeededChanges(current, desired) + c.Assert(changes, DeepEquals, []*update.Change{ + {Entry: osutil.MountEntry{Dir: "/snap/foo", Name: "/snap/foo_bar", Options: []string{osutil.XSnapdOriginOvername()}}, Action: update.Keep}, + {Entry: osutil.MountEntry{Dir: "/foo/bar", Name: "/foo/bar_bar", Options: []string{osutil.XSnapdOriginOvername()}}, Action: update.Keep}, + {Entry: osutil.MountEntry{Dir: "/common/stuff", Name: "/dev/sda1"}, Action: update.Mount}, + {Entry: osutil.MountEntry{Dir: "/common/unrelated"}, Action: update.Mount}, + }) +} + +// Parallel instance with mounts inside +func (s *changeSuite) TestNeededChangesParallelInstancesInsideMount(c *C) { + + desired := &osutil.MountProfile{Entries: []osutil.MountEntry{ + {Dir: "/foo/bar/baz"}, + {Dir: "/foo/bar", Name: "/foo/bar_bar", Options: []string{osutil.XSnapdOriginOvername()}}, + {Dir: "/snap/foo", Name: "/snap/foo_bar", Options: []string{osutil.XSnapdOriginOvername()}}, + }} + current := &osutil.MountProfile{Entries: []osutil.MountEntry{ + {Dir: "/foo/bar", Name: "/foo/bar_bar", Options: []string{osutil.XSnapdOriginOvername()}}, + {Dir: "/foo/bar/zed"}, + {Dir: "/snap/foo", Name: "/snap/foo_bar", Options: []string{osutil.XSnapdOriginOvername()}}, + }} + changes := update.NeededChanges(current, desired) + c.Assert(changes, DeepEquals, []*update.Change{ + {Entry: osutil.MountEntry{Dir: "/snap/foo", Name: "/snap/foo_bar", Options: []string{osutil.XSnapdOriginOvername()}}, Action: update.Keep}, + {Entry: osutil.MountEntry{Dir: "/foo/bar/zed"}, Action: update.Unmount}, + {Entry: osutil.MountEntry{Dir: "/foo/bar", Name: "/foo/bar_bar", Options: []string{osutil.XSnapdOriginOvername()}}, Action: update.Keep}, + {Entry: osutil.MountEntry{Dir: "/foo/bar/baz"}, Action: update.Mount}, + }) +} + +func (s *changeSuite) TestNeededChangesRepeatedDir(c *C) { + desired := &osutil.MountProfile{Entries: []osutil.MountEntry{ + {Name: "tmpfs", Dir: "/foo/mytmp", Type: "tmpfs", Options: []string{osutil.XSnapdOriginLayout()}}, + }} + current := &osutil.MountProfile{Entries: []osutil.MountEntry{ + {Name: "/foo/bar", Dir: "/foo/bar", Type: "none", + Options: []string{osutil.XSnapdSynthetic(), osutil.XSnapdNeededBy("/foo/mytmp")}}, + {Name: "tmpfs", Dir: "/foo/mytmp", Type: "tmpfs", Options: []string{osutil.XSnapdOriginLayout()}}, + {Name: "tmpfs", Dir: "/foo/bar", Type: "tmpfs", + Options: []string{osutil.XSnapdSynthetic(), osutil.XSnapdNeededBy("/foo/bar/two")}}, + }} + changes := update.NeededChanges(current, desired) + + // Make sure that we unmount the one that is needed by an entry + // not desired anymore (even though it is in the same mountpoint + // as the one needed by /foo/mytmp). + c.Assert(changes, DeepEquals, []*update.Change{ + {Entry: osutil.MountEntry{Name: "tmpfs", Dir: "/foo/bar", Type: "tmpfs", + Options: []string{osutil.XSnapdSynthetic(), osutil.XSnapdNeededBy("/foo/bar/two"), osutil.XSnapdDetach()}}, Action: update.Unmount}, + {Entry: osutil.MountEntry{Name: "tmpfs", Dir: "/foo/mytmp", Type: "tmpfs", + Options: []string{osutil.XSnapdOriginLayout()}}, Action: update.Keep}, + {Entry: osutil.MountEntry{Name: "/foo/bar", Dir: "/foo/bar", Type: "none", + Options: []string{osutil.XSnapdSynthetic(), osutil.XSnapdNeededBy("/foo/mytmp")}}, Action: update.Keep}, + }) +} + +func (s *changeSuite) TestRuntimeUsingSymlinks(c *C) { + dirs.SetRootDir(c.MkDir()) + defer func() { + dirs.SetRootDir("") + }() + + optDir := filepath.Join(dirs.GlobalRootDir, "/opt") + optFooRuntimeDir := filepath.Join(dirs.GlobalRootDir, "/opt/foo-runtime") + snapAppX1FooRuntimeDir := filepath.Join(dirs.GlobalRootDir, "/snap/app/x1/foo-runtime") + snapAppX2FooRuntimeDir := filepath.Join(dirs.GlobalRootDir, "/snap/app/x2/foo-runtime") + snapFooRuntimeX1OptFooRuntime := filepath.Join(dirs.GlobalRootDir, "/snap/foo-runtime/x1/opt/foo-runtime") + + // We start with a runtime shared from one snap to another and then exposed + // to /opt with a symbolic link. This is the initial state of the + // application in version v1. + initial := &osutil.MountProfile{} + desiredV1 := &osutil.MountProfile{Entries: []osutil.MountEntry{ + {Name: "none", Dir: optFooRuntimeDir, Type: "none", Options: []string{"x-snapd.kind=symlink", "x-snapd.symlink=" + snapAppX1FooRuntimeDir, "x-snapd.origin=layout"}}, + {Name: snapFooRuntimeX1OptFooRuntime, Dir: snapAppX1FooRuntimeDir, Type: "none", Options: []string{"bind", "ro"}}, + }} + // The changes we compute are trivial, simply perform each operation in order. + changes := update.NeededChanges(initial, desiredV1) + c.Assert(changes, DeepEquals, []*update.Change{ + {Entry: osutil.MountEntry{Name: snapFooRuntimeX1OptFooRuntime, Dir: snapAppX1FooRuntimeDir, Type: "none", Options: []string{"bind", "ro"}}, Action: update.Mount}, + {Entry: osutil.MountEntry{Name: "none", Dir: optFooRuntimeDir, Type: "none", Options: []string{"x-snapd.kind=symlink", "x-snapd.symlink=" + snapAppX1FooRuntimeDir, "x-snapd.origin=layout"}}, Action: update.Mount}, + }) + // After performing both changes we have a new synthesized entry. We get an + // extra writable mimic over /opt so that we can add our symlink. The + // content sharing into $SNAP is applied as expected since the snap ships + // the required mount point. + currentV1 := &osutil.MountProfile{Entries: []osutil.MountEntry{ + {Name: snapFooRuntimeX1OptFooRuntime, Dir: snapAppX1FooRuntimeDir, Type: "none", Options: []string{"bind", "ro"}}, + {Name: "none", Dir: optFooRuntimeDir, Type: "none", Options: []string{"x-snapd.kind=symlink", "x-snapd.symlink=" + snapAppX1FooRuntimeDir, "x-snapd.origin=layout"}}, + {Name: "tmpfs", Dir: optDir, Type: "tmpfs", Options: []string{"x-snapd.synthetic", "x-snapd.needed-by=" + optFooRuntimeDir, "mode=0755", "uid=0", "gid=0"}}, + }} + // We now proceed to replace app v1 with v2 which uses a bind mount instead + // of a symlink. First, let's start with the updated desired profile: + desiredV2 := &osutil.MountProfile{Entries: []osutil.MountEntry{ + {Name: snapAppX2FooRuntimeDir, Dir: optFooRuntimeDir, Type: "none", Options: []string{"rbind", "rw", "x-snapd.origin=layout"}}, + {Name: snapFooRuntimeX1OptFooRuntime, Dir: snapAppX2FooRuntimeDir, Type: "none", Options: []string{"bind", "ro"}}, + }} + + // Let's see what the update algorithm thinks. + changes = update.NeededChanges(currentV1, desiredV2) + c.Assert(changes, DeepEquals, []*update.Change{ + // We are keeping /opt, but re-creating /opt from scratch. + {Entry: osutil.MountEntry{Name: "tmpfs", Dir: optDir, Type: "tmpfs", Options: []string{"x-snapd.synthetic", "x-snapd.needed-by=" + optFooRuntimeDir, "mode=0755", "uid=0", "gid=0"}}, Action: update.Keep}, + {Entry: osutil.MountEntry{Name: "none", Dir: optFooRuntimeDir, Type: "none", Options: []string{"x-snapd.kind=symlink", "x-snapd.symlink=" + snapAppX1FooRuntimeDir, "x-snapd.origin=layout"}}, Action: update.Unmount}, + // We are dropping the content interface bind mount because app changed revision + {Entry: osutil.MountEntry{Name: snapFooRuntimeX1OptFooRuntime, Dir: snapAppX1FooRuntimeDir, Type: "none", Options: []string{"bind", "ro", "x-snapd.detach"}}, Action: update.Unmount}, + // We also adding the updated path of the content interface (for revision x2) + {Entry: osutil.MountEntry{Name: snapFooRuntimeX1OptFooRuntime, Dir: snapAppX2FooRuntimeDir, Type: "none", Options: []string{"bind", "ro"}}, Action: update.Mount}, + // We are adding a new bind mount for /opt/foo-runtime + {Entry: osutil.MountEntry{Name: snapAppX2FooRuntimeDir, Dir: optFooRuntimeDir, Type: "none", Options: []string{"rbind", "rw", "x-snapd.origin=layout"}}, Action: update.Mount}, + }) + + // After performing all those changes this is the profile we observe. + currentV2 := &osutil.MountProfile{Entries: []osutil.MountEntry{ + {Name: "tmpfs", Dir: optDir, Type: "tmpfs", Options: []string{"x-snapd.synthetic", "x-snapd.needed-by=" + optFooRuntimeDir, "mode=0755", "uid=0", "gid=0", "x-snapd.detach"}}, + {Name: snapAppX2FooRuntimeDir, Dir: optFooRuntimeDir, Type: "none", Options: []string{"rbind", "rw", "x-snapd.origin=layout"}}, + {Name: snapFooRuntimeX1OptFooRuntime, Dir: snapAppX2FooRuntimeDir, Type: "none", Options: []string{"bind", "ro"}}, + }} + + // So far so good. To trigger the issue we now revert or refresh to v1 + // again. Let's see what happens here. The desired profiles are already + // known so let's see what the algorithm thinks now. + changes = update.NeededChanges(currentV2, desiredV1) + c.Assert(changes, DeepEquals, []*update.Change{ + // We are dropping the content interface bind mount because app changed revision + {Entry: osutil.MountEntry{Name: snapFooRuntimeX1OptFooRuntime, Dir: snapAppX2FooRuntimeDir, Type: "none", Options: []string{"bind", "ro", "x-snapd.detach"}}, Action: update.Unmount}, + // We are also dropping the bind mount from /opt/runtime since we want a symlink instead + {Entry: osutil.MountEntry{Name: snapAppX2FooRuntimeDir, Dir: optFooRuntimeDir, Type: "none", Options: []string{"rbind", "rw", "x-snapd.origin=layout", "x-snapd.detach"}}, Action: update.Unmount}, + // Keep the tmpfs on /opt + {Entry: osutil.MountEntry{Name: "tmpfs", Dir: optDir, Type: "tmpfs", Options: []string{"x-snapd.synthetic", "x-snapd.needed-by=" + optFooRuntimeDir, "mode=0755", "uid=0", "gid=0", "x-snapd.detach"}}, Action: update.Keep}, + // We are bind mounting the runtime from another snap into $SNAP/foo-runtime + {Entry: osutil.MountEntry{Name: snapFooRuntimeX1OptFooRuntime, Dir: snapAppX1FooRuntimeDir, Type: "none", Options: []string{"bind", "ro"}}, Action: update.Mount}, + // We are providing a symlink /opt/foo-runtime -> to $SNAP/foo-runtime. + {Entry: osutil.MountEntry{Name: "none", Dir: optFooRuntimeDir, Type: "none", Options: []string{"x-snapd.kind=symlink", "x-snapd.symlink=" + snapAppX1FooRuntimeDir, "x-snapd.origin=layout"}}, Action: update.Mount}, + }) + + // The problem is that the tmpfs contains leftovers from the things we + // created and those prevent the execution of this mount profile. +} + +// ######################################## +// Topic: mounting & unmounting filesystems +// ######################################## + +// Change.Perform returns errors from os.Lstat (apart from ErrNotExist) +func (s *changeSuite) TestPerformFilesystemMountLstatError(c *C) { + s.sys.InsertFault(`lstat "/target"`, errTesting) + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "device", Dir: "/target", Type: "type"}} + synth, err := chg.Perform(s.as) + c.Assert(err, ErrorMatches, `cannot inspect "/target": testing`) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/target"`, E: errTesting}, + }) +} + +// Change.Perform wants to mount a filesystem. +func (s *changeSuite) TestPerformFilesystemMount(c *C) { + s.sys.InsertOsLstatResult(`lstat "/target"`, testutil.FileInfoDir) + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "device", Dir: "/target", Type: "type"}} + synth, err := chg.Perform(s.as) + c.Assert(err, IsNil) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/target"`, R: testutil.FileInfoDir}, + {C: `mount "device" "/target" "type" 0 ""`}, + }) +} + +// Change.Perform wants to mount a filesystem with sharing changes. +func (s *changeSuite) TestPerformFilesystemMountAndShareChanges(c *C) { + s.sys.InsertOsLstatResult(`lstat "/target"`, testutil.FileInfoDir) + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "device", Dir: "/target", Type: "type", Options: []string{"shared"}}} + synth, err := chg.Perform(s.as) + c.Assert(err, IsNil) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/target"`, R: testutil.FileInfoDir}, + {C: `mount "device" "/target" "type" 0 ""`}, + {C: `mount "none" "/target" "" MS_SHARED ""`}, + }) +} + +// Change.Perform wants to mount a filesystem but it fails. +func (s *changeSuite) TestPerformFilesystemMountWithError(c *C) { + s.sys.InsertOsLstatResult(`lstat "/target"`, testutil.FileInfoDir) + s.sys.InsertFault(`mount "device" "/target" "type" 0 ""`, errTesting) + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "device", Dir: "/target", Type: "type"}} + synth, err := chg.Perform(s.as) + c.Assert(err, Equals, errTesting) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/target"`, R: testutil.FileInfoDir}, + {C: `mount "device" "/target" "type" 0 ""`, E: errTesting}, + }) +} + +// Change.Perform wants to mount a filesystem with sharing changes but mounting fails. +func (s *changeSuite) TestPerformFilesystemMountAndShareWithError(c *C) { + s.sys.InsertOsLstatResult(`lstat "/target"`, testutil.FileInfoDir) + s.sys.InsertFault(`mount "device" "/target" "type" 0 ""`, errTesting) + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "device", Dir: "/target", Type: "type", Options: []string{"shared"}}} + synth, err := chg.Perform(s.as) + c.Assert(err, Equals, errTesting) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/target"`, R: testutil.FileInfoDir}, + {C: `mount "device" "/target" "type" 0 ""`, E: errTesting}, + }) +} + +// Change.Perform wants to mount a filesystem but the mount point isn't there. +func (s *changeSuite) TestPerformFilesystemMountWithoutMountPoint(c *C) { + defer s.as.MockUnrestrictedPaths("/")() // Treat test path as unrestricted. + s.sys.InsertFault(`lstat "/target"`, syscall.ENOENT) + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "device", Dir: "/target", Type: "type"}} + synth, err := chg.Perform(s.as) + c.Assert(err, IsNil) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/target"`, E: syscall.ENOENT}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `mkdirat 3 "target" 0755`}, + {C: `openat 3 "target" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `fchown 4 0 0`}, + {C: `close 4`}, + {C: `close 3`}, + {C: `mount "device" "/target" "type" 0 ""`}, + }) +} + +// Change.Perform wants to create a filesystem but the mount point isn't there and cannot be created. +func (s *changeSuite) TestPerformFilesystemMountWithoutMountPointWithErrors(c *C) { + defer s.as.MockUnrestrictedPaths("/")() // Treat test path as unrestricted. + s.sys.InsertFault(`lstat "/target"`, syscall.ENOENT) + s.sys.InsertFault(`mkdirat 3 "target" 0755`, errTesting) + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "device", Dir: "/target", Type: "type"}} + synth, err := chg.Perform(s.as) + c.Assert(err, ErrorMatches, `cannot create directory "/target": testing`) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/target"`, E: syscall.ENOENT}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `mkdirat 3 "target" 0755`, E: errTesting}, + {C: `close 3`}, + }) +} + +// Change.Perform wants to mount a filesystem but the mount point isn't there and the parent is read-only. +func (s *changeSuite) TestPerformFilesystemMountWithoutMountPointAndReadOnlyBase(c *C) { + defer s.as.MockUnrestrictedPaths("/")() // Treat test path as unrestricted. + s.sys.InsertFault(`lstat "/rofs/target"`, syscall.ENOENT) + s.sys.InsertFault(`mkdirat 3 "rofs" 0755`, syscall.EEXIST) + s.sys.InsertFault(`openat 3 "target" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, syscall.ENOENT, nil) // works on 2nd try + s.sys.InsertFault(`mkdirat 4 "target" 0755`, syscall.EROFS, nil) // works on 2nd try + s.sys.InsertSysLstatResult(`lstat "/rofs" `, syscall.Stat_t{Uid: 0, Gid: 0, Mode: 0755}) + s.sys.InsertReadDirResult(`readdir "/rofs"`, nil) // pretend /rofs is empty. + s.sys.InsertFault(`lstat "/tmp/.snap/rofs"`, syscall.ENOENT) + s.sys.InsertOsLstatResult(`lstat "/rofs"`, testutil.FileInfoDir) + s.sys.InsertFstatResult(`fstat 4 `, syscall.Stat_t{}) + s.sys.InsertFstatResult(`fstat 7 `, syscall.Stat_t{}) + s.sys.InsertFstatResult(`fstat 6 `, syscall.Stat_t{}) + s.sys.InsertFstatfsResult(`fstatfs 6 `, syscall.Statfs_t{}) + + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "device", Dir: "/rofs/target", Type: "type"}} + synth, err := chg.Perform(s.as) + c.Assert(err, IsNil) + c.Assert(synth, DeepEquals, []*update.Change{ + {Action: update.Mount, Entry: osutil.MountEntry{ + Name: "tmpfs", Dir: "/rofs", Type: "tmpfs", + Options: []string{"x-snapd.synthetic", "x-snapd.needed-by=/rofs/target", "mode=0755", "uid=0", "gid=0"}}, + }, + }) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + // sniff mount target + {C: `lstat "/rofs/target"`, E: syscall.ENOENT}, + + // /rofs/target is missing, create it + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `mkdirat 3 "rofs" 0755`, E: syscall.EEXIST}, + {C: `openat 3 "rofs" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `close 3`}, + {C: `mkdirat 4 "target" 0755`, E: syscall.EROFS}, + {C: `close 4`}, + + // error, read only filesystem, create a mimic + {C: `lstat "/rofs" `, R: syscall.Stat_t{Uid: 0, Gid: 0, Mode: 0755}}, + {C: `readdir "/rofs"`, R: []fs.DirEntry(nil)}, + {C: `lstat "/tmp/.snap/rofs"`, E: syscall.ENOENT}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `mkdirat 3 "tmp" 0755`}, + {C: `openat 3 "tmp" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `fchown 4 0 0`}, + {C: `mkdirat 4 ".snap" 0755`}, + {C: `openat 4 ".snap" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 5}, + {C: `fchown 5 0 0`}, + {C: `close 4`}, + {C: `close 3`}, + {C: `mkdirat 5 "rofs" 0755`}, + {C: `openat 5 "rofs" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `fchown 3 0 0`}, + {C: `close 3`}, + {C: `close 5`}, + {C: `lstat "/rofs"`, R: testutil.FileInfoDir}, + + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "rofs" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 4}, + {C: `fstat 4 `, R: syscall.Stat_t{}}, + {C: `close 3`}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "tmp" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 5}, + {C: `openat 5 ".snap" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 6}, + {C: `openat 6 "rofs" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 7}, + {C: `fstat 7 `, R: syscall.Stat_t{}}, + {C: `close 6`}, + {C: `close 5`}, + {C: `close 3`}, + {C: `mount "/proc/self/fd/4" "/proc/self/fd/7" "" MS_BIND|MS_REC ""`}, + {C: `close 7`}, + {C: `close 4`}, + + {C: `lstat "/rofs"`, R: testutil.FileInfoDir}, + {C: `mount "tmpfs" "/rofs" "tmpfs" 0 "mode=0755,uid=0,gid=0"`}, + {C: `mount "none" "/tmp/.snap/rofs" "" MS_REC|MS_PRIVATE ""`}, + {C: `unmount "/tmp/.snap/rofs" UMOUNT_NOFOLLOW|MNT_DETACH`}, + + // Perform clean up after the unmount operation. + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "tmp" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 4}, + {C: `openat 4 ".snap" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 5}, + {C: `openat 5 "rofs" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 6}, + {C: `fstat 6 `, R: syscall.Stat_t{}}, + {C: `close 5`}, + {C: `close 4`}, + {C: `close 3`}, + {C: `fstatfs 6 `, R: syscall.Statfs_t{}}, + {C: `remove "/tmp/.snap/rofs"`}, + {C: `close 6`}, + + // mimic ready, re-try initial mkdir + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `mkdirat 3 "rofs" 0755`, E: syscall.EEXIST}, + {C: `openat 3 "rofs" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `close 3`}, + {C: `mkdirat 4 "target" 0755`}, + {C: `openat 4 "target" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `fchown 3 0 0`}, + {C: `close 3`}, + {C: `close 4`}, + + // mount the filesystem + {C: `mount "device" "/rofs/target" "type" 0 ""`}, + }) +} + +// Change.Perform wants to mount a filesystem but the mount point isn't there and the parent is read-only and mimic fails during planning. +func (s *changeSuite) TestPerformFilesystemMountWithoutMountPointAndReadOnlyBaseErrorWhilePlanning(c *C) { + defer s.as.MockUnrestrictedPaths("/")() // Treat test path as unrestricted. + s.sys.InsertFault(`lstat "/rofs/target"`, syscall.ENOENT) + s.sys.InsertFault(`mkdirat 3 "rofs" 0755`, syscall.EEXIST) + s.sys.InsertFault(`openat 3 "target" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, syscall.ENOENT) + s.sys.InsertFault(`mkdirat 4 "target" 0755`, syscall.EROFS) + s.sys.InsertFault(`lstat "/tmp/.snap/rofs"`, syscall.ENOENT) + s.sys.InsertOsLstatResult(`lstat "/rofs"`, testutil.FileInfoDir) + s.sys.InsertSysLstatResult(`lstat "/rofs" `, syscall.Stat_t{Uid: 0, Gid: 0, Mode: 0755}) + s.sys.InsertReadDirResult(`readdir "/rofs"`, nil) + s.sys.InsertFault(`readdir "/rofs"`, errTesting) // make the writable mimic fail + + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "device", Dir: "/rofs/target", Type: "type"}} + synth, err := chg.Perform(s.as) + c.Assert(err, ErrorMatches, `cannot create writable mimic over "/rofs": testing`) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + // sniff mount target + {C: `lstat "/rofs/target"`, E: syscall.ENOENT}, + + // /rofs/target is missing, create it + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `mkdirat 3 "rofs" 0755`, E: syscall.EEXIST}, + {C: `openat 3 "rofs" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `close 3`}, + {C: `mkdirat 4 "target" 0755`, E: syscall.EROFS}, + {C: `close 4`}, + + // error, read only filesystem, create a mimic + {C: `lstat "/rofs" `, R: syscall.Stat_t{Uid: 0, Gid: 0, Mode: 0755}}, + {C: `readdir "/rofs"`, E: errTesting}, + // cannot create mimic, that's it + }) +} + +// Change.Perform wants to mount a filesystem but the mount point isn't there and the parent is read-only and mimic fails during execution. +func (s *changeSuite) TestPerformFilesystemMountWithoutMountPointAndReadOnlyBaseErrorWhileExecuting(c *C) { + defer s.as.MockUnrestrictedPaths("/")() // Treat test path as unrestricted. + s.sys.InsertFault(`lstat "/rofs/target"`, syscall.ENOENT) + s.sys.InsertFault(`mkdirat 3 "rofs" 0755`, syscall.EEXIST) + s.sys.InsertFault(`openat 3 "target" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, syscall.ENOENT) + s.sys.InsertFault(`mkdirat 4 "target" 0755`, syscall.EROFS) + s.sys.InsertFault(`lstat "/tmp/.snap/rofs"`, syscall.ENOENT) + s.sys.InsertOsLstatResult(`lstat "/rofs"`, testutil.FileInfoDir) + s.sys.InsertSysLstatResult(`lstat "/rofs" `, syscall.Stat_t{Uid: 0, Gid: 0, Mode: 0755}) + s.sys.InsertReadDirResult(`readdir "/rofs"`, nil) + s.sys.InsertFault(`mkdirat 4 ".snap" 0755`, errTesting) // make the writable mimic fail + + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "device", Dir: "/rofs/target", Type: "type"}} + synth, err := chg.Perform(s.as) + c.Assert(err, ErrorMatches, `cannot create writable mimic over "/rofs": cannot create directory "/tmp/.snap": testing`) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + // sniff mount target + {C: `lstat "/rofs/target"`, E: syscall.ENOENT}, + + // /rofs/target is missing, create it + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `mkdirat 3 "rofs" 0755`, E: syscall.EEXIST}, + {C: `openat 3 "rofs" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `close 3`}, + {C: `mkdirat 4 "target" 0755`, E: syscall.EROFS}, + {C: `close 4`}, + + // error, read only filesystem, create a mimic + {C: `lstat "/rofs" `, R: syscall.Stat_t{Uid: 0, Gid: 0, Mode: 0755}}, + {C: `readdir "/rofs"`, R: []fs.DirEntry(nil)}, + {C: `lstat "/tmp/.snap/rofs"`, E: syscall.ENOENT}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `mkdirat 3 "tmp" 0755`}, + {C: `openat 3 "tmp" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `fchown 4 0 0`}, + {C: `mkdirat 4 ".snap" 0755`, E: errTesting}, + {C: `close 4`}, + {C: `close 3`}, + // cannot create mimic, that's it + }) +} + +// Change.Perform wants to mount a filesystem but there's a symlink in mount point. +func (s *changeSuite) TestPerformFilesystemMountWithSymlinkInMountPoint(c *C) { + s.sys.InsertOsLstatResult(`lstat "/target"`, testutil.FileInfoSymlink) + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "device", Dir: "/target", Type: "type"}} + synth, err := chg.Perform(s.as) + c.Assert(err, ErrorMatches, `cannot use "/target" as mount point: not a directory`) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/target"`, R: testutil.FileInfoSymlink}, + }) +} + +// Change.Perform wants to mount a filesystem but there's a file in mount point. +func (s *changeSuite) TestPerformFilesystemMountWithFileInMountPoint(c *C) { + s.sys.InsertOsLstatResult(`lstat "/target"`, testutil.FileInfoFile) + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "device", Dir: "/target", Type: "type"}} + synth, err := chg.Perform(s.as) + c.Assert(err, ErrorMatches, `cannot use "/target" as mount point: not a directory`) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/target"`, R: testutil.FileInfoFile}, + }) +} + +// Change.Perform wants to unmount a filesystem. +func (s *changeSuite) TestPerformFilesystemUnmount(c *C) { + s.sys.InsertFstatResult(`fstat 4 `, syscall.Stat_t{}) + s.sys.InsertFstatfsResult(`fstatfs 4 `, syscall.Statfs_t{}) + chg := &update.Change{Action: update.Unmount, Entry: osutil.MountEntry{Name: "device", Dir: "/target", Type: "type"}} + synth, err := chg.Perform(s.as) + c.Assert(err, IsNil) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `unmount "/target" UMOUNT_NOFOLLOW`}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "target" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 4}, + {C: `fstat 4 `, R: syscall.Stat_t{}}, + {C: `close 3`}, + {C: `fstatfs 4 `, R: syscall.Statfs_t{}}, + {C: `remove "/target"`}, + {C: `close 4`}, + }) + c.Assert(synth, HasLen, 0) +} + +// Change.Perform wants to detach a bind mount. +func (s *changeSuite) TestPerformFilesystemDetch(c *C) { + s.sys.InsertFstatResult(`fstat 4 `, syscall.Stat_t{}) + s.sys.InsertFstatfsResult(`fstatfs 4 `, syscall.Statfs_t{}) + chg := &update.Change{Action: update.Unmount, Entry: osutil.MountEntry{Name: "/something", Dir: "/target", Options: []string{"x-snapd.detach"}}} + synth, err := chg.Perform(s.as) + c.Assert(err, IsNil) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `mount "none" "/target" "" MS_REC|MS_PRIVATE ""`}, + {C: `unmount "/target" UMOUNT_NOFOLLOW|MNT_DETACH`}, + + // Perform clean up after the unmount operation. + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "target" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 4}, + {C: `fstat 4 `, R: syscall.Stat_t{}}, + {C: `close 3`}, + {C: `fstatfs 4 `, R: syscall.Statfs_t{}}, + {C: `remove "/target"`}, + {C: `close 4`}, + }) + c.Assert(synth, HasLen, 0) +} + +// Change.Perform wants to unmount a filesystem but it fails. +func (s *changeSuite) TestPerformFilesystemUnmountError(c *C) { + s.sys.InsertFault(`unmount "/target" UMOUNT_NOFOLLOW`, errTesting) + chg := &update.Change{Action: update.Unmount, Entry: osutil.MountEntry{Name: "device", Dir: "/target", Type: "type"}} + synth, err := chg.Perform(s.as) + c.Assert(err, Equals, errTesting) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `unmount "/target" UMOUNT_NOFOLLOW`, E: errTesting}, + }) + c.Assert(synth, HasLen, 0) +} + +// Change.Perform passes non-flag options to the kernel. +func (s *changeSuite) TestPerformFilesystemMountWithOptions(c *C) { + s.sys.InsertOsLstatResult(`lstat "/target"`, testutil.FileInfoDir) + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "device", Dir: "/target", Type: "type", Options: []string{"ro", "funky"}}} + synth, err := chg.Perform(s.as) + c.Assert(err, IsNil) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/target"`, R: testutil.FileInfoDir}, + {C: `mount "device" "/target" "type" MS_RDONLY "funky"`}, + }) +} + +// Change.Perform doesn't pass snapd-specific options to the kernel. +func (s *changeSuite) TestPerformFilesystemMountWithSnapdSpecificOptions(c *C) { + s.sys.InsertOsLstatResult(`lstat "/target"`, testutil.FileInfoDir) + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "device", Dir: "/target", Type: "type", Options: []string{"ro", "x-snapd.funky"}}} + synth, err := chg.Perform(s.as) + c.Assert(err, IsNil) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/target"`, R: testutil.FileInfoDir}, + {C: `mount "device" "/target" "type" MS_RDONLY ""`}, + }) +} + +// ############################################### +// Topic: bind-mounting and unmounting directories +// ############################################### + +// Change.Perform wants to bind mount a directory but the target cannot be stat'ed. +func (s *changeSuite) TestPerformDirectoryBindMountTargetLstatError(c *C) { + s.sys.InsertFault(`lstat "/target"`, errTesting) + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "/source", Dir: "/target", Options: []string{"bind"}}} + synth, err := chg.Perform(s.as) + c.Assert(err, ErrorMatches, `cannot inspect "/target": testing`) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/target"`, E: errTesting}, + }) +} + +// Change.Perform wants to bind mount a directory but the source cannot be stat'ed. +func (s *changeSuite) TestPerformDirectoryBindMountSourceLstatError(c *C) { + s.sys.InsertOsLstatResult(`lstat "/target"`, testutil.FileInfoDir) + s.sys.InsertFault(`lstat "/source"`, errTesting) + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "/source", Dir: "/target", Options: []string{"bind"}}} + synth, err := chg.Perform(s.as) + c.Assert(err, ErrorMatches, `cannot inspect "/source": testing`) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/target"`, R: testutil.FileInfoDir}, + {C: `lstat "/source"`, E: errTesting}, + }) +} + +// Change.Perform wants to bind mount a directory. +func (s *changeSuite) TestPerformDirectoryBindMount(c *C) { + s.sys.InsertOsLstatResult(`lstat "/source"`, testutil.FileInfoDir) + s.sys.InsertOsLstatResult(`lstat "/target"`, testutil.FileInfoDir) + s.sys.InsertFstatResult(`fstat 4 `, syscall.Stat_t{}) + s.sys.InsertFstatResult(`fstat 5 `, syscall.Stat_t{}) + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "/source", Dir: "/target", Options: []string{"bind"}}} + synth, err := chg.Perform(s.as) + c.Assert(err, IsNil) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/target"`, R: testutil.FileInfoDir}, + {C: `lstat "/source"`, R: testutil.FileInfoDir}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "source" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 4}, + {C: `fstat 4 `, R: syscall.Stat_t{}}, + {C: `close 3`}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "target" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 5}, + {C: `fstat 5 `, R: syscall.Stat_t{}}, + {C: `close 3`}, + {C: `mount "/proc/self/fd/4" "/proc/self/fd/5" "" MS_BIND ""`}, + {C: `close 5`}, + {C: `close 4`}, + }) +} + +// Change.Perform wants to bind mount a directory with sharing changes. +func (s *changeSuite) TestPerformRecursiveDirectorySharedBindMount(c *C) { + s.sys.InsertOsLstatResult(`lstat "/source"`, testutil.FileInfoDir) + s.sys.InsertOsLstatResult(`lstat "/target"`, testutil.FileInfoDir) + s.sys.InsertFstatResult(`fstat 4 `, syscall.Stat_t{}) + s.sys.InsertFstatResult(`fstat 5 `, syscall.Stat_t{}) + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "/source", Dir: "/target", Options: []string{"rshared", "rbind"}}} + synth, err := chg.Perform(s.as) + c.Assert(err, IsNil) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/target"`, R: testutil.FileInfoDir}, + {C: `lstat "/source"`, R: testutil.FileInfoDir}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "source" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 4}, + {C: `fstat 4 `, R: syscall.Stat_t{}}, + {C: `close 3`}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "target" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 5}, + {C: `fstat 5 `, R: syscall.Stat_t{}}, + {C: `close 3`}, + {C: `mount "/proc/self/fd/4" "/proc/self/fd/5" "" MS_BIND|MS_REC ""`}, + {C: `close 5`}, + {C: `close 4`}, + {C: `mount "none" "/target" "" MS_REC|MS_SHARED ""`}, + }) +} + +// Change.Perform wants to bind mount a directory but it fails. +func (s *changeSuite) TestPerformDirectoryBindMountWithError(c *C) { + s.sys.InsertOsLstatResult(`lstat "/target"`, testutil.FileInfoDir) + s.sys.InsertOsLstatResult(`lstat "/source"`, testutil.FileInfoDir) + s.sys.InsertFstatResult(`fstat 4 `, syscall.Stat_t{}) + s.sys.InsertFstatResult(`fstat 5 `, syscall.Stat_t{}) + s.sys.InsertFault(`mount "/proc/self/fd/4" "/proc/self/fd/5" "" MS_BIND ""`, errTesting) + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "/source", Dir: "/target", Options: []string{"bind"}}} + synth, err := chg.Perform(s.as) + c.Assert(err, Equals, errTesting) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/target"`, R: testutil.FileInfoDir}, + {C: `lstat "/source"`, R: testutil.FileInfoDir}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "source" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 4}, + {C: `fstat 4 `, R: syscall.Stat_t{}}, + {C: `close 3`}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "target" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 5}, + {C: `fstat 5 `, R: syscall.Stat_t{}}, + {C: `close 3`}, + {C: `mount "/proc/self/fd/4" "/proc/self/fd/5" "" MS_BIND ""`, E: errTesting}, + {C: `close 5`}, + {C: `close 4`}, + }) +} + +// Change.Perform wants to bind mount a directory but the mount point isn't there. +func (s *changeSuite) TestPerformDirectoryBindMountWithoutMountPoint(c *C) { + defer s.as.MockUnrestrictedPaths("/")() // Treat test path as unrestricted. + s.sys.InsertOsLstatResult(`lstat "/source"`, testutil.FileInfoDir) + s.sys.InsertFault(`lstat "/target"`, syscall.ENOENT) + s.sys.InsertFstatResult(`fstat 4 `, syscall.Stat_t{}) + s.sys.InsertFstatResult(`fstat 5 `, syscall.Stat_t{}) + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "/source", Dir: "/target", Options: []string{"bind"}}} + synth, err := chg.Perform(s.as) + c.Assert(err, IsNil) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/target"`, E: syscall.ENOENT}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `mkdirat 3 "target" 0755`}, + {C: `openat 3 "target" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `fchown 4 0 0`}, + {C: `close 4`}, + {C: `close 3`}, + {C: `lstat "/source"`, R: testutil.FileInfoDir}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "source" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 4}, + {C: `fstat 4 `, R: syscall.Stat_t{}}, + {C: `close 3`}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "target" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 5}, + {C: `fstat 5 `, R: syscall.Stat_t{}}, + {C: `close 3`}, + {C: `mount "/proc/self/fd/4" "/proc/self/fd/5" "" MS_BIND ""`}, + {C: `close 5`}, + {C: `close 4`}, + }) +} + +// Change.Perform wants to bind mount a directory but the mount source isn't there. +func (s *changeSuite) TestPerformDirectoryBindMountWithoutMountSource(c *C) { + defer s.as.MockUnrestrictedPaths("/")() // Treat test path as unrestricted. + s.sys.InsertFault(`lstat "/source"`, syscall.ENOENT) + s.sys.InsertOsLstatResult(`lstat "/target"`, testutil.FileInfoDir) + s.sys.InsertFstatResult(`fstat 4 `, syscall.Stat_t{}) + s.sys.InsertFstatResult(`fstat 5 `, syscall.Stat_t{}) + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "/source", Dir: "/target", Options: []string{"bind"}}} + synth, err := chg.Perform(s.as) + c.Assert(err, IsNil) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/target"`, R: testutil.FileInfoDir}, + {C: `lstat "/source"`, E: syscall.ENOENT}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `mkdirat 3 "source" 0755`}, + {C: `openat 3 "source" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `fchown 4 0 0`}, + {C: `close 4`}, + {C: `close 3`}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "source" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 4}, + {C: `fstat 4 `, R: syscall.Stat_t{}}, + {C: `close 3`}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "target" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 5}, + {C: `fstat 5 `, R: syscall.Stat_t{}}, + {C: `close 3`}, + {C: `mount "/proc/self/fd/4" "/proc/self/fd/5" "" MS_BIND ""`}, + {C: `close 5`}, + {C: `close 4`}, + }) +} + +// Change.Perform wants to create a directory bind mount but the mount point isn't there and cannot be created. +func (s *changeSuite) TestPerformDirectoryBindMountWithoutMountPointWithErrors(c *C) { + defer s.as.MockUnrestrictedPaths("/")() // Treat test path as unrestricted. + s.sys.InsertFault(`lstat "/target"`, syscall.ENOENT) + s.sys.InsertFault(`mkdirat 3 "target" 0755`, errTesting) + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "/source", Dir: "/target", Options: []string{"bind"}}} + synth, err := chg.Perform(s.as) + c.Assert(err, ErrorMatches, `cannot create directory "/target": testing`) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/target"`, E: syscall.ENOENT}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `mkdirat 3 "target" 0755`, E: errTesting}, + {C: `close 3`}, + }) +} + +// Change.Perform wants to create a directory bind mount but the mount source isn't there and cannot be created. +func (s *changeSuite) TestPerformDirectoryBindMountWithoutMountSourceWithErrors(c *C) { + defer s.as.MockUnrestrictedPaths("/")() // Treat test path as unrestricted. + s.sys.InsertFault(`lstat "/source"`, syscall.ENOENT) + s.sys.InsertFault(`mkdirat 3 "source" 0755`, errTesting) + s.sys.InsertOsLstatResult(`lstat "/target"`, testutil.FileInfoDir) + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "/source", Dir: "/target", Options: []string{"bind"}}} + synth, err := chg.Perform(s.as) + c.Assert(err, ErrorMatches, `cannot create directory "/source": testing`) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/target"`, R: testutil.FileInfoDir}, + {C: `lstat "/source"`, E: syscall.ENOENT}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `mkdirat 3 "source" 0755`, E: errTesting}, + {C: `close 3`}, + }) +} + +// Change.Perform wants to bind mount a directory but the mount point isn't there and the parent is read-only. +func (s *changeSuite) TestPerformDirectoryBindMountWithoutMountPointAndReadOnlyBase(c *C) { + defer s.as.MockUnrestrictedPaths("/")() // Treat test path as unrestricted. + s.sys.InsertFault(`lstat "/rofs/target"`, syscall.ENOENT) + s.sys.InsertFault(`mkdirat 3 "rofs" 0755`, syscall.EEXIST) + s.sys.InsertFault(`openat 3 "target" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, syscall.ENOENT, nil) // works on 2nd try + s.sys.InsertFault(`mkdirat 4 "target" 0755`, syscall.EROFS, nil) // works on 2nd try + s.sys.InsertSysLstatResult(`lstat "/rofs" `, syscall.Stat_t{Uid: 0, Gid: 0, Mode: 0755}) + s.sys.InsertReadDirResult(`readdir "/rofs"`, nil) // pretend /rofs is empty. + s.sys.InsertFault(`lstat "/tmp/.snap/rofs"`, syscall.ENOENT) + s.sys.InsertOsLstatResult(`lstat "/rofs"`, testutil.FileInfoDir) + s.sys.InsertOsLstatResult(`lstat "/source"`, testutil.FileInfoDir) + s.sys.InsertFstatResult(`fstat 4 `, syscall.Stat_t{}) + s.sys.InsertFstatResult(`fstat 7 `, syscall.Stat_t{}) + s.sys.InsertFstatResult(`fstat 6 `, syscall.Stat_t{}) + s.sys.InsertFstatResult(`fstat 6 `, syscall.Stat_t{}) + s.sys.InsertFstatfsResult(`fstatfs 6 `, syscall.Statfs_t{}) + + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "/source", Dir: "/rofs/target", Options: []string{"bind"}}} + synth, err := chg.Perform(s.as) + c.Assert(err, IsNil) + c.Assert(synth, DeepEquals, []*update.Change{ + {Action: update.Mount, Entry: osutil.MountEntry{ + Name: "tmpfs", Dir: "/rofs", Type: "tmpfs", + Options: []string{"x-snapd.synthetic", "x-snapd.needed-by=/rofs/target", "mode=0755", "uid=0", "gid=0"}}, + }, + }) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + // sniff mount target + {C: `lstat "/rofs/target"`, E: syscall.ENOENT}, + + // /rofs/target is missing, create it + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `mkdirat 3 "rofs" 0755`, E: syscall.EEXIST}, + {C: `openat 3 "rofs" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `close 3`}, + {C: `mkdirat 4 "target" 0755`, E: syscall.EROFS}, + {C: `close 4`}, + + // error, read only filesystem, create a mimic + {C: `lstat "/rofs" `, R: syscall.Stat_t{Uid: 0, Gid: 0, Mode: 0755}}, + {C: `readdir "/rofs"`, R: []fs.DirEntry(nil)}, + {C: `lstat "/tmp/.snap/rofs"`, E: syscall.ENOENT}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `mkdirat 3 "tmp" 0755`}, + {C: `openat 3 "tmp" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `fchown 4 0 0`}, + {C: `mkdirat 4 ".snap" 0755`}, + {C: `openat 4 ".snap" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 5}, + {C: `fchown 5 0 0`}, + {C: `close 4`}, + {C: `close 3`}, + {C: `mkdirat 5 "rofs" 0755`}, + {C: `openat 5 "rofs" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `fchown 3 0 0`}, + {C: `close 3`}, + {C: `close 5`}, + {C: `lstat "/rofs"`, R: testutil.FileInfoDir}, + + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "rofs" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 4}, + {C: `fstat 4 `, R: syscall.Stat_t{}}, + {C: `close 3`}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "tmp" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 5}, + {C: `openat 5 ".snap" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 6}, + {C: `openat 6 "rofs" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 7}, + {C: `fstat 7 `, R: syscall.Stat_t{}}, + {C: `close 6`}, + {C: `close 5`}, + {C: `close 3`}, + {C: `mount "/proc/self/fd/4" "/proc/self/fd/7" "" MS_BIND|MS_REC ""`}, + {C: `close 7`}, + {C: `close 4`}, + + {C: `lstat "/rofs"`, R: testutil.FileInfoDir}, + {C: `mount "tmpfs" "/rofs" "tmpfs" 0 "mode=0755,uid=0,gid=0"`}, + {C: `mount "none" "/tmp/.snap/rofs" "" MS_REC|MS_PRIVATE ""`}, + {C: `unmount "/tmp/.snap/rofs" UMOUNT_NOFOLLOW|MNT_DETACH`}, + + // Perform clean up after the unmount operation. + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "tmp" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 4}, + {C: `openat 4 ".snap" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 5}, + {C: `openat 5 "rofs" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 6}, + {C: `fstat 6 `, R: syscall.Stat_t{}}, + {C: `close 5`}, + {C: `close 4`}, + {C: `close 3`}, + {C: `fstatfs 6 `, R: syscall.Statfs_t{}}, + {C: `remove "/tmp/.snap/rofs"`}, + {C: `close 6`}, + + // mimic ready, re-try initial mkdir + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `mkdirat 3 "rofs" 0755`, E: syscall.EEXIST}, + {C: `openat 3 "rofs" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `close 3`}, + {C: `mkdirat 4 "target" 0755`}, + {C: `openat 4 "target" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `fchown 3 0 0`}, + {C: `close 3`}, + {C: `close 4`}, + + // sniff mount source + {C: `lstat "/source"`, R: testutil.FileInfoDir}, + + // mount the filesystem + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "source" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 4}, + {C: `fstat 4 `, R: syscall.Stat_t{}}, + {C: `close 3`}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "rofs" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 5}, + {C: `openat 5 "target" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 6}, + {C: `fstat 6 `, R: syscall.Stat_t{}}, + {C: `close 5`}, + {C: `close 3`}, + {C: `mount "/proc/self/fd/4" "/proc/self/fd/6" "" MS_BIND ""`}, + {C: `close 6`}, + {C: `close 4`}, + }) +} + +// Change.Perform wants to bind mount a directory but the mount source isn't there and the parent is read-only. +func (s *changeSuite) TestPerformDirectoryBindMountWithoutMountSourceAndReadOnlyBase(c *C) { + defer s.as.MockUnrestrictedPaths("/")() // Treat test path as unrestricted. + s.sys.InsertOsLstatResult(`lstat "/rofs"`, testutil.FileInfoDir) + s.sys.InsertFault(`lstat "/rofs/source"`, syscall.ENOENT) + s.sys.InsertOsLstatResult(`lstat "/target"`, testutil.FileInfoDir) + s.sys.InsertFault(`mkdirat 3 "rofs" 0755`, syscall.EEXIST) + s.sys.InsertFault(`mkdirat 4 "source" 0755`, syscall.EROFS) + + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "/rofs/source", Dir: "/target", Options: []string{"bind"}}} + synth, err := chg.Perform(s.as) + c.Assert(err, ErrorMatches, `cannot operate on read-only filesystem at /rofs`) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/target"`, R: testutil.FileInfoDir}, + {C: `lstat "/rofs/source"`, E: syscall.ENOENT}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `mkdirat 3 "rofs" 0755`, E: syscall.EEXIST}, + {C: `openat 3 "rofs" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `close 3`}, + {C: `mkdirat 4 "source" 0755`, E: syscall.EROFS}, + {C: `close 4`}, + }) +} + +// Change.Perform wants to bind mount a directory but the mount source isn't there and the parent is read-only but this is for a layout. +func (s *changeSuite) TestPerformDirectoryBindMountWithoutMountSourceAndReadOnlyBaseForLayout(c *C) { + defer s.as.MockUnrestrictedPaths("/")() // Treat test path as unrestricted. + s.sys.InsertOsLstatResult(`lstat "/target"`, testutil.FileInfoDir) + s.sys.InsertFault(`lstat "/rofs/source"`, syscall.ENOENT) + s.sys.InsertFault(`mkdirat 3 "rofs" 0755`, syscall.EEXIST) + s.sys.InsertFault(`openat 3 "source" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, syscall.ENOENT, nil) // works on 2nd try + s.sys.InsertFault(`mkdirat 4 "source" 0755`, syscall.EROFS, nil) // works on 2nd try + s.sys.InsertReadDirResult(`readdir "/rofs"`, nil) // pretend /rofs is empty. + s.sys.InsertFault(`lstat "/tmp/.snap/rofs"`, syscall.ENOENT) + s.sys.InsertOsLstatResult(`lstat "/rofs"`, testutil.FileInfoDir) + s.sys.InsertSysLstatResult(`lstat "/rofs" `, syscall.Stat_t{Uid: 0, Gid: 0, Mode: 0755}) + s.sys.InsertFstatResult(`fstat 4 `, syscall.Stat_t{}) + s.sys.InsertFstatResult(`fstat 5 `, syscall.Stat_t{}) + s.sys.InsertFstatResult(`fstat 7 `, syscall.Stat_t{}) + s.sys.InsertFstatResult(`fstat 6 `, syscall.Stat_t{}) + s.sys.InsertFstatfsResult(`fstatfs 6 `, syscall.Statfs_t{}) + + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "/rofs/source", Dir: "/target", Options: []string{"bind", "x-snapd.origin=layout"}}} + synth, err := chg.Perform(s.as) + c.Assert(err, IsNil) + c.Check(synth, DeepEquals, []*update.Change{ + {Action: update.Mount, Entry: osutil.MountEntry{ + Name: "tmpfs", Dir: "/rofs", Type: "tmpfs", + Options: []string{"x-snapd.synthetic", "x-snapd.needed-by=/target", "mode=0755", "uid=0", "gid=0"}}, + }, + }) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + // sniff mount target and source + {C: `lstat "/target"`, R: testutil.FileInfoDir}, + {C: `lstat "/rofs/source"`, E: syscall.ENOENT}, + + // /rofs/source is missing, create it + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `mkdirat 3 "rofs" 0755`, E: syscall.EEXIST}, + {C: `openat 3 "rofs" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `close 3`}, + {C: `mkdirat 4 "source" 0755`, E: syscall.EROFS}, + {C: `close 4`}, + + // error /rofs is a read-only filesystem, create a mimic + {C: `lstat "/rofs" `, R: syscall.Stat_t{Mode: 0755}}, + {C: `readdir "/rofs"`, R: []fs.DirEntry(nil)}, + {C: `lstat "/tmp/.snap/rofs"`, E: syscall.ENOENT}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `mkdirat 3 "tmp" 0755`}, + {C: `openat 3 "tmp" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `fchown 4 0 0`}, + {C: `mkdirat 4 ".snap" 0755`}, + {C: `openat 4 ".snap" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 5}, + {C: `fchown 5 0 0`}, + {C: `close 4`}, + {C: `close 3`}, + {C: `mkdirat 5 "rofs" 0755`}, + {C: `openat 5 "rofs" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `fchown 3 0 0`}, + {C: `close 3`}, + {C: `close 5`}, + {C: `lstat "/rofs"`, R: testutil.FileInfoDir}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "rofs" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 4}, + {C: `fstat 4 `, R: syscall.Stat_t{}}, + {C: `close 3`}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "tmp" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 5}, + {C: `openat 5 ".snap" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 6}, + {C: `openat 6 "rofs" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 7}, + {C: `fstat 7 `, R: syscall.Stat_t{}}, + {C: `close 6`}, + {C: `close 5`}, + {C: `close 3`}, + {C: `mount "/proc/self/fd/4" "/proc/self/fd/7" "" MS_BIND|MS_REC ""`}, + {C: `close 7`}, + {C: `close 4`}, + {C: `lstat "/rofs"`, R: testutil.FileInfoDir}, + {C: `mount "tmpfs" "/rofs" "tmpfs" 0 "mode=0755,uid=0,gid=0"`}, + {C: `mount "none" "/tmp/.snap/rofs" "" MS_REC|MS_PRIVATE ""`}, + {C: `unmount "/tmp/.snap/rofs" UMOUNT_NOFOLLOW|MNT_DETACH`}, + + // Perform clean up after the unmount operation. + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "tmp" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 4}, + {C: `openat 4 ".snap" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 5}, + {C: `openat 5 "rofs" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 6}, + {C: `fstat 6 `, R: syscall.Stat_t{}}, + {C: `close 5`}, + {C: `close 4`}, + {C: `close 3`}, + {C: `fstatfs 6 `, R: syscall.Statfs_t{}}, + {C: `remove "/tmp/.snap/rofs"`}, + {C: `close 6`}, + + // /rofs/source was missing (we checked earlier), create it + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `mkdirat 3 "rofs" 0755`, E: syscall.EEXIST}, + {C: `openat 3 "rofs" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `close 3`}, + {C: `mkdirat 4 "source" 0755`}, + {C: `openat 4 "source" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `fchown 3 0 0`}, + {C: `close 3`}, + {C: `close 4`}, + + // bind mount /rofs/source -> /target + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "rofs" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 4}, + {C: `openat 4 "source" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 5}, + {C: `fstat 5 `, R: syscall.Stat_t{}}, + {C: `close 4`}, + {C: `close 3`}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "target" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 4}, + {C: `fstat 4 `, R: syscall.Stat_t{}}, + {C: `close 3`}, + {C: `mount "/proc/self/fd/5" "/proc/self/fd/4" "" MS_BIND ""`}, + {C: `close 4`}, + {C: `close 5`}, + }) +} + +// Change.Perform wants to bind mount a directory but there's a symlink in mount point. +func (s *changeSuite) TestPerformDirectoryBindMountWithSymlinkInMountPoint(c *C) { + s.sys.InsertOsLstatResult(`lstat "/target"`, testutil.FileInfoSymlink) + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "/source", Dir: "/target", Options: []string{"bind"}}} + synth, err := chg.Perform(s.as) + c.Assert(err, ErrorMatches, `cannot use "/target" as mount point: not a directory`) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/target"`, R: testutil.FileInfoSymlink}, + }) +} + +// Change.Perform wants to bind mount a directory but there's a file in mount mount. +func (s *changeSuite) TestPerformDirectoryBindMountWithFileInMountPoint(c *C) { + s.sys.InsertOsLstatResult(`lstat "/target"`, testutil.FileInfoFile) + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "/source", Dir: "/target", Options: []string{"bind"}}} + synth, err := chg.Perform(s.as) + c.Assert(err, ErrorMatches, `cannot use "/target" as mount point: not a directory`) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/target"`, R: testutil.FileInfoFile}, + }) +} + +// Change.Perform wants to bind mount a directory but there's a symlink in source. +func (s *changeSuite) TestPerformDirectoryBindMountWithSymlinkInMountSource(c *C) { + s.sys.InsertOsLstatResult(`lstat "/target"`, testutil.FileInfoDir) + s.sys.InsertOsLstatResult(`lstat "/source"`, testutil.FileInfoSymlink) + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "/source", Dir: "/target", Options: []string{"bind"}}} + synth, err := chg.Perform(s.as) + c.Assert(err, ErrorMatches, `cannot use "/source" as bind-mount source: not a directory`) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/target"`, R: testutil.FileInfoDir}, + {C: `lstat "/source"`, R: testutil.FileInfoSymlink}, + }) +} + +// Change.Perform wants to bind mount a directory but there's a file in source. +func (s *changeSuite) TestPerformDirectoryBindMountWithFileInMountSource(c *C) { + s.sys.InsertOsLstatResult(`lstat "/target"`, testutil.FileInfoDir) + s.sys.InsertOsLstatResult(`lstat "/source"`, testutil.FileInfoFile) + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "/source", Dir: "/target", Options: []string{"bind"}}} + synth, err := chg.Perform(s.as) + c.Assert(err, ErrorMatches, `cannot use "/source" as bind-mount source: not a directory`) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/target"`, R: testutil.FileInfoDir}, + {C: `lstat "/source"`, R: testutil.FileInfoFile}, + }) +} + +// Change.Perform wants to unmount a directory bind mount. +func (s *changeSuite) TestPerformDirectoryBindUnmount(c *C) { + s.sys.InsertFstatResult(`fstat 4 `, syscall.Stat_t{}) + s.sys.InsertFstatfsResult(`fstatfs 4 `, syscall.Statfs_t{}) + chg := &update.Change{Action: update.Unmount, Entry: osutil.MountEntry{Name: "/source", Dir: "/target", Options: []string{"bind"}}} + synth, err := chg.Perform(s.as) + c.Assert(err, IsNil) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `unmount "/target" UMOUNT_NOFOLLOW`}, + + // Perform clean up after the unmount operation. + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "target" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 4}, + {C: `fstat 4 `, R: syscall.Stat_t{}}, + {C: `close 3`}, + {C: `fstatfs 4 `, R: syscall.Statfs_t{}}, + {C: `remove "/target"`}, + {C: `close 4`}, + }) + c.Assert(synth, HasLen, 0) +} + +// Change.Perform wants to unmount a directory bind mount but it fails. +func (s *changeSuite) TestPerformDirectoryBindUnmountError(c *C) { + s.sys.InsertFault(`unmount "/target" UMOUNT_NOFOLLOW`, errTesting) + chg := &update.Change{Action: update.Unmount, Entry: osutil.MountEntry{Name: "/source", Dir: "/target", Options: []string{"bind"}}} + synth, err := chg.Perform(s.as) + c.Assert(err, Equals, errTesting) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `unmount "/target" UMOUNT_NOFOLLOW`, E: errTesting}, + }) + c.Assert(synth, HasLen, 0) +} + +// ######################################### +// Topic: bind-mounting and unmounting files +// ######################################### + +// Change.Perform wants to bind mount a file but the target cannot be stat'ed. +func (s *changeSuite) TestPerformFileBindMountTargetLstatError(c *C) { + s.sys.InsertFault(`lstat "/target"`, errTesting) + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "/source", Dir: "/target", Options: []string{"bind", "x-snapd.kind=file"}}} + synth, err := chg.Perform(s.as) + c.Assert(err, ErrorMatches, `cannot inspect "/target": testing`) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/target"`, E: errTesting}, + }) +} + +// Change.Perform wants to bind mount a file but the source cannot be stat'ed. +func (s *changeSuite) TestPerformFileBindMountSourceLstatError(c *C) { + s.sys.InsertOsLstatResult(`lstat "/target"`, testutil.FileInfoFile) + s.sys.InsertFault(`lstat "/source"`, errTesting) + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "/source", Dir: "/target", Options: []string{"bind", "x-snapd.kind=file"}}} + synth, err := chg.Perform(s.as) + c.Assert(err, ErrorMatches, `cannot inspect "/source": testing`) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/target"`, R: testutil.FileInfoFile}, + {C: `lstat "/source"`, E: errTesting}, + }) +} + +// Change.Perform wants to bind mount a file. +func (s *changeSuite) TestPerformFileBindMount(c *C) { + s.sys.InsertOsLstatResult(`lstat "/source"`, testutil.FileInfoFile) + s.sys.InsertOsLstatResult(`lstat "/target"`, testutil.FileInfoFile) + s.sys.InsertFstatResult(`fstat 4 `, syscall.Stat_t{}) + s.sys.InsertFstatResult(`fstat 5 `, syscall.Stat_t{}) + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "/source", Dir: "/target", Options: []string{"bind", "x-snapd.kind=file"}}} + synth, err := chg.Perform(s.as) + c.Assert(err, IsNil) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/target"`, R: testutil.FileInfoFile}, + {C: `lstat "/source"`, R: testutil.FileInfoFile}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "source" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 4}, + {C: `fstat 4 `, R: syscall.Stat_t{}}, + {C: `close 3`}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "target" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 5}, + {C: `fstat 5 `, R: syscall.Stat_t{}}, + {C: `close 3`}, + {C: `mount "/proc/self/fd/4" "/proc/self/fd/5" "" MS_BIND ""`}, + {C: `close 5`}, + {C: `close 4`}, + }) +} + +// Change.Perform wants to bind mount a file but it fails. +func (s *changeSuite) TestPerformFileBindMountWithError(c *C) { + s.sys.InsertOsLstatResult(`lstat "/target"`, testutil.FileInfoFile) + s.sys.InsertOsLstatResult(`lstat "/source"`, testutil.FileInfoFile) + s.sys.InsertFstatResult(`fstat 4 `, syscall.Stat_t{}) + s.sys.InsertFstatResult(`fstat 5 `, syscall.Stat_t{}) + s.sys.InsertFault(`mount "/proc/self/fd/4" "/proc/self/fd/5" "" MS_BIND ""`, errTesting) + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "/source", Dir: "/target", Options: []string{"bind", "x-snapd.kind=file"}}} + synth, err := chg.Perform(s.as) + c.Assert(err, Equals, errTesting) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/target"`, R: testutil.FileInfoFile}, + {C: `lstat "/source"`, R: testutil.FileInfoFile}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "source" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 4}, + {C: `fstat 4 `, R: syscall.Stat_t{}}, + {C: `close 3`}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "target" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 5}, + {C: `fstat 5 `, R: syscall.Stat_t{}}, + {C: `close 3`}, + {C: `mount "/proc/self/fd/4" "/proc/self/fd/5" "" MS_BIND ""`, E: errTesting}, + {C: `close 5`}, + {C: `close 4`}, + }) +} + +// Change.Perform wants to bind mount a file but the mount point isn't there. +func (s *changeSuite) TestPerformFileBindMountWithoutMountPoint(c *C) { + defer s.as.MockUnrestrictedPaths("/")() // Treat test path as unrestricted. + s.sys.InsertOsLstatResult(`lstat "/source"`, testutil.FileInfoFile) + s.sys.InsertFault(`lstat "/target"`, syscall.ENOENT) + s.sys.InsertFstatResult(`fstat 4 `, syscall.Stat_t{}) + s.sys.InsertFstatResult(`fstat 5 `, syscall.Stat_t{}) + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "/source", Dir: "/target", Options: []string{"bind", "x-snapd.kind=file"}}} + synth, err := chg.Perform(s.as) + c.Assert(err, IsNil) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/target"`, E: syscall.ENOENT}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `openat 3 "target" O_NOFOLLOW|O_CLOEXEC|O_CREAT|O_EXCL 0755`, R: 4}, + {C: `fchown 4 0 0`}, + {C: `close 4`}, + {C: `close 3`}, + {C: `lstat "/source"`, R: testutil.FileInfoFile}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "source" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 4}, + {C: `fstat 4 `, R: syscall.Stat_t{}}, + {C: `close 3`}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "target" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 5}, + {C: `fstat 5 `, R: syscall.Stat_t{}}, + {C: `close 3`}, + {C: `mount "/proc/self/fd/4" "/proc/self/fd/5" "" MS_BIND ""`}, + {C: `close 5`}, + {C: `close 4`}, + }) +} + +// Change.Perform wants to create a directory bind mount but the mount point isn't there and cannot be created. +func (s *changeSuite) TestPerformFileBindMountWithoutMountPointWithErrors(c *C) { + defer s.as.MockUnrestrictedPaths("/")() // Treat test path as unrestricted. + s.sys.InsertFault(`lstat "/target"`, syscall.ENOENT) + s.sys.InsertFault(`openat 3 "target" O_NOFOLLOW|O_CLOEXEC|O_CREAT|O_EXCL 0755`, errTesting) + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "/source", Dir: "/target", Options: []string{"bind", "x-snapd.kind=file"}}} + synth, err := chg.Perform(s.as) + c.Assert(err, ErrorMatches, `cannot open file "/target": testing`) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/target"`, E: syscall.ENOENT}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `openat 3 "target" O_NOFOLLOW|O_CLOEXEC|O_CREAT|O_EXCL 0755`, E: errTesting}, + {C: `close 3`}, + }) +} + +// Change.Perform wants to bind mount a file but the mount source isn't there. +func (s *changeSuite) TestPerformFileBindMountWithoutMountSource(c *C) { + defer s.as.MockUnrestrictedPaths("/")() // Treat test path as unrestricted. + s.sys.InsertFault(`lstat "/source"`, syscall.ENOENT) + s.sys.InsertOsLstatResult(`lstat "/target"`, testutil.FileInfoFile) + s.sys.InsertFstatResult(`fstat 4 `, syscall.Stat_t{}) + s.sys.InsertFstatResult(`fstat 5 `, syscall.Stat_t{}) + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "/source", Dir: "/target", Options: []string{"bind", "x-snapd.kind=file"}}} + synth, err := chg.Perform(s.as) + c.Assert(err, IsNil) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/target"`, R: testutil.FileInfoFile}, + {C: `lstat "/source"`, E: syscall.ENOENT}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `openat 3 "source" O_NOFOLLOW|O_CLOEXEC|O_CREAT|O_EXCL 0755`, R: 4}, + {C: `fchown 4 0 0`}, + {C: `close 4`}, + {C: `close 3`}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "source" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 4}, + {C: `fstat 4 `, R: syscall.Stat_t{}}, + {C: `close 3`}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "target" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 5}, + {C: `fstat 5 `, R: syscall.Stat_t{}}, + {C: `close 3`}, + {C: `mount "/proc/self/fd/4" "/proc/self/fd/5" "" MS_BIND ""`}, + {C: `close 5`}, + {C: `close 4`}, + }) +} + +// Change.Perform wants to create a file bind mount but the mount source isn't there and cannot be created. +func (s *changeSuite) TestPerformFileBindMountWithoutMountSourceWithErrors(c *C) { + defer s.as.MockUnrestrictedPaths("/")() // Treat test path as unrestricted. + s.sys.InsertFault(`lstat "/source"`, syscall.ENOENT) + s.sys.InsertFault(`openat 3 "source" O_NOFOLLOW|O_CLOEXEC|O_CREAT|O_EXCL 0755`, errTesting) + s.sys.InsertOsLstatResult(`lstat "/target"`, testutil.FileInfoFile) + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "/source", Dir: "/target", Options: []string{"bind", "x-snapd.kind=file"}}} + synth, err := chg.Perform(s.as) + c.Assert(err, ErrorMatches, `cannot open file "/source": testing`) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/target"`, R: testutil.FileInfoFile}, + {C: `lstat "/source"`, E: syscall.ENOENT}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `openat 3 "source" O_NOFOLLOW|O_CLOEXEC|O_CREAT|O_EXCL 0755`, E: errTesting}, + {C: `close 3`}, + }) +} + +// Change.Perform wants to bind mount a file but the mount point isn't there and the parent is read-only. +func (s *changeSuite) TestPerformFileBindMountWithoutMountPointAndReadOnlyBase(c *C) { + defer s.as.MockUnrestrictedPaths("/")() // Treat test path as unrestricted. + s.sys.InsertFault(`lstat "/rofs/target"`, syscall.ENOENT) + s.sys.InsertFault(`mkdirat 3 "rofs" 0755`, syscall.EEXIST) + s.sys.InsertFault(`openat 4 "target" O_NOFOLLOW|O_CLOEXEC|O_CREAT|O_EXCL 0755`, syscall.EROFS, nil) // works on 2nd try + s.sys.InsertSysLstatResult(`lstat "/rofs" `, syscall.Stat_t{Uid: 0, Gid: 0, Mode: 0755}) + s.sys.InsertReadDirResult(`readdir "/rofs"`, nil) // pretend /rofs is empty. + s.sys.InsertFault(`lstat "/tmp/.snap/rofs"`, syscall.ENOENT) + s.sys.InsertOsLstatResult(`lstat "/rofs"`, testutil.FileInfoDir) + s.sys.InsertOsLstatResult(`lstat "/source"`, testutil.FileInfoFile) + s.sys.InsertFstatResult(`fstat 4 `, syscall.Stat_t{}) + s.sys.InsertFstatResult(`fstat 6 `, syscall.Stat_t{}) + s.sys.InsertFstatResult(`fstat 7 `, syscall.Stat_t{}) + s.sys.InsertFstatResult(`fstat 6 `, syscall.Stat_t{}) + s.sys.InsertFstatfsResult(`fstatfs 6 `, syscall.Statfs_t{}) + + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "/source", Dir: "/rofs/target", Options: []string{"bind", "x-snapd.kind=file"}}} + synth, err := chg.Perform(s.as) + c.Assert(err, IsNil) + c.Assert(synth, DeepEquals, []*update.Change{ + {Action: update.Mount, Entry: osutil.MountEntry{ + Name: "tmpfs", Dir: "/rofs", Type: "tmpfs", + Options: []string{"x-snapd.synthetic", "x-snapd.needed-by=/rofs/target", "mode=0755", "uid=0", "gid=0"}}, + }, + }) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + // sniff mount target + {C: `lstat "/rofs/target"`, E: syscall.ENOENT}, + + // /rofs/target is missing, create it + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `mkdirat 3 "rofs" 0755`, E: syscall.EEXIST}, + {C: `openat 3 "rofs" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `close 3`}, + {C: `openat 4 "target" O_NOFOLLOW|O_CLOEXEC|O_CREAT|O_EXCL 0755`, E: syscall.EROFS}, + {C: `close 4`}, + + // error, read only filesystem, create a mimic + {C: `lstat "/rofs" `, R: syscall.Stat_t{Mode: 0755}}, + {C: `readdir "/rofs"`, R: []fs.DirEntry(nil)}, + {C: `lstat "/tmp/.snap/rofs"`, E: syscall.ENOENT}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `mkdirat 3 "tmp" 0755`}, + {C: `openat 3 "tmp" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `fchown 4 0 0`}, + {C: `mkdirat 4 ".snap" 0755`}, + {C: `openat 4 ".snap" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 5}, + {C: `fchown 5 0 0`}, + {C: `close 4`}, + {C: `close 3`}, + {C: `mkdirat 5 "rofs" 0755`}, + {C: `openat 5 "rofs" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `fchown 3 0 0`}, + {C: `close 3`}, + {C: `close 5`}, + {C: `lstat "/rofs"`, R: testutil.FileInfoDir}, + + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "rofs" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 4}, + {C: `fstat 4 `, R: syscall.Stat_t{}}, + {C: `close 3`}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "tmp" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 5}, + {C: `openat 5 ".snap" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 6}, + {C: `openat 6 "rofs" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 7}, + {C: `fstat 7 `, R: syscall.Stat_t{}}, + {C: `close 6`}, + {C: `close 5`}, + {C: `close 3`}, + {C: `mount "/proc/self/fd/4" "/proc/self/fd/7" "" MS_BIND|MS_REC ""`}, + {C: `close 7`}, + {C: `close 4`}, + + {C: `lstat "/rofs"`, R: testutil.FileInfoDir}, + {C: `mount "tmpfs" "/rofs" "tmpfs" 0 "mode=0755,uid=0,gid=0"`}, + {C: `mount "none" "/tmp/.snap/rofs" "" MS_REC|MS_PRIVATE ""`}, + {C: `unmount "/tmp/.snap/rofs" UMOUNT_NOFOLLOW|MNT_DETACH`}, + + // Perform clean up after the unmount operation. + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "tmp" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 4}, + {C: `openat 4 ".snap" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 5}, + {C: `openat 5 "rofs" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 6}, + {C: `fstat 6 `, R: syscall.Stat_t{}}, + {C: `close 5`}, + {C: `close 4`}, + {C: `close 3`}, + {C: `fstatfs 6 `, R: syscall.Statfs_t{}}, + {C: `remove "/tmp/.snap/rofs"`}, + {C: `close 6`}, + + // mimic ready, re-try initial mkdir + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `mkdirat 3 "rofs" 0755`, E: syscall.EEXIST}, + {C: `openat 3 "rofs" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `close 3`}, + {C: `openat 4 "target" O_NOFOLLOW|O_CLOEXEC|O_CREAT|O_EXCL 0755`, R: 3}, + {C: `fchown 3 0 0`}, + {C: `close 3`}, + {C: `close 4`}, + + // sniff mount source + {C: `lstat "/source"`, R: testutil.FileInfoFile}, + + // mount the filesystem + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "source" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 4}, + {C: `fstat 4 `, R: syscall.Stat_t{}}, + {C: `close 3`}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "rofs" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 5}, + {C: `openat 5 "target" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 6}, + {C: `fstat 6 `, R: syscall.Stat_t{}}, + {C: `close 5`}, + {C: `close 3`}, + {C: `mount "/proc/self/fd/4" "/proc/self/fd/6" "" MS_BIND ""`}, + {C: `close 6`}, + {C: `close 4`}, + }) +} + +// Change.Perform wants to bind mount a file but there's a symlink in mount point. +func (s *changeSuite) TestPerformFileBindMountWithSymlinkInMountPoint(c *C) { + s.sys.InsertOsLstatResult(`lstat "/target"`, testutil.FileInfoSymlink) + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "/source", Dir: "/target", Options: []string{"bind", "x-snapd.kind=file"}}} + synth, err := chg.Perform(s.as) + c.Assert(err, ErrorMatches, `cannot use "/target" as mount point: not a regular file`) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/target"`, R: testutil.FileInfoSymlink}, + }) +} + +// Change.Perform wants to bind mount a file but there's a directory in mount point. +func (s *changeSuite) TestPerformBindMountFileWithDirectoryInMountPoint(c *C) { + s.sys.InsertOsLstatResult(`lstat "/target"`, testutil.FileInfoDir) + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "/source", Dir: "/target", Options: []string{"bind", "x-snapd.kind=file"}}} + synth, err := chg.Perform(s.as) + c.Assert(err, ErrorMatches, `cannot use "/target" as mount point: not a regular file`) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/target"`, R: testutil.FileInfoDir}, + }) +} + +// Change.Perform wants to bind mount a file but there's a symlink in source. +func (s *changeSuite) TestPerformFileBindMountWithSymlinkInMountSource(c *C) { + s.sys.InsertOsLstatResult(`lstat "/target"`, testutil.FileInfoFile) + s.sys.InsertOsLstatResult(`lstat "/source"`, testutil.FileInfoSymlink) + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "/source", Dir: "/target", Options: []string{"bind", "x-snapd.kind=file"}}} + synth, err := chg.Perform(s.as) + c.Assert(err, ErrorMatches, `cannot use "/source" as bind-mount source: not a regular file`) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/target"`, R: testutil.FileInfoFile}, + {C: `lstat "/source"`, R: testutil.FileInfoSymlink}, + }) +} + +// Change.Perform wants to bind mount a file but there's a directory in source. +func (s *changeSuite) TestPerformFileBindMountWithDirectoryInMountSource(c *C) { + s.sys.InsertOsLstatResult(`lstat "/target"`, testutil.FileInfoFile) + s.sys.InsertOsLstatResult(`lstat "/source"`, testutil.FileInfoDir) + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "/source", Dir: "/target", Options: []string{"bind", "x-snapd.kind=file"}}} + synth, err := chg.Perform(s.as) + c.Assert(err, ErrorMatches, `cannot use "/source" as bind-mount source: not a regular file`) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/target"`, R: testutil.FileInfoFile}, + {C: `lstat "/source"`, R: testutil.FileInfoDir}, + }) +} + +// Change.Perform wants to unmount a file bind mount made on empty squashfs placeholder. +func (s *changeSuite) TestPerformFileBindUnmountOnSquashfs(c *C) { + s.sys.InsertFstatfsResult(`fstatfs 4 `, syscall.Statfs_t{Type: update.SquashfsMagic}) + s.sys.InsertFstatResult(`fstat 4 `, syscall.Stat_t{Size: 0}) + chg := &update.Change{Action: update.Unmount, Entry: osutil.MountEntry{Name: "/source", Dir: "/target", Options: []string{"bind", "x-snapd.kind=file"}}} + synth, err := chg.Perform(s.as) + c.Assert(err, IsNil) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `unmount "/target" UMOUNT_NOFOLLOW`}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "target" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 4}, + {C: `fstat 4 `, R: syscall.Stat_t{}}, + {C: `close 3`}, + {C: `fstatfs 4 `, R: syscall.Statfs_t{Type: update.SquashfsMagic}}, + {C: `close 4`}, + }) + c.Assert(synth, HasLen, 0) +} + +// Change.Perform wants to unmount a file bind mount made on non-empty ext4 placeholder. +func (s *changeSuite) TestPerformFileBindUnmountOnExt4NonEmpty(c *C) { + s.sys.InsertFstatfsResult(`fstatfs 4 `, syscall.Statfs_t{Type: update.Ext4Magic}) + s.sys.InsertFstatResult(`fstat 4 `, syscall.Stat_t{Size: 1}) + chg := &update.Change{Action: update.Unmount, Entry: osutil.MountEntry{Name: "/source", Dir: "/target", Options: []string{"bind", "x-snapd.kind=file"}}} + synth, err := chg.Perform(s.as) + c.Assert(err, IsNil) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `unmount "/target" UMOUNT_NOFOLLOW`}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "target" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 4}, + {C: `fstat 4 `, R: syscall.Stat_t{Size: 1}}, + {C: `close 3`}, + {C: `fstatfs 4 `, R: syscall.Statfs_t{Type: update.Ext4Magic}}, + {C: `fstat 4 `, R: syscall.Stat_t{Size: 1}}, + {C: `close 4`}, + }) + c.Assert(synth, HasLen, 0) +} + +// Change.Perform wants to unmount a file bind mount made on empty tmpfs placeholder. +func (s *changeSuite) TestPerformFileBindUnmountOnTmpfsEmpty(c *C) { + s.sys.InsertFstatfsResult(`fstatfs 4 `, syscall.Statfs_t{Type: update.TmpfsMagic}) + s.sys.InsertFstatResult(`fstat 4 `, syscall.Stat_t{Size: 0}) + chg := &update.Change{Action: update.Unmount, Entry: osutil.MountEntry{Name: "/source", Dir: "/target", Options: []string{"bind", "x-snapd.kind=file"}}} + synth, err := chg.Perform(s.as) + c.Assert(err, IsNil) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `unmount "/target" UMOUNT_NOFOLLOW`}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "target" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 4}, + {C: `fstat 4 `, R: syscall.Stat_t{Size: 0}}, + {C: `close 3`}, + {C: `fstatfs 4 `, R: syscall.Statfs_t{Type: update.TmpfsMagic}}, + {C: `fstat 4 `, R: syscall.Stat_t{Size: 0}}, + {C: `remove "/target"`}, + {C: `close 4`}, + }) + c.Assert(synth, HasLen, 0) +} + +// Change.Perform wants to unmount a file bind mount made on empty tmpfs placeholder but it is busy!. +func (s *changeSuite) TestPerformFileBindUnmountOnTmpfsEmptyButBusy(c *C) { + restore := osutil.MockMountInfo("") + defer restore() + s.sys.InsertFstatfsResult(`fstatfs 4 `, syscall.Statfs_t{Type: update.TmpfsMagic}) + s.sys.InsertFstatResult(`fstat 4 `, syscall.Stat_t{Size: 0}) + s.sys.InsertFault(`remove "/target"`, syscall.EBUSY) + chg := &update.Change{Action: update.Unmount, Entry: osutil.MountEntry{Name: "/source", Dir: "/target", Options: []string{"bind", "x-snapd.kind=file"}}} + synth, err := chg.Perform(s.as) + c.Assert(err, IsNil) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `unmount "/target" UMOUNT_NOFOLLOW`}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "target" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 4}, + {C: `fstat 4 `, R: syscall.Stat_t{Size: 0}}, + {C: `close 3`}, + {C: `fstatfs 4 `, R: syscall.Statfs_t{Type: update.TmpfsMagic}}, + {C: `fstat 4 `, R: syscall.Stat_t{Size: 0}}, + {C: `remove "/target"`, E: syscall.EBUSY}, + {C: `close 4`}, + }) + c.Assert(synth, HasLen, 0) +} + +// Change.Perform wants to unmount a file bind mount but it fails. +func (s *changeSuite) TestPerformFileBindUnmountError(c *C) { + s.sys.InsertFault(`unmount "/target" UMOUNT_NOFOLLOW`, errTesting) + chg := &update.Change{Action: update.Unmount, Entry: osutil.MountEntry{Name: "/source", Dir: "/target", Options: []string{"bind", "x-snapd.kind=file"}}} + synth, err := chg.Perform(s.as) + c.Assert(err, Equals, errTesting) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `unmount "/target" UMOUNT_NOFOLLOW`, E: errTesting}, + }) + c.Assert(synth, HasLen, 0) +} + +// ############################################################# +// Topic: handling mounts with the x-snapd.ignore-missing option +// ############################################################# + +func (s *changeSuite) TestPerformMountWithIgnoredMissingMountSource(c *C) { + s.sys.InsertFault(`lstat "/source"`, syscall.ENOENT) + s.sys.InsertOsLstatResult(`lstat "/target"`, testutil.FileInfoDir) + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "/source", Dir: "/target", Options: []string{"bind", "x-snapd.ignore-missing"}}} + synth, err := chg.Perform(s.as) + c.Assert(err, Equals, update.ErrIgnoredMissingMount) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/target"`, R: testutil.FileInfoDir}, + {C: `lstat "/source"`, E: syscall.ENOENT}, + }) +} + +func (s *changeSuite) TestPerformMountWithIgnoredMissingMountPoint(c *C) { + s.sys.InsertFault(`lstat "/target"`, syscall.ENOENT) + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "/source", Dir: "/target", Options: []string{"bind", "x-snapd.ignore-missing"}}} + synth, err := chg.Perform(s.as) + c.Assert(err, Equals, update.ErrIgnoredMissingMount) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/target"`, E: syscall.ENOENT}, + }) +} + +// ######################## +// Topic: creating symlinks +// ######################## + +// Change.Perform wants to create a symlink but name cannot be stat'ed. +func (s *changeSuite) TestPerformCreateSymlinkNameLstatError(c *C) { + s.sys.InsertFault(`lstat "/name"`, errTesting) + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "unused", Dir: "/name", Options: []string{"x-snapd.kind=symlink", "x-snapd.symlink=/oldname"}}} + synth, err := chg.Perform(s.as) + c.Assert(err, ErrorMatches, `cannot inspect "/name": testing`) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/name"`, E: errTesting}, + }) +} + +// Change.Perform wants to create a symlink. +func (s *changeSuite) TestPerformCreateSymlink(c *C) { + defer s.as.MockUnrestrictedPaths("/")() // Treat test path as unrestricted. + s.sys.InsertFault(`lstat "/name"`, syscall.ENOENT) + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "unused", Dir: "/name", Options: []string{"x-snapd.kind=symlink", "x-snapd.symlink=/oldname"}}} + synth, err := chg.Perform(s.as) + c.Assert(err, IsNil) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/name"`, E: syscall.ENOENT}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `symlinkat "/oldname" 3 "name"`}, + {C: `close 3`}, + }) +} + +// Change.Perform wants to create a symlink but it fails. +func (s *changeSuite) TestPerformCreateSymlinkWithError(c *C) { + defer s.as.MockUnrestrictedPaths("/")() // Treat test path as unrestricted. + s.sys.InsertFault(`lstat "/name"`, syscall.ENOENT) + s.sys.InsertFault(`symlinkat "/oldname" 3 "name"`, errTesting) + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "unused", Dir: "/name", Options: []string{"x-snapd.kind=symlink", "x-snapd.symlink=/oldname"}}} + synth, err := chg.Perform(s.as) + c.Assert(err, ErrorMatches, `cannot create symlink "/name": testing`) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/name"`, E: syscall.ENOENT}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `symlinkat "/oldname" 3 "name"`, E: errTesting}, + {C: `close 3`}, + }) +} + +// Change.Perform wants to create a symlink but the target is empty. +func (s *changeSuite) TestPerformCreateSymlinkWithNoTargetError(c *C) { + s.sys.InsertFault(`lstat "/name"`, syscall.ENOENT) + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "unused", Dir: "/name", Options: []string{"x-snapd.kind=symlink", "x-snapd.symlink="}}} + synth, err := chg.Perform(s.as) + c.Assert(err, ErrorMatches, `cannot create symlink with empty target: "/name"`) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/name"`, E: syscall.ENOENT}, + }) +} + +// Change.Perform wants to create a symlink but the base directory isn't there. +func (s *changeSuite) TestPerformCreateSymlinkWithoutBaseDir(c *C) { + defer s.as.MockUnrestrictedPaths("/")() // Treat test path as unrestricted. + s.sys.InsertFault(`lstat "/base/name"`, syscall.ENOENT) + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "unused", Dir: "/base/name", Options: []string{"x-snapd.kind=symlink", "x-snapd.symlink=/oldname"}}} + synth, err := chg.Perform(s.as) + c.Assert(err, IsNil) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/base/name"`, E: syscall.ENOENT}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `mkdirat 3 "base" 0755`}, + {C: `openat 3 "base" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `fchown 4 0 0`}, + {C: `close 3`}, + {C: `symlinkat "/oldname" 4 "name"`}, + {C: `close 4`}, + }) +} + +// Change.Perform wants to create a symlink but the base directory isn't there and cannot be created. +func (s *changeSuite) TestPerformCreateSymlinkWithoutBaseDirWithErrors(c *C) { + defer s.as.MockUnrestrictedPaths("/")() // Treat test path as unrestricted. + s.sys.InsertFault(`lstat "/base/name"`, syscall.ENOENT) + s.sys.InsertFault(`mkdirat 3 "base" 0755`, errTesting) + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "unused", Dir: "/base/name", Options: []string{"x-snapd.kind=symlink", "x-snapd.symlink=/oldname"}}} + synth, err := chg.Perform(s.as) + c.Assert(err, ErrorMatches, `cannot create directory "/base": testing`) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/base/name"`, E: syscall.ENOENT}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `mkdirat 3 "base" 0755`, E: errTesting}, + {C: `close 3`}, + }) +} + +// Change.Perform wants to create a symlink but the base directory isn't there and the parent is read-only. +func (s *changeSuite) TestPerformCreateSymlinkWithoutBaseDirAndReadOnlyBase(c *C) { + defer s.as.MockUnrestrictedPaths("/")() // Treat test path as unrestricted. + s.sys.InsertFault(`lstat "/rofs/name"`, syscall.ENOENT) + s.sys.InsertFault(`mkdirat 3 "rofs" 0755`, syscall.EEXIST) + s.sys.InsertFault(`symlinkat "/oldname" 4 "name"`, syscall.EROFS, nil) // works on 2nd try + s.sys.InsertSysLstatResult(`lstat "/rofs" `, syscall.Stat_t{Uid: 0, Gid: 0, Mode: 0755}) + s.sys.InsertReadDirResult(`readdir "/rofs"`, nil) // pretend /rofs is empty. + s.sys.InsertFault(`lstat "/tmp/.snap/rofs"`, syscall.ENOENT) + s.sys.InsertOsLstatResult(`lstat "/rofs"`, testutil.FileInfoDir) + s.sys.InsertFstatResult(`fstat 4 `, syscall.Stat_t{}) + s.sys.InsertFstatResult(`fstat 7 `, syscall.Stat_t{}) + s.sys.InsertFstatResult(`fstat 6 `, syscall.Stat_t{}) + s.sys.InsertFstatfsResult(`fstatfs 6 `, syscall.Statfs_t{}) + + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "unused", Dir: "/rofs/name", Options: []string{"x-snapd.kind=symlink", "x-snapd.symlink=/oldname"}}} + synth, err := chg.Perform(s.as) + c.Assert(err, IsNil) + c.Assert(synth, DeepEquals, []*update.Change{ + {Action: update.Mount, Entry: osutil.MountEntry{ + Name: "tmpfs", Dir: "/rofs", Type: "tmpfs", + Options: []string{"x-snapd.synthetic", "x-snapd.needed-by=/rofs/name", "mode=0755", "uid=0", "gid=0"}}, + }, + }) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + // sniff symlink name + {C: `lstat "/rofs/name"`, E: syscall.ENOENT}, + + // create base name (/rofs) + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `mkdirat 3 "rofs" 0755`, E: syscall.EEXIST}, + {C: `openat 3 "rofs" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `close 3`}, + // create symlink + {C: `symlinkat "/oldname" 4 "name"`, E: syscall.EROFS}, + {C: `close 4`}, + + // error, read only filesystem, create a mimic + {C: `lstat "/rofs" `, R: syscall.Stat_t{Mode: 0755}}, + {C: `readdir "/rofs"`, R: []fs.DirEntry(nil)}, + {C: `lstat "/tmp/.snap/rofs"`, E: syscall.ENOENT}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `mkdirat 3 "tmp" 0755`}, + {C: `openat 3 "tmp" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `fchown 4 0 0`}, + {C: `mkdirat 4 ".snap" 0755`}, + {C: `openat 4 ".snap" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 5}, + {C: `fchown 5 0 0`}, + {C: `close 4`}, + {C: `close 3`}, + {C: `mkdirat 5 "rofs" 0755`}, + {C: `openat 5 "rofs" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `fchown 3 0 0`}, + {C: `close 3`}, + {C: `close 5`}, + {C: `lstat "/rofs"`, R: testutil.FileInfoDir}, + + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "rofs" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 4}, + {C: `fstat 4 `, R: syscall.Stat_t{}}, + {C: `close 3`}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "tmp" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 5}, + {C: `openat 5 ".snap" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 6}, + {C: `openat 6 "rofs" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 7}, + {C: `fstat 7 `, R: syscall.Stat_t{}}, + {C: `close 6`}, + {C: `close 5`}, + {C: `close 3`}, + {C: `mount "/proc/self/fd/4" "/proc/self/fd/7" "" MS_BIND|MS_REC ""`}, + {C: `close 7`}, + {C: `close 4`}, + + {C: `lstat "/rofs"`, R: testutil.FileInfoDir}, + {C: `mount "tmpfs" "/rofs" "tmpfs" 0 "mode=0755,uid=0,gid=0"`}, + {C: `mount "none" "/tmp/.snap/rofs" "" MS_REC|MS_PRIVATE ""`}, + {C: `unmount "/tmp/.snap/rofs" UMOUNT_NOFOLLOW|MNT_DETACH`}, + + // Perform clean up after the unmount operation. + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "tmp" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 4}, + {C: `openat 4 ".snap" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 5}, + {C: `openat 5 "rofs" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 6}, + {C: `fstat 6 `, R: syscall.Stat_t{}}, + {C: `close 5`}, + {C: `close 4`}, + {C: `close 3`}, + {C: `fstatfs 6 `, R: syscall.Statfs_t{}}, + {C: `remove "/tmp/.snap/rofs"`}, + {C: `close 6`}, + + // mimic ready, re-try initial base mkdir + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `mkdirat 3 "rofs" 0755`, E: syscall.EEXIST}, + {C: `openat 3 "rofs" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `close 3`}, + // create symlink + {C: `symlinkat "/oldname" 4 "name"`}, + {C: `close 4`}, + }) +} + +// Change.Perform wants to create a symlink but there's a file in the way. +func (s *changeSuite) TestPerformCreateSymlinkWithFileInTheWay(c *C) { + s.sys.InsertOsLstatResult(`lstat "/name"`, testutil.FileInfoFile) + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "unused", Dir: "/name", Options: []string{"x-snapd.kind=symlink", "x-snapd.symlink=/oldname"}}} + synth, err := chg.Perform(s.as) + c.Assert(err, ErrorMatches, `cannot create symlink in "/name": existing file in the way`) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/name"`, R: testutil.FileInfoFile}, + }) +} + +// Change.Perform wants to create a symlink but a correct symlink is already present. +func (s *changeSuite) TestPerformCreateSymlinkWithGoodSymlinkPresent(c *C) { + defer s.as.MockUnrestrictedPaths("/")() // Treat test path as unrestricted. + s.sys.InsertOsLstatResult(`lstat "/name"`, testutil.FileInfoSymlink) + s.sys.InsertFault(`symlinkat "/oldname" 3 "name"`, syscall.EEXIST) + s.sys.InsertFstatResult(`fstat 4 `, syscall.Stat_t{Mode: syscall.S_IFLNK}) + s.sys.InsertReadlinkatResult(`readlinkat 4 "" `, "/oldname") + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "unused", Dir: "/name", Options: []string{"x-snapd.kind=symlink", "x-snapd.symlink=/oldname"}}} + synth, err := chg.Perform(s.as) + c.Assert(err, IsNil) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/name"`, R: testutil.FileInfoSymlink}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `symlinkat "/oldname" 3 "name"`, E: syscall.EEXIST}, + {C: `openat 3 "name" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 4}, + {C: `fstat 4 `, R: syscall.Stat_t{Mode: syscall.S_IFLNK}}, + {C: `readlinkat 4 "" `, R: "/oldname"}, + {C: `close 4`}, + {C: `close 3`}, + }) +} + +// Change.Perform wants to create a symlink but a incorrect symlink is already present. +func (s *changeSuite) TestPerformCreateSymlinkWithBadSymlinkPresent(c *C) { + defer s.as.MockUnrestrictedPaths("/")() // Treat test path as unrestricted. + s.sys.InsertOsLstatResult(`lstat "/name"`, testutil.FileInfoSymlink) + s.sys.InsertFault(`symlinkat "/oldname" 3 "name"`, syscall.EEXIST) + s.sys.InsertFstatResult(`fstat 4 `, syscall.Stat_t{Mode: syscall.S_IFLNK}) + s.sys.InsertReadlinkatResult(`readlinkat 4 "" `, "/evil") + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "unused", Dir: "/name", Options: []string{"x-snapd.kind=symlink", "x-snapd.symlink=/oldname"}}} + synth, err := chg.Perform(s.as) + c.Assert(err, ErrorMatches, `cannot create symbolic link "/name": existing symbolic link in the way`) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/name"`, R: testutil.FileInfoSymlink}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `symlinkat "/oldname" 3 "name"`, E: syscall.EEXIST}, + {C: `openat 3 "name" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 4}, + {C: `fstat 4 `, R: syscall.Stat_t{Mode: syscall.S_IFLNK}}, + {C: `readlinkat 4 "" `, R: "/evil"}, + {C: `close 4`}, + {C: `close 3`}, + }) +} + +func (s *changeSuite) TestPerformRemoveSymlink(c *C) { + chg := &update.Change{Action: update.Unmount, Entry: osutil.MountEntry{Name: "unused", Dir: "/name", Options: []string{"x-snapd.kind=symlink", "x-snapd.symlink=/oldname"}}} + synth, err := chg.Perform(s.as) + c.Assert(err, IsNil) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `remove "/name"`}, + }) +} + +// Change.Perform wants to create a symlink in /etc and the write is made private. +func (s *changeSuite) TestPerformCreateSymlinkWithAvoidedTrespassing(c *C) { + defer s.as.MockUnrestrictedPaths("/tmp/")() // Allow writing to /tmp + + s.sys.InsertFault(`lstat "/etc/demo.conf"`, syscall.ENOENT) + s.sys.InsertFstatfsResult(`fstatfs 3 `, syscall.Statfs_t{Type: update.SquashfsMagic}) + s.sys.InsertFstatResult(`fstat 3 `, syscall.Stat_t{}) + s.sys.InsertFault(`mkdirat 3 "etc" 0755`, syscall.EEXIST) + s.sys.InsertFstatfsResult(`fstatfs 4 `, + // On 1st call ext4, on 2nd call tmpfs + syscall.Statfs_t{Type: update.Ext4Magic}, + syscall.Statfs_t{Type: update.TmpfsMagic}) + s.sys.InsertFstatResult(`fstat 4 `, syscall.Stat_t{}) + s.sys.InsertSysLstatResult(`lstat "/etc" `, syscall.Stat_t{Mode: 0755}) + otherConf := testutil.FakeDirEntry("other.conf", 0755) + s.sys.InsertReadDirResult(`readdir "/etc"`, []fs.DirEntry{otherConf}) + s.sys.InsertFault(`lstat "/tmp/.snap/etc"`, syscall.ENOENT) + s.sys.InsertFault(`lstat "/tmp/.snap/etc/other.conf"`, syscall.ENOENT) + s.sys.InsertOsLstatResult(`lstat "/etc"`, testutil.FileInfoDir) + otherConfInfo, err := otherConf.Info() + c.Assert(err, IsNil) + s.sys.InsertOsLstatResult(`lstat "/etc/other.conf"`, otherConfInfo) + s.sys.InsertFault(`mkdirat 3 "tmp" 0755`, syscall.EEXIST) + s.sys.InsertFstatResult(`fstat 5 `, syscall.Stat_t{Mode: syscall.S_IFREG}) + s.sys.InsertFstatResult(`fstat 4 `, syscall.Stat_t{Mode: syscall.S_IFDIR}) + s.sys.InsertFstatResult(`fstat 7 `, syscall.Stat_t{Mode: syscall.S_IFDIR}) + s.sys.InsertFstatResult(`fstat 6 `, syscall.Stat_t{}) + s.sys.InsertFstatfsResult(`fstatfs 6 `, syscall.Statfs_t{}) + + // This is the change we want to perform: + // put a layout symlink at /etc/demo.conf -> /oldname + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "unused", Dir: "/etc/demo.conf", Options: []string{"x-snapd.kind=symlink", "x-snapd.symlink=/oldname"}}} + synth, err := chg.Perform(s.as) + c.Check(err, IsNil) + c.Check(synth, HasLen, 2) + // We have created some synthetic change (made /etc a new tmpfs and re-populate it) + c.Assert(synth[0], DeepEquals, &update.Change{ + Entry: osutil.MountEntry{Name: "tmpfs", Dir: "/etc", Type: "tmpfs", Options: []string{"x-snapd.synthetic", "x-snapd.needed-by=/etc/demo.conf", "mode=0755", "uid=0", "gid=0"}}, + Action: "mount"}) + c.Assert(synth[1], DeepEquals, &update.Change{ + Entry: osutil.MountEntry{Name: "/etc/other.conf", Dir: "/etc/other.conf", Options: []string{"bind", "x-snapd.kind=file", "x-snapd.synthetic", "x-snapd.needed-by=/etc/demo.conf"}}, + Action: "mount"}) + + // And this is exactly how we made that happen: + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + // Attempt to construct a symlink /etc/demo.conf -> /oldname. + // This stops as soon as we notice that /etc is an ext4 filesystem. + // To avoid writing to it directly we need a writable mimic. + {C: `lstat "/etc/demo.conf"`, E: syscall.ENOENT}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `fstatfs 3 `, R: syscall.Statfs_t{Type: update.SquashfsMagic}}, + {C: `fstat 3 `, R: syscall.Stat_t{}}, + {C: `mkdirat 3 "etc" 0755`, E: syscall.EEXIST}, + {C: `openat 3 "etc" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `close 3`}, + {C: `fstatfs 4 `, R: syscall.Statfs_t{Type: update.Ext4Magic}}, + {C: `fstat 4 `, R: syscall.Stat_t{Mode: 0x4000}}, + {C: `close 4`}, + + // Create a writable mimic over /etc, scan the contents of /etc first. + // For convenience we pretend that /etc is empty. The mimic + // replicates /etc in /tmp/.snap/etc for subsequent re-construction. + {C: `lstat "/etc" `, R: syscall.Stat_t{Mode: 0755}}, + {C: `readdir "/etc"`, R: []fs.DirEntry{otherConf}}, + {C: `lstat "/tmp/.snap/etc"`, E: syscall.ENOENT}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `mkdirat 3 "tmp" 0755`, E: syscall.EEXIST}, + {C: `openat 3 "tmp" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `mkdirat 4 ".snap" 0755`}, + {C: `openat 4 ".snap" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 5}, + {C: `fchown 5 0 0`}, + {C: `close 4`}, + {C: `close 3`}, + {C: `mkdirat 5 "etc" 0755`}, + {C: `openat 5 "etc" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `fchown 3 0 0`}, + {C: `close 3`}, + {C: `close 5`}, + + // Prepare a secure bind mount operation /etc -> /tmp/.snap/etc + {C: `lstat "/etc"`, R: testutil.FileInfoDir}, + + // Open an O_PATH descriptor to /etc. We need this as a source of a + // secure bind mount operation. We also ensure that the descriptor + // refers to a directory. + // NOTE: we keep fd 4 open for subsequent use. + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "etc" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 4}, + {C: `fstat 4 `, R: syscall.Stat_t{Mode: syscall.S_IFDIR}}, + {C: `close 3`}, + + // Open an O_PATH descriptor to /tmp/.snap/etc. We need this as a + // target of a secure bind mount operation. We also ensure that the + // descriptor refers to a directory. + // NOTE: we keep fd 7 open for subsequent use. + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "tmp" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 5}, + {C: `openat 5 ".snap" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 6}, + {C: `openat 6 "etc" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 7}, + {C: `fstat 7 `, R: syscall.Stat_t{Mode: syscall.S_IFDIR}}, + {C: `close 6`}, + {C: `close 5`}, + {C: `close 3`}, + + // Perform the secure bind mount operation /etc -> /tmp/.snap/etc + // and release the two associated file descriptors. + {C: `mount "/proc/self/fd/4" "/proc/self/fd/7" "" MS_BIND|MS_REC ""`}, + {C: `close 7`}, + {C: `close 4`}, + + // Mount a tmpfs over /etc, re-constructing the original mode and + // ownership. Bind mount each original file over and detach the copy + // of /etc we had in /tmp/.snap/etc. + + {C: `lstat "/etc"`, R: testutil.FileInfoDir}, + {C: `mount "tmpfs" "/etc" "tmpfs" 0 "mode=0755,uid=0,gid=0"`}, + // Here we restore the contents of /etc: here it's just one file - other.conf + {C: `lstat "/etc/other.conf"`, R: otherConfInfo}, + {C: `lstat "/tmp/.snap/etc/other.conf"`, E: syscall.ENOENT}, + + // Create /tmp/.snap/etc/other.conf as an empty file. + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `mkdirat 3 "tmp" 0755`, E: syscall.EEXIST}, + {C: `openat 3 "tmp" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `mkdirat 4 ".snap" 0755`}, + {C: `openat 4 ".snap" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 5}, + {C: `fchown 5 0 0`}, + {C: `mkdirat 5 "etc" 0755`}, + {C: `openat 5 "etc" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 6}, + {C: `fchown 6 0 0`}, + {C: `close 5`}, + {C: `close 4`}, + {C: `close 3`}, + // NOTE: This is without O_DIRECTORY and with O_CREAT|O_EXCL, + // we are creating an empty file for the subsequent bind mount. + {C: `openat 6 "other.conf" O_NOFOLLOW|O_CLOEXEC|O_CREAT|O_EXCL 0755`, R: 3}, + {C: `fchown 3 0 0`}, + {C: `close 3`}, + {C: `close 6`}, + + // Open O_PATH to /tmp/.snap/etc/other.conf + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "tmp" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 4}, + {C: `openat 4 ".snap" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 5}, + {C: `openat 5 "etc" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 6}, + {C: `openat 6 "other.conf" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 7}, + {C: `fstat 7 `, R: syscall.Stat_t{Mode: syscall.S_IFDIR}}, + {C: `close 6`}, + {C: `close 5`}, + {C: `close 4`}, + {C: `close 3`}, + + // Open O_PATH to /etc/other.conf + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "etc" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 4}, + {C: `openat 4 "other.conf" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 5}, + {C: `fstat 5 `, R: syscall.Stat_t{Mode: syscall.S_IFREG}}, + {C: `close 4`}, + {C: `close 3`}, + + // Restore the /etc/other.conf file with a secure bind mount. + {C: `mount "/proc/self/fd/7" "/proc/self/fd/5" "" MS_BIND ""`}, + {C: `close 5`}, + {C: `close 7`}, + + // We're done restoring now. + {C: `mount "none" "/tmp/.snap/etc" "" MS_REC|MS_PRIVATE ""`}, + {C: `unmount "/tmp/.snap/etc" UMOUNT_NOFOLLOW|MNT_DETACH`}, + + // Perform clean up after the unmount operation. + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "tmp" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 4}, + {C: `openat 4 ".snap" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 5}, + {C: `openat 5 "etc" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 6}, + {C: `fstat 6 `, R: syscall.Stat_t{}}, + {C: `close 5`}, + {C: `close 4`}, + {C: `close 3`}, + {C: `fstatfs 6 `, R: syscall.Statfs_t{}}, + {C: `remove "/tmp/.snap/etc"`}, + {C: `close 6`}, + + // The mimic is now complete and subsequent writes to /etc are private + // to the mount namespace of the process. + + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `fstatfs 3 `, R: syscall.Statfs_t{Type: update.SquashfsMagic}}, + {C: `fstat 3 `, R: syscall.Stat_t{}}, + {C: `mkdirat 3 "etc" 0755`, E: syscall.EEXIST}, + {C: `openat 3 "etc" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `close 3`}, + {C: `fstatfs 4 `, R: syscall.Statfs_t{Type: update.TmpfsMagic}}, + {C: `fstat 4 `, R: syscall.Stat_t{Mode: 0x4000}}, + {C: `symlinkat "/oldname" 4 "demo.conf"`}, + {C: `close 4`}, + }) +} + +// Change.Perform wants to remove a directory which is a bind mount of ext4 from onto squashfs. +func (s *changeSuite) TestPerformRmdirOnExt4OnSquashfs(c *C) { + defer s.as.MockUnrestrictedPaths("/tmp/")() // Allow writing to /tmp + + s.sys.InsertFstatResult(`fstat 4 `, syscall.Stat_t{}) + // Pretend that /root is an ext4 bind mount from somewhere. + s.sys.InsertFstatfsResult(`fstatfs 4 `, syscall.Statfs_t{Type: update.Ext4Magic}) + // Pretend that removing /root returns EROFS (it really can!). + s.sys.InsertFault(`remove "/root"`, syscall.EROFS) + + // This is the change we want to perform: + // - unmount a layout from /root + chg := &update.Change{Action: update.Unmount, Entry: osutil.MountEntry{Name: "unused", Dir: "/root", Options: []string{"x-snapd.origin=layout"}}} + synth, err := chg.Perform(s.as) + // The change succeeded even though we were unable to remove the /root + // directory because it is backed by a squashfs, which is not modelled by + // this test but is modelled by the integration test. + c.Check(err, IsNil) + c.Check(synth, HasLen, 0) + + // And this is exactly how we made that happen: + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `unmount "/root" UMOUNT_NOFOLLOW`}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "root" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 4}, + {C: `fstat 4 `, R: syscall.Stat_t{}}, + {C: `close 3`}, + {C: `fstatfs 4 `, R: syscall.Statfs_t{Type: update.Ext4Magic}}, + {C: `remove "/root"`, E: syscall.EROFS}, + {C: `close 4`}, + }) +} + +// ######################## +// Topic: ensuring dirs +// ######################## + +// Change.Perform wants to ensure a directory but name cannot be stat'ed +func (s *changeSuite) TestPerformEnsureDirNameLstatError(c *C) { + s.sys.InsertFault(`lstat "/home/user/.local/share/missing"`, errTesting) + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{ + Name: "unused", + Dir: "/home/user/.local/share/missing", + Options: []string{"x-snapd.kind=ensure-dir", "x-snapd.must-exist-dir=/home/user"}, + }} + synth, err := chg.Perform(s.as) + c.Assert(err, ErrorMatches, `cannot inspect "/home/user/.local/share/missing": testing`) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + // Change.ensureTarget osLstat fails + {C: `lstat "/home/user/.local/share/missing"`, E: errTesting}, + }) +} + +// Change.Perform wants to ensure a directory but there's a file in the way of the target +func (s *changeSuite) TestPerformEnsureDirFileInTheWayOfTarget(c *C) { + s.sys.InsertOsLstatResult(`lstat "/home/user/.local/share/missing"`, testutil.FileInfoFile) + + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{ + Name: "unused", + Dir: "/home/user/.local/share/missing", + Options: []string{"x-snapd.kind=ensure-dir", "x-snapd.must-exist-dir=/home/user"}, + }} + synth, err := chg.Perform(s.as) + c.Assert(err, ErrorMatches, `cannot create ensure-dir target "/home/user/.local/share/missing": existing file in the way`) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + // Change.ensureTarget osLstat succeeds and file is not a directory + {C: `lstat "/home/user/.local/share/missing"`, R: testutil.FileInfoFile}, + }) +} + +// Change.Perform wants to ensure a directory with must-exist-dir missing +func (s *changeSuite) TestPerformEnsureDirMustExistDirMissing(c *C) { + s.sys.InsertFault(`lstat "/home/user/missing"`, syscall.ENOENT) + s.sys.InsertFault(`lstat "/home/user"`, syscall.ENOENT) + + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{ + Name: "unused", + Dir: "/home/user/missing", + Options: []string{"x-snapd.kind=ensure-dir", "x-snapd.must-exist-dir=/home/user"}, + }} + + synth, err := chg.Perform(s.as) + c.Assert(err, ErrorMatches, `parent directory "/home/user" does not exist`) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + // Change.ensureTarget osLstat succeeds and file does not exist, resulting in call to Change.createPath + {C: `lstat "/home/user/missing"`, E: syscall.ENOENT}, + // Change.createPath case "ensure-dir" with uid != 0 resulting in call to utils.MkdirAllWithin + // utils.MkdirAllWithin checks if "/home/user/missing" is missing + {C: `lstat "/home/user/missing"`, E: syscall.ENOENT}, + // utils.MkdirAllWithin checks if must-exist-dir "/home/user" exists + {C: `lstat "/home/user"`, E: syscall.ENOENT}, + }) +} + +// Change.Perform wants to ensure a directory but there's a file in the way of a parent +func (s *changeSuite) TestPerformEnsureDirFileInTheWayOfParent(c *C) { + s.sys.InsertFault(`lstat "/home/user/missing"`, syscall.ENOENT) + s.sys.InsertOsLstatResult(`lstat "/home/user"`, testutil.FileInfoFile) + + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{ + Name: "unused", + Dir: "/home/user/missing", + Options: []string{"x-snapd.kind=ensure-dir", "x-snapd.must-exist-dir=/home/user"}, + }} + synth, err := chg.Perform(s.as) + c.Assert(err, ErrorMatches, `cannot use parent path "/home/user": not a directory`) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + // Change.ensureTarget osLstat succeeds and file does not exist, resulting in call to Change.createPath + {C: `lstat "/home/user/missing"`, E: syscall.ENOENT}, + // Change.createPath case "ensure-dir" with uid != 0 resulting in call to utils.MkdirAllWithin + // utils.MkdirAllWithin checks if "/home/user/missing" is missing + {C: `lstat "/home/user/missing"`, E: syscall.ENOENT}, + // utils.MkdirAllWithin checks if must-exist-dir "/home/user" exists + {C: `lstat "/home/user"`, R: testutil.FileInfoFile}, + }) +} + +// Change.Perform wants to ensure a directory but fails with an error +func (s *changeSuite) TestPerformEnsureDirError(c *C) { + s.sys.InsertFault(`lstat "/home/user/missing"`, syscall.ENOENT) + s.sys.InsertFault(`lstat "/home/user"`, errTesting) + + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{ + Name: "unused", + Dir: "/home/user/missing", + Options: []string{"x-snapd.kind=ensure-dir", "x-snapd.must-exist-dir=/home/user"}, + }} + synth, err := chg.Perform(s.as) + c.Assert(err, ErrorMatches, `cannot inspect parent path "/home/user": testing`) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + // Change.ensureTarget osLstat succeeds and file does not exist, resulting in call to Change.createPath + {C: `lstat "/home/user/missing"`, E: syscall.ENOENT}, + // Change.createPath case "ensure-dir" with uid != 0 resulting in call to utils.MkdirAllWithin + // utils.MkdirAllWithin checks if "/home/user/missing" is missing + {C: `lstat "/home/user/missing"`, E: syscall.ENOENT}, + // utils.MkdirAllWithin checks if must-exist-dir "/home/user" exists + {C: `lstat "/home/user"`, E: errTesting}, + }) +} + +// Change.Perform wants to ensure a directory (scenario 1) +// Scenario: MustExistDir /home/user exists, but child directories .local, .local/share and .local/share/missing does not +func (s *changeSuite) TestPerformEnsureDirScenario1(c *C) { + // Allow writing to /home/user + defer s.as.MockUnrestrictedPaths("/home/user")() + + s.sys.InsertFault(`lstat "/home/user/.local/share/missing"`, syscall.ENOENT) + s.sys.InsertFault(`lstat "/home/user/.local"`, syscall.ENOENT) + s.sys.InsertOsLstatResult(`lstat "/home/user"`, testutil.FileInfoDir) + + restoreGetuid := update.MockGetuid(func() sys.UserID { + return 1000 + }) + defer restoreGetuid() + + restoreGetgid := update.MockGetgid(func() sys.GroupID { + return 1000 + }) + defer restoreGetgid() + + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{ + Name: "unused", + Dir: "/home/user/.local/share/missing", + Options: []string{"x-snapd.kind=ensure-dir", "x-snapd.must-exist-dir=/home/user"}, + }} + synth, err := chg.Perform(s.as) + c.Assert(err, IsNil) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + // Change.ensureTarget osLstat succeeds and file does not exist, resulting in call to Change.createPath + {C: `lstat "/home/user/.local/share/missing"`, E: syscall.ENOENT}, + // Change.createPath case "ensure-dir" with uid != 0 resulting in call to utils.MkdirAllWithin + // utils.MkdirAllWithin checks if "/home/user/.local/share/missing" is missing + {C: `lstat "/home/user/.local/share/missing"`, E: syscall.ENOENT}, + // utils.MkdirAllWithin checks if must-exist-dir "/home/user" exists + {C: `lstat "/home/user"`, R: testutil.FileInfoDir, E: nil}, + // utils.MkdirAllWithin interates to find the first missing directory, in this case "/home/user/.local" + {C: `lstat "/home/user/.local"`, E: syscall.ENOENT}, + // utils.MkdirAllWithin opens must-exist-dir "/home/user" and calls utils.Mkdir + {C: `open "/home/user" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + // utils.Mkdir creates missing directory ".local" + {C: `mkdirat 3 ".local" 0700`}, + {C: `openat 3 ".local" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `fchown 4 1000 1000`}, + // utils.MkdirAllWithin iterates through the remaining missing dirs "share/missing" + // and calls utils.Mkdir to create them + {C: `mkdirat 4 "share" 0700`}, + {C: `openat 4 "share" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 5}, + {C: `fchown 5 1000 1000`}, + {C: `mkdirat 5 "missing" 0700`}, + {C: `openat 5 "missing" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 6}, + {C: `fchown 6 1000 1000`}, + // Closing of file descriptors in reverse order + {C: `close 6`}, + {C: `close 5`}, + {C: `close 4`}, + {C: `close 3`}, + }) +} + +// Change.Perform wants to ensure a directory (scenario 2) +// Scenario: MustExistDir /home/user and child directories .local and .local/share exists, but .local/share/missing does not +func (s *changeSuite) TestPerformEnsureDirScenario2(c *C) { + // Allow writing to /home/user + defer s.as.MockUnrestrictedPaths("/home/user")() + + s.sys.InsertFault(`lstat "/home/user/.local/share/missing"`, syscall.ENOENT) + s.sys.InsertOsLstatResult(`lstat "/home/user/.local/share"`, testutil.FileInfoDir) + s.sys.InsertOsLstatResult(`lstat "/home/user/.local"`, testutil.FileInfoDir) + s.sys.InsertOsLstatResult(`lstat "/home/user"`, testutil.FileInfoDir) + + restoreGetuid := update.MockGetuid(func() sys.UserID { + return 1000 + }) + defer restoreGetuid() + + restoreGetgid := update.MockGetgid(func() sys.GroupID { + return 1000 + }) + defer restoreGetgid() + + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{ + Name: "unused", + Dir: "/home/user/.local/share/missing", + Options: []string{"x-snapd.kind=ensure-dir", "x-snapd.must-exist-dir=/home/user"}, + }} + synth, err := chg.Perform(s.as) + c.Assert(err, IsNil) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + // Change.ensureTarget osLstat succeeds and file does not exist, resulting in call to Change.createPath + {C: `lstat "/home/user/.local/share/missing"`, E: syscall.ENOENT}, + // Change.createPath case "ensure-dir" with uid != 0 resulting in call to utils.MkdirAllWithin + // utils.MkdirAllWithin checks if "/home/user/.local/share/missing" is missing + {C: `lstat "/home/user/.local/share/missing"`, E: syscall.ENOENT}, + // utils.MkdirAllWithin checks if must-exist-dir "/home/user" exists + {C: `lstat "/home/user"`, R: testutil.FileInfoDir, E: nil}, + // utils.MkdirAllWithin interates to find the first missing directory, but does not check target + // dir "/home/user/.local/share/missing", because at this point it is already confirmed missing + {C: `lstat "/home/user/.local"`, R: testutil.FileInfoDir}, + {C: `lstat "/home/user/.local/share"`, R: testutil.FileInfoDir}, + // utils.MkdirAllWithin opens "/home/user/.local/share" and calls utils.Mkdir + {C: `open "/home/user/.local/share" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + // utils.Mkdir creates missing directory "missing" + {C: `mkdirat 3 "missing" 0700`}, + {C: `openat 3 "missing" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `fchown 4 1000 1000`}, + // Closing of file descriptors in reverse order + {C: `close 4`}, + {C: `close 3`}, + }) +} + +// ########### +// Topic: misc +// ########### + +// Change.Perform handles unknown actions. +func (s *changeSuite) TestPerformUnknownAction(c *C) { + chg := &update.Change{Action: update.Action("42")} + synth, err := chg.Perform(s.as) + c.Assert(err, ErrorMatches, `cannot process mount change: unknown action: .*`) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), HasLen, 0) +} + +// Change.Perform wants to keep a mount entry unchanged. +func (s *changeSuite) TestPerformKeep(c *C) { + chg := &update.Change{Action: update.Keep} + synth, err := chg.Perform(s.as) + c.Assert(err, IsNil) + c.Assert(synth, HasLen, 0) + c.Assert(s.sys.RCalls(), HasLen, 0) +} + +// ############################################ +// Topic: change history tracked in Assumptions +// ############################################ + +func (s *changeSuite) TestPerformedChangesAreTracked(c *C) { + s.sys.InsertOsLstatResult(`lstat "/target"`, testutil.FileInfoDir) + c.Assert(s.as.PastChanges(), HasLen, 0) + + chg := &update.Change{Action: update.Mount, Entry: osutil.MountEntry{Name: "device", Dir: "/target", Type: "type"}} + _, err := chg.Perform(s.as) + c.Assert(err, IsNil) + c.Assert(s.as.PastChanges(), DeepEquals, []*update.Change{ + {Action: update.Mount, Entry: osutil.MountEntry{Name: "device", Dir: "/target", Type: "type"}}, + }) + + s.sys.InsertFstatResult(`fstat 4 `, syscall.Stat_t{}) + s.sys.InsertFstatfsResult(`fstatfs 4 `, syscall.Statfs_t{}) + chg = &update.Change{Action: update.Unmount, Entry: osutil.MountEntry{Name: "device", Dir: "/target", Type: "type"}} + _, err = chg.Perform(s.as) + c.Assert(err, IsNil) + + chg = &update.Change{Action: update.Keep, Entry: osutil.MountEntry{Name: "tmpfs", Dir: "/target", Type: "tmpfs"}} + _, err = chg.Perform(s.as) + c.Assert(err, IsNil) + c.Assert(s.as.PastChanges(), DeepEquals, []*update.Change{ + // past changes stack in order. + {Action: update.Mount, Entry: osutil.MountEntry{Name: "device", Dir: "/target", Type: "type"}}, + {Action: update.Unmount, Entry: osutil.MountEntry{Name: "device", Dir: "/target", Type: "type"}}, + {Action: update.Keep, Entry: osutil.MountEntry{Name: "tmpfs", Dir: "/target", Type: "tmpfs"}}, + }) +} + +func (s *changeSuite) TestComplexPropagatingChanges(c *C) { + // This problem is more subtle. It is a variant of the regression test + // implemented in tests/regression/lp-1831010. Here, we have four directories: + // + // - $SNAP/a + // - $SNAP/b + // - $SNAP/b/c + // - $SNAP/d + // + // but snapd's mount profile contains only two entries: + // + // 1) recursive-bind $SNAP/a -> $SNAP/b/c (ie, mount --rbind $SNAP/a $SNAP/b/c) + // 2) recursive-bind $SNAP/b -> $SNAP/d (ie, mount --rbind $SNAP/b $SNAP/d) + // + // Both mount operations are performed under a substrate that is MS_SHARED. + // Therefore, due to the rules that decide upon propagation of bind mounts + // the propagation of the new mount entries is also shared. This is + // documented in section 5b of + // https://www.kernel.org/doc/Documentation/filesystems/sharedsubtree.txt. + // + // Interactive experimentation shows that the following three mount points exist + // after this operation, as illustrated by findmnt: + // + // TARGET SOURCE FSTYPE OPTIONS + // ... + // └─/snap/test-snapd-layout/x1 /dev/loop1 squashfs ro,nodev,relatime + // ├─/snap/test-snapd-layout/x1/b/c /dev/loop1[/a] squashfs ro,nodev,relatime + // └─/snap/test-snapd-layout/x1/d /dev/loop1[/b] squashfs ro,nodev,relatime + // └─/snap/test-snapd-layout/x1/d/c /dev/loop1[/a] squashfs ro,nodev,relatime + // + // Note that after the first mount operation only one mount point is created, namely + // $SNAP/a -> $SNAP/b/c. The second recursive bind mount not only creates + // $SNAP/b -> $SNAP/d, but also replicates $SNAP/a -> $SNAP/b/c as + // $SNAP/a -> $SNAP/d/c. + // + // The test will simulate a refresh of the snap from revision x1 to revision + // x2. When this happens the mount profile associated with x1 must be undone + // and the mount profile associated with x2 must be constructed. Because + // ordering matters, let's first consider the order of construction of x1 + // itself. Starting from nothing, apply x1 as follows: + x1 := &osutil.MountProfile{ + Entries: []osutil.MountEntry{ + {Name: "/snap/app/x1/a", Dir: "/snap/app/x1/b/c", Type: "none", Options: []string{"rbind", "rw", "x-snapd.origin=layout"}}, + {Name: "/snap/app/x1/b", Dir: "/snap/app/x1/d", Type: "none", Options: []string{"rbind", "rw", "x-snapd.origin=layout"}}, + }, + } + changes := update.NeededChanges(&osutil.MountProfile{}, x1) + c.Assert(changes, DeepEquals, []*update.Change{ + {Action: update.Mount, Entry: osutil.MountEntry{Name: "/snap/app/x1/a", Dir: "/snap/app/x1/b/c", Type: "none", Options: []string{"rbind", "rw", "x-snapd.origin=layout"}}}, + {Action: update.Mount, Entry: osutil.MountEntry{Name: "/snap/app/x1/b", Dir: "/snap/app/x1/d", Type: "none", Options: []string{"rbind", "rw", "x-snapd.origin=layout"}}}, + }) + // We can see that x1 is constructed in alphabetical order, first recursively + // bind mount at $SNAP/a the directory $SNAP/b/c, second recursively bind + // mount at $SNAP/b the directory $SNAP/d. + x2 := &osutil.MountProfile{ + Entries: []osutil.MountEntry{ + {Name: "/snap/app/x2/a", Dir: "/snap/app/x2/b/c", Type: "none", Options: []string{"rbind", "rw", "x-snapd.origin=layout"}}, + {Name: "/snap/app/x2/b", Dir: "/snap/app/x2/d", Type: "none", Options: []string{"rbind", "rw", "x-snapd.origin=layout"}}, + }, + } + // When we are asked to refresh to revision x2, using the same layout, we + // simply undo x1 and then create x2, which apart from the difference in + // revision name, is exactly the same. The undo code, however, does not take + // the replicated mount point under consideration and therefore attempts to + // detach "x1/d", which normally fails with EBUSY. To counter this, the + // unmount operation first switches the mount point to recursive private + // propagation, before actually unmounting it. This ensures that propagation + // doesn't self-conflict, simply because there isn't any left. + changes = update.NeededChanges(x1, x2) + c.Assert(changes, DeepEquals, []*update.Change{ + {Action: update.Unmount, Entry: osutil.MountEntry{Name: "/snap/app/x1/b", Dir: "/snap/app/x1/d", Type: "none", Options: []string{"rbind", "rw", "x-snapd.origin=layout", "x-snapd.detach"}}}, + {Action: update.Unmount, Entry: osutil.MountEntry{Name: "/snap/app/x1/a", Dir: "/snap/app/x1/b/c", Type: "none", Options: []string{"rbind", "rw", "x-snapd.origin=layout", "x-snapd.detach"}}}, + {Action: update.Mount, Entry: osutil.MountEntry{Name: "/snap/app/x2/a", Dir: "/snap/app/x2/b/c", Type: "none", Options: []string{"rbind", "rw", "x-snapd.origin=layout"}}}, + {Action: update.Mount, Entry: osutil.MountEntry{Name: "/snap/app/x2/b", Dir: "/snap/app/x2/d", Type: "none", Options: []string{"rbind", "rw", "x-snapd.origin=layout"}}}, + }) +} + +func (s *changeSuite) TestUnmountFailsWithEINVALAndUnmounted(c *C) { + // We wanted to unmount /target, which failed with EINVAL. + // Because /target is no longer mounted, we consume the error and carry on. + restore := osutil.MockMountInfo("") + defer restore() + s.sys.InsertFault(`unmount "/target" UMOUNT_NOFOLLOW`, syscall.EINVAL) + s.sys.InsertFstatResult(`fstat 4 `, syscall.Stat_t{}) + s.sys.InsertFstatfsResult(`fstatfs 4 `, syscall.Statfs_t{}) + chg := &update.Change{Action: update.Unmount, Entry: osutil.MountEntry{Name: "tmpfs", Dir: "/target", Type: "tmpfs"}} + _, err := chg.Perform(s.as) + c.Assert(err, IsNil) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `unmount "/target" UMOUNT_NOFOLLOW`, E: syscall.EINVAL}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "target" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 4}, + {C: `fstat 4 `, R: syscall.Stat_t{}}, + {C: `close 3`}, + {C: `fstatfs 4 `, R: syscall.Statfs_t{}}, + {C: `remove "/target"`}, + {C: `close 4`}, + }) +} + +func (s *changeSuite) TestUnmountFailsWithEINVALButStillMounted(c *C) { + // We wanted to unmount /target, which failed with EINVAL. + // Because /target is still mounted, we propagate the error. + restore := osutil.MockMountInfo("132 28 0:82 / /target rw,relatime shared:74 - tmpfs tmpfs rw") + defer restore() + s.sys.InsertFault(`unmount "/target" UMOUNT_NOFOLLOW`, syscall.EINVAL) + chg := &update.Change{Action: update.Unmount, Entry: osutil.MountEntry{Name: "tmpfs", Dir: "/target", Type: "tmpfs"}} + _, err := chg.Perform(s.as) + c.Assert(err, Equals, syscall.EINVAL) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `unmount "/target" UMOUNT_NOFOLLOW`, E: syscall.EINVAL}, + }) +} + +// Change.Perform sets x-snapd.needed-by to mount entry ID. +func (s *changeSuite) TestSyntheticNeededByUsesMountEntryID(c *C) { + defer s.as.MockUnrestrictedPaths("/")() // Treat test path as unrestricted. + s.sys.InsertOsLstatResult(`lstat "/usr/share/target"`, testutil.FileInfoFile) + s.sys.InsertFault(`lstat "/snap/some-snap/x1/rofs/dir/target"`, syscall.ENOENT) + s.sys.InsertFault(`mkdirat 3 "snap" 0755`, syscall.EEXIST) + s.sys.InsertFault(`mkdirat 4 "some-snap" 0755`, syscall.EEXIST) + s.sys.InsertFault(`mkdirat 5 "x1" 0755`, syscall.EEXIST) + s.sys.InsertFault(`mkdirat 6 "rofs" 0755`, syscall.EEXIST) + s.sys.InsertFault(`mkdirat 7 "dir" 0755`, syscall.EROFS, nil) + s.sys.InsertSysLstatResult(`lstat "/snap/some-snap/x1/rofs" `, syscall.Stat_t{}) + s.sys.InsertReadDirResult(`readdir "/snap/some-snap/x1/rofs"`, []fs.DirEntry{}) + s.sys.InsertOsLstatResult(`lstat "/tmp/.snap/snap/some-snap/x1/rofs"`, testutil.FileInfoDir) + s.sys.InsertOsLstatResult(`lstat "/snap/some-snap/x1/rofs"`, testutil.FileInfoDir) + s.sys.InsertFstatResult(`fstat 7 `, syscall.Stat_t{}) + s.sys.InsertFstatResult(`fstat 10 `, syscall.Stat_t{}) + s.sys.InsertFstatResult(`fstat 9 `, syscall.Stat_t{}) + s.sys.InsertFstatfsResult(`fstatfs 9 `, syscall.Statfs_t{}) + s.sys.InsertFstatResult(`fstat 6 `, syscall.Stat_t{}) + + // layout mount + chg := &update.Change{ + Action: update.Mount, + Entry: osutil.MountEntry{ + Name: "/snap/some-snap/x1/rofs/dir/target", + Dir: "/usr/share/target", + Options: []string{"rbind", "rw", "x-snapd.id=test-id", osutil.XSnapdKindFile(), osutil.XSnapdOriginLayout()}, + }, + } + + synth, err := chg.Perform(s.as) + c.Assert(err, IsNil) + c.Check(synth, HasLen, 1) + c.Check(synth[0].Entry.XSnapdNeededBy(), Equals, "test-id") +} + +// Change.Perform sets x-snapd.needed-by to default mount entry ID (i.e. target directory). +func (s *changeSuite) TestSyntheticNeededByUsesDefaultMountEntryID(c *C) { + defer s.as.MockUnrestrictedPaths("/")() // Treat test path as unrestricted. + s.sys.InsertOsLstatResult(`lstat "/usr/share/target"`, testutil.FileInfoFile) + s.sys.InsertFault(`lstat "/snap/some-snap/x1/rofs/dir/target"`, syscall.ENOENT) + s.sys.InsertFault(`mkdirat 3 "snap" 0755`, syscall.EEXIST) + s.sys.InsertFault(`mkdirat 4 "some-snap" 0755`, syscall.EEXIST) + s.sys.InsertFault(`mkdirat 5 "x1" 0755`, syscall.EEXIST) + s.sys.InsertFault(`mkdirat 6 "rofs" 0755`, syscall.EEXIST) + s.sys.InsertFault(`mkdirat 7 "dir" 0755`, syscall.EROFS, nil) + s.sys.InsertSysLstatResult(`lstat "/snap/some-snap/x1/rofs" `, syscall.Stat_t{}) + s.sys.InsertReadDirResult(`readdir "/snap/some-snap/x1/rofs"`, []fs.DirEntry{}) + s.sys.InsertOsLstatResult(`lstat "/tmp/.snap/snap/some-snap/x1/rofs"`, testutil.FileInfoDir) + s.sys.InsertOsLstatResult(`lstat "/snap/some-snap/x1/rofs"`, testutil.FileInfoDir) + s.sys.InsertFstatResult(`fstat 7 `, syscall.Stat_t{}) + s.sys.InsertFstatResult(`fstat 10 `, syscall.Stat_t{}) + s.sys.InsertFstatResult(`fstat 9 `, syscall.Stat_t{}) + s.sys.InsertFstatfsResult(`fstatfs 9 `, syscall.Statfs_t{}) + s.sys.InsertFstatResult(`fstat 6 `, syscall.Stat_t{}) + + // layout mount + chg := &update.Change{ + Action: update.Mount, + Entry: osutil.MountEntry{ + Name: "/snap/some-snap/x1/rofs/dir/target", + Dir: "/usr/share/target", + Options: []string{"rbind", "rw", osutil.XSnapdKindFile(), osutil.XSnapdOriginLayout()}, + }, + } + + synth, err := chg.Perform(s.as) + c.Assert(err, IsNil) + c.Check(synth, HasLen, 1) + // XSnapdEntryID defaults to entry target directory if x-snapd.id is unset + c.Check(synth[0].Entry.XSnapdNeededBy(), Equals, "/usr/share/target") +} diff --git a/cmd/snap-update-ns/common.go b/cmd/snap-update-ns/common.go new file mode 100644 index 00000000..805d843f --- /dev/null +++ b/cmd/snap-update-ns/common.go @@ -0,0 +1,125 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "fmt" + + "github.com/snapcore/snapd/cmd/snaplock" + "github.com/snapcore/snapd/logger" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/sandbox/cgroup" +) + +type CommonProfileUpdateContext struct { + // instanceName is the name of the snap instance to update. + instanceName string + + // fromSnapConfine indicates that the update is triggered by snap-confine + // and not from snapd. When set, snap-confine is still constructing the user + // mount namespace and is delegating mount profile application to snap-update-ns. + fromSnapConfine bool + + currentProfilePath string + desiredProfilePath string +} + +// InstanceName returns the snap instance name being updated. +func (upCtx *CommonProfileUpdateContext) InstanceName() string { + return upCtx.instanceName +} + +// Lock acquires locks / freezes needed to synchronize mount namespace changes. +func (upCtx *CommonProfileUpdateContext) Lock() (func(), error) { + instanceName := upCtx.instanceName + + // Lock the mount namespace so that any concurrently attempted invocations + // of snap-confine are synchronized and will see consistent state. + lock, err := snaplock.OpenLock(instanceName) + if err != nil { + return nil, fmt.Errorf("cannot open lock file for mount namespace of snap %q: %s", instanceName, err) + } + + logger.Debugf("locking mount namespace of snap %q", instanceName) + if upCtx.fromSnapConfine { + // When --from-snap-confine is passed then we just ensure that the + // namespace is locked. This is used by snap-confine to use + // snap-update-ns to apply mount profiles. + if err := lock.TryLock(); err != osutil.ErrAlreadyLocked { + // If we managed to grab the lock we should drop it. + lock.Close() + return nil, fmt.Errorf("mount namespace of snap %q is not locked but --from-snap-confine was used", instanceName) + } + } else { + if err := lock.Lock(); err != nil { + return nil, fmt.Errorf("cannot lock mount namespace of snap %q: %s", instanceName, err) + } + } + + // Freeze the mount namespace and unfreeze it later. This lets us perform + // modifications without snap processes attempting to construct + // symlinks or perform other malicious activity (such as attempting to + // introduce a symlink that would cause us to mount something other + // than what we expected). + logger.Debugf("freezing processes of snap %q", instanceName) + if err := cgroup.FreezeSnapProcesses(instanceName); err != nil { + // If we cannot freeze the processes we should drop the lock. + lock.Close() + return nil, err + } + + unlock := func() { + logger.Debugf("unlocking mount namespace of snap %q", instanceName) + lock.Close() + logger.Debugf("thawing processes of snap %q", instanceName) + cgroup.ThawSnapProcesses(instanceName) + } + return unlock, nil +} + +func (upCtx *CommonProfileUpdateContext) Assumptions() *Assumptions { + return nil +} + +// LoadDesiredProfile loads the desired mount profile. +func (upCtx *CommonProfileUpdateContext) LoadDesiredProfile() (*osutil.MountProfile, error) { + profile, err := osutil.LoadMountProfile(upCtx.desiredProfilePath) + if err != nil { + return nil, fmt.Errorf("cannot load desired mount profile of snap %q: %s", upCtx.instanceName, err) + } + return profile, nil +} + +// LoadCurrentProfile loads the current mount profile. +func (upCtx *CommonProfileUpdateContext) LoadCurrentProfile() (*osutil.MountProfile, error) { + profile, err := osutil.LoadMountProfile(upCtx.currentProfilePath) + if err != nil { + return nil, fmt.Errorf("cannot load current mount profile of snap %q: %s", upCtx.instanceName, err) + } + return profile, nil +} + +// SaveCurrentProfile saves the current mount profile. +func (upCtx *CommonProfileUpdateContext) SaveCurrentProfile(profile *osutil.MountProfile) error { + if err := profile.Save(upCtx.currentProfilePath); err != nil { + return fmt.Errorf("cannot save current mount profile of snap %q: %s", upCtx.instanceName, err) + } + return nil +} diff --git a/cmd/snap-update-ns/common_test.go b/cmd/snap-update-ns/common_test.go new file mode 100644 index 00000000..98b089de --- /dev/null +++ b/cmd/snap-update-ns/common_test.go @@ -0,0 +1,175 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "bytes" + "os" + "path/filepath" + + . "gopkg.in/check.v1" + + update "github.com/snapcore/snapd/cmd/snap-update-ns" + "github.com/snapcore/snapd/cmd/snaplock" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/sandbox/cgroup" + "github.com/snapcore/snapd/testutil" +) + +type commonSuite struct { + dir string + upCtx *update.CommonProfileUpdateContext +} + +var _ = Suite(&commonSuite{}) + +func (s *commonSuite) SetUpTest(c *C) { + s.dir = c.MkDir() + s.upCtx = update.NewCommonProfileUpdateContext("foo", false, + filepath.Join(s.dir, "current.fstab"), + filepath.Join(s.dir, "desired.fstab")) +} + +func (s *commonSuite) TestInstanceName(c *C) { + c.Check(s.upCtx.InstanceName(), Equals, "foo") +} + +func (s *commonSuite) TestLock(c *C) { + // Mock away real freezer code, allowing test code to return an error when freezing. + var freezingError error + restore := cgroup.MockFreezing(func(string) error { return freezingError }, func(string) error { return nil }) + defer restore() + // Mock system directories, we use the lock directory. + dirs.SetRootDir(s.dir) + defer dirs.SetRootDir("") + + // We will use 2nd lock for our testing. + testLock, err := snaplock.OpenLock(s.upCtx.InstanceName()) + c.Assert(err, IsNil) + defer testLock.Close() + + // When fromSnapConfine is false we acquire our own lock. + s.upCtx.SetFromSnapConfine(false) + c.Check(s.upCtx.FromSnapConfine(), Equals, false) + unlock, err := s.upCtx.Lock() + c.Assert(err, IsNil) + // The lock is acquired now. We should not be able to get another lock. + c.Check(testLock.TryLock(), Equals, osutil.ErrAlreadyLocked) + // We can release the original lock now and see our test lock working. + unlock() + c.Assert(testLock.TryLock(), IsNil) + + // When fromSnapConfine is true we test existing lock but don't grab one. + s.upCtx.SetFromSnapConfine(true) + c.Check(s.upCtx.FromSnapConfine(), Equals, true) + err = testLock.Lock() + c.Assert(err, IsNil) + unlock, err = s.upCtx.Lock() + c.Assert(err, IsNil) + unlock() + + // When the test lock is unlocked the common update helper reports an error + // since it was expecting the lock to be held. Oh, and the lock is not leaked. + testLock.Unlock() + unlock, err = s.upCtx.Lock() + c.Check(err, ErrorMatches, `mount namespace of snap "foo" is not locked but --from-snap-confine was used`) + c.Check(unlock, IsNil) + c.Assert(testLock.TryLock(), IsNil) + + // When freezing fails the lock acquired internally is not leaked. + freezingError = errTesting + s.upCtx.SetFromSnapConfine(false) + c.Check(s.upCtx.FromSnapConfine(), Equals, false) + testLock.Unlock() + unlock, err = s.upCtx.Lock() + c.Check(err, Equals, errTesting) + c.Check(unlock, IsNil) + c.Check(testLock.TryLock(), IsNil) +} + +func (s *commonSuite) TestLoadDesiredProfile(c *C) { + upCtx := s.upCtx + text := "tmpfs /tmp tmpfs defaults 0 0\n" + + // Ask the common profile update helper to read the desired profile. + profile, err := upCtx.LoadCurrentProfile() + c.Assert(err, IsNil) + + // A profile that is not present on disk just reads as a valid empty profile. + c.Check(profile.Entries, HasLen, 0) + + // Write a desired user mount profile for snap "foo". + path := upCtx.DesiredProfilePath() + c.Assert(os.MkdirAll(filepath.Dir(path), 0755), IsNil) + c.Assert(os.WriteFile(path, []byte(text), 0644), IsNil) + + // Ask the common profile update helper to read the desired profile. + profile, err = upCtx.LoadDesiredProfile() + c.Assert(err, IsNil) + builder := &bytes.Buffer{} + profile.WriteTo(builder) + + // The profile is returned unchanged. + c.Check(builder.String(), Equals, text) +} + +func (s *commonSuite) TestLoadCurrentProfile(c *C) { + upCtx := s.upCtx + text := "tmpfs /tmp tmpfs defaults 0 0\n" + + // Ask the common profile update helper to read the current profile. + profile, err := upCtx.LoadCurrentProfile() + c.Assert(err, IsNil) + + // A profile that is not present on disk just reads as a valid empty profile. + c.Check(profile.Entries, HasLen, 0) + + // Write a current user mount profile for snap "foo". + path := upCtx.CurrentProfilePath() + c.Assert(os.MkdirAll(filepath.Dir(path), 0755), IsNil) + c.Assert(os.WriteFile(path, []byte(text), 0644), IsNil) + + // Ask the common profile update helper to read the current profile. + profile, err = upCtx.LoadCurrentProfile() + c.Assert(err, IsNil) + builder := &bytes.Buffer{} + profile.WriteTo(builder) + + // The profile is returned unchanged. + c.Check(builder.String(), Equals, text) +} + +func (s *commonSuite) TestSaveCurrentProfile(c *C) { + upCtx := s.upCtx + text := "tmpfs /tmp tmpfs defaults 0 0\n" + + // Prepare a mount profile to be saved. + profile, err := osutil.LoadMountProfileText(text) + c.Assert(err, IsNil) + + // Prepare the directory for saving the profile. + path := upCtx.CurrentProfilePath() + c.Assert(os.MkdirAll(filepath.Dir(path), 0755), IsNil) + + // Ask the common profile update to write the current profile. + c.Assert(upCtx.SaveCurrentProfile(profile), IsNil) + c.Check(path, testutil.FileEquals, text) +} diff --git a/cmd/snap-update-ns/expand.go b/cmd/snap-update-ns/expand.go new file mode 100644 index 00000000..fd3cec18 --- /dev/null +++ b/cmd/snap-update-ns/expand.go @@ -0,0 +1,85 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2023 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "fmt" + "strings" + + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/osutil" +) + +// expandPrefixVariable expands the given variable at the beginning of a path-like string if it exists. +func expandPrefixVariable(path, variable, value string) (string, bool) { + if strings.HasPrefix(path, variable) { + if len(path) == len(variable) { + return value, true + } + if len(path) > len(variable) && path[len(variable)] == '/' { + return value + path[len(variable):], true + } + } + return path, false +} + +// xdgRuntimeDir returns the path to XDG_RUNTIME_DIR for a given user ID. +func xdgRuntimeDir(uid int) string { + return fmt.Sprintf("%s/%d", dirs.XdgRuntimeDirBase, uid) +} + +// expandXdgRuntimeDir expands the $XDG_RUNTIME_DIR variable in the given mount profile. +func expandXdgRuntimeDir(profile *osutil.MountProfile, uid int) { + const envVar = "$XDG_RUNTIME_DIR" + value := xdgRuntimeDir(uid) + for i := range profile.Entries { + profile.Entries[i].Name, _ = expandPrefixVariable(profile.Entries[i].Name, envVar, value) + profile.Entries[i].Dir, _ = expandPrefixVariable(profile.Entries[i].Dir, envVar, value) + } +} + +// expandHomeDir expands the $HOME variable in the given mount profile for entries +// of mount kind "ensure-dir". It returns an error if expansion is required but home +// err indicates that path should not be used for expansion. +func expandHomeDir(profile *osutil.MountProfile, home func() (path string, err error)) error { + const envVar = "$HOME" + homePath, homeErr := home() + + for i := range profile.Entries { + if profile.Entries[i].XSnapdKind() != "ensure-dir" { + continue + } + + dir, dirExpanded := expandPrefixVariable(profile.Entries[i].Dir, envVar, homePath) + mustExistDir, mustExistDirExpanded := expandPrefixVariable(profile.Entries[i].XSnapdMustExistDir(), envVar, homePath) + + if homeErr == nil { + if dirExpanded { + profile.Entries[i].Dir = dir + } + if mustExistDirExpanded { + osutil.ReplaceMountEntryOption(&profile.Entries[i], osutil.XSnapdMustExistDir(mustExistDir)) + } + } else if dirExpanded || mustExistDirExpanded { + return fmt.Errorf("cannot expand mount entry (%s): %v", profile.Entries[i], homeErr) + } + } + return nil +} diff --git a/cmd/snap-update-ns/expand_test.go b/cmd/snap-update-ns/expand_test.go new file mode 100644 index 00000000..29ac67cc --- /dev/null +++ b/cmd/snap-update-ns/expand_test.go @@ -0,0 +1,103 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2023 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "bytes" + "errors" + "strings" + + . "gopkg.in/check.v1" + + update "github.com/snapcore/snapd/cmd/snap-update-ns" + "github.com/snapcore/snapd/osutil" +) + +type expandSuite struct{} + +var _ = Suite(&expandSuite{}) + +func (s *expandSuite) TestXdgRuntimeDir(c *C) { + c.Check(update.XdgRuntimeDir(1234), Equals, "/run/user/1234") +} + +func (s *expandSuite) TestExpandPrefixVariable(c *C) { + value, isExpanded := update.ExpandPrefixVariable("$FOO", "$FOO", "/foo") + c.Assert(isExpanded, Equals, true) + c.Check(value, Equals, "/foo") + + value, isExpanded = update.ExpandPrefixVariable("$FOO/", "$FOO", "/foo") + c.Assert(isExpanded, Equals, true) + c.Check(value, Equals, "/foo/") + + value, isExpanded = update.ExpandPrefixVariable("$FOO/bar", "$FOO", "/foo") + c.Assert(isExpanded, Equals, true) + c.Check(value, Equals, "/foo/bar") + + value, isExpanded = update.ExpandPrefixVariable("$FOObar", "$FOO", "/foo") + c.Assert(isExpanded, Equals, false) + c.Check(value, Equals, "$FOObar") + + value, isExpanded = update.ExpandPrefixVariable("$FOO/bar", "$FOO", "") + c.Assert(isExpanded, Equals, true) + c.Check(value, Equals, "/bar") +} + +func (s *expandSuite) TestExpandXdgRuntimeDir(c *C) { + input := "$XDG_RUNTIME_DIR/doc/by-app/snap.foo $XDG_RUNTIME_DIR/doc none bind,rw 0 0\n" + output := "/run/user/1234/doc/by-app/snap.foo /run/user/1234/doc none bind,rw 0 0\n" + profile, err := osutil.ReadMountProfile(strings.NewReader(input)) + c.Assert(err, IsNil) + update.ExpandXdgRuntimeDir(profile, 1234) + builder := &bytes.Buffer{} + profile.WriteTo(builder) + c.Check(builder.String(), Equals, output) +} + +func (s *expandSuite) TestExpandHomeDirHappy(c *C) { + input := "none $HOME/.local/share none x-snapd.kind=ensure-dir,x-snapd.must-exist-dir=$HOME 0 0\n" + + "none $HOME/.local/share none x-snapd.kind=not-ensure-dir,x-snapd.must-exist-dir=$HOME 0 0\n" + output := "none /home/user/.local/share none x-snapd.kind=ensure-dir,x-snapd.must-exist-dir=/home/user 0 0\n" + + "none $HOME/.local/share none x-snapd.kind=not-ensure-dir,x-snapd.must-exist-dir=$HOME 0 0\n" + profile, err := osutil.ReadMountProfile(strings.NewReader(input)) + c.Assert(err, IsNil) + home := func() (path string, err error) { + return "/home/user", nil + } + c.Assert(update.ExpandHomeDir(profile, home), IsNil) + builder := &bytes.Buffer{} + profile.WriteTo(builder) + c.Check(builder.String(), Equals, output) +} + +func (s *expandSuite) TestExpandHomeDirHomeError(c *C) { + input := "none $HOME/.local/share none x-snapd.kind=ensure-dir,x-snapd.must-exist-dir=$HOME 0 0\n" + + "none $HOME/.local/share none x-snapd.kind=not-ensure-dir,x-snapd.must-exist-dir=$HOME 0 0\n" + profile, err := osutil.ReadMountProfile(strings.NewReader(input)) + c.Assert(err, IsNil) + home := func() (path string, err error) { + return "/home/user", errors.New("invalid home directory") + } + err = update.ExpandHomeDir(profile, home) + c.Assert(err, ErrorMatches, `cannot expand mount entry \(none \$HOME/.local/share none x-snapd.kind=ensure-dir,x-snapd.must-exist-dir=\$HOME 0 0\): invalid home directory`) + builder := &bytes.Buffer{} + profile.WriteTo(builder) + c.Check(builder.String(), Equals, input) +} diff --git a/cmd/snap-update-ns/export_test.go b/cmd/snap-update-ns/export_test.go new file mode 100644 index 00000000..8041bf49 --- /dev/null +++ b/cmd/snap-update-ns/export_test.go @@ -0,0 +1,299 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "io/fs" + "os" + "syscall" + + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/osutil/sys" + "github.com/snapcore/snapd/testutil" +) + +var ( + // change + ValidateInstanceName = validateInstanceName + ProcessArguments = processArguments + + // utils + PlanWritableMimic = planWritableMimic + ExecWritableMimic = execWritableMimic + + // bootstrap + ClearBootstrapError = clearBootstrapError + + // trespassing + IsReadOnly = isReadOnly + IsPrivateTmpfsCreatedBySnapd = isPrivateTmpfsCreatedBySnapd + + // system + DesiredSystemProfilePath = desiredSystemProfilePath + CurrentSystemProfilePath = currentSystemProfilePath + + // user + IsPlausibleHome = isPlausibleHome + DesiredUserProfilePath = desiredUserProfilePath + CurrentUserProfilePath = currentUserProfilePath + + // expand + XdgRuntimeDir = xdgRuntimeDir + ExpandPrefixVariable = expandPrefixVariable + ExpandXdgRuntimeDir = expandXdgRuntimeDir + ExpandHomeDir = expandHomeDir + + // update + ExecuteMountProfileUpdate = executeMountProfileUpdate +) + +// SystemCalls encapsulates various system interactions performed by this module. +type SystemCalls interface { + OsLstat(name string) (os.FileInfo, error) + SysLstat(name string, buf *syscall.Stat_t) error + ReadDir(dirname string) ([]fs.DirEntry, error) + Symlinkat(oldname string, dirfd int, newname string) error + Readlinkat(dirfd int, path string, buf []byte) (int, error) + Remove(name string) error + + Close(fd int) error + Fchdir(fd int) error + Fchown(fd int, uid sys.UserID, gid sys.GroupID) error + Mkdirat(dirfd int, path string, mode uint32) error + Mount(source string, target string, fstype string, flags uintptr, data string) (err error) + Open(path string, flags int, mode uint32) (fd int, err error) + Openat(dirfd int, path string, flags int, mode uint32) (fd int, err error) + Unmount(target string, flags int) error + Fstat(fd int, buf *syscall.Stat_t) error + Fstatfs(fd int, buf *syscall.Statfs_t) error +} + +// MockSystemCalls replaces real system calls with those of the argument. +func MockSystemCalls(sc SystemCalls) (restore func()) { + // save + oldOsLstat := osLstat + oldRemove := osRemove + oldOsReadDir := osReadDir + + oldSysClose := sysClose + oldSysFchown := sysFchown + oldSysMkdirat := sysMkdirat + oldSysMount := sysMount + oldSysOpen := sysOpen + oldSysOpenat := sysOpenat + oldSysUnmount := sysUnmount + oldSysSymlinkat := sysSymlinkat + oldReadlinkat := sysReadlinkat + oldFstat := sysFstat + oldFstatfs := sysFstatfs + oldSysFchdir := sysFchdir + oldSysLstat := sysLstat + + // override + osLstat = sc.OsLstat + osRemove = sc.Remove + osReadDir = sc.ReadDir + + sysClose = sc.Close + sysFchown = sc.Fchown + sysMkdirat = sc.Mkdirat + sysMount = sc.Mount + sysOpen = sc.Open + sysOpenat = sc.Openat + sysUnmount = sc.Unmount + sysSymlinkat = sc.Symlinkat + sysReadlinkat = sc.Readlinkat + sysFstat = sc.Fstat + sysFstatfs = sc.Fstatfs + sysFchdir = sc.Fchdir + sysLstat = sc.SysLstat + + return func() { + // restore + osLstat = oldOsLstat + osRemove = oldRemove + osReadDir = oldOsReadDir + + sysClose = oldSysClose + sysFchown = oldSysFchown + sysMkdirat = oldSysMkdirat + sysMount = oldSysMount + sysOpen = oldSysOpen + sysOpenat = oldSysOpenat + sysUnmount = oldSysUnmount + sysSymlinkat = oldSysSymlinkat + sysReadlinkat = oldReadlinkat + sysFstat = oldFstat + sysFstatfs = oldFstatfs + sysFchdir = oldSysFchdir + sysLstat = oldSysLstat + } +} + +func MockGetuid(fn func() sys.UserID) (restore func()) { + oldSysGetuid := sysGetuid + sysGetuid = fn + return func() { + sysGetuid = oldSysGetuid + } +} + +func MockGetgid(fn func() sys.GroupID) (restore func()) { + oldSysGetgid := sysGetgid + sysGetgid = fn + return func() { + sysGetgid = oldSysGetgid + } +} + +func MockChangePerform(f func(chg *Change, as *Assumptions) ([]*Change, error)) func() { + origChangePerform := changePerform + changePerform = f + return func() { + changePerform = origChangePerform + } +} + +func MockIsDirectory(fn func(string) bool) (restore func()) { + r := testutil.Backup(&osutilIsDirectory) + osutilIsDirectory = fn + return r +} + +func MockNeededChanges(f func(old, new *osutil.MountProfile) []*Change) (restore func()) { + origNeededChanges := NeededChanges + NeededChanges = f + return func() { + NeededChanges = origNeededChanges + } +} + +func MockReadDir(fn func(string) ([]fs.DirEntry, error)) (restore func()) { + old := osReadDir + osReadDir = fn + return func() { + osReadDir = old + } +} + +// MockSnapConfineUserEnv provide the environment variables provided by snap-confine +// when it calls snap-update-ns for a specific user +func MockSnapConfineUserEnv(xdgNew, realHomeNew string) (restore func()) { + xdgCur, xdgExists := os.LookupEnv("XDG_RUNTIME_DIR") + realHomeCur, realHomeExists := os.LookupEnv("SNAP_REAL_HOME") + + os.Setenv("XDG_RUNTIME_DIR", xdgNew) + os.Setenv("SNAP_REAL_HOME", realHomeNew) + + return func() { + if xdgExists { + os.Setenv("XDG_RUNTIME_DIR", xdgCur) + } else { + os.Unsetenv("XDG_RUNTIME_DIR") + } + + if realHomeExists { + os.Setenv("SNAP_REAL_HOME", realHomeCur) + } else { + os.Unsetenv("SNAP_REAL_HOME") + } + } +} + +func MockReadlink(fn func(string) (string, error)) (restore func()) { + old := osReadlink + osReadlink = fn + return func() { + osReadlink = old + } +} + +func MockSysMkdirat(fn func(dirfd int, path string, mode uint32) (err error)) (restore func()) { + old := sysMkdirat + sysMkdirat = fn + return func() { + sysMkdirat = old + } +} + +func MockSysMount(fn func(source string, target string, fstype string, flags uintptr, data string) (err error)) (restore func()) { + old := sysMount + sysMount = fn + return func() { + sysMount = old + } +} + +func MockSysUnmount(fn func(target string, flags int) (err error)) (restore func()) { + old := sysUnmount + sysUnmount = fn + return func() { + sysUnmount = old + } +} + +func MockSysFchown(fn func(fd int, uid sys.UserID, gid sys.GroupID) error) (restore func()) { + old := sysFchown + sysFchown = fn + return func() { + sysFchown = old + } +} + +func (as *Assumptions) IsRestricted(path string) bool { + return as.isRestricted(path) +} + +func (as *Assumptions) PastChanges() []*Change { + return as.pastChanges +} + +func (as *Assumptions) CanWriteToDirectory(dirFd int, dirName string) (bool, error) { + return as.canWriteToDirectory(dirFd, dirName) +} + +func (as *Assumptions) UnrestrictedPaths() []string { + return as.unrestrictedPaths +} + +func (upCtx *CommonProfileUpdateContext) CurrentProfilePath() string { + return upCtx.currentProfilePath +} + +func (upCtx *CommonProfileUpdateContext) DesiredProfilePath() string { + return upCtx.desiredProfilePath +} + +func (upCtx *CommonProfileUpdateContext) FromSnapConfine() bool { + return upCtx.fromSnapConfine +} + +func (upCtx *CommonProfileUpdateContext) SetFromSnapConfine(v bool) { + upCtx.fromSnapConfine = v +} + +func NewCommonProfileUpdateContext(instanceName string, fromSnapConfine bool, currentProfilePath, desiredProfilePath string) *CommonProfileUpdateContext { + return &CommonProfileUpdateContext{ + instanceName: instanceName, + fromSnapConfine: fromSnapConfine, + currentProfilePath: currentProfilePath, + desiredProfilePath: desiredProfilePath, + } +} diff --git a/cmd/snap-update-ns/main.go b/cmd/snap-update-ns/main.go new file mode 100644 index 00000000..e355ba2e --- /dev/null +++ b/cmd/snap-update-ns/main.go @@ -0,0 +1,101 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "fmt" + "os" + "syscall" + + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/logger" +) + +var opts struct { + FromSnapConfine bool `long:"from-snap-confine"` + UserMounts bool `long:"user-mounts"` + UserID int `short:"u"` + Positionals struct { + SnapName string `positional-arg-name:"SNAP_NAME" required:"yes"` + } `positional-args:"true"` +} + +// IMPORTANT: all the code in main() until bootstrap is finished may be run +// with elevated privileges when invoking snap-update-ns from the setuid +// snap-confine. + +func main() { + logger.SimpleSetup() + if err := run(); err != nil { + fmt.Printf("cannot update snap namespace: %s\n", err) + os.Exit(1) + } + // END IMPORTANT +} + +func parseArgs(args []string) error { + parser := flags.NewParser(&opts, flags.HelpFlag|flags.PassDoubleDash|flags.PassAfterNonOption) + _, err := parser.ParseArgs(args) + return err +} + +// IMPORTANT: all the code in run() until BootStrapError() is finished may +// be run with elevated privileges when invoking snap-update-ns from +// the setuid snap-confine. + +func run() error { + // There is some C code that runs before main() is started. + // That code always runs and sets an error condition if it fails. + // Here we just check for the error. + if err := BootstrapError(); err != nil { + // If there is no mount namespace to transition to let's just quit + // instantly without any errors as there is nothing to do anymore. + if err == ErrNoNamespace { + logger.Debugf("no preserved mount namespace, nothing to update") + return nil + } + return err + } + // END IMPORTANT + + if err := parseArgs(os.Args[1:]); err != nil { + return err + } + + // Explicitly set the umask to 0 to prevent permission bits + // being masked out when creating files and directories. + // + // While snap-confine already does this for us, we inherit + // snapd's umask when it invokes us. + syscall.Umask(0) + + var upCtx MountProfileUpdateContext + if opts.UserMounts { + userUpCtx, err := NewUserProfileUpdateContext(opts.Positionals.SnapName, opts.FromSnapConfine, os.Getuid()) + if err != nil { + return fmt.Errorf("cannot create user profile update context: %v", err) + } + upCtx = userUpCtx + } else { + upCtx = NewSystemProfileUpdateContext(opts.Positionals.SnapName, opts.FromSnapConfine) + } + return executeMountProfileUpdate(upCtx) +} diff --git a/cmd/snap-update-ns/main_test.go b/cmd/snap-update-ns/main_test.go new file mode 100644 index 00000000..ff85ea7f --- /dev/null +++ b/cmd/snap-update-ns/main_test.go @@ -0,0 +1,440 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "testing" + + . "gopkg.in/check.v1" + + update "github.com/snapcore/snapd/cmd/snap-update-ns" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/features" + "github.com/snapcore/snapd/logger" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/sandbox/cgroup" + "github.com/snapcore/snapd/testutil" +) + +func Test(t *testing.T) { TestingT(t) } + +type mainSuite struct { + testutil.BaseTest + as *update.Assumptions + log *bytes.Buffer +} + +var _ = Suite(&mainSuite{}) + +func (s *mainSuite) SetUpTest(c *C) { + s.BaseTest.SetUpTest(c) + s.as = &update.Assumptions{} + buf, restore := logger.MockLogger() + s.AddCleanup(restore) + s.log = buf + s.AddCleanup(cgroup.MockVersion(cgroup.V1, nil)) +} + +func (s *mainSuite) TestExecuteMountProfileUpdate(c *C) { + dirs.SetRootDir(c.MkDir()) + defer dirs.SetRootDir("/") + + // mount targets look at the the actual local filesystem + if !osutil.IsDirectory("/usr/share/fonts") || !osutil.IsDirectory("/usr/local/share/fonts") { + c.Skip("missing local directories (/usr/share/fonts or /usr/local/share/fonts)") + } + + restore := update.MockChangePerform(func(chg *update.Change, as *update.Assumptions) ([]*update.Change, error) { + return nil, nil + }) + defer restore() + + snapName := "foo" + desiredProfileContent := `/var/lib/snapd/hostfs/usr/share/fonts /usr/share/fonts none bind,ro 0 0 +/var/lib/snapd/hostfs/usr/local/share/fonts /usr/local/share/fonts none bind,ro 0 0` + + desiredProfilePath := fmt.Sprintf("%s/snap.%s.fstab", dirs.SnapMountPolicyDir, snapName) + err := os.MkdirAll(filepath.Dir(desiredProfilePath), 0755) + c.Assert(err, IsNil) + err = os.WriteFile(desiredProfilePath, []byte(desiredProfileContent), 0644) + c.Assert(err, IsNil) + + currentProfilePath := fmt.Sprintf("%s/snap.%s.fstab", dirs.SnapRunNsDir, snapName) + err = os.MkdirAll(filepath.Dir(currentProfilePath), 0755) + c.Assert(err, IsNil) + err = os.WriteFile(currentProfilePath, nil, 0644) + c.Assert(err, IsNil) + + upCtx := update.NewSystemProfileUpdateContext(snapName, false) + err = update.ExecuteMountProfileUpdate(upCtx) + c.Assert(err, IsNil) + + c.Check(currentProfilePath, testutil.FileEquals, `/var/lib/snapd/hostfs/usr/local/share/fonts /usr/local/share/fonts none bind,ro 0 0 +/var/lib/snapd/hostfs/usr/share/fonts /usr/share/fonts none bind,ro 0 0 +`) +} + +func (s *mainSuite) TestAddingSyntheticChanges(c *C) { + dirs.SetRootDir(c.MkDir()) + defer dirs.SetRootDir("/") + + // The snap `mysnap` wishes to export it's usr/share/mysnap directory and + // make it appear as if it was in /usr/share/mysnap directly. + const snapName = "mysnap" + const currentProfileContent = "" + const desiredProfileContent = "/snap/mysnap/42/usr/share/mysnap /usr/share/mysnap none bind,ro 0 0" + + currentProfilePath := fmt.Sprintf("%s/snap.%s.fstab", dirs.SnapRunNsDir, snapName) + desiredProfilePath := fmt.Sprintf("%s/snap.%s.fstab", dirs.SnapMountPolicyDir, snapName) + + c.Assert(os.MkdirAll(filepath.Dir(currentProfilePath), 0755), IsNil) + c.Assert(os.MkdirAll(filepath.Dir(desiredProfilePath), 0755), IsNil) + c.Assert(os.WriteFile(currentProfilePath, []byte(currentProfileContent), 0644), IsNil) + c.Assert(os.WriteFile(desiredProfilePath, []byte(desiredProfileContent), 0644), IsNil) + + // In order to make that work, /usr/share had to be converted to a writable + // mimic. Some actions were performed under the hood and now we see a + // subset of them as synthetic changes here. + // + // Note that if you compare this to the code that plans a writable mimic + // you will see that there are additional changes that are _not_ + // represented here. The changes have only one goal: tell + // snap-update-ns how the mimic can be undone in case it is no longer + // needed. + restore := update.MockChangePerform(func(chg *update.Change, as *update.Assumptions) ([]*update.Change, error) { + // The change that we were asked to perform is to create a bind mount + // from within the snap to /usr/share/mysnap. + c.Assert(chg, DeepEquals, &update.Change{ + Action: update.Mount, Entry: osutil.MountEntry{ + Name: "/snap/mysnap/42/usr/share/mysnap", + Dir: "/usr/share/mysnap", Type: "none", + Options: []string{"bind", "ro"}}}) + synthetic := []*update.Change{ + // The original directory (which was a part of the core snap and is + // read only) was hidden with a tmpfs. + {Action: update.Mount, Entry: osutil.MountEntry{ + Dir: "/usr/share", Name: "tmpfs", Type: "tmpfs", + Options: []string{"x-snapd.synthetic", "x-snapd.needed-by=/usr/share/mysnap"}}}, + // For the sake of brevity we will only represent a few of the + // entries typically there. Normally this list can get quite long. + // Also note that the entry is a little fake. In reality it was + // constructed using a temporary bind mount that contained the + // original mount entries of /usr/share but this fact was lost. + // Again, the only point of this entry is to correctly perform an + // undo operation when /usr/share/mysnap is no longer needed. + {Action: update.Mount, Entry: osutil.MountEntry{ + Dir: "/usr/share/adduser", Name: "/usr/share/adduser", + Options: []string{"bind", "ro", "x-snapd.synthetic", "x-snapd.needed-by=/usr/share/mysnap"}}}, + {Action: update.Mount, Entry: osutil.MountEntry{ + Dir: "/usr/share/awk", Name: "/usr/share/awk", + Options: []string{"bind", "ro", "x-snapd.synthetic", "x-snapd.needed-by=/usr/share/mysnap"}}}, + } + return synthetic, nil + }) + defer restore() + + upCtx := update.NewSystemProfileUpdateContext(snapName, false) + c.Assert(update.ExecuteMountProfileUpdate(upCtx), IsNil) + + c.Check(currentProfilePath, testutil.FileEquals, + `tmpfs /usr/share tmpfs x-snapd.synthetic,x-snapd.needed-by=/usr/share/mysnap 0 0 +/usr/share/adduser /usr/share/adduser none bind,ro,x-snapd.synthetic,x-snapd.needed-by=/usr/share/mysnap 0 0 +/usr/share/awk /usr/share/awk none bind,ro,x-snapd.synthetic,x-snapd.needed-by=/usr/share/mysnap 0 0 +/snap/mysnap/42/usr/share/mysnap /usr/share/mysnap none bind,ro 0 0 +`) +} + +func (s *mainSuite) TestRemovingSyntheticChanges(c *C) { + dirs.SetRootDir(c.MkDir()) + defer dirs.SetRootDir("/") + + c.Assert(os.MkdirAll(dirs.FeaturesDir, 0755), IsNil) + c.Assert(os.WriteFile(features.RobustMountNamespaceUpdates.ControlFile(), []byte(nil), 0644), IsNil) + + // The snap `mysnap` no longer wishes to export it's usr/share/mysnap + // directory. All the synthetic changes that were associated with that mount + // entry can be discarded. + const snapName = "mysnap" + const currentProfileContent = `tmpfs /usr/share tmpfs x-snapd.synthetic,x-snapd.needed-by=/usr/share/mysnap 0 0 +/usr/share/adduser /usr/share/adduser none bind,ro,x-snapd.synthetic,x-snapd.needed-by=/usr/share/mysnap 0 0 +/usr/share/awk /usr/share/awk none bind,ro,x-snapd.synthetic,x-snapd.needed-by=/usr/share/mysnap 0 0 +/snap/mysnap/42/usr/share/mysnap /usr/share/mysnap none bind,ro 0 0 +` + const desiredProfileContent = "" + + currentProfilePath := fmt.Sprintf("%s/snap.%s.fstab", dirs.SnapRunNsDir, snapName) + desiredProfilePath := fmt.Sprintf("%s/snap.%s.fstab", dirs.SnapMountPolicyDir, snapName) + + c.Assert(os.MkdirAll(filepath.Dir(currentProfilePath), 0755), IsNil) + c.Assert(os.MkdirAll(filepath.Dir(desiredProfilePath), 0755), IsNil) + c.Assert(os.WriteFile(currentProfilePath, []byte(currentProfileContent), 0644), IsNil) + c.Assert(os.WriteFile(desiredProfilePath, []byte(desiredProfileContent), 0644), IsNil) + + n := -1 + restore := update.MockChangePerform(func(chg *update.Change, as *update.Assumptions) ([]*update.Change, error) { + n++ + switch n { + case 0: + c.Assert(chg, DeepEquals, &update.Change{ + Action: update.Unmount, + Entry: osutil.MountEntry{ + Name: "/snap/mysnap/42/usr/share/mysnap", + Dir: "/usr/share/mysnap", Type: "none", + Options: []string{"bind", "ro", "x-snapd.detach"}, + }, + }) + case 1: + c.Check(chg, DeepEquals, &update.Change{ + Action: update.Unmount, + Entry: osutil.MountEntry{ + Name: "/usr/share/awk", Dir: "/usr/share/awk", Type: "none", + Options: []string{"bind", "ro", "x-snapd.synthetic", "x-snapd.needed-by=/usr/share/mysnap", "x-snapd.detach"}, + }, + }) + case 2: + c.Check(chg, DeepEquals, &update.Change{ + Action: update.Unmount, + Entry: osutil.MountEntry{ + Name: "/usr/share/adduser", Dir: "/usr/share/adduser", Type: "none", + Options: []string{"bind", "ro", "x-snapd.synthetic", "x-snapd.needed-by=/usr/share/mysnap", "x-snapd.detach"}, + }, + }) + case 3: + c.Check(chg, DeepEquals, &update.Change{ + Action: update.Unmount, + Entry: osutil.MountEntry{ + Name: "tmpfs", Dir: "/usr/share", Type: "tmpfs", + Options: []string{"x-snapd.synthetic", "x-snapd.needed-by=/usr/share/mysnap", "x-snapd.detach"}, + }, + }) + default: + panic(fmt.Sprintf("unexpected call n=%d, chg: %v", n, *chg)) + } + return nil, nil + }) + defer restore() + + upCtx := update.NewSystemProfileUpdateContext(snapName, false) + c.Assert(update.ExecuteMountProfileUpdate(upCtx), IsNil) + + c.Check(currentProfilePath, testutil.FileEquals, "") +} + +func (s *mainSuite) TestApplyingLayoutChanges(c *C) { + dirs.SetRootDir(c.MkDir()) + defer dirs.SetRootDir("/") + + const snapName = "mysnap" + const currentProfileContent = "" + const desiredProfileContent = "/snap/mysnap/42/usr/share/mysnap /usr/share/mysnap none bind,ro,x-snapd.origin=layout 0 0" + + currentProfilePath := fmt.Sprintf("%s/snap.%s.fstab", dirs.SnapRunNsDir, snapName) + desiredProfilePath := fmt.Sprintf("%s/snap.%s.fstab", dirs.SnapMountPolicyDir, snapName) + + c.Assert(os.MkdirAll(filepath.Dir(currentProfilePath), 0755), IsNil) + c.Assert(os.MkdirAll(filepath.Dir(desiredProfilePath), 0755), IsNil) + c.Assert(os.WriteFile(currentProfilePath, []byte(currentProfileContent), 0644), IsNil) + c.Assert(os.WriteFile(desiredProfilePath, []byte(desiredProfileContent), 0644), IsNil) + + n := -1 + restore := update.MockChangePerform(func(chg *update.Change, as *update.Assumptions) ([]*update.Change, error) { + n++ + switch n { + case 0: + c.Assert(chg, DeepEquals, &update.Change{ + Action: update.Mount, + Entry: osutil.MountEntry{ + Name: "/snap/mysnap/42/usr/share/mysnap", + Dir: "/usr/share/mysnap", Type: "none", + Options: []string{"bind", "ro", "x-snapd.origin=layout"}, + }, + }) + return nil, fmt.Errorf("testing") + default: + panic(fmt.Sprintf("unexpected call n=%d, chg: %v", n, *chg)) + } + }) + defer restore() + + // The error was not ignored, we bailed out. + upCtx := update.NewSystemProfileUpdateContext(snapName, false) + c.Assert(update.ExecuteMountProfileUpdate(upCtx), ErrorMatches, "testing") + + c.Check(currentProfilePath, testutil.FileEquals, "") +} + +func (s *mainSuite) TestApplyingParallelInstanceChanges(c *C) { + dirs.SetRootDir(c.MkDir()) + defer dirs.SetRootDir("/") + + const snapName = "mysnap" + const currentProfileContent = "" + const desiredProfileContent = "/snap/mysnap_foo /snap/mysnap none rbind,x-snapd.origin=overname 0 0" + + currentProfilePath := fmt.Sprintf("%s/snap.%s.fstab", dirs.SnapRunNsDir, snapName) + desiredProfilePath := fmt.Sprintf("%s/snap.%s.fstab", dirs.SnapMountPolicyDir, snapName) + + c.Assert(os.MkdirAll(filepath.Dir(currentProfilePath), 0755), IsNil) + c.Assert(os.MkdirAll(filepath.Dir(desiredProfilePath), 0755), IsNil) + c.Assert(os.WriteFile(currentProfilePath, []byte(currentProfileContent), 0644), IsNil) + c.Assert(os.WriteFile(desiredProfilePath, []byte(desiredProfileContent), 0644), IsNil) + + n := -1 + restore := update.MockChangePerform(func(chg *update.Change, as *update.Assumptions) ([]*update.Change, error) { + n++ + switch n { + case 0: + c.Assert(chg, DeepEquals, &update.Change{ + Action: update.Mount, + Entry: osutil.MountEntry{ + Name: "/snap/mysnap_foo", + Dir: "/snap/mysnap", Type: "none", + Options: []string{"rbind", "x-snapd.origin=overname"}, + }, + }) + return nil, fmt.Errorf("testing") + default: + panic(fmt.Sprintf("unexpected call n=%d, chg: %v", n, *chg)) + } + }) + defer restore() + + // The error was not ignored, we bailed out. + upCtx := update.NewSystemProfileUpdateContext(snapName, false) + c.Assert(update.ExecuteMountProfileUpdate(upCtx), ErrorMatches, "testing") + + c.Check(currentProfilePath, testutil.FileEquals, "") +} + +func (s *mainSuite) TestApplyIgnoredMissingMount(c *C) { + dirs.SetRootDir(c.MkDir()) + defer dirs.SetRootDir("/") + + const snapName = "mysnap" + const currentProfileContent = "" + const desiredProfileContent = "/source /target none bind,x-snapd.ignore-missing 0 0" + + currentProfilePath := fmt.Sprintf("%s/snap.%s.fstab", dirs.SnapRunNsDir, snapName) + desiredProfilePath := fmt.Sprintf("%s/snap.%s.fstab", dirs.SnapMountPolicyDir, snapName) + + c.Assert(os.MkdirAll(filepath.Dir(currentProfilePath), 0755), IsNil) + c.Assert(os.MkdirAll(filepath.Dir(desiredProfilePath), 0755), IsNil) + c.Assert(os.WriteFile(currentProfilePath, []byte(currentProfileContent), 0644), IsNil) + c.Assert(os.WriteFile(desiredProfilePath, []byte(desiredProfileContent), 0644), IsNil) + + n := -1 + restore := update.MockChangePerform(func(chg *update.Change, as *update.Assumptions) ([]*update.Change, error) { + n++ + switch n { + case 0: + c.Assert(chg, DeepEquals, &update.Change{ + Action: update.Mount, + Entry: osutil.MountEntry{ + Name: "/source", + Dir: "/target", + Type: "none", + Options: []string{"bind", "x-snapd.ignore-missing"}, + }, + }) + return nil, update.ErrIgnoredMissingMount + default: + panic(fmt.Sprintf("unexpected call n=%d, chg: %v", n, *chg)) + } + }) + defer restore() + + // The error was ignored, and no mount was recorded in the profile + upCtx := update.NewSystemProfileUpdateContext(snapName, false) + c.Assert(update.ExecuteMountProfileUpdate(upCtx), IsNil) + c.Check(s.log.String(), Equals, "") + c.Check(currentProfilePath, testutil.FileEquals, "") +} + +func (s *mainSuite) TestApplyUserFstabHomeRequiredAndValid(c *C) { + dirs.SetRootDir(c.MkDir()) + defer dirs.SetRootDir("/") + + var changes []update.Change + restore := update.MockChangePerform(func(chg *update.Change, as *update.Assumptions) ([]*update.Change, error) { + changes = append(changes, *chg) + return nil, nil + }) + defer restore() + + snapName := "foo" + desiredProfileContent := `$XDG_RUNTIME_DIR/doc/by-app/snap.foo $XDG_RUNTIME_DIR/doc none bind,rw 0 0 +none $HOME/.local/share none x-snapd.kind=ensure-dir,x-snapd.must-exist-dir=$HOME 0 0` + desiredProfilePath := fmt.Sprintf("%s/snap.%s.user-fstab", dirs.SnapMountPolicyDir, snapName) + err := os.MkdirAll(filepath.Dir(desiredProfilePath), 0755) + c.Assert(err, IsNil) + err = os.WriteFile(desiredProfilePath, []byte(desiredProfileContent), 0644) + c.Assert(err, IsNil) + + tmpHomeDir := c.MkDir() + restoreEnv := update.MockSnapConfineUserEnv("/run/user/1000/snap.snapname", tmpHomeDir) + defer restoreEnv() + upCtx, err := update.NewUserProfileUpdateContext(snapName, true, 1000) + c.Assert(err, IsNil) + err = update.ExecuteMountProfileUpdate(upCtx) + c.Assert(err, IsNil) + c.Assert(changes, HasLen, 2) + + c.Assert(changes[0].Entry.Name, Equals, "none") + c.Assert(changes[0].Entry.Dir, Equals, tmpHomeDir+"/.local/share") + c.Assert(changes[0].Entry.XSnapdMustExistDir(), Equals, tmpHomeDir) + + xdgRuntimeDir := fmt.Sprintf("%s/%d", dirs.XdgRuntimeDirBase, 1000) + c.Assert(changes[1].Action, Equals, update.Mount) + c.Assert(changes[1].Entry.Name, Equals, xdgRuntimeDir+"/doc/by-app/snap.foo") + c.Assert(changes[1].Entry.Dir, Matches, xdgRuntimeDir+"/doc") +} + +func (s *mainSuite) TestApplyUserFstabErrorHomeRequiredAndMissing(c *C) { + dirs.SetRootDir(c.MkDir()) + defer dirs.SetRootDir("/") + var changes []update.Change + restore := update.MockChangePerform(func(chg *update.Change, as *update.Assumptions) ([]*update.Change, error) { + changes = append(changes, *chg) + return nil, nil + }) + defer restore() + + snapName := "foo" + desiredProfileContent := `$XDG_RUNTIME_DIR/doc/by-app/snap.foo $XDG_RUNTIME_DIR/doc none bind,rw 0 0 +none $HOME/.local/share none x-snapd.kind=ensure-dir,x-snapd.must-exist-dir=$HOME 0 0` + desiredProfilePath := fmt.Sprintf("%s/snap.%s.user-fstab", dirs.SnapMountPolicyDir, snapName) + err := os.MkdirAll(filepath.Dir(desiredProfilePath), 0755) + c.Assert(err, IsNil) + err = os.WriteFile(desiredProfilePath, []byte(desiredProfileContent), 0644) + c.Assert(err, IsNil) + + tmpHomeDir := c.MkDir() + "/does-not-exist" + restoreEnv := update.MockSnapConfineUserEnv("/run/user/1000/snap.snapname", tmpHomeDir) + defer restoreEnv() + upCtx, err := update.NewUserProfileUpdateContext(snapName, true, 1000) + c.Assert(err, IsNil) + err = update.ExecuteMountProfileUpdate(upCtx) + c.Assert(err, ErrorMatches, `cannot expand mount entry \(none \$HOME/.local/share none x-snapd.kind=ensure-dir,x-snapd.must-exist-dir=\$HOME 0 0\): cannot use invalid home directory `+fmt.Sprintf("\"%s\"", tmpHomeDir)+": no such file or directory") + c.Assert(changes, HasLen, 0) +} diff --git a/cmd/snap-update-ns/secure_bindmount.go b/cmd/snap-update-ns/secure_bindmount.go new file mode 100644 index 00000000..c000d55b --- /dev/null +++ b/cmd/snap-update-ns/secure_bindmount.go @@ -0,0 +1,97 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2018 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "fmt" + "syscall" +) + +// BindMount performs a bind mount between two absolute paths containing no +// symlinks. +func BindMount(sourceDir, targetDir string, flags uint) error { + // This function only attempts to handle bind mounts. Expanding to other + // mounts will require examining do_mount() from fs/namespace.c of the + // kernel that called functions (eventually) verify `DCACHE_CANT_MOUNT` is + // not set (eg, by calling lock_mount()). + if flags&syscall.MS_BIND == 0 { + return fmt.Errorf("cannot perform non-bind mount operation") + } + + // The kernel doesn't support recursively switching a tree of bind mounts + // to read only, and we haven't written a work around. + if flags&syscall.MS_RDONLY != 0 && flags&syscall.MS_REC != 0 { + return fmt.Errorf("cannot use MS_RDONLY and MS_REC together") + } + + // Step 1: acquire file descriptors representing the source and destination + // directories, ensuring no symlinks are followed. + sourceFd, err := OpenPath(sourceDir) + if err != nil { + return err + } + defer sysClose(sourceFd) + targetFd, err := OpenPath(targetDir) + if err != nil { + return err + } + defer sysClose(targetFd) + + // Step 2: perform a bind mount between the paths identified by the two + // file descriptors. We primarily care about privilege escalation here and + // trying to race the sysMount() by removing any part of the dir (sourceDir + // or targetDir) after we have an open file descriptor to it (sourceFd or + // targetFd) to then replace an element of the dir's path with a symlink + // will cause the fd path (ie, sourceFdPath or targetFdPath) to be marked + // as unmountable within the kernel (this path is also changed to show as + // '(deleted)'). Alternatively, simply renaming the dir (sourceDir or + // targetDir) after we have an open file descriptor to it (sourceFd or + // targetFd) causes the mount to happen with the newly renamed path, but + // this rename is controlled by DAC so while the user could race the mount + // source or target, this rename can't be used to gain privileged access to + // files. For systems with AppArmor enabled, this raced rename would be + // denied by the per-snap snap-update-ns AppArmor profle. + sourceFdPath := fmt.Sprintf("/proc/self/fd/%d", sourceFd) + targetFdPath := fmt.Sprintf("/proc/self/fd/%d", targetFd) + bindFlags := syscall.MS_BIND | (flags & syscall.MS_REC) + if err := sysMount(sourceFdPath, targetFdPath, "", uintptr(bindFlags), ""); err != nil { + return err + } + + // Step 3: optionally change to readonly + if flags&syscall.MS_RDONLY != 0 { + // We need to look up the target directory a second time, because + // targetFd refers to the path shadowed by the mount point. + mountFd, err := OpenPath(targetDir) + if err != nil { + // FIXME: the mount occurred, but the user moved the target + // somewhere + return err + } + defer sysClose(mountFd) + mountFdPath := fmt.Sprintf("/proc/self/fd/%d", mountFd) + remountFlags := syscall.MS_REMOUNT | syscall.MS_BIND | syscall.MS_RDONLY + if err := sysMount("none", mountFdPath, "", uintptr(remountFlags), ""); err != nil { + sysUnmount(mountFdPath, syscall.MNT_DETACH|umountNoFollow) + return err + } + } + return nil +} diff --git a/cmd/snap-update-ns/secure_bindmount_test.go b/cmd/snap-update-ns/secure_bindmount_test.go new file mode 100644 index 00000000..9d62cc79 --- /dev/null +++ b/cmd/snap-update-ns/secure_bindmount_test.go @@ -0,0 +1,200 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2018 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "syscall" + + . "gopkg.in/check.v1" + + update "github.com/snapcore/snapd/cmd/snap-update-ns" + "github.com/snapcore/snapd/testutil" +) + +type secureBindMountSuite struct { + testutil.BaseTest + sys *testutil.SyscallRecorder +} + +var _ = Suite(&secureBindMountSuite{}) + +func (s *secureBindMountSuite) SetUpTest(c *C) { + s.BaseTest.SetUpTest(c) + s.sys = &testutil.SyscallRecorder{} + s.BaseTest.AddCleanup(update.MockSystemCalls(s.sys)) +} + +func (s *secureBindMountSuite) TearDownTest(c *C) { + s.sys.CheckForStrayDescriptors(c) + s.BaseTest.TearDownTest(c) +} + +func (s *secureBindMountSuite) TestMount(c *C) { + s.sys.InsertFstatResult(`fstat 5 `, syscall.Stat_t{}) + s.sys.InsertFstatResult(`fstat 6 `, syscall.Stat_t{}) + err := update.BindMount("/source/dir", "/target/dir", syscall.MS_BIND) + c.Assert(err, IsNil) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "source" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 4}, + {C: `openat 4 "dir" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 5}, + {C: `fstat 5 `, R: syscall.Stat_t{}}, + {C: `close 4`}, // "/source" + {C: `close 3`}, // "/" + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "target" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 4}, + {C: `openat 4 "dir" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 6}, + {C: `fstat 6 `, R: syscall.Stat_t{}}, + {C: `close 4`}, // "/target" + {C: `close 3`}, // "/" + {C: `mount "/proc/self/fd/5" "/proc/self/fd/6" "" MS_BIND ""`}, + {C: `close 6`}, // "/target/dir" + {C: `close 5`}, // "/source/dir" + }) +} + +func (s *secureBindMountSuite) TestMountRecursive(c *C) { + s.sys.InsertFstatResult(`fstat 5 `, syscall.Stat_t{}) + s.sys.InsertFstatResult(`fstat 6 `, syscall.Stat_t{}) + err := update.BindMount("/source/dir", "/target/dir", syscall.MS_BIND|syscall.MS_REC) + c.Assert(err, IsNil) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "source" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 4}, + {C: `openat 4 "dir" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 5}, + {C: `fstat 5 `, R: syscall.Stat_t{}}, + {C: `close 4`}, // "/source" + {C: `close 3`}, // "/" + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "target" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 4}, + {C: `openat 4 "dir" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 6}, + {C: `fstat 6 `, R: syscall.Stat_t{}}, + {C: `close 4`}, // "/target" + {C: `close 3`}, // "/" + {C: `mount "/proc/self/fd/5" "/proc/self/fd/6" "" MS_BIND|MS_REC ""`}, + {C: `close 6`}, // "/target/dir" + {C: `close 5`}, // "/source/dir" + }) +} + +func (s *secureBindMountSuite) TestMountReadOnly(c *C) { + s.sys.InsertFstatResult(`fstat 5 `, syscall.Stat_t{}) + s.sys.InsertFstatResult(`fstat 6 `, syscall.Stat_t{}) + s.sys.InsertFstatResult(`fstat 7 `, syscall.Stat_t{}) + err := update.BindMount("/source/dir", "/target/dir", syscall.MS_BIND|syscall.MS_RDONLY) + c.Assert(err, IsNil) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "source" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 4}, + {C: `openat 4 "dir" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 5}, + {C: `fstat 5 `, R: syscall.Stat_t{}}, + {C: `close 4`}, // "/source" + {C: `close 3`}, // "/" + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "target" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 4}, + {C: `openat 4 "dir" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 6}, + {C: `fstat 6 `, R: syscall.Stat_t{}}, + {C: `close 4`}, // "/target" + {C: `close 3`}, // "/" + {C: `mount "/proc/self/fd/5" "/proc/self/fd/6" "" MS_BIND ""`}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "target" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 4}, + {C: `openat 4 "dir" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 7}, + {C: `fstat 7 `, R: syscall.Stat_t{}}, + {C: `close 4`}, // "/target" + {C: `close 3`}, // "/" + {C: `mount "none" "/proc/self/fd/7" "" MS_REMOUNT|MS_BIND|MS_RDONLY ""`}, + {C: `close 7`}, // "/target/dir" + {C: `close 6`}, // "/target/dir" + {C: `close 5`}, // "/source/dir" + }) +} + +func (s *secureBindMountSuite) TestBindFlagRequired(c *C) { + err := update.BindMount("/source/dir", "/target/dir", syscall.MS_REC) + c.Assert(err, ErrorMatches, "cannot perform non-bind mount operation") + c.Check(s.sys.RCalls(), HasLen, 0) +} + +func (s *secureBindMountSuite) TestMountReadOnlyRecursive(c *C) { + err := update.BindMount("/source/dir", "/target/dir", syscall.MS_BIND|syscall.MS_RDONLY|syscall.MS_REC) + c.Assert(err, ErrorMatches, "cannot use MS_RDONLY and MS_REC together") + c.Check(s.sys.RCalls(), HasLen, 0) +} + +func (s *secureBindMountSuite) TestBindMountFails(c *C) { + s.sys.InsertFstatResult(`fstat 5 `, syscall.Stat_t{}) + s.sys.InsertFstatResult(`fstat 6 `, syscall.Stat_t{}) + s.sys.InsertFault(`mount "/proc/self/fd/5" "/proc/self/fd/6" "" MS_BIND ""`, errTesting) + err := update.BindMount("/source/dir", "/target/dir", syscall.MS_BIND|syscall.MS_RDONLY) + c.Assert(err, ErrorMatches, "testing") + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "source" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 4}, + {C: `openat 4 "dir" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 5}, + {C: `fstat 5 `, R: syscall.Stat_t{}}, + {C: `close 4`}, // "/source" + {C: `close 3`}, // "/" + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "target" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 4}, + {C: `openat 4 "dir" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 6}, + {C: `fstat 6 `, R: syscall.Stat_t{}}, + {C: `close 4`}, // "/target" + {C: `close 3`}, // "/" + {C: `mount "/proc/self/fd/5" "/proc/self/fd/6" "" MS_BIND ""`, E: errTesting}, + {C: `close 6`}, // "/target/dir" + {C: `close 5`}, // "/source/dir" + }) +} + +func (s *secureBindMountSuite) TestRemountReadOnlyFails(c *C) { + s.sys.InsertFstatResult(`fstat 5 `, syscall.Stat_t{}) + s.sys.InsertFstatResult(`fstat 6 `, syscall.Stat_t{}) + s.sys.InsertFstatResult(`fstat 7 `, syscall.Stat_t{}) + s.sys.InsertFault(`mount "none" "/proc/self/fd/7" "" MS_REMOUNT|MS_BIND|MS_RDONLY ""`, errTesting) + err := update.BindMount("/source/dir", "/target/dir", syscall.MS_BIND|syscall.MS_RDONLY) + c.Assert(err, ErrorMatches, "testing") + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "source" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 4}, + {C: `openat 4 "dir" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 5}, + {C: `fstat 5 `, R: syscall.Stat_t{}}, + {C: `close 4`}, // "/source" + {C: `close 3`}, // "/" + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "target" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 4}, + {C: `openat 4 "dir" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 6}, + {C: `fstat 6 `, R: syscall.Stat_t{}}, + {C: `close 4`}, // "/target" + {C: `close 3`}, // "/" + {C: `mount "/proc/self/fd/5" "/proc/self/fd/6" "" MS_BIND ""`}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "target" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 4}, + {C: `openat 4 "dir" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 7}, + {C: `fstat 7 `, R: syscall.Stat_t{}}, + {C: `close 4`}, // "/target" + {C: `close 3`}, // "/" + {C: `mount "none" "/proc/self/fd/7" "" MS_REMOUNT|MS_BIND|MS_RDONLY ""`, E: errTesting}, + {C: `unmount "/proc/self/fd/7" UMOUNT_NOFOLLOW|MNT_DETACH`}, + {C: `close 7`}, // "/target/dir" + {C: `close 6`}, // "/target/dir" + {C: `close 5`}, // "/source/dir" + }) +} diff --git a/cmd/snap-update-ns/sorting.go b/cmd/snap-update-ns/sorting.go new file mode 100644 index 00000000..43363e27 --- /dev/null +++ b/cmd/snap-update-ns/sorting.go @@ -0,0 +1,110 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "strings" + + "github.com/snapcore/snapd/osutil" +) + +// byOvernameAndMountPoint allows sorting an array of entries by the +// source of mount entry (whether it's an overname or not) and +// lexically by mount point name. Automagically adds a trailing slash +// to paths. +type byOvernameAndMountPoint []osutil.MountEntry + +func (c byOvernameAndMountPoint) Len() int { return len(c) } +func (c byOvernameAndMountPoint) Swap(i, j int) { c[i], c[j] = c[j], c[i] } +func (c byOvernameAndMountPoint) Less(i, j int) bool { + iMe := c[i] + jMe := c[j] + + iOrigin := iMe.XSnapdOrigin() + jOrigin := jMe.XSnapdOrigin() + if iOrigin != jOrigin { + // overname entries should always be sorted first, before + // entries from layouts or content interface + if iOrigin == "overname" { + // overname ith element should be sorted before + return true + } + if jOrigin == "overname" { + // non-overname ith element should be sorted after + return false + } + } + + iDir := c[i].Dir + jDir := c[j].Dir + if !strings.HasSuffix(iDir, "/") { + iDir = iDir + "/" + } + if !strings.HasSuffix(jDir, "/") { + jDir = jDir + "/" + } + return iDir < jDir +} + +// byOriginAndMountPoint allows sorting an array of entries by the +// source of mount entry (overname, other, layout) and lexically by +// mount point name. Automagically adds a trailing slash to paths. +type byOriginAndMountPoint []osutil.MountEntry + +func (c byOriginAndMountPoint) Len() int { return len(c) } +func (c byOriginAndMountPoint) Swap(i, j int) { c[i], c[j] = c[j], c[i] } +func (c byOriginAndMountPoint) Less(i, j int) bool { + iMe := c[i] + jMe := c[j] + + iOrigin := iMe.XSnapdOrigin() + jOrigin := jMe.XSnapdOrigin() + if iOrigin != jOrigin { + // overname entries should always be sorted first, before + // entries from layouts or content interface + if iOrigin == "overname" { + // overname ith element should be sorted before + return true + } + if jOrigin == "overname" { + // non-overname ith element should be sorted after + return false + } + // neither is overname, can be layout or nothing (implied + // content) + if iOrigin == "layout" { + return false + } + // i is not layout, so it must be nothing (implied content) + if jOrigin == "layout" { + return true + } + } + + iDir := c[i].Dir + jDir := c[j].Dir + if !strings.HasSuffix(iDir, "/") { + iDir = iDir + "/" + } + if !strings.HasSuffix(jDir, "/") { + jDir = jDir + "/" + } + return iDir < jDir +} diff --git a/cmd/snap-update-ns/sorting_test.go b/cmd/snap-update-ns/sorting_test.go new file mode 100644 index 00000000..6d1c0b0c --- /dev/null +++ b/cmd/snap-update-ns/sorting_test.go @@ -0,0 +1,163 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "sort" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/osutil" +) + +type sortSuite struct { + sort func([]osutil.MountEntry) + layoutsLast bool +} + +type byOriginAndMountPointSuite struct { + sortSuite +} + +type byOvernameAndMountPointSuite struct { + sortSuite +} + +var ( + _ = Suite(&byOriginAndMountPointSuite{sortSuite{ + sort: func(entries []osutil.MountEntry) { sort.Sort(byOriginAndMountPoint(entries)) }, + layoutsLast: true, + }}) + _ = Suite(&byOvernameAndMountPointSuite{sortSuite{ + sort: func(entries []osutil.MountEntry) { sort.Sort(byOvernameAndMountPoint(entries)) }, + }}) +) + +func (s *sortSuite) TestTrailingSlashesComparison(c *C) { + // Naively sorted entries. + entries := []osutil.MountEntry{ + {Dir: "/a/b"}, + {Dir: "/a/b-1"}, + {Dir: "/a/b-1/3"}, + {Dir: "/a/b/c"}, + } + s.sort(entries) + // Entries sorted as if they had a trailing slash. + c.Assert(entries, DeepEquals, []osutil.MountEntry{ + {Dir: "/a/b-1"}, + {Dir: "/a/b-1/3"}, + {Dir: "/a/b"}, + {Dir: "/a/b/c"}, + }) +} + +func (s *sortSuite) TestParallelInstancesAndSimple(c *C) { + // Naively sorted entries. + entries := []osutil.MountEntry{ + {Dir: "/a/b-1"}, + {Dir: "/a/b", Options: []string{osutil.XSnapdOriginLayout()}}, + {Dir: "/snap/bar/baz", Options: []string{osutil.XSnapdOriginLayout()}}, + {Dir: "/snap/bar", Options: []string{osutil.XSnapdOriginOvername()}}, + {Dir: "/a/b-1/3"}, + {Dir: "/var/snap/bar", Options: []string{osutil.XSnapdOriginOvername()}}, + {Dir: "/a/b/c"}, + } + s.sort(entries) + // Entries sorted as if they had a trailing slash. + expected := []osutil.MountEntry{ + {Dir: "/snap/bar", Options: []string{osutil.XSnapdOriginOvername()}}, + {Dir: "/var/snap/bar", Options: []string{osutil.XSnapdOriginOvername()}}, + {Dir: "/a/b-1"}, + {Dir: "/a/b-1/3"}, + } + if s.layoutsLast { + expected = append(expected, []osutil.MountEntry{ + // implicit content before layouts + {Dir: "/a/b/c"}, + // layouts + {Dir: "/a/b", Options: []string{osutil.XSnapdOriginLayout()}}, + {Dir: "/snap/bar/baz", Options: []string{osutil.XSnapdOriginLayout()}}, + }...) + } else { + expected = append(expected, []osutil.MountEntry{ + // just lexicographic + {Dir: "/a/b", Options: []string{osutil.XSnapdOriginLayout()}}, + {Dir: "/a/b/c"}, + {Dir: "/snap/bar/baz", Options: []string{osutil.XSnapdOriginLayout()}}, + }...) + + } + c.Assert(entries, DeepEquals, expected) +} + +func (s *sortSuite) TestOvernameOrder(c *C) { + expected := []osutil.MountEntry{ + {Dir: "/a/b/2", Options: []string{osutil.XSnapdOriginOvername()}}, + {Dir: "/a/b"}, + } + entries := []osutil.MountEntry{ + {Dir: "/a/b"}, + {Dir: "/a/b/2", Options: []string{osutil.XSnapdOriginOvername()}}, + } + entriesRev := []osutil.MountEntry{ + {Dir: "/a/b/2", Options: []string{osutil.XSnapdOriginOvername()}}, + {Dir: "/a/b"}, + } + s.sort(entries) + c.Assert(entries, DeepEquals, expected) + s.sort(entriesRev) + c.Assert(entriesRev, DeepEquals, expected) +} + +func (s *sortSuite) TestParallelInstancesAlmostSorted(c *C) { + // use a mount profile that was seen to be broken in the wild + entries := []osutil.MountEntry{ + {Dir: "/snap/foo", Options: []string{osutil.XSnapdOriginOvername()}}, + {Dir: "/var/snap/foo", Options: []string{osutil.XSnapdOriginOvername()}}, + {Dir: "/snap/foo/44/foo/certs", Options: []string{osutil.XSnapdOriginLayout()}}, + {Dir: "/snap/foo/44/foo/config", Options: []string{osutil.XSnapdOriginLayout()}}, + {Dir: "/snap/foo/44/usr/bin/python", Options: []string{osutil.XSnapdOriginLayout()}}, + {Dir: "/snap/foo/44/usr/bin/python3", Options: []string{osutil.XSnapdOriginLayout()}}, + {Dir: "/usr/bin/java", Options: []string{osutil.XSnapdOriginLayout()}}, + {Dir: "/usr/bin/java8", Options: []string{osutil.XSnapdOriginLayout()}}, + {Dir: "/usr/bin/node", Options: []string{osutil.XSnapdOriginLayout()}}, + {Dir: "/usr/bin/nodejs12x", Options: []string{osutil.XSnapdOriginLayout()}}, + {Dir: "/usr/bin/python2.7", Options: []string{osutil.XSnapdOriginLayout()}}, + {Dir: "/usr/bin/python3.7", Options: []string{osutil.XSnapdOriginLayout()}}, + {Dir: "/usr/bin/python3.8", Options: []string{osutil.XSnapdOriginLayout()}}, + } + s.sort(entries) + // overname entries are always first + c.Assert(entries, DeepEquals, []osutil.MountEntry{ + {Dir: "/snap/foo", Options: []string{osutil.XSnapdOriginOvername()}}, + {Dir: "/var/snap/foo", Options: []string{osutil.XSnapdOriginOvername()}}, + {Dir: "/snap/foo/44/foo/certs", Options: []string{osutil.XSnapdOriginLayout()}}, + {Dir: "/snap/foo/44/foo/config", Options: []string{osutil.XSnapdOriginLayout()}}, + {Dir: "/snap/foo/44/usr/bin/python", Options: []string{osutil.XSnapdOriginLayout()}}, + {Dir: "/snap/foo/44/usr/bin/python3", Options: []string{osutil.XSnapdOriginLayout()}}, + {Dir: "/usr/bin/java", Options: []string{osutil.XSnapdOriginLayout()}}, + {Dir: "/usr/bin/java8", Options: []string{osutil.XSnapdOriginLayout()}}, + {Dir: "/usr/bin/node", Options: []string{osutil.XSnapdOriginLayout()}}, + {Dir: "/usr/bin/nodejs12x", Options: []string{osutil.XSnapdOriginLayout()}}, + {Dir: "/usr/bin/python2.7", Options: []string{osutil.XSnapdOriginLayout()}}, + {Dir: "/usr/bin/python3.7", Options: []string{osutil.XSnapdOriginLayout()}}, + {Dir: "/usr/bin/python3.8", Options: []string{osutil.XSnapdOriginLayout()}}, + }) +} diff --git a/cmd/snap-update-ns/system.go b/cmd/snap-update-ns/system.go new file mode 100644 index 00000000..18d80ac3 --- /dev/null +++ b/cmd/snap-update-ns/system.go @@ -0,0 +1,103 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "fmt" + "os" + + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/snap" +) + +// SystemProfileUpdateContext contains information about update to system-wide mount namespace. +type SystemProfileUpdateContext struct { + CommonProfileUpdateContext +} + +// NewSystemProfileUpdateContext returns encapsulated information for performing a per-user mount namespace update. +func NewSystemProfileUpdateContext(instanceName string, fromSnapConfine bool) *SystemProfileUpdateContext { + return &SystemProfileUpdateContext{CommonProfileUpdateContext: CommonProfileUpdateContext{ + instanceName: instanceName, + fromSnapConfine: fromSnapConfine, + currentProfilePath: currentSystemProfilePath(instanceName), + desiredProfilePath: desiredSystemProfilePath(instanceName), + }} +} + +// Assumptions returns information about file system mutability rules. +// +// System mount profiles can write to /tmp (this is required for constructing +// writable mimics) to /var/snap (where $SNAP_DATA is for services), /snap/$SNAP_NAME, +// and, in case of instances, /snap/$SNAP_INSTANCE_NAME. +func (upCtx *SystemProfileUpdateContext) Assumptions() *Assumptions { + // Allow creating directories related to this snap name. + // + // Note that we allow /var/snap instead of /var/snap/$SNAP_NAME because + // content interface connections can readily create missing mount points on + // both sides of the interface connection. + // + // We scope /snap/$SNAP_NAME because only one side of the connection can be + // created, as snaps are read-only, the mimic construction will kick-in and + // create the missing directory but this directory is only visible from the + // snap that we are operating on (either plug or slot side, the point is, + // the mount point is not universally visible). + // + // /snap/$SNAP_NAME needs to be there as the code that creates such mount + // points must traverse writable host filesystem that contains /snap/*/ and + // normally such access is off-limits. This approach allows /snap/foo + // without allowing /snap/bin, for example. + // + // /snap/$SNAP_INSTANCE_NAME and /snap/$SNAP_NAME are added to allow + // remapping for parallel installs only when the snap has an instance key + as := &Assumptions{} + instanceName := upCtx.InstanceName() + as.AddUnrestrictedPaths("/tmp", "/var/snap", "/snap/"+instanceName, "/dev/shm", "/run/systemd") + if snapName := snap.InstanceSnap(instanceName); snapName != instanceName { + as.AddUnrestrictedPaths("/snap/" + snapName) + } + // Allow snap-update-ns to write to host's /tmp directory. This is + // specifically here to allow two snaps to share X11 sockets that are placed + // in the /tmp/.X11-unix/ directory in the private /tmp directories provided + // by snap-confine. The X11 interface cannot offer a precise permission for + // the slot-side snap, as there is no mechanism to convey this information. + // As such, provide write access to all of /tmp. + as.AddUnrestrictedPaths("/var/lib/snapd/hostfs/tmp") + as.AddModeHint("/var/lib/snapd/hostfs/tmp/snap-private-tmp/snap.*", 0700) + as.AddModeHint("/var/lib/snapd/hostfs/tmp/snap-private-tmp/snap.*/tmp", 0777|os.ModeSticky) + // This is to ensure that unprivileged users can create the socket. This + // permission only matters if the plug-side app constructs its mount + // namespace before the slot-side app is launched. + as.AddModeHint("/var/lib/snapd/hostfs/tmp/snap-private-tmp/snap.*/tmp/.X11-unix", 0777|os.ModeSticky) + // This is to ensure private shared-memory directories have + // the right permissions. + as.AddModeHint("/dev/shm/snap.*", 0777|os.ModeSticky) + return as +} + +// desiredSystemProfilePath returns the path of the fstab-like file with the desired, system-wide mount profile for a snap. +func desiredSystemProfilePath(snapName string) string { + return fmt.Sprintf("%s/snap.%s.fstab", dirs.SnapMountPolicyDir, snapName) +} + +// currentSystemProfilePath returns the path of the fstab-like file with the applied, system-wide mount profile for a snap. +func currentSystemProfilePath(snapName string) string { + return fmt.Sprintf("%s/snap.%s.fstab", dirs.SnapRunNsDir, snapName) +} diff --git a/cmd/snap-update-ns/system_test.go b/cmd/snap-update-ns/system_test.go new file mode 100644 index 00000000..066541a1 --- /dev/null +++ b/cmd/snap-update-ns/system_test.go @@ -0,0 +1,161 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "bytes" + "os" + "path/filepath" + + . "gopkg.in/check.v1" + + update "github.com/snapcore/snapd/cmd/snap-update-ns" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/sandbox/cgroup" + "github.com/snapcore/snapd/testutil" +) + +type systemSuite struct{} + +var _ = Suite(&systemSuite{}) + +func (s *systemSuite) TestLockCgroup(c *C) { + dirs.SetRootDir(c.MkDir()) + defer dirs.SetRootDir("/") + + restore := cgroup.MockVersion(cgroup.V1, nil) + defer restore() + + var frozen []string + var thawed []string + happyFreeze := func(snapName string) error { + frozen = append(frozen, snapName) + return nil + } + happyThaw := func(snapName string) error { + thawed = append(thawed, snapName) + return nil + } + cgroup.MockFreezing(happyFreeze, happyThaw) + + upCtx := update.NewSystemProfileUpdateContext("foo", false) + unlock, err := upCtx.Lock() + c.Assert(err, IsNil) + c.Check(unlock, NotNil) + + c.Check(frozen, DeepEquals, []string{"foo"}) + c.Check(thawed, HasLen, 0) + + unlock() + c.Check(frozen, DeepEquals, []string{"foo"}) + c.Check(thawed, DeepEquals, []string{"foo"}) +} + +func (s *systemSuite) TestAssumptions(c *C) { + // Non-instances can access /tmp, /var/snap and /snap/$SNAP_NAME + upCtx := update.NewSystemProfileUpdateContext("foo", false) + as := upCtx.Assumptions() + c.Check(as.UnrestrictedPaths(), DeepEquals, []string{"/tmp", "/var/snap", "/snap/foo", "/dev/shm", "/run/systemd", "/var/lib/snapd/hostfs/tmp"}) + c.Check(as.ModeForPath("/stuff"), Equals, os.FileMode(0755)) + c.Check(as.ModeForPath("/tmp"), Equals, os.FileMode(0755)) + c.Check(as.ModeForPath("/var/lib/snapd/hostfs/tmp"), Equals, os.FileMode(0755)) + c.Check(as.ModeForPath("/var/lib/snapd/hostfs/tmp/snap-private-tmp/snap.x11-server"), Equals, os.FileMode(0700)) + c.Check(as.ModeForPath("/var/lib/snapd/hostfs/tmp/snap-private-tmp/snap.x11-server/tmp"), Equals, os.FileMode(0777)|os.ModeSticky) + c.Check(as.ModeForPath("/var/lib/snapd/hostfs/tmp/snap-private-tmp/snap.x11-server/foo"), Equals, os.FileMode(0755)) + c.Check(as.ModeForPath("/var/lib/snapd/hostfs/tmp/snap-private-tmp/snap.x11-server/tmp/.X11-unix"), Equals, os.FileMode(0777)|os.ModeSticky) + c.Check(as.ModeForPath("/dev/shm/snap.some-snap"), Equals, os.FileMode(0777)|os.ModeSticky) + + // Instances can, in addition, access /snap/$SNAP_INSTANCE_NAME + upCtx = update.NewSystemProfileUpdateContext("foo_instance", false) + as = upCtx.Assumptions() + c.Check(as.UnrestrictedPaths(), DeepEquals, []string{"/tmp", "/var/snap", "/snap/foo_instance", "/dev/shm", "/run/systemd", "/snap/foo", "/var/lib/snapd/hostfs/tmp"}) +} + +func (s *systemSuite) TestLoadDesiredProfile(c *C) { + // Mock directories. + dirs.SetRootDir(c.MkDir()) + defer dirs.SetRootDir("/") + + upCtx := update.NewSystemProfileUpdateContext("foo", false) + text := "/snap/foo/42/dir /snap/bar/13/dir none bind,rw 0 0\n" + + // Write a desired system mount profile for snap "foo". + path := update.DesiredSystemProfilePath(upCtx.InstanceName()) + c.Assert(os.MkdirAll(filepath.Dir(path), 0755), IsNil) + c.Assert(os.WriteFile(path, []byte(text), 0644), IsNil) + + // Ask the system profile update helper to read the desired profile. + profile, err := upCtx.LoadDesiredProfile() + c.Assert(err, IsNil) + builder := &bytes.Buffer{} + profile.WriteTo(builder) + + c.Check(builder.String(), Equals, text) +} + +func (s *systemSuite) TestLoadCurrentProfile(c *C) { + // Mock directories. + dirs.SetRootDir(c.MkDir()) + defer dirs.SetRootDir("/") + + upCtx := update.NewSystemProfileUpdateContext("foo", false) + text := "/snap/foo/42/dir /snap/bar/13/dir none bind,rw 0 0\n" + + // Write a current system mount profile for snap "foo". + path := update.CurrentSystemProfilePath(upCtx.InstanceName()) + c.Assert(os.MkdirAll(filepath.Dir(path), 0755), IsNil) + c.Assert(os.WriteFile(path, []byte(text), 0644), IsNil) + + // Ask the system profile update helper to read the current profile. + profile, err := upCtx.LoadCurrentProfile() + c.Assert(err, IsNil) + builder := &bytes.Buffer{} + profile.WriteTo(builder) + + // The profile is returned unchanged. + c.Check(builder.String(), Equals, text) +} + +func (s *systemSuite) TestSaveCurrentProfile(c *C) { + // Mock directories and create directory for runtime mount profiles. + dirs.SetRootDir(c.MkDir()) + defer dirs.SetRootDir("/") + c.Assert(os.MkdirAll(dirs.SnapRunNsDir, 0755), IsNil) + + upCtx := update.NewSystemProfileUpdateContext("foo", false) + text := "/snap/foo/42/dir /snap/bar/13/dir none bind,rw 0 0\n" + + // Prepare a mount profile to be saved. + profile, err := osutil.LoadMountProfileText(text) + c.Assert(err, IsNil) + + // Ask the system profile update to write the current profile. + c.Assert(upCtx.SaveCurrentProfile(profile), IsNil) + c.Check(update.CurrentSystemProfilePath(upCtx.InstanceName()), testutil.FileEquals, text) +} + +func (s *systemSuite) TestDesiredSystemProfilePath(c *C) { + c.Check(update.DesiredSystemProfilePath("foo"), Equals, "/var/lib/snapd/mount/snap.foo.fstab") +} + +func (s *systemSuite) TestCurrentSystemProfilePath(c *C) { + c.Check(update.CurrentSystemProfilePath("foo"), Equals, "/run/snapd/ns/snap.foo.fstab") +} diff --git a/cmd/snap-update-ns/trespassing.go b/cmd/snap-update-ns/trespassing.go new file mode 100644 index 00000000..bdccf9dd --- /dev/null +++ b/cmd/snap-update-ns/trespassing.go @@ -0,0 +1,296 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2017-2018 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "syscall" + + "github.com/snapcore/snapd/logger" +) + +// Assumptions track the assumptions about the state of the filesystem. +// +// Assumptions constitute the global part of the write restriction management. +// Assumptions are global in the sense that they span multiple distinct write +// operations. In contrast, Restrictions track per-operation state. +type Assumptions struct { + unrestrictedPaths []string + pastChanges []*Change + + // verifiedDevices represents the set of devices that are verified as a tmpfs + // that was mounted by snapd. Those are only discovered on-demand. The + // major:minor number is packed into one uint64 as in syscall.Stat_t.Dev + // field. + verifiedDevices map[uint64]bool + + // modeHints overrides implicit 0755 mode of directories created while + // ensuring source and target paths exist. + modeHints []ModeHint +} + +// ModeHint provides mode for directories created to satisfy mount changes. +type ModeHint struct { + PathGlob string + Mode os.FileMode +} + +// AddUnrestrictedPaths adds a list of directories where writing is allowed +// even if it would hit the real host filesystem (or transit through the host +// filesystem). This is intended to be used with certain well-known locations +// such as /tmp, $SNAP_DATA and $SNAP. +func (as *Assumptions) AddUnrestrictedPaths(paths ...string) { + as.unrestrictedPaths = append(as.unrestrictedPaths, paths...) +} + +// AddModeHint adds a path glob and mode used when creating path elements. +func (as *Assumptions) AddModeHint(pathGlob string, mode os.FileMode) { + as.modeHints = append(as.modeHints, ModeHint{PathGlob: pathGlob, Mode: mode}) +} + +// ModeForPath returns the mode for creating a directory at a given path. +// +// The default mode is 0755 but AddModeHint calls can influence the mode at a +// specific path. When matching path elements, "*" does not match the directory +// separator. In effect it can only be used as a wildcard for a specific +// directory name. This constraint makes hints easier to model in practice. +// +// When multiple hints match the given path, ModeForPath panics. +func (as *Assumptions) ModeForPath(path string) os.FileMode { + mode := os.FileMode(0755) + var foundHint *ModeHint + for _, hint := range as.modeHints { + if ok, _ := filepath.Match(hint.PathGlob, path); ok { + if foundHint == nil { + mode = hint.Mode + foundHint = &hint + } else { + panic(fmt.Errorf("cannot find unique mode for path %q: %q and %q both provide hints", + path, foundHint.PathGlob, foundHint.PathGlob)) + } + } + } + return mode +} + +// isRestricted checks whether a path falls under restricted writing scheme. +// +// Provided path is the full, absolute path of the entity that needs to be +// created (directory, file or symbolic link). +func (as *Assumptions) isRestricted(path string) bool { + // Anything rooted at one of the unrestricted paths is not restricted. + // Those are for things like /var/snap/, for example. + for _, p := range as.unrestrictedPaths { + if p == "/" || p == path || strings.HasPrefix(path, filepath.Clean(p)+"/") { + return false + } + + } + // All other paths are restricted + return true +} + +// MockUnrestrictedPaths replaces the set of path paths without any restrictions. +func (as *Assumptions) MockUnrestrictedPaths(paths ...string) (restore func()) { + old := as.unrestrictedPaths + as.unrestrictedPaths = paths + return func() { + as.unrestrictedPaths = old + } +} + +// AddChange records the fact that a change was applied to the system. +func (as *Assumptions) AddChange(change *Change) { + as.pastChanges = append(as.pastChanges, change) +} + +// canWriteToDirectory returns true if writing to a given directory is allowed. +// +// Writing is allowed in one of thee cases: +// 1. The directory is in one of the explicitly permitted locations. +// This is the strongest permission as it explicitly allows writing to +// places that may show up on the host, one of the examples being $SNAP_DATA. +// 2. The directory is on a read-only filesystem. +// 3. The directory is on a tmpfs created by snapd. +func (as *Assumptions) canWriteToDirectory(dirFd int, dirName string) (bool, error) { + if !as.isRestricted(dirName) { + return true, nil + } + var fsData syscall.Statfs_t + if err := sysFstatfs(dirFd, &fsData); err != nil { + return false, fmt.Errorf("cannot fstatfs %q: %s", dirName, err) + } + var fileData syscall.Stat_t + if err := sysFstat(dirFd, &fileData); err != nil { + return false, fmt.Errorf("cannot fstat %q: %s", dirName, err) + } + // Writing to read only directories is allowed because EROFS is handled + // by each of the writing helpers already. + if ok := isReadOnly(dirName, &fsData); ok { + return true, nil + } + // Writing to a trusted tmpfs is allowed because those are not leaking to + // the host. Also, each time we find a good tmpfs we explicitly remember the device major/minor, + if as.verifiedDevices[fileData.Dev] { + return true, nil + } + if ok := isPrivateTmpfsCreatedBySnapd(dirName, &fsData, &fileData, as.pastChanges); ok { + if as.verifiedDevices == nil { + as.verifiedDevices = make(map[uint64]bool) + } + // Don't record 0:0 as those are all to easy to add in tests and would + // skew tests using zero-initialized structures. Real device numbers + // are not zero either so this is not a test-only conditional. + if fileData.Dev != 0 { + as.verifiedDevices[fileData.Dev] = true + } + return true, nil + } + // If writing is not not allowed by one of the three rules above then it is + // disallowed. + return false, nil +} + +// RestrictionsFor computes restrictions for the desired path. +func (as *Assumptions) RestrictionsFor(desiredPath string) *Restrictions { + // Writing to a restricted path results in step-by-step validation of each + // directory, starting from the root of the file system. Unless writing is + // allowed a mimic must be constructed to ensure that writes are not visible in + // undesired locations of the host filesystem. + if as.isRestricted(desiredPath) { + return &Restrictions{assumptions: as, desiredPath: desiredPath, restricted: true} + } + return nil +} + +// Restrictions contains meta-data of a compound write operation. +// +// This structure helps functions that write to the filesystem to keep track of +// the ultimate destination across several calls (e.g. the function that +// creates a file needs to call helpers to create subsequent directories). +// Keeping track of the desired path aids in constructing useful error +// messages. +// +// In addition the structure keeps track of the restricted write mode flag which +// is based on the full path of the desired object being constructed. This allows +// various write helpers to avoid trespassing on host filesystem in places that +// are not expected to be written to by snapd (e.g. outside of $SNAP_DATA). +type Restrictions struct { + assumptions *Assumptions + desiredPath string + restricted bool +} + +// Check verifies whether writing to a directory would trespass on the host. +// +// The check is only performed in restricted mode. If the check fails a +// TrespassingError is returned. +func (rs *Restrictions) Check(dirFd int, dirName string) error { + if rs == nil || !rs.restricted { + return nil + } + // In restricted mode check the directory before attempting to write to it. + ok, err := rs.assumptions.canWriteToDirectory(dirFd, dirName) + if ok || err != nil { + return err + } + if dirName == "/" { + // If writing to / is not allowed then we are in a tough spot because + // we cannot construct a writable mimic over /. This should never + // happen in normal circumstances because the root filesystem is some + // kind of base snap. + return fmt.Errorf("cannot recover from trespassing over /") + } + logger.Debugf("trespassing violated %q while striving to %q", dirName, rs.desiredPath) + logger.Debugf("restricted mode: %#v", rs.restricted) + logger.Debugf("unrestricted paths: %q", rs.assumptions.unrestrictedPaths) + logger.Debugf("verified devices: %v", rs.assumptions.verifiedDevices) + logger.Debugf("past changes: %v", rs.assumptions.pastChanges) + return &TrespassingError{ViolatedPath: filepath.Clean(dirName), DesiredPath: rs.desiredPath} +} + +// Lift lifts write restrictions for the desired path. +// +// This function should be called when, as subsequent components of a path are +// either discovered or created, the conditions for using restricted mode are +// no longer true. +func (rs *Restrictions) Lift() { + if rs != nil { + rs.restricted = false + } +} + +// TrespassingError is an error when filesystem operation would affect the host. +type TrespassingError struct { + ViolatedPath string + DesiredPath string +} + +// Error returns a formatted error message. +func (e *TrespassingError) Error() string { + return fmt.Sprintf("cannot write to %q because it would affect the host in %q", e.DesiredPath, e.ViolatedPath) +} + +// isReadOnly checks whether the underlying filesystem is read only or is mounted as such. +func isReadOnly(dirName string, fsData *syscall.Statfs_t) bool { + // If something is mounted with f_flags & ST_RDONLY then is read-only. + if fsData.Flags&StReadOnly == StReadOnly { + return true + } + // If something is a known read-only file-system then it is safe. + // Older copies of snapd were not mounting squashfs as read only. + if fsData.Type == SquashfsMagic { + return true + } + return false +} + +// isPrivateTmpfsCreatedBySnapd checks whether a directory resides on a tmpfs mounted by snapd +// +// The function inspects the directory and a list of changes that were applied +// to the mount namespace. A directory is trusted if it is a tmpfs that was +// mounted by snap-confine or snapd-update-ns. Note that sub-directories of a +// trusted tmpfs are not considered trusted by this function. +func isPrivateTmpfsCreatedBySnapd(dirName string, fsData *syscall.Statfs_t, fileData *syscall.Stat_t, changes []*Change) bool { + // If something is not a tmpfs it cannot be the trusted tmpfs we are looking for. + if fsData.Type != TmpfsMagic { + return false + } + // Any of the past changes that mounted a tmpfs exactly at the directory we + // are inspecting is considered as trusted. This is conservative because it + // doesn't trust sub-directories of a trusted tmpfs. This approach is + // sufficient for the intended use. + // + // The algorithm goes over all the changes in reverse and picks up the + // first tmpfs mount or unmount action that matches the directory name. + // The set of constraints in snap-update-ns and snapd prevent from mounting + // over an existing mount point so we don't need to consider e.g. a bind + // mount shadowing an active tmpfs. + for i := len(changes) - 1; i >= 0; i-- { + change := changes[i] + if change.Entry.Type == "tmpfs" && change.Entry.Dir == dirName { + return change.Action == Mount || change.Action == Keep + } + } + return false +} diff --git a/cmd/snap-update-ns/trespassing_test.go b/cmd/snap-update-ns/trespassing_test.go new file mode 100644 index 00000000..fe55853a --- /dev/null +++ b/cmd/snap-update-ns/trespassing_test.go @@ -0,0 +1,478 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2017-2018 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "syscall" + + . "gopkg.in/check.v1" + + update "github.com/snapcore/snapd/cmd/snap-update-ns" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/testutil" +) + +type trespassingSuite struct { + testutil.BaseTest + sys *testutil.SyscallRecorder +} + +var _ = Suite(&trespassingSuite{}) + +func (s *trespassingSuite) SetUpTest(c *C) { + s.BaseTest.SetUpTest(c) + s.sys = &testutil.SyscallRecorder{} + s.BaseTest.AddCleanup(update.MockSystemCalls(s.sys)) +} + +func (s *trespassingSuite) TearDownTest(c *C) { + s.BaseTest.TearDownTest(c) + s.sys.CheckForStrayDescriptors(c) +} + +// AddUnrestrictedPaths and IsRestricted + +func (s *trespassingSuite) TestAddUnrestrictedPaths(c *C) { + a := &update.Assumptions{} + c.Assert(a.IsRestricted("/etc/test.conf"), Equals, true) + + a.AddUnrestrictedPaths("/etc") + c.Assert(a.IsRestricted("/etc/test.conf"), Equals, false) + c.Assert(a.IsRestricted("/etc/"), Equals, false) + c.Assert(a.IsRestricted("/etc"), Equals, false) + c.Assert(a.IsRestricted("/etc2"), Equals, true) + + a.AddUnrestrictedPaths("/") + c.Assert(a.IsRestricted("/foo"), Equals, false) + +} + +func (s *trespassingSuite) TestMockUnrestrictedPaths(c *C) { + a := &update.Assumptions{} + c.Assert(a.IsRestricted("/etc/test.conf"), Equals, true) + restore := a.MockUnrestrictedPaths("/etc/") + c.Assert(a.IsRestricted("/etc/test.conf"), Equals, false) + restore() + c.Assert(a.IsRestricted("/etc/test.conf"), Equals, true) +} + +// canWriteToDirectory and AddChange + +// We are not allowed to write to ext4. +func (s *trespassingSuite) TestCanWriteToDirectoryWritableExt4(c *C) { + a := &update.Assumptions{} + + path := "/etc" + fd, err := s.sys.Open(path, syscall.O_DIRECTORY, 0) + c.Assert(err, IsNil) + defer s.sys.Close(fd) + + s.sys.InsertFstatfsResult(`fstatfs 3 `, syscall.Statfs_t{Type: update.Ext4Magic}) + s.sys.InsertFstatResult(`fstat 3 `, syscall.Stat_t{}) + + ok, err := a.CanWriteToDirectory(fd, path) + c.Assert(err, IsNil) + c.Assert(ok, Equals, false) +} + +// We are allowed to write to ext4 that was mounted read-only. +func (s *trespassingSuite) TestCanWriteToDirectoryReadOnlyExt4(c *C) { + a := &update.Assumptions{} + + path := "/etc" + fd, err := s.sys.Open(path, syscall.O_DIRECTORY, 0) + c.Assert(err, IsNil) + defer s.sys.Close(fd) + + s.sys.InsertFstatfsResult(`fstatfs 3 `, syscall.Statfs_t{Type: update.Ext4Magic, Flags: update.StReadOnly}) + s.sys.InsertFstatResult(`fstat 3 `, syscall.Stat_t{}) + + ok, err := a.CanWriteToDirectory(fd, path) + c.Assert(err, IsNil) + c.Assert(ok, Equals, true) +} + +// We are not allowed to write to tmpfs. +func (s *trespassingSuite) TestCanWriteToDirectoryTmpfs(c *C) { + a := &update.Assumptions{} + + path := "/etc" + fd, err := s.sys.Open(path, syscall.O_DIRECTORY, 0) + c.Assert(err, IsNil) + defer s.sys.Close(fd) + + s.sys.InsertFstatfsResult(`fstatfs 3 `, syscall.Statfs_t{Type: update.TmpfsMagic}) + s.sys.InsertFstatResult(`fstat 3 `, syscall.Stat_t{}) + + ok, err := a.CanWriteToDirectory(fd, path) + c.Assert(err, IsNil) + c.Assert(ok, Equals, false) +} + +// We are allowed to write to tmpfs that was mounted by snapd. +func (s *trespassingSuite) TestCanWriteToDirectoryTmpfsMountedBySnapd(c *C) { + a := &update.Assumptions{} + + path := "/etc" + fd, err := s.sys.Open(path, syscall.O_DIRECTORY, 0) + c.Assert(err, IsNil) + defer s.sys.Close(fd) + + s.sys.InsertFstatfsResult(`fstatfs 3 `, syscall.Statfs_t{Type: update.TmpfsMagic}) + s.sys.InsertFstatResult(`fstat 3 `, syscall.Stat_t{}) + + a.AddChange(&update.Change{ + Action: update.Mount, + Entry: osutil.MountEntry{Type: "tmpfs", Dir: path}}) + + ok, err := a.CanWriteToDirectory(fd, path) + c.Assert(err, IsNil) + c.Assert(ok, Equals, true) +} + +// We are allowed to write to tmpfs that was mounted by snapd in another run. +func (s *trespassingSuite) TestCanWriteToDirectoryTmpfsMountedBySnapdEarlier(c *C) { + a := &update.Assumptions{} + + path := "/etc" + fd, err := s.sys.Open(path, syscall.O_DIRECTORY, 0) + c.Assert(err, IsNil) + defer s.sys.Close(fd) + + s.sys.InsertFstatfsResult(`fstatfs 3 `, syscall.Statfs_t{Type: update.TmpfsMagic}) + s.sys.InsertFstatResult(`fstat 3 `, syscall.Stat_t{}) + + a.AddChange(&update.Change{ + Action: update.Keep, + Entry: osutil.MountEntry{Type: "tmpfs", Dir: path}}) + + ok, err := a.CanWriteToDirectory(fd, path) + c.Assert(err, IsNil) + c.Assert(ok, Equals, true) +} + +// We are allowed to write to directory beneath a tmpfs that was mounted by snapd. +func (s *trespassingSuite) TestCanWriteToDirectoryUnderTmpfsMountedBySnapd(c *C) { + a := &update.Assumptions{} + + fd, err := s.sys.Open("/etc", syscall.O_DIRECTORY, 0) + c.Assert(err, IsNil) + defer s.sys.Close(fd) + + s.sys.InsertFstatfsResult(`fstatfs 3 `, syscall.Statfs_t{Type: update.TmpfsMagic}) + s.sys.InsertFstatResult(`fstat 3 `, syscall.Stat_t{Dev: 0x42}) + + a.AddChange(&update.Change{ + Action: update.Mount, + Entry: osutil.MountEntry{Type: "tmpfs", Dir: "/etc"}}) + + ok, err := a.CanWriteToDirectory(fd, "/etc") + c.Assert(err, IsNil) + c.Assert(ok, Equals, true) + + // Now we have primed the assumption state with knowledge of 0x42 device as + // a verified tmpfs. We can now exploit it by trying to write to + // /etc/conf.d and seeing that is allowed even though /etc/conf.d itself is + // not a mount point representing tmpfs. + + fd2, err := s.sys.Open("/etc/conf.d", syscall.O_DIRECTORY, 0) + c.Assert(err, IsNil) + defer s.sys.Close(fd2) + + s.sys.InsertFstatfsResult(`fstatfs 4 `, syscall.Statfs_t{Type: update.TmpfsMagic}) + s.sys.InsertFstatResult(`fstat 4 `, syscall.Stat_t{Dev: 0x42}) + + ok, err = a.CanWriteToDirectory(fd2, "/etc/conf.d") + c.Assert(err, IsNil) + c.Assert(ok, Equals, true) +} + +// We are allowed to write to directory which is a bind mount of something, beneath a tmpfs that was mounted by snapd. +func (s *trespassingSuite) TestCanWriteToDirectoryUnderReboundTmpfsMountedBySnapd(c *C) { + a := &update.Assumptions{} + + fd, err := s.sys.Open("/etc", syscall.O_DIRECTORY, 0) + c.Assert(err, IsNil) + c.Assert(fd, Equals, 3) + defer s.sys.Close(fd) + + s.sys.InsertFstatfsResult(`fstatfs 3 `, syscall.Statfs_t{Type: update.TmpfsMagic}) + s.sys.InsertFstatResult(`fstat 3 `, syscall.Stat_t{Dev: 0x42}) + + a.AddChange(&update.Change{ + Action: update.Mount, + Entry: osutil.MountEntry{Type: "tmpfs", Dir: "/etc"}}) + + ok, err := a.CanWriteToDirectory(fd, "/etc") + c.Assert(err, IsNil) + c.Assert(ok, Equals, true) + + // Now we have primed the assumption state with knowledge of 0x42 device as + // a verified tmpfs. Unlike in the test above though the directory + // /etc/conf.d is a bind mount from another tmpfs that we know nothing + // about. + fd2, err := s.sys.Open("/etc/conf.d", syscall.O_DIRECTORY, 0) + c.Assert(err, IsNil) + c.Assert(fd2, Equals, 4) + defer s.sys.Close(fd2) + + s.sys.InsertFstatfsResult(`fstatfs 4 `, syscall.Statfs_t{Type: update.TmpfsMagic}) + s.sys.InsertFstatResult(`fstat 4 `, syscall.Stat_t{Dev: 0xdeadbeef}) + + ok, err = a.CanWriteToDirectory(fd2, "/etc/conf.d") + c.Assert(err, IsNil) + c.Assert(ok, Equals, false) +} + +// We are allowed to write to an unrestricted path. +func (s *trespassingSuite) TestCanWriteToDirectoryUnrestricted(c *C) { + a := &update.Assumptions{} + + path := "/var/snap/foo/common" + fd, err := s.sys.Open(path, syscall.O_DIRECTORY, 0) + c.Assert(err, IsNil) + defer s.sys.Close(fd) + + s.sys.InsertFstatfsResult(`fstatfs 3 `, syscall.Statfs_t{Type: update.Ext4Magic}) + s.sys.InsertFstatResult(`fstat 3 `, syscall.Stat_t{}) + + a.AddUnrestrictedPaths(path) + + ok, err := a.CanWriteToDirectory(fd, path) + c.Assert(err, IsNil) + c.Assert(ok, Equals, true) +} + +// Errors from fstatfs are propagated to the caller. +func (s *trespassingSuite) TestCanWriteToDirectoryErrorsFstatfs(c *C) { + a := &update.Assumptions{} + + path := "/etc" + fd, err := s.sys.Open(path, syscall.O_DIRECTORY, 0) + c.Assert(err, IsNil) + defer s.sys.Close(fd) + + s.sys.InsertFault(`fstatfs 3 `, errTesting) + + ok, err := a.CanWriteToDirectory(fd, path) + c.Assert(err, ErrorMatches, `cannot fstatfs "/etc": testing`) + c.Assert(ok, Equals, false) +} + +// Errors from fstat are propagated to the caller. +func (s *trespassingSuite) TestCanWriteToDirectoryErrorsFstat(c *C) { + a := &update.Assumptions{} + + path := "/etc" + fd, err := s.sys.Open(path, syscall.O_DIRECTORY, 0) + c.Assert(err, IsNil) + defer s.sys.Close(fd) + + s.sys.InsertFstatfsResult(`fstatfs 3 `, syscall.Statfs_t{}) + s.sys.InsertFault(`fstat 3 `, errTesting) + + ok, err := a.CanWriteToDirectory(fd, path) + c.Assert(err, ErrorMatches, `cannot fstat "/etc": testing`) + c.Assert(ok, Equals, false) +} + +// RestrictionsFor, Check and LiftRestrictions + +func (s *trespassingSuite) TestRestrictionsForEtc(c *C) { + a := &update.Assumptions{} + + // There are restrictions for writing in /etc. + rs := a.RestrictionsFor("/etc/test.conf") + c.Assert(rs, NotNil) + + fd, err := s.sys.Open("/etc", syscall.O_DIRECTORY, 0) + c.Assert(err, IsNil) + defer s.sys.Close(fd) + s.sys.InsertFstatfsResult(`fstatfs 3 `, syscall.Statfs_t{Type: update.Ext4Magic}) + s.sys.InsertFstatResult(`fstat 3 `, syscall.Stat_t{}) + + // Check reports trespassing error, restrictions may be lifted though. + err = rs.Check(fd, "/etc") + c.Assert(err, ErrorMatches, `cannot write to "/etc/test.conf" because it would affect the host in "/etc"`) + c.Assert(err.(*update.TrespassingError).ViolatedPath, Equals, "/etc") + c.Assert(err.(*update.TrespassingError).DesiredPath, Equals, "/etc/test.conf") + + rs.Lift() + c.Assert(rs.Check(fd, "/etc"), IsNil) +} + +// Check returns errors from lower layers. +func (s *trespassingSuite) TestRestrictionsForErrors(c *C) { + a := &update.Assumptions{} + + rs := a.RestrictionsFor("/etc/test.conf") + c.Assert(rs, NotNil) + + fd, err := s.sys.Open("/etc", syscall.O_DIRECTORY, 0) + c.Assert(err, IsNil) + defer s.sys.Close(fd) + s.sys.InsertFault(`fstatfs 3 `, errTesting) + + err = rs.Check(fd, "/etc") + c.Assert(err, ErrorMatches, `cannot fstatfs "/etc": testing`) +} + +func (s *trespassingSuite) TestRestrictionsForVarSnap(c *C) { + a := &update.Assumptions{} + a.AddUnrestrictedPaths("/var/snap") + + // There are no restrictions in $SNAP_COMMON. + rs := a.RestrictionsFor("/var/snap/foo/common/test.conf") + c.Assert(rs, IsNil) + + // Nil restrictions have working Check and Lift methods. + c.Assert(rs.Check(3, "unused"), IsNil) + rs.Lift() +} + +func (s *trespassingSuite) TestRestrictionsForRunSystemd(c *C) { + a := &update.Assumptions{} + a.AddUnrestrictedPaths("/run/systemd") + + // There should be no restrictions under /run/systemd + rs := a.RestrictionsFor("/run/systemd/journal") + c.Assert(rs, IsNil) + rs = a.RestrictionsFor("/run/systemd/journal.namespace") + c.Assert(rs, IsNil) + + // however we should still disallow anything else under /run + rs = a.RestrictionsFor("/run/test.txt") + c.Assert(rs, NotNil) + + fd, err := s.sys.Open("/run", syscall.O_DIRECTORY, 0) + c.Assert(err, IsNil) + defer s.sys.Close(fd) + s.sys.InsertFstatfsResult(`fstatfs 3 `, syscall.Statfs_t{Type: update.Ext4Magic}) + s.sys.InsertFstatResult(`fstat 3 `, syscall.Stat_t{}) + + err = rs.Check(fd, "/run") + c.Assert(err, ErrorMatches, `cannot write to "/run/test.txt" because it would affect the host in "/run"`) + c.Assert(err.(*update.TrespassingError).ViolatedPath, Equals, "/run") + c.Assert(err.(*update.TrespassingError).DesiredPath, Equals, "/run/test.txt") + + rs.Lift() + c.Assert(rs.Check(fd, "/run"), IsNil) +} + +func (s *trespassingSuite) TestRestrictionsForRootfsEntries(c *C) { + a := &update.Assumptions{} + + // The root directory is special, it's not a trespassing error we can + // recover from because we cannot construct a writable mimic for the root + // directory today. + rs := a.RestrictionsFor("/foo.conf") + + fd, err := s.sys.Open("/", syscall.O_DIRECTORY, 0) + c.Assert(err, IsNil) + defer s.sys.Close(fd) + s.sys.InsertFstatfsResult(`fstatfs 3 `, syscall.Statfs_t{Type: update.Ext4Magic}) + s.sys.InsertFstatResult(`fstat 3 `, syscall.Stat_t{}) + + // Nil restrictions have working Check and Lift methods. + c.Assert(rs.Check(fd, "/"), ErrorMatches, `cannot recover from trespassing over /`) +} + +// isReadOnly + +func (s *trespassingSuite) TestIsReadOnlySquashfsMountedRo(c *C) { + path := "/some/path" + statfs := &syscall.Statfs_t{Type: update.SquashfsMagic, Flags: update.StReadOnly} + result := update.IsReadOnly(path, statfs) + c.Assert(result, Equals, true) +} + +func (s *trespassingSuite) TestIsReadOnlySquashfsMountedRw(c *C) { + path := "/some/path" + statfs := &syscall.Statfs_t{Type: update.SquashfsMagic} + result := update.IsReadOnly(path, statfs) + c.Assert(result, Equals, true) +} + +func (s *trespassingSuite) TestIsReadOnlyExt4MountedRw(c *C) { + path := "/some/path" + statfs := &syscall.Statfs_t{Type: update.Ext4Magic} + result := update.IsReadOnly(path, statfs) + c.Assert(result, Equals, false) +} + +// isSnapdCreatedPrivateTmpfs + +func (s *trespassingSuite) TestIsPrivateTmpfsCreatedBySnapdNotATmpfs(c *C) { + path := "/some/path" + // An ext4 (which is not a tmpfs) is not a private tmpfs. + statfs := &syscall.Statfs_t{Type: update.Ext4Magic} + stat := &syscall.Stat_t{} + result := update.IsPrivateTmpfsCreatedBySnapd(path, statfs, stat, nil) + c.Assert(result, Equals, false) +} + +func (s *trespassingSuite) TestIsPrivateTmpfsCreatedBySnapdNotTrusted(c *C) { + path := "/some/path" + // A tmpfs is not private if it doesn't come from a change we made. + statfs := &syscall.Statfs_t{Type: update.TmpfsMagic} + stat := &syscall.Stat_t{} + result := update.IsPrivateTmpfsCreatedBySnapd(path, statfs, stat, nil) + c.Assert(result, Equals, false) +} + +func (s *trespassingSuite) TestIsPrivateTmpfsCreatedBySnapdViaChanges(c *C) { + path := "/some/path" + // A tmpfs is private because it was mounted by snap-update-ns. + statfs := &syscall.Statfs_t{Type: update.TmpfsMagic} + stat := &syscall.Stat_t{} + + // A tmpfs was mounted in the past so it is private. + result := update.IsPrivateTmpfsCreatedBySnapd(path, statfs, stat, []*update.Change{ + {Action: update.Mount, Entry: osutil.MountEntry{Name: "tmpfs", Dir: path, Type: "tmpfs"}}, + }) + c.Assert(result, Equals, true) + + // A tmpfs was mounted but then it was unmounted so it is not private anymore. + result = update.IsPrivateTmpfsCreatedBySnapd(path, statfs, stat, []*update.Change{ + {Action: update.Mount, Entry: osutil.MountEntry{Name: "tmpfs", Dir: path, Type: "tmpfs"}}, + {Action: update.Unmount, Entry: osutil.MountEntry{Name: "tmpfs", Dir: path, Type: "tmpfs"}}, + }) + c.Assert(result, Equals, false) + + // Finally, after the mounting and unmounting the tmpfs was mounted again. + result = update.IsPrivateTmpfsCreatedBySnapd(path, statfs, stat, []*update.Change{ + {Action: update.Mount, Entry: osutil.MountEntry{Name: "tmpfs", Dir: path, Type: "tmpfs"}}, + {Action: update.Unmount, Entry: osutil.MountEntry{Name: "tmpfs", Dir: path, Type: "tmpfs"}}, + {Action: update.Mount, Entry: osutil.MountEntry{Name: "tmpfs", Dir: path, Type: "tmpfs"}}, + }) + c.Assert(result, Equals, true) +} + +func (s *trespassingSuite) TestIsPrivateTmpfsCreatedBySnapdDeeper(c *C) { + path := "/some/path/below" + // A tmpfs is not private beyond the exact mount point from a change. + // That is, sub-directories of a private tmpfs are not recognized as private. + statfs := &syscall.Statfs_t{Type: update.TmpfsMagic} + stat := &syscall.Stat_t{} + result := update.IsPrivateTmpfsCreatedBySnapd(path, statfs, stat, []*update.Change{ + {Action: update.Mount, Entry: osutil.MountEntry{Name: "tmpfs", Dir: "/some/path", Type: "tmpfs"}}, + }) + c.Assert(result, Equals, false) +} diff --git a/cmd/snap-update-ns/update.go b/cmd/snap-update-ns/update.go new file mode 100644 index 00000000..cd7c10b0 --- /dev/null +++ b/cmd/snap-update-ns/update.go @@ -0,0 +1,102 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "github.com/snapcore/snapd/logger" + "github.com/snapcore/snapd/osutil" +) + +// MountProfileUpdateContext provides the context of a mount namespace update. +// The context provides a way to synchronize the operation with other users of +// the snap system, to load and save the mount profiles and to provide the file +// system assumptions with which the mount namespace will be modified. +type MountProfileUpdateContext interface { + // Lock obtains locks appropriate for the update. + Lock() (unlock func(), err error) + // Assumptions computes filesystem assumptions under which the update shall operate. + Assumptions() *Assumptions + // LoadDesiredProfile loads the mount profile that should be constructed. + LoadDesiredProfile() (*osutil.MountProfile, error) + // LoadCurrentProfile loads the mount profile that is currently applied. + LoadCurrentProfile() (*osutil.MountProfile, error) + // SaveCurrentProfile saves the mount profile that is currently applied. + SaveCurrentProfile(*osutil.MountProfile) error +} + +func executeMountProfileUpdate(upCtx MountProfileUpdateContext) error { + unlock, err := upCtx.Lock() + if err != nil { + return err + } + defer unlock() + + desired, err := upCtx.LoadDesiredProfile() + if err != nil { + return err + } + + currentBefore, err := upCtx.LoadCurrentProfile() + if err != nil { + return err + } + + // Synthesize mount changes that were applied before for the purpose of the tmpfs detector. + as := upCtx.Assumptions() + for _, entry := range currentBefore.Entries { + as.AddChange(&Change{Action: Mount, Entry: entry}) + } + + // Compute the needed changes and perform each change if + // needed, collecting those that we managed to perform or that + // were performed already. + changesNeeded := NeededChanges(currentBefore, desired) + + var changesMade []*Change + for _, change := range changesNeeded { + synthesised, err := change.Perform(as) + changesMade = append(changesMade, synthesised...) + if err != nil { + // We may have done something even if Perform itself has + // failed. We need to collect synthesized changes and + // store them. + origin := change.Entry.XSnapdOrigin() + if origin == "layout" || origin == "overname" { + // TODO: convert the test to a method over origin. + return err + } else if err != ErrIgnoredMissingMount { + logger.Noticef("cannot change mount namespace according to change %s: %s", change, err) + } + continue + } + + changesMade = append(changesMade, change) + } + + // Compute the new current profile so that it contains only changes that were made + // and save it back for next runs. + var currentAfter osutil.MountProfile + for _, change := range changesMade { + if change.Action == Mount || change.Action == Keep { + currentAfter.Entries = append(currentAfter.Entries, change.Entry) + } + } + return upCtx.SaveCurrentProfile(¤tAfter) +} diff --git a/cmd/snap-update-ns/update_test.go b/cmd/snap-update-ns/update_test.go new file mode 100644 index 00000000..f231bb08 --- /dev/null +++ b/cmd/snap-update-ns/update_test.go @@ -0,0 +1,439 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "bytes" + "fmt" + "path/filepath" + "syscall" + + . "gopkg.in/check.v1" + + update "github.com/snapcore/snapd/cmd/snap-update-ns" + "github.com/snapcore/snapd/logger" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/osutil/sys" + "github.com/snapcore/snapd/testutil" +) + +type updateSuite struct { + testutil.BaseTest + log *bytes.Buffer +} + +var _ = Suite(&updateSuite{}) + +func (s *updateSuite) SetUpTest(c *C) { + s.BaseTest.SetUpTest(c) + buf, restore := logger.MockLogger() + s.BaseTest.AddCleanup(restore) + s.log = buf +} + +func (s *updateSuite) TestSmoke(c *C) { + upCtx := &testProfileUpdateContext{} + c.Assert(update.ExecuteMountProfileUpdate(upCtx), IsNil) +} + +func (s *updateSuite) TestUpdateFlow(c *C) { + // The flow of update is as follows: + // - the current profile and the desired profiles are loaded + // - the needed changes are computed + // - the needed changes are performed (one by one) + // - the updated current profile is saved + var funcsCalled []string + var nChanges int + upCtx := &testProfileUpdateContext{ + loadCurrentProfile: func() (*osutil.MountProfile, error) { + funcsCalled = append(funcsCalled, "loaded-current") + return &osutil.MountProfile{}, nil + }, + loadDesiredProfile: func() (*osutil.MountProfile, error) { + funcsCalled = append(funcsCalled, "loaded-desired") + return &osutil.MountProfile{}, nil + }, + neededChanges: func(old, new *osutil.MountProfile) []*update.Change { + funcsCalled = append(funcsCalled, "changes-computed") + return []*update.Change{{}, {}} + }, + performChange: func(change *update.Change, as *update.Assumptions) ([]*update.Change, error) { + nChanges++ + funcsCalled = append(funcsCalled, fmt.Sprintf("change-%d-performed", nChanges)) + return nil, nil + }, + saveCurrentProfile: func(*osutil.MountProfile) error { + funcsCalled = append(funcsCalled, "saved-current") + return nil + }, + } + restore := upCtx.MockRelatedFunctions() + defer restore() + c.Assert(update.ExecuteMountProfileUpdate(upCtx), IsNil) + c.Assert(funcsCalled, DeepEquals, []string{"loaded-desired", "loaded-current", + "changes-computed", "change-1-performed", "change-2-performed", "saved-current"}) + c.Assert(update.ExecuteMountProfileUpdate(upCtx), IsNil) +} + +func (s *updateSuite) TestResultingProfile(c *C) { + // When the mount namespace is changed by performing actions the updated + // current profile is comprised of the past changes that were reused (kept + // unchanged) as well as newly mounted entries. Unmounted entries simple + // cease to be. + var saved *osutil.MountProfile + upCtx := &testProfileUpdateContext{ + neededChanges: func(old, new *osutil.MountProfile) []*update.Change { + return []*update.Change{ + {Action: update.Keep, Entry: osutil.MountEntry{Dir: "/keep"}}, + {Action: update.Unmount, Entry: osutil.MountEntry{Dir: "/unmount"}}, + {Action: update.Mount, Entry: osutil.MountEntry{Dir: "/mount"}}, + } + }, + saveCurrentProfile: func(profile *osutil.MountProfile) error { + saved = profile + return nil + }, + } + restore := upCtx.MockRelatedFunctions() + defer restore() + c.Assert(update.ExecuteMountProfileUpdate(upCtx), IsNil) + c.Check(saved, DeepEquals, &osutil.MountProfile{Entries: []osutil.MountEntry{ + {Dir: "/keep"}, + {Dir: "/mount"}, + }}) +} + +func (s *updateSuite) TestSynthesizedPastChanges(c *C) { + // When an mount update is performed it runs under the assumption + // that past changes (i.e. the current profile) did occur. This is used + // by the trespassing detector. + text := `tmpfs /usr tmpfs 0 0` + entry, err := osutil.ParseMountEntry(text) + c.Assert(err, IsNil) + as := &update.Assumptions{} + upCtx := &testProfileUpdateContext{ + loadCurrentProfile: func() (*osutil.MountProfile, error) { return osutil.LoadMountProfileText(text) }, + loadDesiredProfile: func() (*osutil.MountProfile, error) { return osutil.LoadMountProfileText(text) }, + assumptions: func() *update.Assumptions { return as }, + } + restore := upCtx.MockRelatedFunctions() + defer restore() + + // Perform the update, this will modify assumptions. + c.Check(as.PastChanges(), HasLen, 0) + c.Assert(update.ExecuteMountProfileUpdate(upCtx), IsNil) + c.Check(as.PastChanges(), HasLen, 1) + c.Check(as.PastChanges(), DeepEquals, []*update.Change{{ + Action: update.Mount, + Entry: entry, + }}) +} + +func (s *updateSuite) TestSyntheticChanges(c *C) { + // When a mount change is performed it may cause additional mount changes + // to be performed, that were needed internally. Such changes are recorded + // and saved into the current profile. + var saved *osutil.MountProfile + upCtx := &testProfileUpdateContext{ + loadDesiredProfile: func() (*osutil.MountProfile, error) { + return &osutil.MountProfile{Entries: []osutil.MountEntry{ + {Dir: "/subdir/mount"}, + }}, nil + }, + saveCurrentProfile: func(profile *osutil.MountProfile) error { + saved = profile + return nil + }, + neededChanges: func(old, new *osutil.MountProfile) []*update.Change { + return []*update.Change{ + {Action: update.Mount, Entry: osutil.MountEntry{Dir: "/subdir/mount"}}, + } + }, + performChange: func(change *update.Change, as *update.Assumptions) ([]*update.Change, error) { + // If we are trying to mount /subdir/mount then synthesize a change + // for making a tmpfs on /subdir. + if change.Action == update.Mount && change.Entry.Dir == "/subdir/mount" { + return []*update.Change{ + {Action: update.Mount, Entry: osutil.MountEntry{Dir: "/subdir", Type: "tmpfs"}}, + }, nil + } + return nil, nil + }, + } + restore := upCtx.MockRelatedFunctions() + defer restore() + c.Assert(update.ExecuteMountProfileUpdate(upCtx), IsNil) + c.Check(saved, DeepEquals, &osutil.MountProfile{Entries: []osutil.MountEntry{ + {Dir: "/subdir", Type: "tmpfs"}, + {Dir: "/subdir/mount"}, + }}) +} + +func (s *updateSuite) TestCannotPerformContentInterfaceChange(c *C) { + // When performing a mount change for a content interface fails, we simply + // ignore the error carry on. Such changes are not stored in the updated + // current profile. + var saved *osutil.MountProfile + upCtx := &testProfileUpdateContext{ + saveCurrentProfile: func(profile *osutil.MountProfile) error { + saved = profile + return nil + }, + neededChanges: func(old, new *osutil.MountProfile) []*update.Change { + return []*update.Change{ + {Action: update.Mount, Entry: osutil.MountEntry{Dir: "/dir-1"}}, + {Action: update.Mount, Entry: osutil.MountEntry{Dir: "/dir-2"}}, + {Action: update.Mount, Entry: osutil.MountEntry{Dir: "/dir-3"}}, + {Action: update.Mount, Entry: osutil.MountEntry{Dir: "/dir-4"}}, + } + }, + performChange: func(change *update.Change, as *update.Assumptions) ([]*update.Change, error) { + // The change to /dir-2 cannot be made. + if change.Action == update.Mount && change.Entry.Dir == "/dir-2" { + return nil, errTesting + } + // The change to /dir-4 cannot be made either but with a special reason. + if change.Action == update.Mount && change.Entry.Dir == "/dir-4" { + return nil, update.ErrIgnoredMissingMount + } + return nil, nil + }, + } + restore := upCtx.MockRelatedFunctions() + defer restore() + c.Assert(update.ExecuteMountProfileUpdate(upCtx), IsNil) + c.Check(saved, DeepEquals, &osutil.MountProfile{Entries: []osutil.MountEntry{ + {Dir: "/dir-1"}, + {Dir: "/dir-3"}, + }}) + // A message is logged though, unless specifically silenced with a crafted error. + c.Check(s.log.String(), testutil.Contains, "cannot change mount namespace according to change mount (none /dir-2 none defaults 0 0): testing") + c.Check(s.log.String(), Not(testutil.Contains), "cannot change mount namespace according to change mount (none /dir-4 none defaults 0 0): ") +} + +func (s *updateSuite) TestCannotPerformLayoutChange(c *C) { + // When performing a mount change for a layout, errors are immediately fatal. + var saved *osutil.MountProfile + upCtx := &testProfileUpdateContext{ + saveCurrentProfile: func(profile *osutil.MountProfile) error { + saved = profile + return nil + }, + neededChanges: func(old, new *osutil.MountProfile) []*update.Change { + return []*update.Change{ + {Action: update.Mount, Entry: osutil.MountEntry{Dir: "/dir-1"}}, + {Action: update.Mount, Entry: osutil.MountEntry{Dir: "/dir-2", Options: []string{"x-snapd.origin=layout"}}}, + {Action: update.Mount, Entry: osutil.MountEntry{Dir: "/dir-3"}}, + } + }, + performChange: func(change *update.Change, as *update.Assumptions) ([]*update.Change, error) { + // The change to /dir-2 cannot be made. + if change.Action == update.Mount && change.Entry.Dir == "/dir-2" { + return nil, errTesting + } + return nil, nil + }, + } + restore := upCtx.MockRelatedFunctions() + defer restore() + err := update.ExecuteMountProfileUpdate(upCtx) + c.Check(err, Equals, errTesting) + c.Check(saved, IsNil) +} + +func (s *updateSuite) TestCannotPerformOvermountChange(c *C) { + // When performing a mount change for an "overname", errors are immediately fatal. + var saved *osutil.MountProfile + upCtx := &testProfileUpdateContext{ + saveCurrentProfile: func(profile *osutil.MountProfile) error { + saved = profile + return nil + }, + neededChanges: func(old, new *osutil.MountProfile) []*update.Change { + return []*update.Change{ + {Action: update.Mount, Entry: osutil.MountEntry{Dir: "/dir-1"}}, + {Action: update.Mount, Entry: osutil.MountEntry{Dir: "/dir-2", Options: []string{"x-snapd.origin=overname"}}}, + {Action: update.Mount, Entry: osutil.MountEntry{Dir: "/dir-3"}}, + } + }, + performChange: func(change *update.Change, as *update.Assumptions) ([]*update.Change, error) { + // The change to /dir-2 cannot be made. + if change.Action == update.Mount && change.Entry.Dir == "/dir-2" { + return nil, errTesting + } + return nil, nil + }, + } + restore := upCtx.MockRelatedFunctions() + defer restore() + err := update.ExecuteMountProfileUpdate(upCtx) + c.Check(err, Equals, errTesting) + c.Check(saved, IsNil) +} + +func (s *updateSuite) TestKeepSyntheticMountsLP2043993(c *C) { + baseSourceDir := c.MkDir() + sourceFile := filepath.Join(baseSourceDir, "rofs/dir/source") + + baseTargetDir := c.MkDir() + targetFile := filepath.Join(baseTargetDir, "target") + + as := update.Assumptions{} + restore := as.MockUnrestrictedPaths("/") + defer restore() + + // mock for permission errors + restore = update.MockSysFchown(func(fd int, uid sys.UserID, gid sys.GroupID) error { + return nil + }) + defer restore() + + // mock for permission errors + restore = update.MockSysMount(func(source, target, fstype string, flags uintptr, data string) (err error) { + return nil + }) + defer restore() + + // mock for permission errors + restore = update.MockSysUnmount(func(target string, flags int) (err error) { + return nil + }) + defer restore() + + var mkdiratFailed bool + restore = update.MockSysMkdirat(func(dirfd int, path string, mode uint32) (err error) { + if path == "dir" && !mkdiratFailed { + mkdiratFailed = true + return syscall.EROFS + } + return syscall.Mkdirat(dirfd, path, mode) + }) + defer restore() + + desiredMountEntry := osutil.MountEntry{ + Name: sourceFile, + Dir: targetFile, + Options: []string{"rbind", "rw", "x-snapd.id=test-id", osutil.XSnapdKindFile(), osutil.XSnapdOriginLayout()}, + } + + var saved osutil.MountProfile + upCtx := &testProfileUpdateContext{ + assumptions: func() *update.Assumptions { + return &as + }, + loadDesiredProfile: func() (*osutil.MountProfile, error) { + return &osutil.MountProfile{Entries: []osutil.MountEntry{desiredMountEntry}}, nil + }, + loadCurrentProfile: func() (*osutil.MountProfile, error) { + return &saved, nil + }, + saveCurrentProfile: func(profile *osutil.MountProfile) error { + saved.Entries = profile.Entries + return nil + }, + } + + c.Assert(update.ExecuteMountProfileUpdate(upCtx), IsNil) + c.Assert(saved.Entries, HasLen, 2) + // synth mount created due to read-only fs + c.Check(saved.Entries[0].Type, Equals, "tmpfs") + c.Check(saved.Entries[0].Name, Equals, "tmpfs") + c.Check(saved.Entries[0].Dir, Equals, filepath.Join(baseSourceDir, "rofs")) + c.Check(saved.Entries[0].XSnapdSynthetic(), Equals, true) + c.Check(saved.Entries[0].XSnapdNeededBy(), Equals, "test-id") + // desired mount exists + c.Check(saved.Entries[1], DeepEquals, desiredMountEntry) + + c.Assert(update.ExecuteMountProfileUpdate(upCtx), IsNil) + c.Assert(saved.Entries, HasLen, 2) + // TODO: the change of order is a bit unexpected, but that is a larger issue + // synth mount kept because it is needed by a desired mount + c.Check(saved.Entries[1].Type, Equals, "tmpfs") + c.Check(saved.Entries[1].Name, Equals, "tmpfs") + c.Check(saved.Entries[1].Dir, Equals, filepath.Join(baseSourceDir, "rofs")) + c.Check(saved.Entries[1].XSnapdSynthetic(), Equals, true) + c.Check(saved.Entries[1].XSnapdNeededBy(), Equals, "test-id") + c.Check(saved.Entries[0], DeepEquals, desiredMountEntry) +} + +// testProfileUpdateContext implements MountProfileUpdateContext and is suitable for testing. +type testProfileUpdateContext struct { + loadCurrentProfile func() (*osutil.MountProfile, error) + loadDesiredProfile func() (*osutil.MountProfile, error) + saveCurrentProfile func(*osutil.MountProfile) error + assumptions func() *update.Assumptions + + // The remaining functions are defined for consistency but are installed by + // calling their mock helpers. They are not a part of the interface. + neededChanges func(*osutil.MountProfile, *osutil.MountProfile) []*update.Change + performChange func(*update.Change, *update.Assumptions) ([]*update.Change, error) +} + +// MockRelatedFunctions mocks NeededChanges and Change.Perform for the purpose of testing. +func (upCtx *testProfileUpdateContext) MockRelatedFunctions() (restore func()) { + neededChanges := func(*osutil.MountProfile, *osutil.MountProfile) []*update.Change { return nil } + if upCtx.neededChanges != nil { + neededChanges = upCtx.neededChanges + } + restore1 := update.MockNeededChanges(neededChanges) + + performChange := func(*update.Change, *update.Assumptions) ([]*update.Change, error) { return nil, nil } + if upCtx.performChange != nil { + performChange = upCtx.performChange + } + restore2 := update.MockChangePerform(performChange) + + return func() { + restore1() + restore2() + } +} + +func (upCtx *testProfileUpdateContext) Lock() (unlock func(), err error) { + return func() {}, nil +} + +func (upCtx *testProfileUpdateContext) Assumptions() *update.Assumptions { + if upCtx.assumptions != nil { + return upCtx.assumptions() + } + return &update.Assumptions{} +} + +func (upCtx *testProfileUpdateContext) LoadCurrentProfile() (*osutil.MountProfile, error) { + if upCtx.loadCurrentProfile != nil { + return upCtx.loadCurrentProfile() + } + return &osutil.MountProfile{}, nil +} + +func (upCtx *testProfileUpdateContext) LoadDesiredProfile() (*osutil.MountProfile, error) { + if upCtx.loadDesiredProfile != nil { + return upCtx.loadDesiredProfile() + } + return &osutil.MountProfile{}, nil +} + +func (upCtx *testProfileUpdateContext) SaveCurrentProfile(profile *osutil.MountProfile) error { + if upCtx.saveCurrentProfile != nil { + return upCtx.saveCurrentProfile(profile) + } + return nil +} diff --git a/cmd/snap-update-ns/user.go b/cmd/snap-update-ns/user.go new file mode 100644 index 00000000..78c637d1 --- /dev/null +++ b/cmd/snap-update-ns/user.go @@ -0,0 +1,160 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2017-2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "fmt" + "os" + "path/filepath" + "syscall" + + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/osutil" +) + +// UserProfileUpdateContext contains information about update to per-user mount namespace. +type UserProfileUpdateContext struct { + CommonProfileUpdateContext + // uid is the numeric user identifier associated with the user for which + // the update operation is occurring. It may be the current UID but doesn't + // need to be. + uid int + // home contains the user's real home directory + home string + homeError error +} + +// isPlausibleHome returns an error if the path is empty, not clean, not absolute or cannot +// be opened for reading. +// +// See bootstrap.c function switch_to_privileged_user(). When snap-update-ns is invoked for +// user mounts, the effective uid and gid is changed to the calling user and supplementary +// groups dropped, while retaining capability SYS_ADMIN. Having the effective uid and gid +// changed to the calling user is a prerequisite for isPlausibleHome to function as intended. +func isPlausibleHome(path string) error { + if path == "" { + return fmt.Errorf("cannot allow empty path") + } + if path != filepath.Clean(path) { + return fmt.Errorf("cannot allow unclean path") + } + if !filepath.IsAbs(path) { + return fmt.Errorf("cannot allow relative path") + } + const openFlags = syscall.O_NOFOLLOW | syscall.O_CLOEXEC | syscall.O_DIRECTORY + fd, err := sysOpen(path, openFlags, 0) + if err != nil { + return err + } + sysClose(fd) + return nil +} + +// NewUserProfileUpdateContext returns encapsulated information for performing a per-user mount namespace update. +func NewUserProfileUpdateContext(instanceName string, fromSnapConfine bool, uid int) (*UserProfileUpdateContext, error) { + realHome := os.Getenv("SNAP_REAL_HOME") + var realHomeError error + if realHome == "" { + realHomeError = fmt.Errorf("cannot retrieve home directory") + } + if err := isPlausibleHome(realHome); err != nil { + realHomeError = fmt.Errorf("cannot use invalid home directory %q: %v", realHome, err) + } + + return &UserProfileUpdateContext{ + CommonProfileUpdateContext: CommonProfileUpdateContext{ + instanceName: instanceName, + fromSnapConfine: fromSnapConfine, + currentProfilePath: currentUserProfilePath(instanceName, uid), + desiredProfilePath: desiredUserProfilePath(instanceName), + }, + uid: uid, + home: realHome, + homeError: realHomeError, + }, nil +} + +// Lock acquires locks / freezes needed to synchronize mount namespace changes. +func (upCtx *UserProfileUpdateContext) Lock() (unlock func(), err error) { + // TODO: when persistent user mount namespaces are enabled, grab a lock + // protecting the snap and freeze snap processes here. + return func() {}, nil +} + +// Assumptions returns information about file system mutability rules. +func (upCtx *UserProfileUpdateContext) Assumptions() *Assumptions { + // TODO: configure the secure helper and inform it about directories that + // can be created without trespassing. + as := &Assumptions{} + if upCtx.homeError == nil { + as.AddUnrestrictedPaths(upCtx.home) + } + // TODO: Handle /home/*/snap/* when we do per-user mount namespaces and + // allow defining layout items that refer to SNAP_USER_DATA and + // SNAP_USER_COMMON. + return as +} + +// LoadDesiredProfile loads the desired, per-user mount profile, expanding user-specific variables. +func (upCtx *UserProfileUpdateContext) LoadDesiredProfile() (*osutil.MountProfile, error) { + profile, err := upCtx.CommonProfileUpdateContext.LoadDesiredProfile() + if err != nil { + return nil, err + } + home := func() (path string, err error) { + return upCtx.home, upCtx.homeError + } + if err := expandHomeDir(profile, home); err != nil { + return nil, err + } + // TODO: when SNAP_USER_DATA, SNAP_USER_COMMON or other variables relating + // to the user name and their home directory need to be expanded then + // handle them here. + expandXdgRuntimeDir(profile, upCtx.uid) + return profile, nil +} + +// SaveCurrentProfile does nothing at all. +// +// Per-user mount profiles are not persisted yet. +func (upCtx *UserProfileUpdateContext) SaveCurrentProfile(profile *osutil.MountProfile) error { + // TODO: when persistent user mount namespaces are enabled save the + // current, per-user mount profile here. + return nil +} + +// LoadCurrentProfile returns the empty profile. +// +// Per-user mount profiles are not persisted yet. +func (upCtx *UserProfileUpdateContext) LoadCurrentProfile() (*osutil.MountProfile, error) { + // TODO: when persistent user mount namespaces are enabled load the + // current, per-user mount profile here. + return &osutil.MountProfile{}, nil +} + +// desiredUserProfilePath returns the path of the fstab-like file with the desired, user-specific mount profile for a snap. +func desiredUserProfilePath(snapName string) string { + return fmt.Sprintf("%s/snap.%s.user-fstab", dirs.SnapMountPolicyDir, snapName) +} + +// currentUserProfilePath returns the path of the fstab-like file with the applied, user-specific mount profile for a snap. +func currentUserProfilePath(snapName string, uid int) string { + return fmt.Sprintf("%s/snap.%s.%d.user-fstab", dirs.SnapRunNsDir, snapName, uid) +} diff --git a/cmd/snap-update-ns/user_test.go b/cmd/snap-update-ns/user_test.go new file mode 100644 index 00000000..ba685983 --- /dev/null +++ b/cmd/snap-update-ns/user_test.go @@ -0,0 +1,251 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + + . "gopkg.in/check.v1" + + update "github.com/snapcore/snapd/cmd/snap-update-ns" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/testutil" +) + +type userSuite struct{} + +var _ = Suite(&userSuite{}) + +func (s *userSuite) TestIsPlausibleHomeHappy(c *C) { + tmpHomeDir := c.MkDir() + err := update.IsPlausibleHome(tmpHomeDir) + c.Assert(err, IsNil) +} + +func (s *userSuite) TestIsPlausibleHomeErrorPathEmpty(c *C) { + err := update.IsPlausibleHome("") + c.Assert(err, ErrorMatches, "cannot allow empty path") +} + +func (s *userSuite) TestIsPlausibleHomeErrorPathNotClean(c *C) { + err := update.IsPlausibleHome("/PathNotClean/") + c.Assert(err, ErrorMatches, "cannot allow unclean path") +} + +func (s *userSuite) TestIsPlausibleHomeErrorPathRelative(c *C) { + err := update.IsPlausibleHome("PathRelative") + c.Assert(err, ErrorMatches, "cannot allow relative path") +} + +func (s *userSuite) TestIsPlausibleHomeErrorPathNotExist(c *C) { + tmpHomeDir := c.MkDir() + "/user-does-not-exist" + err := update.IsPlausibleHome(tmpHomeDir) + c.Assert(err, ErrorMatches, "no such file or directory") +} + +func (s *userSuite) TestLock(c *C) { + dirs.SetRootDir(c.MkDir()) + defer dirs.SetRootDir("/") + c.Assert(os.MkdirAll(dirs.FeaturesDir, 0755), IsNil) + tmpHomeDir := c.MkDir() + restore := update.MockSnapConfineUserEnv("/run/user/1234/snap.snapname", tmpHomeDir) + defer restore() + upCtx, err := update.NewUserProfileUpdateContext("foo", false, 1234) + c.Assert(err, IsNil) + + // Locking is a no-op. + unlock, err := upCtx.Lock() + c.Assert(err, IsNil) + c.Check(unlock, NotNil) + unlock() +} + +func (s *userSuite) TestAssumptionsHomeValid(c *C) { + tmpHomeDir := c.MkDir() + restore := update.MockSnapConfineUserEnv("/run/user/1234/snap.snapname", tmpHomeDir) + defer restore() + upCtx, err := update.NewUserProfileUpdateContext("foo", false, 1234) + c.Assert(err, IsNil) + as := upCtx.Assumptions() + c.Check(as.UnrestrictedPaths(), DeepEquals, []string{tmpHomeDir}) + c.Check(as.ModeForPath(tmpHomeDir+"/dir"), Equals, os.FileMode(0755)) +} + +func (s *userSuite) TestAssumptionsHomeInvalid(c *C) { + restore := update.MockSnapConfineUserEnv("/run/user/1234/snap.snapname", "") + defer restore() + upCtx, err := update.NewUserProfileUpdateContext("foo", false, 1234) + c.Assert(err, IsNil) + as := upCtx.Assumptions() + c.Check(as.UnrestrictedPaths(), IsNil) +} + +func (s *userSuite) TestLoadDesiredProfileHomeRequiredAndValid(c *C) { + // Mock directories but to simplify testing use the real value for XDG. + dirs.SetRootDir(c.MkDir()) + defer dirs.SetRootDir("/") + dirs.XdgRuntimeDirBase = "/run/user" + tmpHomeDir := c.MkDir() + restore := update.MockSnapConfineUserEnv("/run/user/1234/snap.snapname", tmpHomeDir) + defer restore() + upCtx, err := update.NewUserProfileUpdateContext("foo", false, 1234) + c.Assert(err, IsNil) + + input := "$XDG_RUNTIME_DIR/doc/by-app/snap.foo $XDG_RUNTIME_DIR/doc none bind,rw 0 0\n" + + "none $HOME/.local/share none x-snapd.kind=ensure-dir,x-snapd.must-exist-dir=$HOME 0 0\n" + output := "/run/user/1234/doc/by-app/snap.foo /run/user/1234/doc none bind,rw 0 0\n" + + fmt.Sprintf("none %s/.local/share none x-snapd.kind=ensure-dir,x-snapd.must-exist-dir=%s 0 0\n", tmpHomeDir, tmpHomeDir) + + // Write a desired user mount profile for snap "foo". + path := update.DesiredUserProfilePath("foo") + c.Assert(os.MkdirAll(filepath.Dir(path), 0755), IsNil) + c.Assert(os.WriteFile(path, []byte(input), 0644), IsNil) + + // Ask the user profile update helper to read the desired profile. + profile, err := upCtx.LoadDesiredProfile() + c.Assert(err, IsNil) + builder := &bytes.Buffer{} + profile.WriteTo(builder) + + // Note that the profile read back contains expanded $XDG_RUNTIME_DIR and $HOME. + c.Check(builder.String(), Equals, output) +} + +func (s *userSuite) TestLoadDesiredProfileHomeNotRequiredAndMissing(c *C) { + // Mock directories but to simplify testing use the real value for XDG. + dirs.SetRootDir(c.MkDir()) + defer dirs.SetRootDir("/") + dirs.XdgRuntimeDirBase = "/run/user" + //tmpHomeDir := c.MkDir() + restore := update.MockSnapConfineUserEnv("/run/user/1234/snap.snapname", "") + defer restore() + upCtx, err := update.NewUserProfileUpdateContext("foo", false, 1234) + c.Assert(err, IsNil) + + input := "$XDG_RUNTIME_DIR/doc/by-app/snap.foo $XDG_RUNTIME_DIR/doc none bind,rw 0 0\n" + output := "/run/user/1234/doc/by-app/snap.foo /run/user/1234/doc none bind,rw 0 0\n" + + // Write a desired user mount profile for snap "foo". + path := update.DesiredUserProfilePath("foo") + c.Assert(os.MkdirAll(filepath.Dir(path), 0755), IsNil) + c.Assert(os.WriteFile(path, []byte(input), 0644), IsNil) + + // Ask the user profile update helper to read the desired profile. + profile, err := upCtx.LoadDesiredProfile() + c.Assert(err, IsNil) + builder := &bytes.Buffer{} + profile.WriteTo(builder) + + // Note that the profile read back contains expanded $XDG_RUNTIME_DIR + c.Check(builder.String(), Equals, output) +} + +func (s *userSuite) TestLoadDesiredProfileErrorHomeRequiredAndMissing(c *C) { + // Mock directories but to simplify testing use the real value for XDG. + dirs.SetRootDir(c.MkDir()) + defer dirs.SetRootDir("/") + dirs.XdgRuntimeDirBase = "/run/user" + //tmpHomeDir := c.MkDir() + restore := update.MockSnapConfineUserEnv("/run/user/1234/snap.snapname", "") + defer restore() + upCtx, err := update.NewUserProfileUpdateContext("foo", false, 1234) + c.Assert(err, IsNil) + + input := "$XDG_RUNTIME_DIR/doc/by-app/snap.foo $XDG_RUNTIME_DIR/doc none bind,rw 0 0\n" + + "none $HOME/.local/share none x-snapd.kind=ensure-dir,x-snapd.must-exist-dir=$HOME 0 0\n" + + // Write a desired user mount profile for snap "foo". + path := update.DesiredUserProfilePath("foo") + c.Assert(os.MkdirAll(filepath.Dir(path), 0755), IsNil) + c.Assert(os.WriteFile(path, []byte(input), 0644), IsNil) + + // Ask the user profile update helper to read the desired profile. + profile, err := upCtx.LoadDesiredProfile() + c.Assert(err, ErrorMatches, `cannot expand mount entry \(none \$HOME/.local/share none x-snapd.kind=ensure-dir,x-snapd.must-exist-dir=\$HOME 0 0\): cannot use invalid home directory \"\": cannot allow empty path`) + c.Assert(profile, IsNil) +} + +func (s *userSuite) TestLoadCurrentProfile(c *C) { + // Mock directories. + dirs.SetRootDir(c.MkDir()) + tmpHomeDir := c.MkDir() + restore := update.MockSnapConfineUserEnv("/run/user/1234/snap.snapname", tmpHomeDir) + defer restore() + upCtx, err := update.NewUserProfileUpdateContext("foo", false, 1234) + c.Assert(err, IsNil) + + // Write a current user mount profile for snap "foo". + text := "/run/user/1234/doc/by-app/snap.foo /run/user/1234/doc none bind,rw 0 0\n" + path := update.CurrentUserProfilePath(upCtx.InstanceName(), 1234) + c.Assert(os.MkdirAll(filepath.Dir(path), 0755), IsNil) + c.Assert(os.WriteFile(path, []byte(text), 0644), IsNil) + + // Ask the user profile update helper to read the current profile. + profile, err := upCtx.LoadCurrentProfile() + c.Assert(err, IsNil) + builder := &bytes.Buffer{} + profile.WriteTo(builder) + + // Note that the profile is empty. + // Currently user profiles are not persisted so the presence of a profile on-disk is ignored. + c.Check(builder.String(), Equals, "") +} + +func (s *userSuite) TestSaveCurrentProfile(c *C) { + // Mock directories and create directory runtime mount profiles. + dirs.SetRootDir(c.MkDir()) + defer dirs.SetRootDir("/") + c.Assert(os.MkdirAll(dirs.SnapRunNsDir, 0755), IsNil) + tmpHomeDir := c.MkDir() + restore := update.MockSnapConfineUserEnv("/run/user/1234/snap.snapname", tmpHomeDir) + defer restore() + upCtx, err := update.NewUserProfileUpdateContext("foo", false, 1234) + c.Assert(err, IsNil) + + // Prepare a mount profile to be saved. + text := "/run/user/1234/doc/by-app/snap.foo /run/user/1234/doc none bind,rw 0 0\n" + profile, err := osutil.LoadMountProfileText(text) + c.Assert(err, IsNil) + + // Write a fake current user mount profile for snap "foo". + path := update.CurrentUserProfilePath("foo", 1234) + c.Assert(os.MkdirAll(filepath.Dir(path), 0755), IsNil) + c.Assert(os.WriteFile(path, []byte("banana"), 0644), IsNil) + + // Ask the user profile update helper to write the current profile. + err = upCtx.SaveCurrentProfile(profile) + c.Assert(err, IsNil) + + // Note that the profile was not modified. + // Currently user profiles are not persisted. + c.Check(path, testutil.FileEquals, "banana") +} + +func (s *userSuite) TestDesiredUserProfilePath(c *C) { + c.Check(update.DesiredUserProfilePath("foo"), Equals, "/var/lib/snapd/mount/snap.foo.user-fstab") +} + +func (s *userSuite) TestCurrentUserProfilePath(c *C) { + c.Check(update.CurrentUserProfilePath("foo", 12345), Equals, "/run/snapd/ns/snap.foo.12345.user-fstab") +} diff --git a/cmd/snap-update-ns/utils.go b/cmd/snap-update-ns/utils.go new file mode 100644 index 00000000..bd67ecb4 --- /dev/null +++ b/cmd/snap-update-ns/utils.go @@ -0,0 +1,787 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "syscall" + + "github.com/snapcore/snapd/logger" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/osutil/sys" + "github.com/snapcore/snapd/strutil" +) + +// not available through syscall +const ( + umountNoFollow = 8 + // StReadOnly is the equivalent of ST_RDONLY + StReadOnly = 1 + // SquashfsMagic is the equivalent of SQUASHFS_MAGIC + SquashfsMagic = 0x73717368 + // Ext4Magic is the equivalent of EXT4_SUPER_MAGIC + Ext4Magic = 0xef53 + // TmpfsMagic is the equivalent of TMPFS_MAGIC + TmpfsMagic = 0x01021994 +) + +// For mocking everything during testing. +var ( + osLstat = os.Lstat + osReadlink = os.Readlink + osRemove = os.Remove + + sysClose = syscall.Close + sysMkdirat = syscall.Mkdirat + sysMount = syscall.Mount + sysOpen = syscall.Open + sysOpenat = syscall.Openat + sysUnmount = syscall.Unmount + sysFchown = sys.Fchown + sysFstat = syscall.Fstat + sysFstatfs = syscall.Fstatfs + sysSymlinkat = osutil.Symlinkat + sysReadlinkat = osutil.Readlinkat + sysFchdir = syscall.Fchdir + sysLstat = syscall.Lstat + + sysGetuid = sys.Getuid + sysGetgid = sys.Getgid + + osReadDir = os.ReadDir +) + +// ReadOnlyFsError is an error encapsulating encountered EROFS. +type ReadOnlyFsError struct { + Path string +} + +func (e *ReadOnlyFsError) Error() string { + return fmt.Sprintf("cannot operate on read-only filesystem at %s", e.Path) +} + +// OpenPath creates a path file descriptor for the given +// path, making sure no components are symbolic links. +// +// The file descriptor is opened using the O_PATH, O_NOFOLLOW, +// and O_CLOEXEC flags. +func OpenPath(path string) (int, error) { + iter, err := strutil.NewPathIterator(path) + if err != nil { + return -1, fmt.Errorf("cannot open path: %s", err) + } + if !filepath.IsAbs(iter.Path()) { + return -1, fmt.Errorf("path %v is not absolute", iter.Path()) + } + iter.Next() // Advance iterator to '/' + // We use the following flags to open: + // O_PATH: we don't intend to use the fd for IO + // O_NOFOLLOW: don't follow symlinks + // O_DIRECTORY: we expect to find directories (except for the leaf) + // O_CLOEXEC: don't leak file descriptors over exec() boundaries + openFlags := sys.O_PATH | syscall.O_NOFOLLOW | syscall.O_DIRECTORY | syscall.O_CLOEXEC + fd, err := sysOpen("/", openFlags, 0) + if err != nil { + return -1, err + } + for iter.Next() { + // Ensure the parent file descriptor is closed + defer sysClose(fd) + if !strings.HasSuffix(iter.CurrentName(), "/") { + openFlags &^= syscall.O_DIRECTORY + } + fd, err = sysOpenat(fd, iter.CurrentNameNoSlash(), openFlags, 0) + if err != nil { + return -1, err + } + } + + var statBuf syscall.Stat_t + err = sysFstat(fd, &statBuf) + if err != nil { + sysClose(fd) + return -1, err + } + if statBuf.Mode&syscall.S_IFMT == syscall.S_IFLNK { + sysClose(fd) + return -1, fmt.Errorf("%q is a symbolic link", path) + } + return fd, nil +} + +// syscallMode returns the syscall-specific mode bits from Go's portable mode bits. +// This is a copy of the same helper in Go's os package. +func syscallMode(i os.FileMode) (o uint32) { + o |= uint32(i.Perm()) + if i&os.ModeSetuid != 0 { + o |= syscall.S_ISUID + } + if i&os.ModeSetgid != 0 { + o |= syscall.S_ISGID + } + if i&os.ModeSticky != 0 { + o |= syscall.S_ISVTX + } + // No mapping for Go's ModeTemporary (plan9 only). + return o +} + +// MkPrefix creates all the missing directories in a given base path and +// returns the file descriptor to the leaf directory as well as the restricted +// flag. This function is a base for secure variants of mkdir, touch and +// symlink. None of the traversed directories can be symbolic links. +func MkPrefix(base string, perm os.FileMode, uid sys.UserID, gid sys.GroupID, rs *Restrictions) (int, error) { + iter, err := strutil.NewPathIterator(base) + if err != nil { + // TODO: Reword the error and adjust the tests. + return -1, fmt.Errorf("cannot split unclean path %q", base) + } + if !filepath.IsAbs(iter.Path()) { + return -1, fmt.Errorf("path %v is not absolute", iter.Path()) + } + iter.Next() // Advance iterator to '/' + + const openFlags = syscall.O_NOFOLLOW | syscall.O_CLOEXEC | syscall.O_DIRECTORY + // Open the root directory and start there. + // + // We don't have to check for possible trespassing on / here because we are + // going to check for it in sec.MkDir call below which verifies that + // trespassing restrictions are not violated. + fd, err := sysOpen("/", openFlags, 0) + if err != nil { + return -1, fmt.Errorf("cannot open root directory: %v", err) + } + for iter.Next() { + // Keep closing the previous descriptor as we go, so that we have the + // last one handy from the MkDir below. + defer sysClose(fd) + fd, err = MkDir(fd, iter.CurrentBaseNoSlash(), iter.CurrentNameNoSlash(), perm, uid, gid, rs) + if err != nil { + return -1, err + } + } + + return fd, nil +} + +// MkDir creates a directory with a given name. +// +// The directory is represented with a file descriptor and its name (for +// convenience). This function is meant to be used to construct subsequent +// elements of some path. The return value contains the newly created file +// descriptor for the new directory or -1 on error. +func MkDir(dirFd int, dirName string, name string, perm os.FileMode, uid sys.UserID, gid sys.GroupID, rs *Restrictions) (int, error) { + if err := rs.Check(dirFd, dirName); err != nil { + return -1, err + } + + made := true + const openFlags = syscall.O_NOFOLLOW | syscall.O_CLOEXEC | syscall.O_DIRECTORY + + if err := sysMkdirat(dirFd, name, syscallMode(perm)); err != nil { + switch err { + case syscall.EEXIST: + made = false + case syscall.EROFS: + // Treat EROFS specially: this is a hint that we have to poke a + // hole using tmpfs. The path below is the location where we + // need to poke the hole. + return -1, &ReadOnlyFsError{Path: dirName} + default: + return -1, fmt.Errorf("cannot create directory %q: %v", filepath.Join(dirName, name), err) + } + } + newFd, err := sysOpenat(dirFd, name, openFlags, 0) + if err != nil { + return -1, fmt.Errorf("cannot open directory %q: %v", filepath.Join(dirName, name), err) + } + if made { + // Chown each segment that we made. + if err := sysFchown(newFd, uid, gid); err != nil { + // Close the FD we opened if we fail here since the caller will get + // an error and won't assume responsibility for the FD. + sysClose(newFd) + return -1, fmt.Errorf("cannot chown directory %q to %d.%d: %v", filepath.Join(dirName, name), uid, gid, err) + } + // As soon as we find a place that is safe to write we can switch off + // the restricted mode (and thus any subsequent checks). This is + // because we only allow "writing" to read-only filesystems where + // writes fail with EROFS or to a tmpfs that snapd has privately + // mounted inside the per-snap mount namespace. As soon as we start + // walking over such tmpfs any subsequent children are either read- + // only bind mounts from $SNAP, other tmpfs'es (e.g. one explicitly + // constructed for a layout) or writable places that are bind-mounted + // from $SNAP_DATA or similar. + rs.Lift() + } + return newFd, err +} + +// MkFile creates a file with a given name. +// +// The directory is represented with a file descriptor and its name (for +// convenience). This function is meant to be used to create the leaf file as +// a preparation for a mount point. Existing files are reused without errors. +// Newly created files have the specified mode and ownership. +func MkFile(dirFd int, dirName string, name string, perm os.FileMode, uid sys.UserID, gid sys.GroupID, rs *Restrictions) error { + if err := rs.Check(dirFd, dirName); err != nil { + return err + } + + made := true + // NOTE: Tests don't show O_RDONLY as has a value of 0 and is not + // translated to textual form. It is added here for explicitness. + const openFlags = syscall.O_NOFOLLOW | syscall.O_CLOEXEC | syscall.O_RDONLY + + // Open the final path segment as a file. Try to create the file (so that + // we know if we need to chown it) but fall back to just opening an + // existing one. + + newFd, err := sysOpenat(dirFd, name, openFlags|syscall.O_CREAT|syscall.O_EXCL, syscallMode(perm)) + if err != nil { + switch err { + case syscall.EEXIST: + // If the file exists then just open it without O_CREAT and O_EXCL + newFd, err = sysOpenat(dirFd, name, openFlags, 0) + if err != nil { + return fmt.Errorf("cannot open file %q: %v", filepath.Join(dirName, name), err) + } + made = false + case syscall.EROFS: + // Treat EROFS specially: this is a hint that we have to poke a + // hole using tmpfs. The path below is the location where we + // need to poke the hole. + return &ReadOnlyFsError{Path: dirName} + default: + return fmt.Errorf("cannot open file %q: %v", filepath.Join(dirName, name), err) + } + } + defer sysClose(newFd) + + if made { + // Chown the file if we made it. + if err := sysFchown(newFd, uid, gid); err != nil { + return fmt.Errorf("cannot chown file %q to %d.%d: %v", filepath.Join(dirName, name), uid, gid, err) + } + } + + return nil +} + +// MkSymlink creates a symlink with a given name. +// +// The directory is represented with a file descriptor and its name (for +// convenience). This function is meant to be used to create the leaf symlink. +// Existing and identical symlinks are reused without errors. +func MkSymlink(dirFd int, dirName string, name string, oldname string, rs *Restrictions) error { + if err := rs.Check(dirFd, dirName); err != nil { + return err + } + + // Create the final path segment as a symlink. + if err := sysSymlinkat(oldname, dirFd, name); err != nil { + switch err { + case syscall.EEXIST: + var objFd int + // If the file exists then just open it for examination. + // Maybe it's the symlink we were hoping to create. + objFd, err = sysOpenat(dirFd, name, syscall.O_CLOEXEC|sys.O_PATH|syscall.O_NOFOLLOW, 0) + if err != nil { + return fmt.Errorf("cannot open existing file %q: %v", filepath.Join(dirName, name), err) + } + defer sysClose(objFd) + var statBuf syscall.Stat_t + err = sysFstat(objFd, &statBuf) + if err != nil { + return fmt.Errorf("cannot inspect existing file %q: %v", filepath.Join(dirName, name), err) + } + if statBuf.Mode&syscall.S_IFMT != syscall.S_IFLNK { + return fmt.Errorf("cannot create symbolic link %q: existing file in the way", filepath.Join(dirName, name)) + } + var n int + buf := make([]byte, len(oldname)+2) + n, err = sysReadlinkat(objFd, "", buf) + if err != nil { + return fmt.Errorf("cannot read symbolic link %q: %v", filepath.Join(dirName, name), err) + } + if string(buf[:n]) != oldname { + return fmt.Errorf("cannot create symbolic link %q: existing symbolic link in the way", filepath.Join(dirName, name)) + } + return nil + case syscall.EROFS: + // Treat EROFS specially: this is a hint that we have to poke a + // hole using tmpfs. The path below is the location where we + // need to poke the hole. + return &ReadOnlyFsError{Path: dirName} + default: + return fmt.Errorf("cannot create symlink %q: %v", filepath.Join(dirName, name), err) + } + } + + return nil +} + +// MkdirAllWithin is the secure variant of os.MkdirAll that creates all the missing directories of the given path within the +// given existing parent directory. +// +// Unlike os.MkdirAll this implementation does not follow any symbolic +// links. At all times the new directory segment is created using mkdirat(2) +// while holding an open file descriptor to the parent directory. +// +// The only handled error is mkdirat(2) that fails with EEXIST. All other +// errors are fatal but there is no attempt to undo anything that was created. +// +// The uid and gid are used for the fchown(2) system call which is performed +// after each segment is created and opened. The special value -1 may be used +// to request that ownership is not changed. +func MkdirAllWithin(path, parent string, perm os.FileMode, uid sys.UserID, gid sys.GroupID, rs *Restrictions) error { + path = filepath.Clean(path) + parent = filepath.Clean(parent) + if !filepath.IsAbs(path) { + return fmt.Errorf("cannot use relative path %q", path) + } + if !filepath.IsAbs(parent) { + return fmt.Errorf("cannot use relative parent path %q", parent) + } + isParent := func(path, parent string) bool { + if path == parent { + return false + } + if parent == "/" { + return true + } + return strings.HasPrefix(path, parent+string(filepath.Separator)) + } + if !isParent(path, parent) { + return fmt.Errorf("path %q is not a parent of %q", parent, path) + } + + // Check if we need to do anything + fi, err := osLstat(path) + if err == nil { + if !fi.Mode().IsDir() { + return fmt.Errorf("cannot create directory %q: existing file in the way", path) + } + return nil + } else if !os.IsNotExist(err) { + return fmt.Errorf("cannot inspect path %q: %v", path, err) + } + + // Check that the parent path exists + fi, err = osLstat(parent) + if err == nil { + if !fi.Mode().IsDir() { + return fmt.Errorf("cannot use parent path %q: not a directory", parent) + } + } else if os.IsNotExist(err) { + return fmt.Errorf("parent directory %q does not exist", parent) + } else { + return fmt.Errorf("cannot inspect parent path %q: %v", parent, err) + } + + iter, err := strutil.NewPathIterator(path) + if err != nil { + return fmt.Errorf("cannot iterate over path %q: %v", path, err) + } + // Advance the iterator to the parent. Finding the parent is + // guaranteed by the earlier check isParent. + for iter.Next() { + if iter.CurrentPathNoSlash() == parent { + break + } + } + // Advance the iterator to the first missing directory + for iter.Next() { + if iter.CurrentPathNoSlash() == path { + // Already confirmed path does not exist + break + } + fi, err = osLstat(iter.CurrentPathNoSlash()) + if err == nil { + if !fi.Mode().IsDir() { + return fmt.Errorf("cannot create directory %q: existing file in the way", iter.CurrentPathNoSlash()) + } + continue + } + if !os.IsNotExist(err) { + return fmt.Errorf("cannot inspect path %q: %v", iter.CurrentPathNoSlash(), err) + } + break + } + + // Create the first missing directory. From this point onward all file descriptors are kept open + // until all missing directories have been created or failure, and then closed in reverse order. + const openFlags = syscall.O_NOFOLLOW | syscall.O_CLOEXEC | syscall.O_DIRECTORY + fd, err := sysOpen(iter.CurrentBaseNoSlash(), openFlags, 0) + if err != nil { + return fmt.Errorf("cannot open directory %q: %v", iter.CurrentBaseNoSlash(), err) + } + defer sysClose(fd) + fd, err = MkDir(fd, iter.CurrentBaseNoSlash(), iter.CurrentNameNoSlash(), perm, uid, gid, rs) + if err != nil { + return err + } + defer sysClose(fd) + + // Create the remaining missing directories + for iter.Next() { + fd, err = MkDir(fd, iter.CurrentBaseNoSlash(), iter.CurrentNameNoSlash(), perm, uid, gid, rs) + if err != nil { + return err + } + defer sysClose(fd) + } + return nil +} + +// MkdirAll is the secure variant of os.MkdirAll. +// +// Unlike the regular version this implementation does not follow any symbolic +// links. At all times the new directory segment is created using mkdirat(2) +// while holding an open file descriptor to the parent directory. +// +// The only handled error is mkdirat(2) that fails with EEXIST. All other +// errors are fatal but there is no attempt to undo anything that was created. +// +// The uid and gid are used for the fchown(2) system call which is performed +// after each segment is created and opened. The special value -1 may be used +// to request that ownership is not changed. +func MkdirAll(path string, perm os.FileMode, uid sys.UserID, gid sys.GroupID, rs *Restrictions) error { + if path != filepath.Clean(path) { + // TODO: Reword the error and adjust the tests. + return fmt.Errorf("cannot split unclean path %q", path) + } + // Only support absolute paths to avoid bugs in snap-confine when + // called from anywhere. + if !filepath.IsAbs(path) { + return fmt.Errorf("cannot create directory with relative path: %q", path) + } + base, name := filepath.Split(path) + base = filepath.Clean(base) // Needed to chomp the trailing slash. + + // Create the prefix. + dirFd, err := MkPrefix(base, perm, uid, gid, rs) + if err != nil { + return err + } + defer sysClose(dirFd) + + if name != "" { + // Create the leaf as a directory. + leafFd, err := MkDir(dirFd, base, name, perm, uid, gid, rs) + if err != nil { + return err + } + defer sysClose(leafFd) + } + + return nil +} + +// MkfileAll is a secure implementation of "mkdir -p $(dirname $1) && touch $1". +// +// This function is like MkdirAll but it creates an empty file instead of +// a directory for the final path component. Each created directory component +// is chowned to the desired user and group. +func MkfileAll(path string, perm os.FileMode, uid sys.UserID, gid sys.GroupID, rs *Restrictions) error { + if path != filepath.Clean(path) { + // TODO: Reword the error and adjust the tests. + return fmt.Errorf("cannot split unclean path %q", path) + } + // Only support absolute paths to avoid bugs in snap-confine when + // called from anywhere. + if !filepath.IsAbs(path) { + return fmt.Errorf("cannot create file with relative path: %q", path) + } + // Only support file names, not directory names. + if strings.HasSuffix(path, "/") { + return fmt.Errorf("cannot create non-file path: %q", path) + } + base, name := filepath.Split(path) + base = filepath.Clean(base) // Needed to chomp the trailing slash. + + // Create the prefix. + dirFd, err := MkPrefix(base, perm, uid, gid, rs) + if err != nil { + return err + } + defer sysClose(dirFd) + + if name != "" { + // Create the leaf as a file. + err = MkFile(dirFd, base, name, perm, uid, gid, rs) + } + return err +} + +// MksymlinkAll is a secure implementation of "ln -s". +func MksymlinkAll(path string, perm os.FileMode, uid sys.UserID, gid sys.GroupID, oldname string, rs *Restrictions) error { + if path != filepath.Clean(path) { + // TODO: Reword the error and adjust the tests. + return fmt.Errorf("cannot split unclean path %q", path) + } + // Only support absolute paths to avoid bugs in snap-confine when + // called from anywhere. + if !filepath.IsAbs(path) { + return fmt.Errorf("cannot create symlink with relative path: %q", path) + } + // Only support file names, not directory names. + if strings.HasSuffix(path, "/") { + return fmt.Errorf("cannot create non-file path: %q", path) + } + if oldname == "" { + return fmt.Errorf("cannot create symlink with empty target: %q", path) + } + + base, name := filepath.Split(path) + base = filepath.Clean(base) // Needed to chomp the trailing slash. + + // Create the prefix. + dirFd, err := MkPrefix(base, perm, uid, gid, rs) + if err != nil { + return err + } + defer sysClose(dirFd) + + if name != "" { + // Create the leaf as a symlink. + err = MkSymlink(dirFd, base, name, oldname, rs) + } + return err +} + +// planWritableMimic plans how to transform a given directory from read-only to writable. +// +// The algorithm is designed to be universally reversible so that it can be +// always de-constructed back to the original directory. The original directory +// is hidden by tmpfs and a subset of things that were present there originally +// is bind mounted back on top of empty directories or empty files. Symlinks +// are re-created directly. Devices and all other elements are not supported +// because they are forbidden in snaps for which this function is designed to +// be used with. Since the original directory is hidden the algorithm relies on +// a temporary directory where the original is bind-mounted during the +// progression of the algorithm. +func planWritableMimic(dir, neededBy string) ([]*Change, error) { + // We need a place for "safe keeping" of what is present in the original + // directory as we are about to attach a tmpfs there, which will hide + // everything inside. + logger.Debugf("create-writable-mimic %q", dir) + safeKeepingDir := filepath.Join("/tmp/.snap/", dir) + + var changes []*Change + + // Stat the original directory to know which mode and ownership to + // replicate on top of the tmpfs we are about to create below. + var sb syscall.Stat_t + if err := sysLstat(dir, &sb); err != nil { + return nil, err + } + + // Bind mount the original directory elsewhere for safe-keeping. + changes = append(changes, &Change{ + Action: Mount, Entry: osutil.MountEntry{ + // NOTE: Here we recursively bind because we realized that not + // doing so doesn't work on core devices which use bind mounts + // extensively to construct writable spaces in /etc and /var and + // elsewhere. + // + // All directories present in the original are also recursively + // bind mounted back to their original location. To unmount this + // contraption we use MNT_DETACH which frees us from having to + // enumerate the mount table, unmount all the things (starting + // with most nested). + // + // The undo logic handles rbind mounts and adds x-snapd.unbind + // flag to them, which in turns translates to MNT_DETACH on + // umount2(2) system call. + Name: dir, Dir: safeKeepingDir, Options: []string{"rbind"}}, + }) + + // Mount tmpfs over the original directory, hiding its contents. + // The mounted tmpfs will mimic the mode and ownership of the original + // directory. + changes = append(changes, &Change{ + Action: Mount, Entry: osutil.MountEntry{ + Name: "tmpfs", Dir: dir, Type: "tmpfs", + Options: []string{ + osutil.XSnapdSynthetic(), + osutil.XSnapdNeededBy(neededBy), + fmt.Sprintf("mode=%#o", sb.Mode&07777), + fmt.Sprintf("uid=%d", sb.Uid), + fmt.Sprintf("gid=%d", sb.Gid), + }, + }, + }) + // Iterate over the items in the original directory (nothing is mounted _yet_). + entries, err := osReadDir(dir) + if err != nil { + return nil, err + } + for _, fi := range entries { + ch := &Change{Action: Mount, Entry: osutil.MountEntry{ + Name: filepath.Join(safeKeepingDir, fi.Name()), + Dir: filepath.Join(dir, fi.Name()), + }} + // Bind mount each element from the safe-keeping directory into the + // tmpfs. Our Change.Perform() engine can create the missing + // directories automatically so we don't bother creating those. + m := fi.Type() + switch { + case m.IsDir(): + ch.Entry.Options = []string{"rbind"} + case m.IsRegular(): + ch.Entry.Options = []string{"bind", osutil.XSnapdKindFile()} + case m&os.ModeSymlink != 0: + if target, err := osReadlink(filepath.Join(dir, fi.Name())); err == nil { + ch.Entry.Options = []string{osutil.XSnapdKindSymlink(), osutil.XSnapdSymlink(target)} + } else { + continue + } + default: + logger.Noticef("skipping unsupported file %s", fi) + continue + } + ch.Entry.Options = append(ch.Entry.Options, osutil.XSnapdSynthetic()) + ch.Entry.Options = append(ch.Entry.Options, osutil.XSnapdNeededBy(neededBy)) + changes = append(changes, ch) + } + // Finally unbind the safe-keeping directory as we don't need it anymore. + changes = append(changes, &Change{ + Action: Unmount, Entry: osutil.MountEntry{Name: "none", Dir: safeKeepingDir, Options: []string{osutil.XSnapdDetach()}}, + }) + return changes, nil +} + +// FatalError is an error that we cannot correct. +type FatalError struct { + error +} + +// execWritableMimic executes the plan for a writable mimic. +// The result is a transformed mount namespace and a set of fake mount changes +// that only exist in order to undo the plan. +// +// Certain assumptions are made about the plan, it must closely resemble that +// created by planWritableMimic, in particular the sequence must look like this: +// +// - bind a directory aside into safekeeping location +// - cover the original with tmpfs +// - bind mount something from safekeeping location to an empty file or +// directory in the tmpfs; this step can repeat any number of times +// - unbind the safekeeping location +// +// Apart from merely executing the plan a fake plan is returned for undo. The +// undo plan skips the following elements as compared to the original plan: +// +// - the initial bind mount that constructs the safekeeping directory is gone +// - the final unmount that removes the safekeeping directory +// - the source of each of the bind mounts that re-populate tmpfs. +// +// In the event of a failure the undo plan is executed and an error is +// returned. If the undo plan fails the function returns a FatalError as it +// cannot fix the system from an inconsistent state. +func execWritableMimic(plan []*Change, as *Assumptions) ([]*Change, error) { + undoChanges := make([]*Change, 0, len(plan)-2) + for i, change := range plan { + if _, err := change.Perform(as); err != nil { + // Drat, we failed! Let's undo everything according to our own undo + // plan, by following it in reverse order. + + recoveryUndoChanges := make([]*Change, 0, len(undoChanges)+1) + if i > 0 { + // The undo plan doesn't contain the entry for the initial bind + // mount of the safe keeping directory but we have already + // performed it. For this recovery phase we need to insert that + // in front of the undo plan manually. + recoveryUndoChanges = append(recoveryUndoChanges, plan[0]) + } + recoveryUndoChanges = append(recoveryUndoChanges, undoChanges...) + + for j := len(recoveryUndoChanges) - 1; j >= 0; j-- { + recoveryUndoChange := recoveryUndoChanges[j] + // All the changes mount something, we need to reverse that. + // The "undo plan" is "a plan that can be undone" not "the plan + // for how to undo" so we need to flip the actions. + recoveryUndoChange.Action = Unmount + if recoveryUndoChange.Entry.OptBool("rbind") { + recoveryUndoChange.Entry.Options = append(recoveryUndoChange.Entry.Options, osutil.XSnapdDetach()) + } + if _, err2 := recoveryUndoChange.Perform(as); err2 != nil { + // Drat, we failed when trying to recover from an error. + // We cannot do anything at this stage. + return nil, &FatalError{error: fmt.Errorf("cannot undo change %q while recovering from earlier error %v: %v", recoveryUndoChange, err, err2)} + } + } + return nil, err + } + if i == 0 || i == len(plan)-1 { + // Don't represent the initial and final changes in the undo plan. + // The initial change is the safe-keeping bind mount, the final + // change is the safe-keeping unmount. + continue + } + if change.Entry.XSnapdKind() == "symlink" { + // Don't represent symlinks in the undo plan. They are removed when + // the tmpfs is unmounted. + continue + + } + // Store an undo change for the change we just performed. + undoOpts := change.Entry.Options + if change.Entry.OptBool("rbind") { + undoOpts = make([]string, 0, len(change.Entry.Options)+1) + undoOpts = append(undoOpts, change.Entry.Options...) + undoOpts = append(undoOpts, "x-snapd.detach") + } + undoChange := &Change{ + Action: Mount, + Entry: osutil.MountEntry{Dir: change.Entry.Dir, Name: change.Entry.Name, Type: change.Entry.Type, Options: undoOpts}, + } + // Because of the use of a temporary bind mount (aka the safe-keeping + // directory) we cannot represent bind mounts fully (the temporary bind + // mount is unmounted as the last stage of this process). For that + // reason let's hide the original location and overwrite it so to + // appear as if the directory was a bind mount over itself. This is not + // fully true (it is a bind mount from the old self to the new empty + // directory or file in the same path, with the tmpfs in place already) + // but this is closer to the truth and more in line with the idea that + // this is just a plan for undoing the operation. + if undoChange.Entry.OptBool("bind") || undoChange.Entry.OptBool("rbind") { + undoChange.Entry.Name = undoChange.Entry.Dir + } + undoChanges = append(undoChanges, undoChange) + } + return undoChanges, nil +} + +func createWritableMimic(dir, neededBy string, as *Assumptions) ([]*Change, error) { + plan, err := planWritableMimic(dir, neededBy) + if err != nil { + return nil, err + } + changes, err := execWritableMimic(plan, as) + if err != nil { + return nil, err + } + return changes, nil +} diff --git a/cmd/snap-update-ns/utils_test.go b/cmd/snap-update-ns/utils_test.go new file mode 100644 index 00000000..c4cce664 --- /dev/null +++ b/cmd/snap-update-ns/utils_test.go @@ -0,0 +1,1601 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "bytes" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "syscall" + + . "gopkg.in/check.v1" + + update "github.com/snapcore/snapd/cmd/snap-update-ns" + "github.com/snapcore/snapd/logger" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/osutil/sys" + "github.com/snapcore/snapd/testutil" +) + +type utilsSuite struct { + testutil.BaseTest + sys *testutil.SyscallRecorder + log *bytes.Buffer + as *update.Assumptions +} + +var _ = Suite(&utilsSuite{}) + +func (s *utilsSuite) SetUpTest(c *C) { + s.BaseTest.SetUpTest(c) + s.sys = &testutil.SyscallRecorder{} + s.BaseTest.AddCleanup(update.MockSystemCalls(s.sys)) + buf, restore := logger.MockLogger() + s.BaseTest.AddCleanup(restore) + s.log = buf + s.as = &update.Assumptions{} +} + +func (s *utilsSuite) TearDownTest(c *C) { + s.BaseTest.TearDownTest(c) + s.sys.CheckForStrayDescriptors(c) +} + +// secure-mkdir-all-within + +// Ensure that we refuse to create a directory with an relative path. +func (s *utilsSuite) TestSecureMkdirAllWithinRelative(c *C) { + err := update.MkdirAllWithin("rel/path", "/", 0755, 123, 456, nil) + c.Assert(err, ErrorMatches, `cannot use relative path "rel/path"`) + c.Assert(s.sys.RCalls(), HasLen, 0) + + err = update.MkdirAllWithin("/abs/path", "", 0755, 123, 456, nil) + c.Assert(err, ErrorMatches, `cannot use relative parent path "."`) + c.Assert(s.sys.RCalls(), HasLen, 0) +} + +// Ensure that we refuse to accept an incorrect parent path. +func (s *utilsSuite) TestSecureMkdirAllWithinBadParent(c *C) { + err := update.MkdirAllWithin("/parent1/parent2/dir", "/parent2/", 0755, 123, 456, nil) + c.Assert(err, ErrorMatches, `path "/parent2" is not a parent of "/parent1/parent2/dir"`) + c.Assert(s.sys.RCalls(), HasLen, 0) + + err = update.MkdirAllWithin("/parent1/parent2/dir", "/parent", 0755, 123, 456, nil) + c.Assert(err, ErrorMatches, `path "/parent" is not a parent of "/parent1/parent2/dir"`) + c.Assert(s.sys.RCalls(), HasLen, 0) + + err = update.MkdirAllWithin("/", "/", 0755, 123, 456, nil) + c.Assert(err, ErrorMatches, `path "/" is not a parent of "/"`) + c.Assert(s.sys.RCalls(), HasLen, 0) + + err = update.MkdirAllWithin("/parent", "/parent/", 0755, 123, 456, nil) + c.Assert(err, ErrorMatches, `path "/parent" is not a parent of "/parent"`) + c.Assert(s.sys.RCalls(), HasLen, 0) +} + +// Path already exists. +func (s *utilsSuite) TestSecureMkdirAllWithinPathExists(c *C) { + s.sys.InsertOsLstatResult(`lstat "/parent/dir"`, testutil.FileInfoDir) + c.Assert(update.MkdirAllWithin("/parent/dir", "/parent", 0755, 123, 456, nil), IsNil) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/parent/dir"`, R: testutil.FileInfoDir}, + }) +} + +// Path exists but not a directory. +func (s *utilsSuite) TestSecureMkdirAllWithinPathExistsNotDir(c *C) { + s.sys.InsertOsLstatResult(`lstat "/parent/dir"`, testutil.FileInfoSymlink) + c.Assert(update.MkdirAllWithin("/parent/dir", "/parent", 0755, 123, 456, nil), ErrorMatches, `cannot create directory "/parent/dir": existing file in the way`) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/parent/dir"`, R: testutil.FileInfoSymlink}, + }) +} + +// Cannot inpect path existence. +func (s *utilsSuite) TestSecureMkdirAllWithinPathStatError(c *C) { + s.sys.InsertFault(`lstat "/parent/dir"`, errors.New("error other than os.ErrNotExist")) + c.Assert(update.MkdirAllWithin("/parent/dir", "/parent", 0755, 123, 456, nil), ErrorMatches, `cannot inspect path "/parent/dir": error other than os.ErrNotExist`) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/parent/dir"`, E: errors.New("error other than os.ErrNotExist")}, + }) +} + +// Parent does not exist. +func (s *utilsSuite) TestSecureMkdirAllWithinParentNotExist(c *C) { + s.sys.InsertFault(`lstat "/parent/dir"`, os.ErrNotExist) + s.sys.InsertFault(`lstat "/parent"`, os.ErrNotExist) + c.Assert(update.MkdirAllWithin("/parent/dir", "/parent", 0755, 123, 456, nil), ErrorMatches, `parent directory "/parent" does not exist`) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/parent/dir"`, E: os.ErrNotExist}, + {C: `lstat "/parent"`, E: os.ErrNotExist}, + }) +} + +// Parent exists but not a directory. +func (s *utilsSuite) TestSecureMkdirAllWithinParentExitsNotDir(c *C) { + s.sys.InsertFault(`lstat "/parent/dir"`, os.ErrNotExist) + s.sys.InsertOsLstatResult(`lstat "/parent"`, testutil.FileInfoSymlink) + c.Assert(update.MkdirAllWithin("/parent/dir", "/parent", 0755, 123, 456, nil), ErrorMatches, `cannot use parent path "/parent": not a directory`) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/parent/dir"`, E: os.ErrNotExist}, + {C: `lstat "/parent"`, R: testutil.FileInfoSymlink}, + }) +} + +// Cannot inspect parent existence. +func (s *utilsSuite) TestSecureMkdirAllWithinParentStatError(c *C) { + s.sys.InsertFault(`lstat "/parent/dir"`, os.ErrNotExist) + s.sys.InsertFault(`lstat "/parent"`, errors.New("error other than os.ErrNotExist")) + c.Assert(update.MkdirAllWithin("/parent/dir", "/parent", 0755, 123, 456, nil), ErrorMatches, `cannot inspect parent path "/parent": error other than os.ErrNotExist`) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/parent/dir"`, E: os.ErrNotExist}, + {C: `lstat "/parent"`, E: errors.New("error other than os.ErrNotExist")}, + }) +} + +// First missing dir exists but not a directory +func (s *utilsSuite) TestSecureMkdirAllFirstMissingDirExistsNotDir(c *C) { + s.sys.InsertFault(`lstat "/parent/missing/dir"`, os.ErrNotExist) + s.sys.InsertOsLstatResult(`lstat "/parent"`, testutil.FileInfoDir) + s.sys.InsertOsLstatResult(`lstat "/parent/missing"`, testutil.FileInfoFile) + c.Assert(update.MkdirAllWithin("/parent/missing/dir", "/parent", 0755, 123, 456, nil), ErrorMatches, `cannot create directory "/parent/missing": existing file in the way`) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/parent/missing/dir"`, E: os.ErrNotExist}, + {C: `lstat "/parent"`, R: testutil.FileInfoDir}, + {C: `lstat "/parent/missing"`, R: testutil.FileInfoFile}, + }) +} + +// Cannot inspect first missing dir +func (s *utilsSuite) TestSecureMkdsrAllFirstMissingDirStatError(c *C) { + s.sys.InsertFault(`lstat "/parent/missing/dir"`, os.ErrNotExist) + s.sys.InsertOsLstatResult(`lstat "/parent"`, testutil.FileInfoDir) + s.sys.InsertFault(`lstat "/parent/missing"`, errors.New("error other than os.ErrNotExist")) + c.Assert(update.MkdirAllWithin("/parent/missing/dir", "/parent", 0755, 123, 456, nil), ErrorMatches, `cannot inspect path "/parent/missing": error other than os.ErrNotExist`) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/parent/missing/dir"`, E: os.ErrNotExist}, + {C: `lstat "/parent"`, R: testutil.FileInfoDir}, + {C: `lstat "/parent/missing"`, E: errors.New("error other than os.ErrNotExist")}, + }) +} + +// Cannot open parent of first missing directory +func (s *utilsSuite) TestSecureMkdirAllOpenParentOfFirstMissingDirError(c *C) { + s.sys.InsertFault(`lstat "/dir"`, os.ErrNotExist) + s.sys.InsertOsLstatResult(`lstat "/"`, testutil.FileInfoDir) + s.sys.InsertFault(`open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, errTesting) + c.Assert(update.MkdirAllWithin("/dir", "/", 0755, 123, 456, nil), ErrorMatches, `cannot open directory "/": testing`) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/dir"`, E: os.ErrNotExist}, + {C: `lstat "/"`, R: testutil.FileInfoDir}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, E: errTesting}, + }) +} + +// Cannot create first missing dir +func (s *utilsSuite) TestSecureMkdirAllCreateFirstMissingDirError(c *C) { + s.sys.InsertFault(`lstat "/dir"`, os.ErrNotExist) + s.sys.InsertOsLstatResult(`lstat "/"`, testutil.FileInfoDir) + s.sys.InsertFault(`mkdirat 3 "dir" 0755`, errTesting) + c.Assert(update.MkdirAllWithin("/dir", "/", 0755, 123, 456, nil), ErrorMatches, `cannot create directory "/dir": testing`) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/dir"`, E: os.ErrNotExist}, + {C: `lstat "/"`, R: testutil.FileInfoDir}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `mkdirat 3 "dir" 0755`, E: errTesting}, + {C: `close 3`}, + }) +} + +// Cannot create second missing dir +func (s *utilsSuite) TestSecureMkdirAllCreateSecondMissingDirError(c *C) { + s.sys.InsertFault(`lstat "/parent1/parent2/dir"`, os.ErrNotExist) + s.sys.InsertOsLstatResult(`lstat "/"`, testutil.FileInfoDir) + s.sys.InsertFault(`lstat "/parent1"`, os.ErrNotExist) + s.sys.InsertFault(`lstat "/parent1/parent2"`, os.ErrNotExist) + s.sys.InsertFault(`mkdirat 4 "parent2" 0755`, errTesting) + c.Assert(update.MkdirAllWithin("/parent1/parent2/dir", "/", 0755, 123, 456, nil), ErrorMatches, `cannot create directory \"/parent1/parent2\": testing`) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/parent1/parent2/dir"`, E: os.ErrNotExist}, + {C: `lstat "/"`, R: testutil.FileInfoDir}, + {C: `lstat "/parent1"`, E: os.ErrNotExist}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `mkdirat 3 "parent1" 0755`}, + {C: `openat 3 "parent1" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `fchown 4 123 456`}, + {C: `mkdirat 4 "parent2" 0755`, E: errTesting}, + {C: `close 4`}, + {C: `close 3`}, + }) +} + +// Ensure that we can create a directory in the top-level directory. +func (s *utilsSuite) TestSecureMkdirAllWithinLevel1(c *C) { + s.sys.InsertFault(`lstat "/dir"`, os.ErrNotExist) + s.sys.InsertOsLstatResult(`lstat "/"`, testutil.FileInfoDir) + c.Assert(update.MkdirAllWithin("/dir", "/", 0755, 123, 456, nil), IsNil) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/dir"`, E: os.ErrNotExist}, + {C: `lstat "/"`, R: testutil.FileInfoDir}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `mkdirat 3 "dir" 0755`}, + {C: `openat 3 "dir" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `fchown 4 123 456`}, + {C: `close 4`}, + {C: `close 3`}, + }) +} + +// Ensure that we can create a directory two levels from the top-level directory. +func (s *utilsSuite) TestSecureMkdirAllWithinLevel2(c *C) { + s.sys.InsertFault(`lstat "/dir1/dir2"`, os.ErrNotExist) + s.sys.InsertFault(`lstat "/dir1"`, os.ErrNotExist) + s.sys.InsertOsLstatResult(`lstat "/"`, testutil.FileInfoDir) + c.Assert(update.MkdirAllWithin("/dir1/dir2", "/", 0755, 123, 456, nil), IsNil) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/dir1/dir2"`, E: os.ErrNotExist}, + {C: `lstat "/"`, R: testutil.FileInfoDir}, + {C: `lstat "/dir1"`, E: os.ErrNotExist}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `mkdirat 3 "dir1" 0755`}, + {C: `openat 3 "dir1" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `fchown 4 123 456`}, + {C: `mkdirat 4 "dir2" 0755`}, + {C: `openat 4 "dir2" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 5}, + {C: `fchown 5 123 456`}, + {C: `close 5`}, + {C: `close 4`}, + {C: `close 3`}, + }) +} + +// Ensure that we can create a directory one level from the one level parent directory. +func (s *utilsSuite) TestSecureMkdirAllWithinLevel1Parent1(c *C) { + s.sys.InsertFault(`lstat "/dir1/dir2"`, os.ErrNotExist) + s.sys.InsertOsLstatResult(`lstat "/dir1"`, testutil.FileInfoDir) + c.Assert(update.MkdirAllWithin("/dir1/dir2", "/dir1", 0755, 123, 456, nil), IsNil) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/dir1/dir2"`, E: os.ErrNotExist}, + {C: `lstat "/dir1"`, R: testutil.FileInfoDir}, + {C: `open "/dir1" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `mkdirat 3 "dir2" 0755`}, + {C: `openat 3 "dir2" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `fchown 4 123 456`}, + {C: `close 4`}, + {C: `close 3`}, + }) +} + +// Ensure that we can create a directory three levels from the top-level directory. +func (s *utilsSuite) TestSecureMkdirAllWithinLevel3(c *C) { + s.sys.InsertFault(`lstat "/dir1/dir2/dir3"`, os.ErrNotExist) + s.sys.InsertFault(`lstat "/dir1/dir2"`, os.ErrNotExist) + s.sys.InsertFault(`lstat "/dir1"`, os.ErrNotExist) + s.sys.InsertOsLstatResult(`lstat "/"`, testutil.FileInfoDir) + c.Assert(update.MkdirAllWithin("/dir1/dir2/dir3", "/", 0755, 123, 456, nil), IsNil) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/dir1/dir2/dir3"`, E: os.ErrNotExist}, + {C: `lstat "/"`, R: testutil.FileInfoDir}, + {C: `lstat "/dir1"`, E: os.ErrNotExist}, + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `mkdirat 3 "dir1" 0755`}, + {C: `openat 3 "dir1" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `fchown 4 123 456`}, + {C: `mkdirat 4 "dir2" 0755`}, + {C: `openat 4 "dir2" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 5}, + {C: `fchown 5 123 456`}, + {C: `mkdirat 5 "dir3" 0755`}, + {C: `openat 5 "dir3" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 6}, + {C: `fchown 6 123 456`}, + {C: `close 6`}, + {C: `close 5`}, + {C: `close 4`}, + {C: `close 3`}, + }) +} + +// Ensure that we can create a directory one level from the two level parent directory. +func (s *utilsSuite) TestSecureMkdirAllWithinLevel3Parent2(c *C) { + s.sys.InsertFault(`lstat "/dir1/dir2/dir3"`, os.ErrNotExist) + s.sys.InsertOsLstatResult(`lstat "/dir1/dir2"`, testutil.FileInfoDir) + c.Assert(update.MkdirAllWithin("/dir1/dir2/dir3", "/dir1/dir2", 0755, 123, 456, nil), IsNil) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/dir1/dir2/dir3"`, E: os.ErrNotExist}, + {C: `lstat "/dir1/dir2"`, R: testutil.FileInfoDir}, + {C: `open "/dir1/dir2" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `mkdirat 3 "dir3" 0755`}, + {C: `openat 3 "dir3" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `fchown 4 123 456`}, + {C: `close 4`}, + {C: `close 3`}, + }) +} + +// Ensure that we can create a directory two levels from the two level parent directory. +func (s *utilsSuite) TestSecureMkdirAllWithinLevel4Parent2(c *C) { + s.sys.InsertFault(`lstat "/dir1/dir2/dir3/dir4"`, os.ErrNotExist) + s.sys.InsertFault(`lstat "/dir1/dir2/dir3"`, os.ErrNotExist) + s.sys.InsertOsLstatResult(`lstat "/dir1/dir2"`, testutil.FileInfoDir) + c.Assert(update.MkdirAllWithin("/dir1/dir2/dir3/dir4", "/dir1/dir2", 0755, 123, 456, nil), IsNil) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/dir1/dir2/dir3/dir4"`, E: os.ErrNotExist}, + {C: `lstat "/dir1/dir2"`, R: testutil.FileInfoDir}, + {C: `lstat "/dir1/dir2/dir3"`, E: os.ErrNotExist}, + {C: `open "/dir1/dir2" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `mkdirat 3 "dir3" 0755`}, + {C: `openat 3 "dir3" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `fchown 4 123 456`}, + {C: `mkdirat 4 "dir4" 0755`}, + {C: `openat 4 "dir4" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 5}, + {C: `fchown 5 123 456`}, + {C: `close 5`}, + {C: `close 4`}, + {C: `close 3`}, + }) +} + +// Ensure that we can create a directory two levels from the two level parent directory with level three existing +func (s *utilsSuite) TestSecureMkdirAllWithinLevel4Parent2Level3Exists(c *C) { + s.sys.InsertFault(`lstat "/dir1/dir2/dir3/dir4"`, os.ErrNotExist) + s.sys.InsertOsLstatResult(`lstat "/dir1/dir2/dir3"`, testutil.FileInfoDir) + s.sys.InsertOsLstatResult(`lstat "/dir1/dir2"`, testutil.FileInfoDir) + c.Assert(update.MkdirAllWithin("/dir1/dir2/dir3/dir4", "/dir1/dir2", 0755, 123, 456, nil), IsNil) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/dir1/dir2/dir3/dir4"`, E: os.ErrNotExist}, + {C: `lstat "/dir1/dir2"`, R: testutil.FileInfoDir}, + {C: `lstat "/dir1/dir2/dir3"`, R: testutil.FileInfoDir}, + {C: `open "/dir1/dir2/dir3" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `mkdirat 3 "dir4" 0755`}, + {C: `openat 3 "dir4" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `fchown 4 123 456`}, + {C: `close 4`}, + {C: `close 3`}, + }) +} + +// Ensure that writes to /etc/demo are interrupted if /etc is restricted. +func (s *utilsSuite) TestSecureMkdirAllWithinRestrictedEtc(c *C) { + s.sys.InsertFstatfsResult(`fstatfs 3 `, syscall.Statfs_t{Type: update.Ext4Magic}) + s.sys.InsertFstatResult(`fstat 3 `, syscall.Stat_t{}) + rs := s.as.RestrictionsFor("/etc/demo") + s.sys.InsertFault(`lstat "/etc/demo"`, os.ErrNotExist) + s.sys.InsertOsLstatResult(`lstat "/"`, testutil.FileInfoDir) + s.sys.InsertOsLstatResult(`lstat "/etc"`, testutil.FileInfoDir) + err := update.MkdirAllWithin("/etc/demo", "/", 0755, 123, 456, rs) + c.Assert(err, ErrorMatches, `cannot write to "/etc/demo" because it would affect the host in "/etc"`) + c.Assert(err.(*update.TrespassingError).ViolatedPath, Equals, "/etc") + c.Assert(err.(*update.TrespassingError).DesiredPath, Equals, "/etc/demo") + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/etc/demo"`, E: os.ErrNotExist}, + {C: `lstat "/"`, R: testutil.FileInfoDir}, + // Skip over inspecting /etc because it exists. + {C: `lstat "/etc"`, R: testutil.FileInfoDir}, + {C: `open "/etc" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + // /etc/demo is ext4 which is writable, refuse further operations. + {C: `fstatfs 3 `, R: syscall.Statfs_t{Type: update.Ext4Magic}}, + {C: `fstat 3 `, R: syscall.Stat_t{}}, + {C: `close 3`}, + }) +} + +// Ensure that writes to /etc/demo allowed if /etc is unrestricted. +func (s *utilsSuite) TestSecureMkdirAllWithinUnrestrictedEtc(c *C) { + defer s.as.MockUnrestrictedPaths("/etc")() + rs := s.as.RestrictionsFor("/etc/demo") + s.sys.InsertFault(`lstat "/etc/demo"`, os.ErrNotExist) + s.sys.InsertOsLstatResult(`lstat "/"`, testutil.FileInfoDir) + s.sys.InsertOsLstatResult(`lstat "/etc"`, testutil.FileInfoDir) + c.Assert(update.MkdirAllWithin("/etc/demo", "/", 0755, 123, 456, rs), IsNil) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `lstat "/etc/demo"`, E: os.ErrNotExist}, + {C: `lstat "/"`, R: testutil.FileInfoDir}, + // Skip over inspecting /etc because it exists. + {C: `lstat "/etc"`, R: testutil.FileInfoDir}, + {C: `open "/etc" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + // No inspection required parent /etc is unrestricted. + {C: `mkdirat 3 "demo" 0755`}, + {C: `openat 3 "demo" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `fchown 4 123 456`}, + {C: `close 4`}, + {C: `close 3`}, + }) +} + +// secure-mkdir-all + +// Ensure that we reject unclean paths. +func (s *utilsSuite) TestSecureMkdirAllUnclean(c *C) { + err := update.MkdirAll("/unclean//path", 0755, 123, 456, nil) + c.Assert(err, ErrorMatches, `cannot split unclean path .*`) + c.Assert(s.sys.RCalls(), HasLen, 0) +} + +// Ensure that we refuse to create a directory with an relative path. +func (s *utilsSuite) TestSecureMkdirAllRelative(c *C) { + err := update.MkdirAll("rel/path", 0755, 123, 456, nil) + c.Assert(err, ErrorMatches, `cannot create directory with relative path: "rel/path"`) + c.Assert(s.sys.RCalls(), HasLen, 0) +} + +// Ensure that we can create the root directory. +func (s *utilsSuite) TestSecureMkdirAllLevel0(c *C) { + c.Assert(update.MkdirAll("/", 0755, 123, 456, nil), IsNil) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `close 3`}, + }) +} + +// Ensure that we can create a directory in the top-level directory. +func (s *utilsSuite) TestSecureMkdirAllLevel1(c *C) { + c.Assert(update.MkdirAll("/path", 0755, 123, 456, nil), IsNil) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `mkdirat 3 "path" 0755`}, + {C: `openat 3 "path" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `fchown 4 123 456`}, + {C: `close 4`}, + {C: `close 3`}, + }) +} + +// Ensure that we can create a directory two levels from the top-level directory. +func (s *utilsSuite) TestSecureMkdirAllLevel2(c *C) { + c.Assert(update.MkdirAll("/path/to", 0755, 123, 456, nil), IsNil) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `mkdirat 3 "path" 0755`}, + {C: `openat 3 "path" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `fchown 4 123 456`}, + {C: `close 3`}, + {C: `mkdirat 4 "to" 0755`}, + {C: `openat 4 "to" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `fchown 3 123 456`}, + {C: `close 3`}, + {C: `close 4`}, + }) +} + +// Ensure that we can create a directory three levels from the top-level directory. +func (s *utilsSuite) TestSecureMkdirAllLevel3(c *C) { + c.Assert(update.MkdirAll("/path/to/something", 0755, 123, 456, nil), IsNil) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `mkdirat 3 "path" 0755`}, + {C: `openat 3 "path" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `fchown 4 123 456`}, + {C: `mkdirat 4 "to" 0755`}, + {C: `openat 4 "to" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 5}, + {C: `fchown 5 123 456`}, + {C: `close 4`}, + {C: `close 3`}, + {C: `mkdirat 5 "something" 0755`}, + {C: `openat 5 "something" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `fchown 3 123 456`}, + {C: `close 3`}, + {C: `close 5`}, + }) +} + +// Ensure that we are not masking out the sticky bit when creating directories +func (s *utilsSuite) TestSecureMkdirAllAllowsStickyBit(c *C) { + s.sys.InsertFault(`mkdirat 3 "dev" 01777`, syscall.EEXIST) + s.sys.InsertFault(`mkdirat 4 "shm" 01777`, syscall.EEXIST) + c.Assert(update.MkdirAll("/dev/shm/snap.foo", 0777|os.ModeSticky, 0, 0, nil), IsNil) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `mkdirat 3 "dev" 01777`, E: syscall.EEXIST}, + {C: `openat 3 "dev" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `mkdirat 4 "shm" 01777`, E: syscall.EEXIST}, + {C: `openat 4 "shm" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 5}, + {C: `close 4`}, + {C: `close 3`}, + {C: `mkdirat 5 "snap.foo" 01777`}, + {C: `openat 5 "snap.foo" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `fchown 3 0 0`}, + {C: `close 3`}, + {C: `close 5`}, + }) +} + +// Ensure that trespassing for prefix is matched using clean base path. +func (s *utilsSuite) TestTrespassingMatcher(c *C) { + // We mounted tmpfs at "/path". + s.as.AddChange(&update.Change{Action: update.Mount, Entry: osutil.MountEntry{Dir: "/path", Type: "tmpfs", Name: "tmpfs"}}) + s.sys.InsertFstatfsResult(`fstatfs 3 `, syscall.Statfs_t{Type: update.SquashfsMagic}) + s.sys.InsertFstatResult(`fstat 3 `, syscall.Stat_t{}) + s.sys.InsertFstatfsResult(`fstatfs 4 `, syscall.Statfs_t{Type: update.TmpfsMagic}) + s.sys.InsertFstatResult(`fstat 4 `, syscall.Stat_t{}) + s.sys.InsertFault(`mkdirat 3 "path" 0755`, syscall.EEXIST) + rs := s.as.RestrictionsFor("/path/to/something") + // Trespassing detector checked "/path", not "/path/" (which would not match). + c.Assert(update.MkdirAll("/path/to/something", 0755, 123, 456, rs), IsNil) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `fstatfs 3 `, R: syscall.Statfs_t{Type: update.SquashfsMagic}}, + {C: `fstat 3 `, R: syscall.Stat_t{}}, + {C: `mkdirat 3 "path" 0755`, E: syscall.EEXIST}, + {C: `openat 3 "path" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `fstatfs 4 `, R: syscall.Statfs_t{Type: update.TmpfsMagic}}, + {C: `fstat 4 `, R: syscall.Stat_t{}}, + + {C: `mkdirat 4 "to" 0755`}, + {C: `openat 4 "to" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 5}, + {C: `fchown 5 123 456`}, + {C: `close 4`}, + {C: `close 3`}, + {C: `mkdirat 5 "something" 0755`}, + {C: `openat 5 "something" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `fchown 3 123 456`}, + {C: `close 3`}, + {C: `close 5`}, + }) +} + +// Ensure that writes to /etc/demo are interrupted if /etc is restricted. +func (s *utilsSuite) TestSecureMkdirAllWithRestrictedEtc(c *C) { + s.sys.InsertFstatfsResult(`fstatfs 3 `, syscall.Statfs_t{Type: update.SquashfsMagic}) + s.sys.InsertFstatResult(`fstat 3 `, syscall.Stat_t{}) + s.sys.InsertFstatfsResult(`fstatfs 4 `, syscall.Statfs_t{Type: update.Ext4Magic}) + s.sys.InsertFstatResult(`fstat 4 `, syscall.Stat_t{}) + s.sys.InsertFault(`mkdirat 3 "etc" 0755`, syscall.EEXIST) + rs := s.as.RestrictionsFor("/etc/demo") + err := update.MkdirAll("/etc/demo", 0755, 123, 456, rs) + c.Assert(err, ErrorMatches, `cannot write to "/etc/demo" because it would affect the host in "/etc"`) + c.Assert(err.(*update.TrespassingError).ViolatedPath, Equals, "/etc") + c.Assert(err.(*update.TrespassingError).DesiredPath, Equals, "/etc/demo") + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + // we are inspecting the type of the filesystem we are about to perform operation on. + {C: `fstatfs 3 `, R: syscall.Statfs_t{Type: update.SquashfsMagic}}, + {C: `fstat 3 `, R: syscall.Stat_t{}}, + {C: `mkdirat 3 "etc" 0755`, E: syscall.EEXIST}, + {C: `openat 3 "etc" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `close 3`}, + // ext4 is writable, refuse further operations. + {C: `fstatfs 4 `, R: syscall.Statfs_t{Type: update.Ext4Magic}}, + {C: `fstat 4 `, R: syscall.Stat_t{}}, + {C: `close 4`}, + }) +} + +// Ensure that writes to /etc/demo allowed if /etc is unrestricted. +func (s *utilsSuite) TestSecureMkdirAllWithUnrestrictedEtc(c *C) { + defer s.as.MockUnrestrictedPaths("/etc")() // Mark /etc as unrestricted. + s.sys.InsertFault(`mkdirat 3 "etc" 0755`, syscall.EEXIST) + rs := s.as.RestrictionsFor("/etc/demo") + c.Assert(update.MkdirAll("/etc/demo", 0755, 123, 456, rs), IsNil) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + // We are not interested in the type of filesystem at / + {C: `mkdirat 3 "etc" 0755`, E: syscall.EEXIST}, + {C: `openat 3 "etc" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + // We are not interested in the type of filesystem at /etc + {C: `close 3`}, + {C: `mkdirat 4 "demo" 0755`}, + {C: `openat 4 "demo" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `fchown 3 123 456`}, + {C: `close 3`}, + {C: `close 4`}, + }) +} + +// Ensure that we can detect read only filesystems. +func (s *utilsSuite) TestSecureMkdirAllROFS(c *C) { + s.sys.InsertFault(`mkdirat 3 "rofs" 0755`, syscall.EEXIST) // just realistic + s.sys.InsertFault(`mkdirat 4 "path" 0755`, syscall.EROFS) + err := update.MkdirAll("/rofs/path", 0755, 123, 456, nil) + c.Assert(err, ErrorMatches, `cannot operate on read-only filesystem at /rofs`) + c.Assert(err.(*update.ReadOnlyFsError).Path, Equals, "/rofs") + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `mkdirat 3 "rofs" 0755`, E: syscall.EEXIST}, + {C: `openat 3 "rofs" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `close 3`}, + {C: `mkdirat 4 "path" 0755`, E: syscall.EROFS}, + {C: `close 4`}, + }) +} + +// Ensure that we don't chown existing directories. +func (s *utilsSuite) TestSecureMkdirAllExistingDirsDontChown(c *C) { + s.sys.InsertFault(`mkdirat 3 "abs" 0755`, syscall.EEXIST) + s.sys.InsertFault(`mkdirat 4 "path" 0755`, syscall.EEXIST) + err := update.MkdirAll("/abs/path", 0755, 123, 456, nil) + c.Assert(err, IsNil) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `mkdirat 3 "abs" 0755`, E: syscall.EEXIST}, + {C: `openat 3 "abs" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `close 3`}, + {C: `mkdirat 4 "path" 0755`, E: syscall.EEXIST}, + {C: `openat 4 "path" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `close 3`}, + {C: `close 4`}, + }) +} + +// Ensure that we we close everything when mkdirat fails. +func (s *utilsSuite) TestSecureMkdirAllMkdiratError(c *C) { + s.sys.InsertFault(`mkdirat 3 "abs" 0755`, errTesting) + err := update.MkdirAll("/abs", 0755, 123, 456, nil) + c.Assert(err, ErrorMatches, `cannot create directory "/abs": testing`) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `mkdirat 3 "abs" 0755`, E: errTesting}, + {C: `close 3`}, + }) +} + +// Ensure that we we close everything when fchown fails. +func (s *utilsSuite) TestSecureMkdirAllFchownError(c *C) { + s.sys.InsertFault(`fchown 4 123 456`, errTesting) + err := update.MkdirAll("/path", 0755, 123, 456, nil) + c.Assert(err, ErrorMatches, `cannot chown directory "/path" to 123.456: testing`) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `mkdirat 3 "path" 0755`}, + {C: `openat 3 "path" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `fchown 4 123 456`, E: errTesting}, + {C: `close 4`}, + {C: `close 3`}, + }) +} + +// Check error path when we cannot open root directory. +func (s *utilsSuite) TestSecureMkdirAllOpenRootError(c *C) { + s.sys.InsertFault(`open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, errTesting) + err := update.MkdirAll("/abs/path", 0755, 123, 456, nil) + c.Assert(err, ErrorMatches, "cannot open root directory: testing") + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, E: errTesting}, + }) +} + +// Check error path when we cannot open non-root directory. +func (s *utilsSuite) TestSecureMkdirAllOpenError(c *C) { + s.sys.InsertFault(`openat 3 "abs" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, errTesting) + err := update.MkdirAll("/abs/path", 0755, 123, 456, nil) + c.Assert(err, ErrorMatches, `cannot open directory "/abs": testing`) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `mkdirat 3 "abs" 0755`}, + {C: `openat 3 "abs" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, E: errTesting}, + {C: `close 3`}, + }) +} + +func (s *utilsSuite) TestPlanWritableMimic(c *C) { + s.sys.InsertSysLstatResult(`lstat "/foo" `, syscall.Stat_t{Uid: 0, Gid: 0, Mode: 0755}) + restore := update.MockReadDir(func(dir string) ([]fs.DirEntry, error) { + c.Assert(dir, Equals, "/foo") + return []fs.DirEntry{ + testutil.FakeDirEntry("file", 0), + testutil.FakeDirEntry("dir", os.ModeDir), + testutil.FakeDirEntry("symlink", os.ModeSymlink), + testutil.FakeDirEntry("error-symlink-readlink", os.ModeSymlink), + // NOTE: None of the filesystem entries below are supported because + // they cannot be placed inside snaps or can only be created at + // runtime in areas that are already writable and this would never + // have to be handled in a writable mimic. + testutil.FakeDirEntry("block-dev", os.ModeDevice), + testutil.FakeDirEntry("char-dev", os.ModeDevice|os.ModeCharDevice), + testutil.FakeDirEntry("socket", os.ModeSocket), + testutil.FakeDirEntry("pipe", os.ModeNamedPipe), + }, nil + }) + defer restore() + restore = update.MockReadlink(func(name string) (string, error) { + switch name { + case "/foo/symlink": + return "target", nil + case "/foo/error-symlink-readlink": + return "", errTesting + } + panic("unexpected") + }) + defer restore() + + changes, err := update.PlanWritableMimic("/foo", "/foo/bar") + c.Assert(err, IsNil) + + c.Assert(changes, DeepEquals, []*update.Change{ + // Store /foo in /tmp/.snap/foo while we set things up + {Entry: osutil.MountEntry{Name: "/foo", Dir: "/tmp/.snap/foo", Options: []string{"rbind"}}, Action: update.Mount}, + // Put a tmpfs over /foo + {Entry: osutil.MountEntry{Name: "tmpfs", Dir: "/foo", Type: "tmpfs", Options: []string{"x-snapd.synthetic", "x-snapd.needed-by=/foo/bar", "mode=0755", "uid=0", "gid=0"}}, Action: update.Mount}, + // Bind mount files and directories over. Note that files are identified by x-snapd.kind=file option. + {Entry: osutil.MountEntry{Name: "/tmp/.snap/foo/file", Dir: "/foo/file", Options: []string{"bind", "x-snapd.kind=file", "x-snapd.synthetic", "x-snapd.needed-by=/foo/bar"}}, Action: update.Mount}, + {Entry: osutil.MountEntry{Name: "/tmp/.snap/foo/dir", Dir: "/foo/dir", Options: []string{"rbind", "x-snapd.synthetic", "x-snapd.needed-by=/foo/bar"}}, Action: update.Mount}, + // Create symlinks. + // Bad symlinks and all other file types are skipped and not + // recorded in mount changes. + {Entry: osutil.MountEntry{Name: "/tmp/.snap/foo/symlink", Dir: "/foo/symlink", Options: []string{"x-snapd.kind=symlink", "x-snapd.symlink=target", "x-snapd.synthetic", "x-snapd.needed-by=/foo/bar"}}, Action: update.Mount}, + // Unmount the safe-keeping directory + {Entry: osutil.MountEntry{Name: "none", Dir: "/tmp/.snap/foo", Options: []string{"x-snapd.detach"}}, Action: update.Unmount}, + }) +} + +func (s *utilsSuite) TestPlanWritableMimicErrors(c *C) { + s.sys.InsertSysLstatResult(`lstat "/foo" `, syscall.Stat_t{Uid: 0, Gid: 0, Mode: 0755}) + restore := update.MockReadDir(func(dir string) ([]fs.DirEntry, error) { + c.Assert(dir, Equals, "/foo") + return nil, errTesting + }) + defer restore() + restore = update.MockReadlink(func(name string) (string, error) { + return "", errTesting + }) + defer restore() + + changes, err := update.PlanWritableMimic("/foo", "/foo/bar") + c.Assert(err, ErrorMatches, "testing") + c.Assert(changes, HasLen, 0) +} + +func (s *utilsSuite) TestExecWirableMimicSuccess(c *C) { + // This plan is the same as in the test above. This is what comes out of planWritableMimic. + plan := []*update.Change{ + {Entry: osutil.MountEntry{Name: "/foo", Dir: "/tmp/.snap/foo", Options: []string{"rbind"}}, Action: update.Mount}, + {Entry: osutil.MountEntry{Name: "tmpfs", Dir: "/foo", Type: "tmpfs", Options: []string{"x-snapd.synthetic", "x-snapd.needed-by=/foo/bar"}}, Action: update.Mount}, + {Entry: osutil.MountEntry{Name: "/tmp/.snap/foo/file", Dir: "/foo/file", Options: []string{"bind", "x-snapd.kind=file", "x-snapd.synthetic", "x-snapd.needed-by=/foo/bar"}}, Action: update.Mount}, + {Entry: osutil.MountEntry{Name: "/tmp/.snap/foo/dir", Dir: "/foo/dir", Options: []string{"rbind", "x-snapd.synthetic", "x-snapd.needed-by=/foo/bar"}}, Action: update.Mount}, + {Entry: osutil.MountEntry{Name: "/tmp/.snap/foo/symlink", Dir: "/foo/symlink", Options: []string{"x-snapd.kind=symlink", "x-snapd.symlink=target", "x-snapd.synthetic", "x-snapd.needed-by=/foo/bar"}}, Action: update.Mount}, + {Entry: osutil.MountEntry{Name: "none", Dir: "/tmp/.snap/foo", Options: []string{"x-snapd.detach"}}, Action: update.Unmount}, + } + + // Mock the act of performing changes, each of the change we perform is coming from the plan. + restore := update.MockChangePerform(func(chg *update.Change, as *update.Assumptions) ([]*update.Change, error) { + c.Assert(plan, testutil.DeepContains, chg) + return nil, nil + }) + defer restore() + + // The executed plan leaves us with a simplified view of the plan that is suitable for undo. + undoPlan, err := update.ExecWritableMimic(plan, s.as) + c.Assert(err, IsNil) + c.Assert(undoPlan, DeepEquals, []*update.Change{ + {Entry: osutil.MountEntry{Name: "tmpfs", Dir: "/foo", Type: "tmpfs", Options: []string{"x-snapd.synthetic", "x-snapd.needed-by=/foo/bar"}}, Action: update.Mount}, + {Entry: osutil.MountEntry{Name: "/foo/file", Dir: "/foo/file", Options: []string{"bind", "x-snapd.kind=file", "x-snapd.synthetic", "x-snapd.needed-by=/foo/bar"}}, Action: update.Mount}, + {Entry: osutil.MountEntry{Name: "/foo/dir", Dir: "/foo/dir", Options: []string{"rbind", "x-snapd.synthetic", "x-snapd.needed-by=/foo/bar", "x-snapd.detach"}}, Action: update.Mount}, + }) +} + +func (s *utilsSuite) TestExecWirableMimicErrorWithRecovery(c *C) { + // This plan is the same as in the test above. This is what comes out of planWritableMimic. + plan := []*update.Change{ + {Entry: osutil.MountEntry{Name: "/foo", Dir: "/tmp/.snap/foo", Options: []string{"rbind"}}, Action: update.Mount}, + {Entry: osutil.MountEntry{Name: "tmpfs", Dir: "/foo", Type: "tmpfs", Options: []string{"x-snapd.synthetic", "x-snapd.needed-by=/foo/bar"}}, Action: update.Mount}, + {Entry: osutil.MountEntry{Name: "/tmp/.snap/foo/file", Dir: "/foo/file", Options: []string{"bind", "x-snapd.kind=file", "x-snapd.synthetic", "x-snapd.needed-by=/foo/bar"}}, Action: update.Mount}, + {Entry: osutil.MountEntry{Name: "/tmp/.snap/foo/symlink", Dir: "/foo/symlink", Options: []string{"x-snapd.kind=symlink", "x-snapd.symlink=target", "x-snapd.synthetic", "x-snapd.needed-by=/foo/bar"}}, Action: update.Mount}, + // NOTE: the next perform will fail. Notably the symlink did not fail. + {Entry: osutil.MountEntry{Name: "/tmp/.snap/foo/dir", Dir: "/foo/dir", Options: []string{"rbind"}}, Action: update.Mount}, + {Entry: osutil.MountEntry{Name: "none", Dir: "/tmp/.snap/foo", Options: []string{"x-snapd.detach"}}, Action: update.Unmount}, + } + + // Mock the act of performing changes. Before we inject a failure we ensure + // that each of the change we perform is coming from the plan. For the + // purpose of the test the change that bind mounts the "dir" over itself + // will fail and will trigger an recovery path. The changes performed in + // the recovery path are recorded. + var recoveryPlan []*update.Change + recovery := false + restore := update.MockChangePerform(func(chg *update.Change, as *update.Assumptions) ([]*update.Change, error) { + if !recovery { + c.Assert(plan, testutil.DeepContains, chg) + if chg.Entry.Name == "/tmp/.snap/foo/dir" { + recovery = true // switch to recovery mode + return nil, errTesting + } + } else { + recoveryPlan = append(recoveryPlan, chg) + } + return nil, nil + }) + defer restore() + + // The executed plan fails, leaving us with the error and an empty undo plan. + undoPlan, err := update.ExecWritableMimic(plan, s.as) + c.Assert(err, Equals, errTesting) + c.Assert(undoPlan, HasLen, 0) + // The changes we managed to perform were undone correctly. + c.Assert(recoveryPlan, DeepEquals, []*update.Change{ + // NOTE: there is no symlink undo entry as it is implicitly undone by unmounting the tmpfs. + {Entry: osutil.MountEntry{Name: "/foo/file", Dir: "/foo/file", Options: []string{"bind", "x-snapd.kind=file", "x-snapd.synthetic", "x-snapd.needed-by=/foo/bar"}}, Action: update.Unmount}, + {Entry: osutil.MountEntry{Name: "tmpfs", Dir: "/foo", Type: "tmpfs", Options: []string{"x-snapd.synthetic", "x-snapd.needed-by=/foo/bar"}}, Action: update.Unmount}, + {Entry: osutil.MountEntry{Name: "/foo", Dir: "/tmp/.snap/foo", Options: []string{"rbind", "x-snapd.detach"}}, Action: update.Unmount}, + }) +} + +func (s *utilsSuite) TestExecWirableMimicErrorNothingDone(c *C) { + // This plan is the same as in the test above. This is what comes out of planWritableMimic. + plan := []*update.Change{ + {Entry: osutil.MountEntry{Name: "/foo", Dir: "/tmp/.snap/foo", Options: []string{"rbind"}}, Action: update.Mount}, + {Entry: osutil.MountEntry{Name: "tmpfs", Dir: "/foo", Type: "tmpfs", Options: []string{"x-snapd.synthetic", "x-snapd.needed-by=/foo/bar"}}, Action: update.Mount}, + {Entry: osutil.MountEntry{Name: "/tmp/.snap/foo/file", Dir: "/foo/file", Options: []string{"bind", "x-snapd.kind=file", "x-snapd.synthetic", "x-snapd.needed-by=/foo/bar"}}, Action: update.Mount}, + {Entry: osutil.MountEntry{Name: "/tmp/.snap/foo/dir", Dir: "/foo/dir", Options: []string{"rbind", "x-snapd.synthetic", "x-snapd.needed-by=/foo/bar"}}, Action: update.Mount}, + {Entry: osutil.MountEntry{Name: "/tmp/.snap/foo/symlink", Dir: "/foo/symlink", Options: []string{"x-snapd.kind=symlink", "x-snapd.symlink=target", "x-snapd.synthetic", "x-snapd.needed-by=/foo/bar"}}, Action: update.Mount}, + {Entry: osutil.MountEntry{Name: "none", Dir: "/tmp/.snap/foo", Options: []string{"x-snapd.detach"}}, Action: update.Unmount}, + } + + // Mock the act of performing changes and just fail on any request. + restore := update.MockChangePerform(func(chg *update.Change, as *update.Assumptions) ([]*update.Change, error) { + return nil, errTesting + }) + defer restore() + + // The executed plan fails, the recovery didn't fail (it's empty) so we just return that error. + undoPlan, err := update.ExecWritableMimic(plan, s.as) + c.Assert(err, Equals, errTesting) + c.Assert(undoPlan, HasLen, 0) +} + +func (s *utilsSuite) TestExecWirableMimicErrorCannotUndo(c *C) { + // This plan is the same as in the test above. This is what comes out of planWritableMimic. + plan := []*update.Change{ + {Entry: osutil.MountEntry{Name: "/foo", Dir: "/tmp/.snap/foo", Options: []string{"rbind"}}, Action: update.Mount}, + {Entry: osutil.MountEntry{Name: "tmpfs", Dir: "/foo", Type: "tmpfs", Options: []string{"x-snapd.synthetic", "x-snapd.needed-by=/foo/bar"}}, Action: update.Mount}, + {Entry: osutil.MountEntry{Name: "/tmp/.snap/foo/file", Dir: "/foo/file", Options: []string{"bind", "x-snapd.kind=file", "x-snapd.synthetic", "x-snapd.needed-by=/foo/bar"}}, Action: update.Mount}, + {Entry: osutil.MountEntry{Name: "/tmp/.snap/foo/dir", Dir: "/foo/dir", Options: []string{"rbind", "x-snapd.synthetic", "x-snapd.needed-by=/foo/bar"}}, Action: update.Mount}, + {Entry: osutil.MountEntry{Name: "/tmp/.snap/foo/symlink", Dir: "/foo/symlink", Options: []string{"x-snapd.kind=symlink", "x-snapd.symlink=target", "x-snapd.synthetic", "x-snapd.needed-by=/foo/bar"}}, Action: update.Mount}, + {Entry: osutil.MountEntry{Name: "none", Dir: "/tmp/.snap/foo", Options: []string{"x-snapd.detach"}}, Action: update.Unmount}, + } + + // Mock the act of performing changes. After performing the first change + // correctly we will fail forever (this includes the recovery path) so the + // execute function ends up in a situation where it cannot perform the + // recovery path and will have to return a fatal error. + i := -1 + restore := update.MockChangePerform(func(chg *update.Change, as *update.Assumptions) ([]*update.Change, error) { + i++ + if i > 0 { + return nil, fmt.Errorf("failure-%d", i) + } + return nil, nil + }) + defer restore() + + // The plan partially succeeded and we cannot undo those changes. + _, err := update.ExecWritableMimic(plan, s.as) + c.Assert(err, ErrorMatches, `cannot undo change ".*" while recovering from earlier error failure-1: failure-2`) + c.Assert(err, FitsTypeOf, &update.FatalError{}) +} + +// realSystemSuite is not isolated / mocked from the system. +type realSystemSuite struct { + as *update.Assumptions +} + +var _ = Suite(&realSystemSuite{}) + +func (s *realSystemSuite) SetUpTest(c *C) { + s.as = &update.Assumptions{} + s.as.AddUnrestrictedPaths("/tmp") +} + +// secure-mkdir-all-within + +// Check that we can actually create directories. +// This doesn't test the chown logic as that requires root. +func (s *realSystemSuite) TestSecureMkdirAllWithinForReal(c *C) { + d := c.MkDir() + // Create d (which already exists) with mode 0777 (but c.MkDir() used 0700 + // internally and since we are not creating the directory we should not be + // changing that. + c.Assert(update.MkdirAllWithin(d, "/tmp", 0777, sys.FlagID, sys.FlagID, nil), IsNil) + fi, err := os.Stat(d) + c.Assert(err, IsNil) + c.Check(fi.IsDir(), Equals, true) + c.Check(fi.Mode().Perm(), Equals, os.FileMode(0700)) + + // Create d1, which is a simple subdirectory, with a distinct mode and + // check that it was applied. Note that default umask 022 is subtracted so + // effective directory has different permissions. + d1 := filepath.Join(d, "subdir") + c.Assert(update.MkdirAllWithin(d1, "/tmp", 0707, sys.FlagID, sys.FlagID, nil), IsNil) + fi, err = os.Stat(d1) + c.Assert(err, IsNil) + c.Check(fi.IsDir(), Equals, true) + c.Check(fi.Mode().Perm(), Equals, os.FileMode(0705)) + + // Create d2, which is a deeper subdirectory, with another distinct mode + // and check that it was applied. + d2 := filepath.Join(d, "subdir/subdir/subdir") + c.Assert(update.MkdirAllWithin(d2, "/tmp", 0750, sys.FlagID, sys.FlagID, nil), IsNil) + fi, err = os.Stat(d1) + c.Assert(err, IsNil) + c.Check(fi.IsDir(), Equals, true) + c.Check(fi.Mode().Perm(), Equals, os.FileMode(0705)) + fi, err = os.Stat(d2) + c.Assert(err, IsNil) + c.Check(fi.IsDir(), Equals, true) + c.Check(fi.Mode().Perm(), Equals, os.FileMode(0750)) +} + +// secure-mkdir-all + +// Check that we can actually create directories. +// This doesn't test the chown logic as that requires root. +func (s *realSystemSuite) TestSecureMkdirAllForReal(c *C) { + d := c.MkDir() + + // Create d (which already exists) with mode 0777 (but c.MkDir() used 0700 + // internally and since we are not creating the directory we should not be + // changing that. + c.Assert(update.MkdirAll(d, 0777, sys.FlagID, sys.FlagID, nil), IsNil) + fi, err := os.Stat(d) + c.Assert(err, IsNil) + c.Check(fi.IsDir(), Equals, true) + c.Check(fi.Mode().Perm(), Equals, os.FileMode(0700)) + + // Create d1, which is a simple subdirectory, with a distinct mode and + // check that it was applied. Note that default umask 022 is subtracted so + // effective directory has different permissions. + d1 := filepath.Join(d, "subdir") + c.Assert(update.MkdirAll(d1, 0707, sys.FlagID, sys.FlagID, nil), IsNil) + fi, err = os.Stat(d1) + c.Assert(err, IsNil) + c.Check(fi.IsDir(), Equals, true) + c.Check(fi.Mode().Perm(), Equals, os.FileMode(0705)) + + // Create d2, which is a deeper subdirectory, with another distinct mode + // and check that it was applied. + d2 := filepath.Join(d, "subdir/subdir/subdir") + c.Assert(update.MkdirAll(d2, 0750, sys.FlagID, sys.FlagID, nil), IsNil) + fi, err = os.Stat(d2) + c.Assert(err, IsNil) + c.Check(fi.IsDir(), Equals, true) + c.Check(fi.Mode().Perm(), Equals, os.FileMode(0750)) +} + +// secure-mkfile-all + +// Ensure that we reject unclean paths. +func (s *utilsSuite) TestSecureMkfileAllUnclean(c *C) { + err := update.MkfileAll("/unclean//path", 0755, 123, 456, nil) + c.Assert(err, ErrorMatches, `cannot split unclean path .*`) + c.Assert(s.sys.RCalls(), HasLen, 0) +} + +// Ensure that we refuse to create a file with an relative path. +func (s *utilsSuite) TestSecureMkfileAllRelative(c *C) { + err := update.MkfileAll("rel/path", 0755, 123, 456, nil) + c.Assert(err, ErrorMatches, `cannot create file with relative path: "rel/path"`) + c.Assert(s.sys.RCalls(), HasLen, 0) +} + +// Ensure that we refuse creating the root directory as a file. +func (s *utilsSuite) TestSecureMkfileAllLevel0(c *C) { + err := update.MkfileAll("/", 0755, 123, 456, nil) + c.Assert(err, ErrorMatches, `cannot create non-file path: "/"`) + c.Assert(s.sys.RCalls(), HasLen, 0) +} + +// Ensure that we can create a file in the top-level directory. +func (s *utilsSuite) TestSecureMkfileAllLevel1(c *C) { + c.Assert(update.MkfileAll("/path", 0755, 123, 456, nil), IsNil) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `openat 3 "path" O_NOFOLLOW|O_CLOEXEC|O_CREAT|O_EXCL 0755`, R: 4}, + {C: `fchown 4 123 456`}, + {C: `close 4`}, + {C: `close 3`}, + }) +} + +// Ensure that we can create a file two levels from the top-level directory. +func (s *utilsSuite) TestSecureMkfileAllLevel2(c *C) { + c.Assert(update.MkfileAll("/path/to", 0755, 123, 456, nil), IsNil) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `mkdirat 3 "path" 0755`}, + {C: `openat 3 "path" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `fchown 4 123 456`}, + {C: `close 3`}, + {C: `openat 4 "to" O_NOFOLLOW|O_CLOEXEC|O_CREAT|O_EXCL 0755`, R: 3}, + {C: `fchown 3 123 456`}, + {C: `close 3`}, + {C: `close 4`}, + }) +} + +// Ensure that we can create a file three levels from the top-level directory. +func (s *utilsSuite) TestSecureMkfileAllLevel3(c *C) { + c.Assert(update.MkfileAll("/path/to/something", 0755, 123, 456, nil), IsNil) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `mkdirat 3 "path" 0755`}, + {C: `openat 3 "path" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `fchown 4 123 456`}, + {C: `mkdirat 4 "to" 0755`}, + {C: `openat 4 "to" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 5}, + {C: `fchown 5 123 456`}, + {C: `close 4`}, + {C: `close 3`}, + {C: `openat 5 "something" O_NOFOLLOW|O_CLOEXEC|O_CREAT|O_EXCL 0755`, R: 3}, + {C: `fchown 3 123 456`}, + {C: `close 3`}, + {C: `close 5`}, + }) +} + +// Ensure that we can detect read only filesystems. +func (s *utilsSuite) TestSecureMkfileAllROFS(c *C) { + s.sys.InsertFault(`mkdirat 3 "rofs" 0755`, syscall.EEXIST) // just realistic + s.sys.InsertFault(`openat 4 "path" O_NOFOLLOW|O_CLOEXEC|O_CREAT|O_EXCL 0755`, syscall.EROFS) + err := update.MkfileAll("/rofs/path", 0755, 123, 456, nil) + c.Check(err, ErrorMatches, `cannot operate on read-only filesystem at /rofs`) + c.Assert(err.(*update.ReadOnlyFsError).Path, Equals, "/rofs") + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `mkdirat 3 "rofs" 0755`, E: syscall.EEXIST}, + {C: `openat 3 "rofs" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `close 3`}, + {C: `openat 4 "path" O_NOFOLLOW|O_CLOEXEC|O_CREAT|O_EXCL 0755`, E: syscall.EROFS}, + {C: `close 4`}, + }) +} + +// Ensure that we don't chown existing files or directories. +func (s *utilsSuite) TestSecureMkfileAllExistingDirsDontChown(c *C) { + s.sys.InsertFault(`mkdirat 3 "abs" 0755`, syscall.EEXIST) + s.sys.InsertFault(`openat 4 "path" O_NOFOLLOW|O_CLOEXEC|O_CREAT|O_EXCL 0755`, syscall.EEXIST) + err := update.MkfileAll("/abs/path", 0755, 123, 456, nil) + c.Check(err, IsNil) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `mkdirat 3 "abs" 0755`, E: syscall.EEXIST}, + {C: `openat 3 "abs" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `close 3`}, + {C: `openat 4 "path" O_NOFOLLOW|O_CLOEXEC|O_CREAT|O_EXCL 0755`, E: syscall.EEXIST}, + {C: `openat 4 "path" O_NOFOLLOW|O_CLOEXEC 0`, R: 3}, + {C: `close 3`}, + {C: `close 4`}, + }) +} + +// Ensure that we we close everything when openat fails. +func (s *utilsSuite) TestSecureMkfileAllOpenat2ndError(c *C) { + s.sys.InsertFault(`openat 3 "abs" O_NOFOLLOW|O_CLOEXEC|O_CREAT|O_EXCL 0755`, syscall.EEXIST) + s.sys.InsertFault(`openat 3 "abs" O_NOFOLLOW|O_CLOEXEC 0`, errTesting) + err := update.MkfileAll("/abs", 0755, 123, 456, nil) + c.Assert(err, ErrorMatches, `cannot open file "/abs": testing`) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `openat 3 "abs" O_NOFOLLOW|O_CLOEXEC|O_CREAT|O_EXCL 0755`, E: syscall.EEXIST}, + {C: `openat 3 "abs" O_NOFOLLOW|O_CLOEXEC 0`, E: errTesting}, + {C: `close 3`}, + }) +} + +// Ensure that we we close everything when openat (non-exclusive) fails. +func (s *utilsSuite) TestSecureMkfileAllOpenatError(c *C) { + s.sys.InsertFault(`openat 3 "abs" O_NOFOLLOW|O_CLOEXEC|O_CREAT|O_EXCL 0755`, errTesting) + err := update.MkfileAll("/abs", 0755, 123, 456, nil) + c.Assert(err, ErrorMatches, `cannot open file "/abs": testing`) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `openat 3 "abs" O_NOFOLLOW|O_CLOEXEC|O_CREAT|O_EXCL 0755`, E: errTesting}, + {C: `close 3`}, + }) +} + +// Ensure that we we close everything when fchown fails. +func (s *utilsSuite) TestSecureMkfileAllFchownError(c *C) { + s.sys.InsertFault(`fchown 4 123 456`, errTesting) + err := update.MkfileAll("/path", 0755, 123, 456, nil) + c.Assert(err, ErrorMatches, `cannot chown file "/path" to 123.456: testing`) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `openat 3 "path" O_NOFOLLOW|O_CLOEXEC|O_CREAT|O_EXCL 0755`, R: 4}, + {C: `fchown 4 123 456`, E: errTesting}, + {C: `close 4`}, + {C: `close 3`}, + }) +} + +// Check error path when we cannot open root directory. +func (s *utilsSuite) TestSecureMkfileAllOpenRootError(c *C) { + s.sys.InsertFault(`open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, errTesting) + err := update.MkfileAll("/abs/path", 0755, 123, 456, nil) + c.Assert(err, ErrorMatches, "cannot open root directory: testing") + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, E: errTesting}, + }) +} + +// Check error path when we cannot open non-root directory. +func (s *utilsSuite) TestSecureMkfileAllOpenError(c *C) { + s.sys.InsertFault(`openat 3 "abs" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, errTesting) + err := update.MkfileAll("/abs/path", 0755, 123, 456, nil) + c.Assert(err, ErrorMatches, `cannot open directory "/abs": testing`) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `mkdirat 3 "abs" 0755`}, + {C: `openat 3 "abs" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, E: errTesting}, + {C: `close 3`}, + }) +} + +// We want to create a symlink in $SNAP_DATA and that's fine. +func (s *utilsSuite) TestSecureMksymlinkAllInSnapData(c *C) { + s.sys.InsertFault(`mkdirat 3 "var" 0755`, syscall.EEXIST) + s.sys.InsertFault(`mkdirat 4 "snap" 0755`, syscall.EEXIST) + s.sys.InsertFault(`mkdirat 5 "foo" 0755`, syscall.EEXIST) + s.sys.InsertFault(`mkdirat 6 "42" 0755`, syscall.EEXIST) + + err := update.MksymlinkAll("/var/snap/foo/42/symlink", 0755, 0, 0, "/oldname", nil) + c.Assert(err, IsNil) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `mkdirat 3 "var" 0755`, E: syscall.EEXIST}, + {C: `openat 3 "var" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `mkdirat 4 "snap" 0755`, E: syscall.EEXIST}, + {C: `openat 4 "snap" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 5}, + {C: `mkdirat 5 "foo" 0755`, E: syscall.EEXIST}, + {C: `openat 5 "foo" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 6}, + {C: `mkdirat 6 "42" 0755`, E: syscall.EEXIST}, + {C: `openat 6 "42" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 7}, + {C: `close 6`}, + {C: `close 5`}, + {C: `close 4`}, + {C: `close 3`}, + {C: `symlinkat "/oldname" 7 "symlink"`}, + {C: `close 7`}, + }) +} + +// We want to create a symlink in /etc but the host filesystem would be affected. +func (s *utilsSuite) TestSecureMksymlinkAllInEtc(c *C) { + s.sys.InsertFstatfsResult(`fstatfs 3 `, syscall.Statfs_t{Type: update.SquashfsMagic}) + s.sys.InsertFstatResult(`fstat 3 `, syscall.Stat_t{}) + s.sys.InsertFstatfsResult(`fstatfs 4 `, syscall.Statfs_t{Type: update.Ext4Magic}) + s.sys.InsertFstatResult(`fstat 4 `, syscall.Stat_t{}) + s.sys.InsertFault(`mkdirat 3 "etc" 0755`, syscall.EEXIST) + rs := s.as.RestrictionsFor("/etc/symlink") + err := update.MksymlinkAll("/etc/symlink", 0755, 0, 0, "/oldname", rs) + c.Assert(err, ErrorMatches, `cannot write to "/etc/symlink" because it would affect the host in "/etc"`) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `fstatfs 3 `, R: syscall.Statfs_t{Type: update.SquashfsMagic}}, + {C: `fstat 3 `, R: syscall.Stat_t{}}, + {C: `mkdirat 3 "etc" 0755`, E: syscall.EEXIST}, + {C: `openat 3 "etc" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `close 3`}, + {C: `fstatfs 4 `, R: syscall.Statfs_t{Type: update.Ext4Magic}}, + {C: `fstat 4 `, R: syscall.Stat_t{}}, + {C: `close 4`}, + }) +} + +// We want to create a symlink deep in /etc but the host filesystem would be affected. +// This just shows that we pick the right place to construct the mimic +func (s *utilsSuite) TestSecureMksymlinkAllDeepInEtc(c *C) { + s.sys.InsertFstatfsResult(`fstatfs 3 `, syscall.Statfs_t{Type: update.SquashfsMagic}) + s.sys.InsertFstatResult(`fstat 3 `, syscall.Stat_t{}) + s.sys.InsertFstatfsResult(`fstatfs 4 `, syscall.Statfs_t{Type: update.Ext4Magic}) + s.sys.InsertFstatResult(`fstat 4 `, syscall.Stat_t{}) + s.sys.InsertFault(`mkdirat 3 "etc" 0755`, syscall.EEXIST) + rs := s.as.RestrictionsFor("/etc/some/other/stuff/symlink") + err := update.MksymlinkAll("/etc/some/other/stuff/symlink", 0755, 0, 0, "/oldname", rs) + c.Assert(err, ErrorMatches, `cannot write to "/etc/some/other/stuff/symlink" because it would affect the host in "/etc"`) + c.Assert(err.(*update.TrespassingError).ViolatedPath, Equals, "/etc") + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `fstatfs 3 `, R: syscall.Statfs_t{Type: update.SquashfsMagic}}, + {C: `fstat 3 `, R: syscall.Stat_t{}}, + {C: `mkdirat 3 "etc" 0755`, E: syscall.EEXIST}, + {C: `openat 3 "etc" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `fstatfs 4 `, R: syscall.Statfs_t{Type: update.Ext4Magic}}, + {C: `fstat 4 `, R: syscall.Stat_t{}}, + {C: `close 4`}, + {C: `close 3`}, + }) +} + +// We want to create a file in /etc but the host filesystem would be affected. +func (s *utilsSuite) TestSecureMkfileAllInEtc(c *C) { + s.sys.InsertFstatfsResult(`fstatfs 3 `, syscall.Statfs_t{Type: update.SquashfsMagic}) + s.sys.InsertFstatResult(`fstat 3 `, syscall.Stat_t{}) + s.sys.InsertFstatfsResult(`fstatfs 4 `, syscall.Statfs_t{Type: update.Ext4Magic}) + s.sys.InsertFstatResult(`fstat 4 `, syscall.Stat_t{}) + s.sys.InsertFault(`mkdirat 3 "etc" 0755`, syscall.EEXIST) + rs := s.as.RestrictionsFor("/etc/file") + err := update.MkfileAll("/etc/file", 0755, 0, 0, rs) + c.Assert(err, ErrorMatches, `cannot write to "/etc/file" because it would affect the host in "/etc"`) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `fstatfs 3 `, R: syscall.Statfs_t{Type: update.SquashfsMagic}}, + {C: `fstat 3 `, R: syscall.Stat_t{}}, + {C: `mkdirat 3 "etc" 0755`, E: syscall.EEXIST}, + {C: `openat 3 "etc" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `close 3`}, + {C: `fstatfs 4 `, R: syscall.Statfs_t{Type: update.Ext4Magic}}, + {C: `fstat 4 `, R: syscall.Stat_t{}}, + {C: `close 4`}, + }) +} + +// We want to create a directory in /etc but the host filesystem would be affected. +func (s *utilsSuite) TestSecureMkdirAllInEtc(c *C) { + s.sys.InsertFstatfsResult(`fstatfs 3 `, syscall.Statfs_t{Type: update.SquashfsMagic}) + s.sys.InsertFstatResult(`fstat 3 `, syscall.Stat_t{}) + s.sys.InsertFstatfsResult(`fstatfs 4 `, syscall.Statfs_t{Type: update.Ext4Magic}) + s.sys.InsertFstatResult(`fstat 4 `, syscall.Stat_t{}) + s.sys.InsertFault(`mkdirat 3 "etc" 0755`, syscall.EEXIST) + rs := s.as.RestrictionsFor("/etc/dir") + err := update.MkdirAll("/etc/dir", 0755, 0, 0, rs) + c.Assert(err, ErrorMatches, `cannot write to "/etc/dir" because it would affect the host in "/etc"`) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `fstatfs 3 `, R: syscall.Statfs_t{Type: update.SquashfsMagic}}, + {C: `fstat 3 `, R: syscall.Stat_t{}}, + {C: `mkdirat 3 "etc" 0755`, E: syscall.EEXIST}, + {C: `openat 3 "etc" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `close 3`}, + {C: `fstatfs 4 `, R: syscall.Statfs_t{Type: update.Ext4Magic}}, + {C: `fstat 4 `, R: syscall.Stat_t{}}, + {C: `close 4`}, + }) +} + +// We want to create a directory in /snap/foo/42/dir and want to know what happens. +func (s *utilsSuite) TestSecureMkdirAllInSNAP(c *C) { + // Allow creating directories under /snap/ related to this snap ("foo"). + // This matches what is done inside main(). + restore := s.as.MockUnrestrictedPaths("/snap/foo") + defer restore() + + s.sys.InsertFault(`mkdirat 3 "snap" 0755`, syscall.EEXIST) + s.sys.InsertFault(`mkdirat 4 "foo" 0755`, syscall.EEXIST) + s.sys.InsertFault(`mkdirat 5 "42" 0755`, syscall.EEXIST) + + rs := s.as.RestrictionsFor("/snap/foo/42/dir") + err := update.MkdirAll("/snap/foo/42/dir", 0755, 0, 0, rs) + c.Assert(err, IsNil) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `mkdirat 3 "snap" 0755`, E: syscall.EEXIST}, + {C: `openat 3 "snap" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `mkdirat 4 "foo" 0755`, E: syscall.EEXIST}, + {C: `openat 4 "foo" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 5}, + {C: `mkdirat 5 "42" 0755`, E: syscall.EEXIST}, + {C: `openat 5 "42" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 6}, + {C: `close 5`}, + {C: `close 4`}, + {C: `close 3`}, + {C: `mkdirat 6 "dir" 0755`}, + {C: `openat 6 "dir" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `fchown 3 0 0`}, + {C: `close 3`}, + {C: `close 6`}, + }) +} + +// We want to create a symlink in /etc which is a tmpfs that we mounted so that is ok. +func (s *utilsSuite) TestSecureMksymlinkAllInEtcAfterMimic(c *C) { + // Because /etc is not on a list of unrestricted paths the write to + // /etc/symlink must be validated with step-by-step operation. + rootStatfs := syscall.Statfs_t{Type: update.SquashfsMagic, Flags: update.StReadOnly} + rootStat := syscall.Stat_t{} + etcStatfs := syscall.Statfs_t{Type: update.TmpfsMagic} + etcStat := syscall.Stat_t{} + s.as.AddChange(&update.Change{Action: update.Mount, Entry: osutil.MountEntry{Dir: "/etc", Type: "tmpfs", Name: "tmpfs"}}) + s.sys.InsertFstatfsResult(`fstatfs 3 `, rootStatfs) + s.sys.InsertFstatResult(`fstat 3 `, rootStat) + s.sys.InsertFault(`mkdirat 3 "etc" 0755`, syscall.EEXIST) + s.sys.InsertFstatfsResult(`fstatfs 4 `, etcStatfs) + s.sys.InsertFstatResult(`fstat 4 `, etcStat) + rs := s.as.RestrictionsFor("/etc/symlink") + err := update.MksymlinkAll("/etc/symlink", 0755, 0, 0, "/oldname", rs) + c.Assert(err, IsNil) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `fstatfs 3 `, R: rootStatfs}, + {C: `fstat 3 `, R: rootStat}, + {C: `mkdirat 3 "etc" 0755`, E: syscall.EEXIST}, + {C: `openat 3 "etc" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `close 3`}, + {C: `fstatfs 4 `, R: etcStatfs}, + {C: `fstat 4 `, R: etcStat}, + {C: `symlinkat "/oldname" 4 "symlink"`}, + {C: `close 4`}, + }) +} + +// We want to create a file in /etc which is a tmpfs created by snapd so that's okay. +func (s *utilsSuite) TestSecureMkfileAllInEtcAfterMimic(c *C) { + s.sys.InsertFstatfsResult(`fstatfs 3 `, syscall.Statfs_t{Type: update.SquashfsMagic}) + s.sys.InsertFstatResult(`fstat 3 `, syscall.Stat_t{}) + s.sys.InsertFstatfsResult(`fstatfs 4 `, syscall.Statfs_t{Type: update.TmpfsMagic}) + s.sys.InsertFstatResult(`fstat 4 `, syscall.Stat_t{}) + s.sys.InsertFault(`mkdirat 3 "etc" 0755`, syscall.EEXIST) + s.as.AddChange(&update.Change{Action: update.Mount, Entry: osutil.MountEntry{Dir: "/etc", Type: "tmpfs", Name: "tmpfs"}}) + rs := s.as.RestrictionsFor("/etc/file") + err := update.MkfileAll("/etc/file", 0755, 0, 0, rs) + c.Assert(err, IsNil) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `fstatfs 3 `, R: syscall.Statfs_t{Type: update.SquashfsMagic}}, + {C: `fstat 3 `, R: syscall.Stat_t{}}, + {C: `mkdirat 3 "etc" 0755`, E: syscall.EEXIST}, + {C: `openat 3 "etc" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `close 3`}, + {C: `fstatfs 4 `, R: syscall.Statfs_t{Type: update.TmpfsMagic}}, + {C: `fstat 4 `, R: syscall.Stat_t{}}, + {C: `openat 4 "file" O_NOFOLLOW|O_CLOEXEC|O_CREAT|O_EXCL 0755`, R: 3}, + {C: `fchown 3 0 0`}, + {C: `close 3`}, + {C: `close 4`}, + }) +} + +// We want to create a directory in /etc which is a tmpfs created by snapd so that is ok. +func (s *utilsSuite) TestSecureMkdirAllInEtcAfterMimic(c *C) { + s.sys.InsertFstatfsResult(`fstatfs 3 `, syscall.Statfs_t{Type: update.SquashfsMagic}) + s.sys.InsertFstatResult(`fstat 3 `, syscall.Stat_t{}) + s.sys.InsertFstatfsResult(`fstatfs 4 `, syscall.Statfs_t{Type: update.TmpfsMagic}) + s.sys.InsertFstatResult(`fstat 4 `, syscall.Stat_t{}) + s.sys.InsertFault(`mkdirat 3 "etc" 0755`, syscall.EEXIST) + s.as.AddChange(&update.Change{Action: update.Mount, Entry: osutil.MountEntry{Dir: "/etc", Type: "tmpfs", Name: "tmpfs"}}) + rs := s.as.RestrictionsFor("/etc/dir") + err := update.MkdirAll("/etc/dir", 0755, 0, 0, rs) + c.Assert(err, IsNil) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `fstatfs 3 `, R: syscall.Statfs_t{Type: update.SquashfsMagic}}, + {C: `fstat 3 `, R: syscall.Stat_t{}}, + {C: `mkdirat 3 "etc" 0755`, E: syscall.EEXIST}, + {C: `openat 3 "etc" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 4}, + {C: `close 3`}, + {C: `fstatfs 4 `, R: syscall.Statfs_t{Type: update.TmpfsMagic}}, + {C: `fstat 4 `, R: syscall.Stat_t{}}, + {C: `mkdirat 4 "dir" 0755`}, + {C: `openat 4 "dir" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY 0`, R: 3}, + {C: `fchown 3 0 0`}, + {C: `close 3`}, + {C: `close 4`}, + }) +} + +// Check that we can actually create files. +// This doesn't test the chown logic as that requires root. +func (s *realSystemSuite) TestSecureMkfileAllForReal(c *C) { + d := c.MkDir() + + // Create f1, which is a simple subdirectory, with a distinct mode and + // check that it was applied. Note that default umask 022 is subtracted so + // effective directory has different permissions. + f1 := filepath.Join(d, "file") + c.Assert(update.MkfileAll(f1, 0707, sys.FlagID, sys.FlagID, nil), IsNil) + fi, err := os.Stat(f1) + c.Assert(err, IsNil) + c.Check(fi.Mode().IsRegular(), Equals, true) + c.Check(fi.Mode().Perm(), Equals, os.FileMode(0705)) + + // Create f2, which is a deeper subdirectory, with another distinct mode + // and check that it was applied. + f2 := filepath.Join(d, "subdir/subdir/file") + c.Assert(update.MkfileAll(f2, 0750, sys.FlagID, sys.FlagID, nil), IsNil) + fi, err = os.Stat(f2) + c.Assert(err, IsNil) + c.Check(fi.Mode().IsRegular(), Equals, true) + c.Check(fi.Mode().Perm(), Equals, os.FileMode(0750)) +} + +// Check that we can actually create symlinks. +// This doesn't test the chown logic as that requires root. +func (s *realSystemSuite) TestSecureMksymlinkAllForReal(c *C) { + d := c.MkDir() + + // Create symlink f1 that points to "oldname" and check that it + // is correct. Note that symlink permissions are always set to 0777 + f1 := filepath.Join(d, "symlink") + err := update.MksymlinkAll(f1, 0755, sys.FlagID, sys.FlagID, "oldname", nil) + c.Assert(err, IsNil) + fi, err := os.Lstat(f1) + c.Assert(err, IsNil) + c.Check(fi.Mode()&os.ModeSymlink, Equals, os.ModeSymlink) + c.Check(fi.Mode().Perm(), Equals, os.FileMode(0777)) + + target, err := os.Readlink(f1) + c.Assert(err, IsNil) + c.Check(target, Equals, "oldname") + + // Create an identical symlink to see that it doesn't fail. + err = update.MksymlinkAll(f1, 0755, sys.FlagID, sys.FlagID, "oldname", nil) + c.Assert(err, IsNil) + + // Create a different symlink and see that it fails now + err = update.MksymlinkAll(f1, 0755, sys.FlagID, sys.FlagID, "other", nil) + c.Assert(err, ErrorMatches, `cannot create symbolic link ".*/symlink": existing symbolic link in the way`) + + // Create an file and check that it clashes with a symlink we attempt to create. + f2 := filepath.Join(d, "file") + err = update.MkfileAll(f2, 0755, sys.FlagID, sys.FlagID, nil) + c.Assert(err, IsNil) + err = update.MksymlinkAll(f2, 0755, sys.FlagID, sys.FlagID, "oldname", nil) + c.Assert(err, ErrorMatches, `cannot create symbolic link ".*/file": existing file in the way`) + + // Create an file and check that it clashes with a symlink we attempt to create. + f3 := filepath.Join(d, "dir") + err = update.MkdirAll(f3, 0755, sys.FlagID, sys.FlagID, nil) + c.Assert(err, IsNil) + err = update.MksymlinkAll(f3, 0755, sys.FlagID, sys.FlagID, "oldname", nil) + c.Assert(err, ErrorMatches, `cannot create symbolic link ".*/dir": existing file in the way`) + + err = update.MksymlinkAll("/", 0755, sys.FlagID, sys.FlagID, "oldname", nil) + c.Assert(err, ErrorMatches, `cannot create non-file path: "/"`) +} + +func (s *utilsSuite) TestCleanTrailingSlash(c *C) { + // This is a validity test for the use of filepath.Clean in secureMk{dir,file}All + c.Assert(filepath.Clean("/path/"), Equals, "/path") + c.Assert(filepath.Clean("path/"), Equals, "path") + c.Assert(filepath.Clean("path/."), Equals, "path") + c.Assert(filepath.Clean("path/.."), Equals, ".") + c.Assert(filepath.Clean("other/path/.."), Equals, "other") +} + +// secure-open-path + +func (s *utilsSuite) TestSecureOpenPath(c *C) { + stat := syscall.Stat_t{Mode: syscall.S_IFDIR} + s.sys.InsertFstatResult("fstat 5 ", stat) + fd, err := update.OpenPath("/foo/bar") + c.Assert(err, IsNil) + defer s.sys.Close(fd) + c.Assert(fd, Equals, 5) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "foo" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 4}, + {C: `openat 4 "bar" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 5}, + {C: `fstat 5 `, R: stat}, + {C: `close 4`}, + {C: `close 3`}, + }) +} + +func (s *utilsSuite) TestSecureOpenPathSingleSegment(c *C) { + stat := syscall.Stat_t{Mode: syscall.S_IFDIR} + s.sys.InsertFstatResult("fstat 4 ", stat) + fd, err := update.OpenPath("/foo") + c.Assert(err, IsNil) + defer s.sys.Close(fd) + c.Assert(fd, Equals, 4) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `openat 3 "foo" O_NOFOLLOW|O_CLOEXEC|O_PATH 0`, R: 4}, + {C: `fstat 4 `, R: stat}, + {C: `close 3`}, + }) +} + +func (s *utilsSuite) TestSecureOpenPathRoot(c *C) { + stat := syscall.Stat_t{Mode: syscall.S_IFDIR} + s.sys.InsertFstatResult("fstat 3 ", stat) + fd, err := update.OpenPath("/") + c.Assert(err, IsNil) + defer s.sys.Close(fd) + c.Assert(fd, Equals, 3) + c.Assert(s.sys.RCalls(), testutil.SyscallsEqual, []testutil.CallResultError{ + {C: `open "/" O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY|O_PATH 0`, R: 3}, + {C: `fstat 3 `, R: stat}, + }) +} + +func (s *realSystemSuite) TestSecureOpenPathDirectory(c *C) { + path := filepath.Join(c.MkDir(), "test") + c.Assert(os.Mkdir(path, 0755), IsNil) + + fd, err := update.OpenPath(path) + c.Assert(err, IsNil) + defer syscall.Close(fd) + + // check that the file descriptor is for the expected path + origDir, err := os.Getwd() + c.Assert(err, IsNil) + defer os.Chdir(origDir) + + c.Assert(syscall.Fchdir(fd), IsNil) + cwd, err := os.Getwd() + c.Assert(err, IsNil) + c.Check(cwd, Equals, path) +} + +func (s *realSystemSuite) TestSecureOpenPathRelativePath(c *C) { + fd, err := update.OpenPath("relative/path") + c.Check(fd, Equals, -1) + c.Check(err, ErrorMatches, "path .* is not absolute") +} + +func (s *realSystemSuite) TestSecureOpenPathUncleanPath(c *C) { + base := c.MkDir() + path := filepath.Join(base, "test") + c.Assert(os.Mkdir(path, 0755), IsNil) + + fd, err := update.OpenPath(base + "//test") + c.Check(fd, Equals, -1) + c.Check(err, ErrorMatches, `cannot open path: cannot iterate over unclean path ".*//test"`) + + fd, err = update.OpenPath(base + "/./test") + c.Check(fd, Equals, -1) + c.Check(err, ErrorMatches, `cannot open path: cannot iterate over unclean path ".*/./test"`) + + fd, err = update.OpenPath(base + "/test/../test") + c.Check(fd, Equals, -1) + c.Check(err, ErrorMatches, `cannot open path: cannot iterate over unclean path ".*/test/../test"`) +} + +func (s *realSystemSuite) TestSecureOpenPathFile(c *C) { + path := filepath.Join(c.MkDir(), "file.txt") + c.Assert(os.WriteFile(path, []byte("hello"), 0644), IsNil) + + fd, err := update.OpenPath(path) + c.Assert(err, IsNil) + defer syscall.Close(fd) + + // Check that the file descriptor matches the file. + var pathStat, fdStat syscall.Stat_t + c.Assert(syscall.Stat(path, &pathStat), IsNil) + c.Assert(syscall.Fstat(fd, &fdStat), IsNil) + c.Check(pathStat, Equals, fdStat) +} + +func (s *realSystemSuite) TestSecureOpenPathNotFound(c *C) { + path := filepath.Join(c.MkDir(), "test") + + fd, err := update.OpenPath(path) + c.Check(fd, Equals, -1) + c.Check(err, ErrorMatches, "no such file or directory") +} + +func (s *realSystemSuite) TestSecureOpenPathSymlink(c *C) { + base := c.MkDir() + dir := filepath.Join(base, "test") + c.Assert(os.Mkdir(dir, 0755), IsNil) + + symlink := filepath.Join(base, "symlink") + c.Assert(os.Symlink(dir, symlink), IsNil) + + fd, err := update.OpenPath(symlink) + c.Check(fd, Equals, -1) + c.Check(err, ErrorMatches, `".*" is a symbolic link`) +} + +func (s *realSystemSuite) TestSecureOpenPathSymlinkedParent(c *C) { + base := c.MkDir() + dir := filepath.Join(base, "dir1") + symlink := filepath.Join(base, "symlink") + + path := filepath.Join(dir, "dir2") + symlinkedPath := filepath.Join(symlink, "dir2") + + c.Assert(os.Mkdir(dir, 0755), IsNil) + c.Assert(os.Symlink(dir, symlink), IsNil) + c.Assert(os.Mkdir(path, 0755), IsNil) + + fd, err := update.OpenPath(symlinkedPath) + c.Check(fd, Equals, -1) + c.Check(err, ErrorMatches, "not a directory") +} diff --git a/cmd/snap/cmd_abort.go b/cmd/snap/cmd_abort.go new file mode 100644 index 00000000..54c92baa --- /dev/null +++ b/cmd/snap/cmd_abort.go @@ -0,0 +1,62 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2014-2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/i18n" +) + +type cmdAbort struct{ changeIDMixin } + +var shortAbortHelp = i18n.G("Abort a pending change") + +var longAbortHelp = i18n.G(` +The abort command attempts to abort a change that still has pending tasks. +`) + +func init() { + addCommand("abort", + shortAbortHelp, + longAbortHelp, + func() flags.Commander { + return &cmdAbort{} + }, + changeIDMixinOptDesc, + changeIDMixinArgDesc, + ) +} + +func (x *cmdAbort) Execute(args []string) error { + if len(args) > 0 { + return ErrExtraArgs + } + + id, err := x.GetChangeID() + if err != nil { + if err == noChangeFoundOK { + return nil + } + return err + } + _, err = x.client.Abort(id) + return err +} diff --git a/cmd/snap/cmd_abort_test.go b/cmd/snap/cmd_abort_test.go new file mode 100644 index 00000000..a50cdaef --- /dev/null +++ b/cmd/snap/cmd_abort_test.go @@ -0,0 +1,89 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2018 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "fmt" + "net/http" + + "gopkg.in/check.v1" + + snap "github.com/snapcore/snapd/cmd/snap" +) + +func (s *SnapSuite) TestAbortLast(c *check.C) { + n := 0 + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + n++ + switch n { + case 1: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/changes") + fmt.Fprintln(w, mockChangesJSON) + case 2: + c.Check(r.Method, check.Equals, "POST") + c.Check(r.URL.Path, check.Equals, "/v2/changes/two") + c.Check(DecodedRequestBody(c, r), check.DeepEquals, map[string]interface{}{"action": "abort"}) + fmt.Fprintln(w, mockChangeJSON) + default: + c.Errorf("expected 2 queries, currently on %d", n) + } + }) + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"abort", "--last=install"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + c.Check(s.Stdout(), check.Equals, "") + c.Check(s.Stderr(), check.Equals, "") + + c.Assert(n, check.Equals, 2) +} + +func (s *SnapSuite) TestAbortLastQuestionmark(c *check.C) { + n := 0 + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + n++ + c.Check(r.Method, check.Equals, "GET") + c.Assert(r.URL.Path, check.Equals, "/v2/changes") + switch n { + case 1, 2: + fmt.Fprintln(w, `{"type": "sync", "result": []}`) + case 3, 4: + fmt.Fprintln(w, mockChangesJSON) + default: + c.Errorf("expected 4 calls, now on %d", n) + } + }) + for i := 0; i < 2; i++ { + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"abort", "--last=foobar?"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + c.Check(s.Stdout(), check.Matches, "") + c.Check(s.Stderr(), check.Equals, "") + + _, err = snap.Parser(snap.Client()).ParseArgs([]string{"abort", "--last=foobar"}) + if i == 0 { + c.Assert(err, check.ErrorMatches, `no changes found`) + } else { + c.Assert(err, check.ErrorMatches, `no changes of type "foobar" found`) + } + } + + c.Check(n, check.Equals, 4) +} diff --git a/cmd/snap/cmd_ack.go b/cmd/snap/cmd_ack.go new file mode 100644 index 00000000..f1908703 --- /dev/null +++ b/cmd/snap/cmd_ack.go @@ -0,0 +1,79 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "fmt" + "os" + + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/client" + "github.com/snapcore/snapd/i18n" +) + +type cmdAck struct { + clientMixin + AckOptions struct { + AssertionFile flags.Filename + } `positional-args:"true" required:"true"` +} + +var shortAckHelp = i18n.G("Add an assertion to the system") +var longAckHelp = i18n.G(` +The ack command tries to add an assertion to the system assertion database. + +The assertion may also be a newer revision of a pre-existing assertion that it +will replace. + +To succeed the assertion must be valid, its signature verified with a known +public key and the assertion consistent with and its prerequisite in the +database. +`) + +func init() { + addCommand("ack", shortAckHelp, longAckHelp, func() flags.Commander { + return &cmdAck{} + }, nil, []argDesc{{ + // TRANSLATORS: This needs to begin with < and end with > + name: i18n.G(""), + // TRANSLATORS: This should not start with a lowercase letter. + desc: i18n.G("Assertion file"), + }}) +} + +func ackFile(cli *client.Client, assertFile string) error { + assertData, err := os.ReadFile(assertFile) + if err != nil { + return err + } + + return cli.Ack(assertData) +} + +func (x *cmdAck) Execute(args []string) error { + if len(args) > 0 { + return ErrExtraArgs + } + if err := ackFile(x.client, string(x.AckOptions.AssertionFile)); err != nil { + return fmt.Errorf("cannot assert: %v", err) + } + return nil +} diff --git a/cmd/snap/cmd_advise.go b/cmd/snap/cmd_advise.go new file mode 100644 index 00000000..86838c1f --- /dev/null +++ b/cmd/snap/cmd_advise.go @@ -0,0 +1,336 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2018 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "net" + "os" + "sort" + "strconv" + + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/advisor" + "github.com/snapcore/snapd/i18n" + "github.com/snapcore/snapd/osutil" +) + +type cmdAdviseSnap struct { + Positionals struct { + CommandOrPkg string + } `positional-args:"true"` + + Format string `long:"format" default:"pretty" choice:"pretty" choice:"json"` + // Command makes advise try to find snaps that provide this command + Command bool `long:"command"` + + // FromApt tells advise that it got started from an apt hook + // and needs to communicate over a socket + FromApt bool `long:"from-apt"` + + // DumpDb dumps the whole advise database + DumpDb bool `long:"dump-db"` +} + +var shortAdviseSnapHelp = i18n.G("Advise on available snaps") +var longAdviseSnapHelp = i18n.G(` +The advise-snap command searches for and suggests the installation of snaps. + +If --command is given, it suggests snaps that provide the given command. +Otherwise it suggests snaps with the given name. +`) + +func init() { + cmd := addCommand("advise-snap", shortAdviseSnapHelp, longAdviseSnapHelp, func() flags.Commander { + return &cmdAdviseSnap{} + }, map[string]string{ + // TRANSLATORS: This should not start with a lowercase letter. + "command": i18n.G("Advise on snaps that provide the given command"), + // TRANSLATORS: This should not start with a lowercase letter. + "dump-db": i18n.G("Dump advise database for use by command-not-found."), + // TRANSLATORS: This should not start with a lowercase letter. + "from-apt": i18n.G("Run as an apt hook"), + // TRANSLATORS: This should not start with a lowercase letter. + "format": i18n.G("Use the given output format"), + }, []argDesc{ + // TRANSLATORS: This needs to begin with < and end with > + {name: i18n.G("")}, + }) + cmd.hidden = true +} + +func outputAdviseExactText(command string, result []advisor.Command) error { + fmt.Fprintf(Stdout, "\n") + // TRANSLATORS: %q is a command name (like "gimp" or "loimpress") + fmt.Fprintf(Stdout, i18n.G("Command %q not found, but can be installed with:\n"), command) + fmt.Fprintf(Stdout, "\n") + for _, snap := range result { + fmt.Fprintf(Stdout, "sudo snap install %s\n", snap.Snap) + } + fmt.Fprintf(Stdout, "\n") + fmt.Fprintln(Stdout, i18n.G("See 'snap info ' for additional versions.")) + fmt.Fprintf(Stdout, "\n") + return nil +} + +func outputAdviseMisspellText(command string, result []advisor.Command) error { + fmt.Fprintf(Stdout, "\n") + fmt.Fprintf(Stdout, i18n.G("Command %q not found, did you mean:\n"), command) + fmt.Fprintf(Stdout, "\n") + for _, snap := range result { + fmt.Fprintf(Stdout, i18n.G(" command %q from snap %q\n"), snap.Command, snap.Snap) + } + fmt.Fprintf(Stdout, "\n") + fmt.Fprintln(Stdout, i18n.G("See 'snap info ' for additional versions.")) + fmt.Fprintf(Stdout, "\n") + return nil +} + +func outputAdviseJSON(command string, results []advisor.Command) error { + enc := json.NewEncoder(Stdout) + enc.Encode(results) + return nil +} + +type jsonRPC struct { + JsonRPC string `json:"jsonrpc"` + Method string `json:"method"` + Params struct { + Command string `json:"command"` + UnknownPackages []string `json:"unknown-packages"` + } +} + +// readRpc reads a apt json rpc protocol 0.1 message as described in +// https://salsa.debian.org/apt-team/apt/blob/master/doc/json-hooks-protocol.md#wire-protocol +func readRpc(r *bufio.Reader) (*jsonRPC, error) { + line, err := r.ReadBytes('\n') + if err != nil && err != io.EOF { + return nil, fmt.Errorf("cannot read json-rpc: %v", err) + } + if osutil.GetenvBool("SNAP_APT_HOOK_DEBUG") { + fmt.Fprintf(os.Stderr, "%s\n", line) + } + + var rpc jsonRPC + if err := json.Unmarshal(line, &rpc); err != nil { + return nil, err + } + // empty \n + emptyNL, _, err := r.ReadLine() + if err != nil { + return nil, err + } + if string(emptyNL) != "" { + return nil, fmt.Errorf("unexpected line: %q (empty)", emptyNL) + } + + return &rpc, nil +} + +func adviseViaAptHook() error { + sockFd := os.Getenv("APT_HOOK_SOCKET") + if sockFd == "" { + return fmt.Errorf("cannot find APT_HOOK_SOCKET env") + } + fd, err := strconv.Atoi(sockFd) + if err != nil { + return fmt.Errorf("expected APT_HOOK_SOCKET to be a decimal integer, found %q", sockFd) + } + + f := os.NewFile(uintptr(fd), "apt-hook-socket") + if f == nil { + return fmt.Errorf("cannot open file descriptor %v", fd) + } + defer f.Close() + + conn, err := net.FileConn(f) + if err != nil { + return fmt.Errorf("cannot connect to %v: %v", fd, err) + } + defer conn.Close() + + r := bufio.NewReader(conn) + + // handshake + rpc, err := readRpc(r) + if err != nil { + return err + } + if rpc.Method != "org.debian.apt.hooks.hello" { + return fmt.Errorf("expected 'hello' method, got: %v", rpc.Method) + } + if _, err := conn.Write([]byte(`{"jsonrpc":"2.0","id":0,"result":{"version":"0.1"}}` + "\n\n")); err != nil { + return err + } + + // payload + rpc, err = readRpc(r) + if err != nil { + return err + } + if rpc.Method == "org.debian.apt.hooks.install.fail" { + for _, pkgName := range rpc.Params.UnknownPackages { + match, err := advisor.FindPackage(pkgName) + if err == nil && match != nil { + fmt.Fprintf(Stdout, "\n") + fmt.Fprintf(Stdout, i18n.G("No apt package %q, but there is a snap with that name.\n"), pkgName) + fmt.Fprintf(Stdout, i18n.G("Try \"snap install %s\"\n"), pkgName) + fmt.Fprintf(Stdout, "\n") + } + } + + } + // if rpc.Method == "org.debian.apt.hooks.search.post" { + // // FIXME: do a snap search here + // // FIXME2: figure out why apt does not tell us the search results + // } + + // bye + rpc, err = readRpc(r) + if err != nil { + return err + } + if rpc.Method != "org.debian.apt.hooks.bye" { + return fmt.Errorf("expected 'bye' method, got: %v", rpc.Method) + } + + return nil +} + +type Snap struct { + Snap string + Version string + Command string +} + +func dumpDbHook() error { + commands, err := advisor.DumpCommands() + if err != nil { + return err + } + + commands_processed := make([]string, 0) + var b []Snap + + var sortedCmds []string + for cmd := range commands { + sortedCmds = append(sortedCmds, cmd) + } + sort.Strings(sortedCmds) + + for _, key := range sortedCmds { + value := commands[key] + err := json.Unmarshal([]byte(value), &b) + if err != nil { + return err + } + for i := range b { + var s = fmt.Sprintf("%s %s %s\n", key, b[i].Snap, b[i].Version) + commands_processed = append(commands_processed, s) + } + } + + for _, value := range commands_processed { + fmt.Fprint(Stdout, value) + } + + return nil +} + +func (x *cmdAdviseSnap) Execute(args []string) error { + if len(args) > 0 { + return ErrExtraArgs + } + + if x.DumpDb { + return dumpDbHook() + } + + if x.FromApt { + return adviseViaAptHook() + } + + if len(x.Positionals.CommandOrPkg) == 0 { + return fmt.Errorf("the required argument `` was not provided") + } + + if x.Command { + return adviseCommand(x.Positionals.CommandOrPkg, x.Format) + } + + return advisePkg(x.Positionals.CommandOrPkg) +} + +func advisePkg(pkgName string) error { + match, err := advisor.FindPackage(pkgName) + if err != nil { + return fmt.Errorf("advise for pkgname failed: %s", err) + } + if match != nil { + fmt.Fprintf(Stdout, i18n.G("Packages matching %q:\n"), pkgName) + fmt.Fprintf(Stdout, " * %s - %s\n", match.Snap, match.Summary) + fmt.Fprintf(Stdout, i18n.G("Try: snap install \n")) + } + + // FIXME: find mispells + + return nil +} + +func adviseCommand(cmd string, format string) error { + // find exact matches + matches, err := advisor.FindCommand(cmd) + if err != nil { + return fmt.Errorf("advise for command failed: %s", err) + } + if len(matches) > 0 { + switch format { + case "json": + return outputAdviseJSON(cmd, matches) + case "pretty": + return outputAdviseExactText(cmd, matches) + default: + return fmt.Errorf("unsupported format %q", format) + } + } + + // find misspellings + matches, err = advisor.FindMisspelledCommand(cmd) + if err != nil { + return err + } + if len(matches) > 0 { + switch format { + case "json": + return outputAdviseJSON(cmd, matches) + case "pretty": + return outputAdviseMisspellText(cmd, matches) + default: + return fmt.Errorf("unsupported format %q", format) + } + } + + return fmt.Errorf("%s: command not found", cmd) +} diff --git a/cmd/snap/cmd_advise_test.go b/cmd/snap/cmd_advise_test.go new file mode 100644 index 00000000..79651003 --- /dev/null +++ b/cmd/snap/cmd_advise_test.go @@ -0,0 +1,265 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2018 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "net" + "os" + "strconv" + "strings" + "syscall" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/advisor" + snap "github.com/snapcore/snapd/cmd/snap" + "github.com/snapcore/snapd/dirs" +) + +type sillyFinder struct{} + +func mkSillyFinder() (advisor.Finder, error) { + return &sillyFinder{}, nil +} + +func (sf *sillyFinder) FindCommand(command string) ([]advisor.Command, error) { + switch command { + case "hello": + return []advisor.Command{ + {Snap: "hello", Command: "hello"}, + {Snap: "hello-wcm", Command: "hello"}, + }, nil + case "error-please": + return nil, fmt.Errorf("get failed") + default: + return nil, nil + } +} + +func (sf *sillyFinder) FindPackage(pkgName string) (*advisor.Package, error) { + switch pkgName { + case "hello": + return &advisor.Package{Snap: "hello", Summary: "summary for hello"}, nil + case "error-please": + return nil, fmt.Errorf("find-pkg failed") + default: + return nil, nil + } +} + +func (*sillyFinder) Close() error { return nil } + +func (s *SnapSuite) TestAdviseCommandHappyText(c *C) { + restore := advisor.ReplaceCommandsFinder(mkSillyFinder) + defer restore() + + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"advise-snap", "--command", "hello"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + c.Assert(s.Stdout(), Equals, ` +Command "hello" not found, but can be installed with: + +sudo snap install hello +sudo snap install hello-wcm + +See 'snap info ' for additional versions. + +`) + c.Assert(s.Stderr(), Equals, "") +} + +func (s *SnapSuite) TestAdviseCommandHappyJSON(c *C) { + restore := advisor.ReplaceCommandsFinder(mkSillyFinder) + defer restore() + + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"advise-snap", "--command", "--format=json", "hello"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + c.Assert(s.Stdout(), Equals, `[{"Snap":"hello","Command":"hello"},{"Snap":"hello-wcm","Command":"hello"}]`+"\n") + c.Assert(s.Stderr(), Equals, "") +} + +func (s *SnapSuite) TestAdviseCommandDumpDb(c *C) { + dirs.SetRootDir(c.MkDir()) + c.Assert(os.MkdirAll(dirs.SnapCacheDir, 0755), IsNil) + defer dirs.SetRootDir("") + + db, err := advisor.Create() + if errors.Is(err, advisor.ErrNotSupported) { + c.Skip("bolt is not supported") + } + c.Assert(err, IsNil) + c.Assert(db.AddSnap("foo", "1.0", "foo summary", []string{"foo", "bar"}), IsNil) + c.Assert(db.Commit(), IsNil) + + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"advise-snap", "--dump-db"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + c.Assert(s.Stderr(), Equals, "") + c.Assert(s.Stdout(), Matches, `bar foo 1.0\nfoo foo 1.0\n`) +} + +func (s *SnapSuite) TestAdviseCommandMisspellText(c *C) { + restore := advisor.ReplaceCommandsFinder(mkSillyFinder) + defer restore() + + for _, misspelling := range []string{"helo", "0hello", "hell0", "hello0"} { + err := snap.AdviseCommand(misspelling, "pretty") + c.Assert(err, IsNil) + c.Assert(s.Stdout(), Equals, fmt.Sprintf(` +Command "%s" not found, did you mean: + + command "hello" from snap "hello" + command "hello" from snap "hello-wcm" + +See 'snap info ' for additional versions. + +`, misspelling)) + c.Assert(s.Stderr(), Equals, "") + + s.stdout.Reset() + s.stderr.Reset() + } +} + +func (s *SnapSuite) TestAdviseFromAptIntegrationNoAptPackage(c *C) { + restore := advisor.ReplaceCommandsFinder(mkSillyFinder) + defer restore() + + fds, err := syscall.Socketpair(syscall.AF_UNIX, syscall.SOCK_STREAM, 0) + c.Assert(err, IsNil) + + os.Setenv("APT_HOOK_SOCKET", strconv.Itoa(int(fds[1]))) + // note we don't close fds[1] ourselves; adviseViaAptHook might, or we might leak it + // (we don't close it here to avoid accidentally closing an arbitrary file descriptor that reused the number) + + done := make(chan bool, 1) + go func() { + f := os.NewFile(uintptr(fds[0]), "advise-sock") + conn, err := net.FileConn(f) + c.Assert(err, IsNil) + defer conn.Close() + defer f.Close() + + // handshake + _, err = conn.Write([]byte(`{"jsonrpc":"2.0","method":"org.debian.apt.hooks.hello","id":0,"params":{"versions":["0.1"]}}` + "\n\n")) + c.Assert(err, IsNil) + + // reply from snap + r := bufio.NewReader(conn) + buf, _, err := r.ReadLine() + c.Assert(err, IsNil) + c.Assert(string(buf), Equals, `{"jsonrpc":"2.0","id":0,"result":{"version":"0.1"}}`) + // plus empty line + buf, _, err = r.ReadLine() + c.Assert(err, IsNil) + c.Assert(string(buf), Equals, ``) + + // payload + _, err = conn.Write([]byte(`{"jsonrpc":"2.0","method":"org.debian.apt.hooks.install.fail","params":{"command":"install","search-terms":["aws-cli"],"unknown-packages":["hello"],"packages":[]}}` + "\n\n")) + c.Assert(err, IsNil) + + // bye + _, err = conn.Write([]byte(`{"jsonrpc":"2.0","method":"org.debian.apt.hooks.bye","params":{}}` + "\n\n")) + c.Assert(err, IsNil) + + done <- true + }() + + cmd := snap.CmdAdviseSnap() + cmd.FromApt = true + err = cmd.Execute(nil) + c.Assert(err, IsNil) + c.Assert(s.Stdout(), Equals, ` +No apt package "hello", but there is a snap with that name. +Try "snap install hello" + +`) + c.Assert(s.Stderr(), Equals, "") + c.Assert(<-done, Equals, true) +} + +func (s *SnapSuite) TestReadRpc(c *C) { + rpc := strings.Replace(` +{ + "jsonrpc": "2.0", + "method": "org.debian.apt.hooks.install.pre-prompt", + "params": { + "command": "install", + "packages": [ + { + "architecture": "amd64", + "automatic": false, + "id": 38033, + "mode": "install", + "name": "hello", + "versions": { + "candidate": { + "architecture": "amd64", + "id": 22712, + "pin": 500, + "version": "4:17.12.3-1ubuntu1" + }, + "install": { + "architecture": "amd64", + "id": 22712, + "pin": 500, + "version": "4:17.12.3-1ubuntu1" + } + } + }, + { + "architecture": "amd64", + "automatic": true, + "id": 38202, + "mode": "install", + "name": "hello-kpart", + "versions": { + "candidate": { + "architecture": "amd64", + "id": 22713, + "pin": 500, + "version": "4:17.12.3-1ubuntu1" + }, + "install": { + "architecture": "amd64", + "id": 22713, + "pin": 500, + "version": "4:17.12.3-1ubuntu1" + } + } + } + ], + "search-terms": [ + "hello" + ], + "unknown-packages": [] + } +}`, "\n", "", -1) + // all apt rpc ends with \n\n + rpc = rpc + "\n\n" + // this can be parsed without errors + _, err := snap.ReadRpc(bufio.NewReader(bytes.NewBufferString(rpc))) + c.Assert(err, IsNil) +} diff --git a/cmd/snap/cmd_alias.go b/cmd/snap/cmd_alias.go new file mode 100644 index 00000000..2d627461 --- /dev/null +++ b/cmd/snap/cmd_alias.go @@ -0,0 +1,118 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "fmt" + "io" + "text/tabwriter" + + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/client" + "github.com/snapcore/snapd/i18n" + "github.com/snapcore/snapd/snap" +) + +type cmdAlias struct { + waitMixin + Positionals struct { + SnapApp appName `required:"yes"` + Alias string `required:"yes"` + } `positional-args:"true"` +} + +// TODO: implement a completer for snapApp + +var shortAliasHelp = i18n.G("Set up a manual alias") +var longAliasHelp = i18n.G(` +The alias command aliases the given snap application to the given alias. + +Once this manual alias is setup the respective application command can be +invoked just using the alias. +`) + +func init() { + addCommand("alias", shortAliasHelp, longAliasHelp, func() flags.Commander { + return &cmdAlias{} + }, waitDescs, []argDesc{ + {name: ""}, + // TRANSLATORS: This needs to begin with < and end with > + {name: i18n.G("")}, + }) +} + +func (x *cmdAlias) Execute(args []string) error { + if len(args) > 0 { + return ErrExtraArgs + } + + snapName, appName := snap.SplitSnapApp(string(x.Positionals.SnapApp)) + alias := x.Positionals.Alias + + id, err := x.client.Alias(snapName, appName, alias) + if err != nil { + return err + } + chg, err := x.wait(id) + if err != nil { + if err == noWait { + return nil + } + return err + } + + return showAliasChanges(chg) +} + +type changedAlias struct { + Snap string `json:"snap"` + App string `json:"app"` + Alias string `json:"alias"` +} + +func showAliasChanges(chg *client.Change) error { + var added, removed []*changedAlias + if err := chg.Get("aliases-added", &added); err != nil && err != client.ErrNoData { + return err + } + if err := chg.Get("aliases-removed", &removed); err != nil && err != client.ErrNoData { + return err + } + w := tabwriter.NewWriter(Stdout, 2, 2, 1, ' ', 0) + if len(added) != 0 { + // TRANSLATORS: this is used to introduce a list of aliases that were added + printChangedAliases(w, i18n.G("Added"), added) + } + if len(removed) != 0 { + // TRANSLATORS: this is used to introduce a list of aliases that were removed + printChangedAliases(w, i18n.G("Removed"), removed) + } + w.Flush() + return nil +} + +func printChangedAliases(w io.Writer, label string, changed []*changedAlias) { + fmt.Fprintf(w, "%s:\n", label) + for _, a := range changed { + // TRANSLATORS: the first %s is a snap command (e.g. "hello-world.echo"), the second is the alias + fmt.Fprintf(w, i18n.G("\t- %s as %s\n"), snap.JoinSnapApp(a.Snap, a.App), a.Alias) + } +} diff --git a/cmd/snap/cmd_alias_test.go b/cmd/snap/cmd_alias_test.go new file mode 100644 index 00000000..97e4926c --- /dev/null +++ b/cmd/snap/cmd_alias_test.go @@ -0,0 +1,75 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "fmt" + "net/http" + + . "gopkg.in/check.v1" + + . "github.com/snapcore/snapd/cmd/snap" +) + +func (s *SnapSuite) TestAliasHelp(c *C) { + msg := `Usage: + snap.test alias [alias-OPTIONS] + +The alias command aliases the given snap application to the given alias. + +Once this manual alias is setup the respective application command can be +invoked just using the alias. + +[alias command options] + --no-wait Do not wait for the operation to finish but just print + the change id. +` + s.testSubCommandHelp(c, "alias", msg) +} + +func (s *SnapSuite) TestAlias(c *C) { + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v2/aliases": + c.Check(r.Method, Equals, "POST") + c.Check(DecodedRequestBody(c, r), DeepEquals, map[string]interface{}{ + "action": "alias", + "snap": "alias-snap", + "app": "cmd1", + "alias": "alias1", + }) + w.WriteHeader(202) + fmt.Fprintln(w, `{"type":"async", "status-code": 202, "change": "zzz"}`) + case "/v2/changes/zzz": + c.Check(r.Method, Equals, "GET") + fmt.Fprintln(w, `{"type":"sync", "result":{"ready": true, "status": "Done", "data": {"aliases-added": [{"alias": "alias1", "snap": "alias-snap", "app": "cmd1"}]}}}`) + default: + c.Fatalf("unexpected path %q", r.URL.Path) + } + }) + rest, err := Parser(Client()).ParseArgs([]string{"alias", "alias-snap.cmd1", "alias1"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + c.Assert(s.Stdout(), Equals, ""+ + "Added:\n"+ + " - alias-snap.cmd1 as alias1\n", + ) + c.Assert(s.Stderr(), Equals, "") +} diff --git a/cmd/snap/cmd_aliases.go b/cmd/snap/cmd_aliases.go new file mode 100644 index 00000000..30817b69 --- /dev/null +++ b/cmd/snap/cmd_aliases.go @@ -0,0 +1,143 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "fmt" + "sort" + "strings" + + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/client" + "github.com/snapcore/snapd/i18n" +) + +type cmdAliases struct { + clientMixin + Positionals struct { + Snap installedSnapName `positional-arg-name:""` + } `positional-args:"true"` +} + +var shortAliasesHelp = i18n.G("List aliases in the system") +var longAliasesHelp = i18n.G(` +The aliases command lists all aliases available in the system and their status. + +$ snap aliases + +Lists only the aliases defined by the specified snap. +`) + +func init() { + addCommand("aliases", shortAliasesHelp, longAliasesHelp, func() flags.Commander { + return &cmdAliases{} + }, nil, nil) +} + +type aliasInfo struct { + Snap string + Command string + Alias string + Status string + Auto string +} + +type aliasInfos []*aliasInfo + +func (infos aliasInfos) Len() int { return len(infos) } +func (infos aliasInfos) Swap(i, j int) { infos[i], infos[j] = infos[j], infos[i] } +func (infos aliasInfos) Less(i, j int) bool { + if infos[i].Snap < infos[j].Snap { + return true + } + if infos[i].Snap == infos[j].Snap { + if infos[i].Command < infos[j].Command { + return true + } + if infos[i].Command == infos[j].Command { + if infos[i].Alias < infos[j].Alias { + return true + } + } + } + return false +} + +func (x *cmdAliases) Execute(args []string) error { + if len(args) > 0 { + return ErrExtraArgs + } + + allStatuses, err := x.client.Aliases() + if err != nil { + return err + } + + var infos aliasInfos + filterSnap := string(x.Positionals.Snap) + if filterSnap != "" { + allStatuses = map[string]map[string]client.AliasStatus{ + filterSnap: allStatuses[filterSnap], + } + } + for snapName, aliasStatuses := range allStatuses { + for alias, aliasStatus := range aliasStatuses { + infos = append(infos, &aliasInfo{ + Snap: snapName, + Command: aliasStatus.Command, + Alias: alias, + Status: aliasStatus.Status, + Auto: aliasStatus.Auto, + }) + } + } + + if len(infos) > 0 { + w := tabWriter() + fmt.Fprintln(w, i18n.G("Command\tAlias\tNotes")) + defer w.Flush() + + sort.Sort(infos) + + for _, info := range infos { + var notes []string + if info.Status != "auto" { + notes = append(notes, info.Status) + if info.Status == "manual" && info.Auto != "" { + notes = append(notes, "override") + } + } + notesStr := strings.Join(notes, ",") + if notesStr == "" { + notesStr = "-" + } + fmt.Fprintf(w, "%s\t%s\t%s\n", info.Command, info.Alias, notesStr) + } + } else { + if filterSnap != "" { + fmt.Fprintf(Stderr, i18n.G("No aliases are currently defined for snap %q.\n"), filterSnap) + } else { + fmt.Fprintln(Stderr, i18n.G("No aliases are currently defined.")) + } + fmt.Fprintln(Stderr, i18n.G("\nUse 'snap help alias' to learn how to create aliases manually.")) + } + return nil +} diff --git a/cmd/snap/cmd_aliases_test.go b/cmd/snap/cmd_aliases_test.go new file mode 100644 index 00000000..8bb7aa92 --- /dev/null +++ b/cmd/snap/cmd_aliases_test.go @@ -0,0 +1,175 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "io" + "net/http" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/client" + . "github.com/snapcore/snapd/cmd/snap" +) + +func (s *SnapSuite) TestAliasesHelp(c *C) { + msg := `Usage: + snap.test aliases [] + +The aliases command lists all aliases available in the system and their status. + +$ snap aliases + +Lists only the aliases defined by the specified snap. +` + s.testSubCommandHelp(c, "aliases", msg) +} + +func (s *SnapSuite) TestAliases(c *C) { + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + c.Check(r.Method, Equals, "GET") + c.Check(r.URL.Path, Equals, "/v2/aliases") + body, err := io.ReadAll(r.Body) + c.Check(err, IsNil) + c.Check(body, DeepEquals, []byte{}) + EncodeResponseBody(c, w, map[string]interface{}{ + "type": "sync", + "result": map[string]map[string]client.AliasStatus{ + "foo": { + "foo0": {Command: "foo", Status: "auto", Auto: "foo"}, + "foo_reset": {Command: "foo.reset", Manual: "reset", Status: "manual"}, + }, + "bar": { + "bar_dump": {Command: "bar.dump", Status: "manual", Manual: "dump"}, + "bar_dump.1": {Command: "bar.dump", Status: "disabled", Auto: "dump"}, + "bar_restore": {Command: "bar.safe-restore", Status: "manual", Auto: "restore", Manual: "safe-restore"}, + }, + }, + }) + }) + rest, err := Parser(Client()).ParseArgs([]string{"aliases"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + expectedStdout := "" + + "Command Alias Notes\n" + + "bar.dump bar_dump manual\n" + + "bar.dump bar_dump.1 disabled\n" + + "bar.safe-restore bar_restore manual,override\n" + + "foo foo0 -\n" + + "foo.reset foo_reset manual\n" + c.Assert(s.Stdout(), Equals, expectedStdout) + c.Assert(s.Stderr(), Equals, "") +} + +func (s *SnapSuite) TestAliasesFilterSnap(c *C) { + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + c.Check(r.Method, Equals, "GET") + c.Check(r.URL.Path, Equals, "/v2/aliases") + body, err := io.ReadAll(r.Body) + c.Check(err, IsNil) + c.Check(body, DeepEquals, []byte{}) + EncodeResponseBody(c, w, map[string]interface{}{ + "type": "sync", + "result": map[string]map[string]client.AliasStatus{ + "foo": { + "foo0": {Command: "foo", Status: "auto", Auto: "foo"}, + "foo_reset": {Command: "foo.reset", Manual: "reset", Status: "manual"}, + }, + "bar": { + "bar_dump": {Command: "bar.dump", Status: "manual", Manual: "dump"}, + "bar_dump.1": {Command: "bar.dump", Status: "disabled", Auto: "dump"}, + }, + }, + }) + }) + rest, err := Parser(Client()).ParseArgs([]string{"aliases", "foo"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + expectedStdout := "" + + "Command Alias Notes\n" + + "foo foo0 -\n" + + "foo.reset foo_reset manual\n" + c.Assert(s.Stdout(), Equals, expectedStdout) + c.Assert(s.Stderr(), Equals, "") +} + +func (s *SnapSuite) TestAliasesNone(c *C) { + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + c.Check(r.Method, Equals, "GET") + c.Check(r.URL.Path, Equals, "/v2/aliases") + body, err := io.ReadAll(r.Body) + c.Check(err, IsNil) + c.Check(body, DeepEquals, []byte{}) + EncodeResponseBody(c, w, map[string]interface{}{ + "type": "sync", + "result": map[string]map[string]client.AliasStatus{}, + }) + }) + _, err := Parser(Client()).ParseArgs([]string{"aliases"}) + c.Assert(err, IsNil) + c.Assert(s.Stdout(), Equals, "") + c.Assert(s.Stderr(), Equals, "No aliases are currently defined.\n\nUse 'snap help alias' to learn how to create aliases manually.\n") +} + +func (s *SnapSuite) TestAliasesNoneFilterSnap(c *C) { + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + c.Check(r.Method, Equals, "GET") + c.Check(r.URL.Path, Equals, "/v2/aliases") + body, err := io.ReadAll(r.Body) + c.Check(err, IsNil) + c.Check(body, DeepEquals, []byte{}) + EncodeResponseBody(c, w, map[string]interface{}{ + "type": "sync", + "result": map[string]map[string]client.AliasStatus{ + "bar": { + "bar0": {Command: "foo", Status: "auto", Auto: "foo"}, + }}, + }) + }) + _, err := Parser(Client()).ParseArgs([]string{"aliases", "not-bar"}) + c.Assert(err, IsNil) + c.Assert(s.Stdout(), Equals, "") + c.Assert(s.Stderr(), Equals, "No aliases are currently defined for snap \"not-bar\".\n\nUse 'snap help alias' to learn how to create aliases manually.\n") +} + +func (s *SnapSuite) TestAliasesSorting(c *C) { + tests := []struct { + snap1 string + cmd1 string + alias1 string + snap2 string + cmd2 string + alias2 string + }{ + {"bar", "bar", "r", "baz", "baz", "z"}, + {"bar", "bar", "bar0", "bar", "bar.app", "bapp"}, + {"bar", "bar.app1", "bapp1", "bar", "bar.app2", "bapp2"}, + {"bar", "bar.app1", "appx", "bar", "bar.app1", "appy"}, + } + + for _, test := range tests { + res := AliasInfoLess(test.snap1, test.alias1, test.cmd1, test.snap2, test.alias2, test.cmd2) + c.Check(res, Equals, true, Commentf("%v", test)) + + rres := AliasInfoLess(test.snap2, test.alias2, test.cmd2, test.snap1, test.alias1, test.cmd1) + c.Check(rres, Equals, false, Commentf("reversed %v", test)) + } + +} diff --git a/cmd/snap/cmd_auto_import.go b/cmd/snap/cmd_auto_import.go new file mode 100644 index 00000000..77308507 --- /dev/null +++ b/cmd/snap/cmd_auto_import.go @@ -0,0 +1,373 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2014-2023 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "crypto" + "encoding/base64" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "syscall" + + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/boot" + "github.com/snapcore/snapd/client" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/i18n" + "github.com/snapcore/snapd/logger" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/release" + "github.com/snapcore/snapd/snapdenv" +) + +const autoImportsName = "auto-import.assert" + +func autoImportCandidates() ([]string, error) { + var cands []string + + isTesting := snapdenv.Testing() + + mnts, err := osutil.LoadMountInfo() + if err != nil { + return nil, fmt.Errorf("couldn't parse mountinfo: %v", err) + } + for _, mnt := range mnts { + // skip everything that is not a device (cgroups, debugfs etc) + if !strings.HasPrefix(mnt.MountSource, "/dev/") { + continue + } + // skip all loop devices (snaps) + if strings.HasPrefix(mnt.MountSource, "/dev/loop") { + continue + } + // skip all ram disks (unless in tests) + if !isTesting && strings.HasPrefix(mnt.MountSource, "/dev/ram") { + continue + } + + // TODO: should the following 2 checks try to be more smart like + // `snap-bootstrap initramfs-mounts` and try to find the boot disk + // and determine what partitions to skip using the disks package? + + // skip all initramfs mounted disks on uc20 + mountPoint := mnt.MountDir + if strings.HasPrefix(mountPoint, boot.InitramfsRunMntDir) { + continue + } + + // skip all seed dir mount points too, as these are bind mounts to the + // initramfs dirs on uc20, this can show up as + // /writable/system-data/var/lib/snapd/seed as well as + // /var/lib/snapd/seed + if strings.HasPrefix(mountPoint, dirs.SnapSeedDir) { + continue + } + + // TODO: we should probably make this a formal dir in dirs.go, but it is + // not directly used since we just use SnapSeedDir instead + writableSystemDataDir := filepath.Join(dirs.GlobalRootDir, "writable", "system-data") + if strings.HasPrefix(mountPoint, dirs.SnapSeedDirUnder(writableSystemDataDir)) { + continue + } + + cand := filepath.Join(mountPoint, autoImportsName) + if osutil.FileExists(cand) { + cands = append(cands, cand) + } + } + + return cands, nil +} + +func queueFile(src string) error { + // refuse huge files, this is for assertions + fi, err := os.Stat(src) + if err != nil { + return err + } + // 640kb ought be to enough for anyone + if fi.Size() > 640*1024 { + msg := fmt.Errorf("cannot queue %s, file size too big: %v", src, fi.Size()) + logger.Noticef("error: %v", msg) + return msg + } + + // ensure name is predictable, weak hash is ok + hash, _, err := osutil.FileDigest(src, crypto.SHA3_384) + if err != nil { + return err + } + + dst := filepath.Join(dirs.SnapAssertsSpoolDir, fmt.Sprintf("%s.assert", base64.URLEncoding.EncodeToString(hash))) + if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { + return err + } + + return osutil.CopyFile(src, dst, osutil.CopyFlagOverwrite) +} + +func autoImportFromSpool(cli *client.Client) (added int, err error) { + files, err := os.ReadDir(dirs.SnapAssertsSpoolDir) + if os.IsNotExist(err) { + return 0, nil + } + if err != nil { + return 0, err + } + + for _, fi := range files { + cand := filepath.Join(dirs.SnapAssertsSpoolDir, fi.Name()) + if err := ackFile(cli, cand); err != nil { + logger.Noticef("error: cannot import %s: %s", cand, err) + continue + } else { + logger.Noticef("imported %s", cand) + added++ + } + // FIXME: only remove stuff older than N days? + if err := os.Remove(cand); err != nil { + return 0, err + } + } + + return added, nil +} + +func autoImportFromAllMounts(cli *client.Client) (int, error) { + cands, err := autoImportCandidates() + if err != nil { + return 0, err + } + + added := 0 + for _, cand := range cands { + err := ackFile(cli, cand) + // the server is not ready yet + if _, ok := err.(client.ConnectionError); ok { + logger.Noticef("queuing for later %s", cand) + if err := queueFile(cand); err != nil { + return 0, err + } + continue + } + if err != nil { + logger.Noticef("error: cannot import %s: %s", cand, err) + continue + } else { + logger.Noticef("imported %s", cand) + } + added++ + } + + return added, nil +} + +var osMkdirTemp = os.MkdirTemp + +func tryMount(deviceName string) (string, error) { + tmpMountTarget, err := osMkdirTemp("", "snapd-auto-import-mount-") + if err != nil { + err = fmt.Errorf("cannot create temporary mount point: %v", err) + logger.Noticef("error: %v", err) + return "", err + } + // udev does not provide much environment ;) + if os.Getenv("PATH") == "" { + os.Setenv("PATH", "/usr/sbin:/usr/bin:/sbin:/bin") + } + // not using syscall.Mount() because we don't know the fs type in advance + cmd := exec.Command("mount", "-t", "ext4,vfat", "-o", "ro", "--make-private", deviceName, tmpMountTarget) + if output, err := cmd.CombinedOutput(); err != nil { + os.Remove(tmpMountTarget) + err = fmt.Errorf("cannot mount %s: %s", deviceName, osutil.OutputErr(output, err)) + logger.Noticef("error: %v", err) + return "", err + } + + return tmpMountTarget, nil +} + +var syscallUnmount = syscall.Unmount + +func doUmount(mp string) error { + if err := syscallUnmount(mp, 0); err != nil { + return err + } + return os.Remove(mp) +} + +type cmdAutoImport struct { + clientMixin + Mount []string `long:"mount" arg-name:""` + + ForceClassic bool `long:"force-classic"` +} + +var shortAutoImportHelp = i18n.G("Inspect devices for actionable information") + +var longAutoImportHelp = i18n.G(` +The auto-import command searches available mounted devices looking for +assertions that are signed by trusted authorities, and potentially +performs system changes based on them. + +If one or more device paths are provided via --mount, these are temporarily +mounted to be inspected as well. Even in that case the command will still +consider all available mounted devices for inspection. + +Assertions to be imported must be made available in the auto-import.assert file +in the root of the filesystem. +`) + +func init() { + cmd := addCommand("auto-import", + shortAutoImportHelp, + longAutoImportHelp, + func() flags.Commander { + return &cmdAutoImport{} + }, map[string]string{ + // TRANSLATORS: This should not start with a lowercase letter. + "mount": i18n.G("Temporarily mount device before inspecting"), + // TRANSLATORS: This should not start with a lowercase letter. + "force-classic": i18n.G("Force import on classic systems"), + }, nil) + cmd.hidden = true +} + +func (x *cmdAutoImport) autoAddUsers() error { + options := client.CreateUserOptions{ + Automatic: true, + } + results, err := x.client.CreateUsers([]*client.CreateUserOptions{&options}) + for _, result := range results { + fmt.Fprintf(Stdout, i18n.G("created user %q\n"), result.Username) + } + + return err +} + +func removableBlockDevices() (removableDevices []string) { + // eg. /sys/block/sda/removable + removable, err := filepath.Glob(filepath.Join(dirs.GlobalRootDir, "/sys/block/*/removable")) + if err != nil { + return nil + } + for _, removableAttr := range removable { + val, err := os.ReadFile(removableAttr) + if err != nil || string(val) != "1\n" { + // non removable + continue + } + // let's see if it has partitions + dev := filepath.Base(filepath.Dir(removableAttr)) + + pattern := fmt.Sprintf(filepath.Join(dirs.GlobalRootDir, "/sys/block/%s/%s*/partition"), dev, dev) + // eg. /sys/block/sda/sda1/partition + partitionAttrs, _ := filepath.Glob(pattern) + + if len(partitionAttrs) == 0 { + // not partitioned? try to use the main device + removableDevices = append(removableDevices, fmt.Sprintf("/dev/%s", dev)) + continue + } + + for _, partAttr := range partitionAttrs { + val, err := os.ReadFile(partAttr) + if err != nil || string(val) != "1\n" { + // non partition? + continue + } + pdev := filepath.Base(filepath.Dir(partAttr)) + removableDevices = append(removableDevices, fmt.Sprintf("/dev/%s", pdev)) + // hasPartitions = true + } + } + sort.Strings(removableDevices) + return removableDevices +} + +// inInstallmode returns true if it's UC20 system in install/factory-reset modes +func inInstallMode() bool { + modeenv, err := boot.ReadModeenv(dirs.GlobalRootDir) + if err != nil { + return false + } + return modeenv.Mode == "install" || modeenv.Mode == "factory-reset" +} + +func (x *cmdAutoImport) Execute(args []string) error { + if len(args) > 0 { + return ErrExtraArgs + } + + if release.OnClassic && !x.ForceClassic { + fmt.Fprintf(Stderr, "auto-import is disabled on classic\n") + return nil + } + // TODO:UC20: workaround for LP: #1860231 + if inInstallMode() { + fmt.Fprintf(Stderr, "auto-import is disabled in install modes\n") + return nil + } + + devices := x.Mount + if len(devices) == 0 { + // coldplug scenario, try all removable devices + devices = removableBlockDevices() + } + + for _, path := range devices { + // udev adds new /dev/loopX devices on the fly when a + // loop mount happens and there is no loop device left. + // + // We need to ignore these events because otherwise both + // our mount and the "mount -o loop" fight over the same + // device and we get nasty errors + if strings.HasPrefix(path, "/dev/loop") { + continue + } + + mp, err := tryMount(path) + if err != nil { + continue // Error was reported. Continue looking. + } + defer doUmount(mp) + } + + added1, err := autoImportFromSpool(x.client) + if err != nil { + return err + } + + added2, err := autoImportFromAllMounts(x.client) + if err != nil { + return err + } + + if added1+added2 > 0 { + return x.autoAddUsers() + } + + return nil +} diff --git a/cmd/snap/cmd_auto_import_test.go b/cmd/snap/cmd_auto_import_test.go new file mode 100644 index 00000000..d82e86b9 --- /dev/null +++ b/cmd/snap/cmd_auto_import_test.go @@ -0,0 +1,561 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016-2023 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "fmt" + "io" + "net/http" + "os" + "path/filepath" + + . "gopkg.in/check.v1" + + snap "github.com/snapcore/snapd/cmd/snap" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/logger" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/release" + "github.com/snapcore/snapd/snap/snaptest" + "github.com/snapcore/snapd/testutil" +) + +func (s *SnapSuite) TestAutoImportAssertsHappy(c *C) { + restore := release.MockOnClassic(false) + defer restore() + + fakeAssertData := []byte("my-assertion") + + n := 0 + total := 2 + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch n { + case 0: + c.Check(r.Method, Equals, "POST") + c.Check(r.URL.Path, Equals, "/v2/assertions") + postData, err := io.ReadAll(r.Body) + c.Assert(err, IsNil) + c.Check(postData, DeepEquals, fakeAssertData) + fmt.Fprintln(w, `{"type": "sync", "result": {"ready": true, "status": "Done"}}`) + n++ + case 1: + c.Check(r.Method, Equals, "POST") + c.Check(r.URL.Path, Equals, "/v2/users") + postData, err := io.ReadAll(r.Body) + c.Assert(err, IsNil) + c.Check(string(postData), Equals, `{"action":"create","automatic":true}`) + + fmt.Fprintln(w, `{"type": "sync", "result": [{"username": "foo"}]}`) + n++ + default: + c.Fatalf("unexpected request: %v (expected %d got %d)", r, total, n) + } + + }) + + testDir := c.MkDir() + fakeAssertsFn := filepath.Join(testDir, "auto-import.assert") + err := os.WriteFile(fakeAssertsFn, fakeAssertData, 0644) + c.Assert(err, IsNil) + + mockMountInfoFmt := fmt.Sprintf(`24 0 8:18 / %s rw,relatime shared:1 - ext4 /dev/sdb2 rw,errors=remount-ro,data=ordered +`, testDir) + restore = osutil.MockMountInfo(mockMountInfoFmt) + defer restore() + + logbuf, restore := logger.MockLogger() + defer restore() + + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"auto-import"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + c.Check(s.Stdout(), Equals, `created user "foo"`+"\n") + // matches because we may get a: + // "WARNING: cannot create syslog logger\n" + // in the output + c.Check(logbuf.String(), Matches, fmt.Sprintf("(?ms).*imported %s\n", fakeAssertsFn)) + c.Check(n, Equals, total) +} + +func (s *SnapSuite) TestAutoImportAssertsNotImportedFromLoop(c *C) { + restore := release.MockOnClassic(false) + defer restore() + + fakeAssertData := []byte("bad-assertion") + + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + // assertion is ignored, nothing is posted to this endpoint + panic("not reached") + }) + + testDir := c.MkDir() + fakeAssertsFn := filepath.Join(testDir, "auto-import.assert") + err := os.WriteFile(fakeAssertsFn, fakeAssertData, 0644) + c.Assert(err, IsNil) + + mockMountInfoFmtWithLoop := `24 0 8:18 / %s rw,relatime shared:1 - squashfs /dev/loop1 rw,errors=remount-ro,data=ordered` + content := fmt.Sprintf(mockMountInfoFmtWithLoop, filepath.Dir(fakeAssertsFn)) + restore = osutil.MockMountInfo(content) + defer restore() + + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"auto-import"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + c.Check(s.Stdout(), Equals, "") + c.Check(s.Stderr(), Equals, "") +} + +func (s *SnapSuite) TestAutoImportAssertsHappyNotOnClassic(c *C) { + restore := release.MockOnClassic(true) + defer restore() + + fakeAssertData := []byte("my-assertion") + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + c.Errorf("auto-import on classic is disabled, but something tried to do a %q with %s", r.Method, r.URL.Path) + }) + + fakeAssertsFn := filepath.Join(c.MkDir(), "auto-import.assert") + err := os.WriteFile(fakeAssertsFn, fakeAssertData, 0644) + c.Assert(err, IsNil) + + mockMountInfoFmt := ` +24 0 8:18 / %s rw,relatime shared:1 - ext4 /dev/sdb2 rw,errors=remount-ro,data=ordered` + restore = osutil.MockMountInfo(mockMountInfoFmt) + defer restore() + + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"auto-import"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + c.Check(s.Stdout(), Equals, "") + c.Check(s.Stderr(), Equals, "auto-import is disabled on classic\n") +} + +func (s *SnapSuite) TestAutoImportIntoSpool(c *C) { + restore := release.MockOnClassic(false) + defer restore() + + logbuf, restore := logger.MockLogger() + defer restore() + + fakeAssertData := []byte("good-assertion") + + // ensure we can not connect + snap.ClientConfig.BaseURL = "can-not-connect-to-this-url" + + fakeAssertsFn := filepath.Join(c.MkDir(), "auto-import.assert") + err := os.WriteFile(fakeAssertsFn, fakeAssertData, 0644) + c.Assert(err, IsNil) + + mockMountInfoFmt := fmt.Sprintf(`24 0 8:18 / %s rw,relatime shared:1 - squashfs /dev/sc1 rw,errors=remount-ro,data=ordered`, filepath.Dir(fakeAssertsFn)) + restore = osutil.MockMountInfo(mockMountInfoFmt) + defer restore() + + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"auto-import"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + c.Check(s.Stdout(), Equals, "") + // matches because we may get a: + // "WARNING: cannot create syslog logger\n" + // in the output + c.Check(logbuf.String(), Matches, "(?ms).*queuing for later.*\n") + + files, err := os.ReadDir(dirs.SnapAssertsSpoolDir) + c.Assert(err, IsNil) + c.Check(files, HasLen, 1) + c.Check(files[0].Name(), Equals, "iOkaeet50rajLvL-0Qsf2ELrTdn3XIXRIBlDewcK02zwRi3_TJlUOTl9AaiDXmDn.assert") +} + +func (s *SnapSuite) TestAutoImportFromSpoolHappy(c *C) { + restore := release.MockOnClassic(false) + defer restore() + + restore = osutil.MockMountInfo(``) + defer restore() + + fakeAssertData := []byte("my-assertion") + + n := 0 + total := 2 + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch n { + case 0: + c.Check(r.Method, Equals, "POST") + c.Check(r.URL.Path, Equals, "/v2/assertions") + postData, err := io.ReadAll(r.Body) + c.Assert(err, IsNil) + c.Check(postData, DeepEquals, fakeAssertData) + fmt.Fprintln(w, `{"type": "sync", "result": {"ready": true, "status": "Done"}}`) + n++ + case 1: + c.Check(r.Method, Equals, "POST") + c.Check(r.URL.Path, Equals, "/v2/users") + postData, err := io.ReadAll(r.Body) + c.Assert(err, IsNil) + c.Check(string(postData), Equals, `{"action":"create","automatic":true}`) + + fmt.Fprintln(w, `{"type": "sync", "result": [{"username": "foo"}]}`) + n++ + default: + c.Fatalf("unexpected request: %v (expected %d got %d)", r, total, n) + } + + }) + + fakeAssertsFn := filepath.Join(dirs.SnapAssertsSpoolDir, "1234343") + err := os.MkdirAll(filepath.Dir(fakeAssertsFn), 0755) + c.Assert(err, IsNil) + err = os.WriteFile(fakeAssertsFn, fakeAssertData, 0644) + c.Assert(err, IsNil) + + logbuf, restore := logger.MockLogger() + defer restore() + + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"auto-import"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + c.Check(s.Stdout(), Equals, `created user "foo"`+"\n") + // matches because we may get a: + // "WARNING: cannot create syslog logger\n" + // in the output + c.Check(logbuf.String(), Matches, fmt.Sprintf("(?ms).*imported %s\n", fakeAssertsFn)) + c.Check(n, Equals, total) + + c.Check(osutil.FileExists(fakeAssertsFn), Equals, false) +} + +func (s *SnapSuite) TestAutoImportIntoSpoolUnhappyTooBig(c *C) { + restore := release.MockOnClassic(false) + defer restore() + + _, restoreLogger := logger.MockLogger() + defer restoreLogger() + + // fake data is bigger than the default assertion limit + fakeAssertData := make([]byte, 641*1024) + + // ensure we can not connect + snap.ClientConfig.BaseURL = "can-not-connect-to-this-url" + + fakeAssertsFn := filepath.Join(c.MkDir(), "auto-import.assert") + err := os.WriteFile(fakeAssertsFn, fakeAssertData, 0644) + c.Assert(err, IsNil) + + mockMountInfoFmt := fmt.Sprintf(`24 0 8:18 / %s rw,relatime shared:1 - squashfs /dev/sc1 rw,errors=remount-ro,data=ordered`, filepath.Dir(fakeAssertsFn)) + restore = osutil.MockMountInfo(mockMountInfoFmt) + defer restore() + + _, err = snap.Parser(snap.Client()).ParseArgs([]string{"auto-import"}) + c.Assert(err, ErrorMatches, "cannot queue .*, file size too big: 656384") +} + +func (s *SnapSuite) testAutoImportUnhappyInInstallMode(c *C, mode string) { + restoreRelease := release.MockOnClassic(false) + defer restoreRelease() + + restoreMountInfo := osutil.MockMountInfo(``) + defer restoreMountInfo() + + _, restoreLogger := logger.MockLogger() + defer restoreLogger() + + modeenvContent := fmt.Sprintf(`mode=%s +recovery_system=20200202 +`, mode) + c.Assert(os.MkdirAll(filepath.Dir(dirs.SnapModeenvFile), 0755), IsNil) + c.Assert(os.WriteFile(dirs.SnapModeenvFile, []byte(modeenvContent), 0644), IsNil) + + _, err := snap.Parser(snap.Client()).ParseArgs([]string{"auto-import"}) + c.Assert(err, IsNil) + c.Check(s.Stdout(), Equals, "") + c.Check(s.Stderr(), Equals, "auto-import is disabled in install modes\n") +} + +func (s *SnapSuite) TestAutoImportUnhappyInInstallMode(c *C) { + s.testAutoImportUnhappyInInstallMode(c, "install") +} + +func (s *SnapSuite) TestAutoImportUnhappyInFactoryResetMode(c *C) { + s.testAutoImportUnhappyInInstallMode(c, "factory-reset") +} + +func (s *SnapSuite) TestAutoImportUnhappyInInstallInInitrdMode(c *C) { + restoreRelease := release.MockOnClassic(false) + defer restoreRelease() + + restoreMountInfo := osutil.MockMountInfo(``) + defer restoreMountInfo() + + _, restoreLogger := logger.MockLogger() + defer restoreLogger() + + modeenvContent := `mode=run +recovery_system=20200202 +` + c.Assert(os.MkdirAll(filepath.Dir(dirs.SnapModeenvFile), 0755), IsNil) + c.Assert(os.WriteFile(dirs.SnapModeenvFile, []byte(modeenvContent), 0644), IsNil) + + _, err := snap.Parser(snap.Client()).ParseArgs([]string{"auto-import"}) + c.Assert(err, IsNil) + c.Check(s.Stdout(), Equals, "") + c.Check(s.Stderr(), Equals, "") +} + +var mountStatic = []string{"mount", "-t", "ext4,vfat", "-o", "ro", "--make-private"} + +func (s *SnapSuite) TestAutoImportFromRemovable(c *C) { + restore := release.MockOnClassic(false) + defer restore() + + restore = osutil.MockMountInfo(``) + defer restore() + + _, restoreLogger := logger.MockLogger() + defer restoreLogger() + + rootdir := c.MkDir() + dirs.SetRootDir(rootdir) + + var umounts []string + restore = snap.MockSyscallUmount(func(p string, _ int) error { + umounts = append(umounts, p) + return nil + }) + defer restore() + + var tmpdirIdx int + restore = snap.MockIoutilTempDir(func(where string, p string) (string, error) { + c.Check(where, Equals, "") + tmpdirIdx++ + return filepath.Join(rootdir, fmt.Sprintf("/tmp/%s%d", p, tmpdirIdx)), nil + }) + defer restore() + + mountCmd := testutil.MockCommand(c, "mount", "") + defer mountCmd.Restore() + + snaptest.PopulateDir(rootdir, [][]string{ + // removable without partitions + {"sys/block/sdremovable/removable", "1\n"}, + // fixed disk + {"sys/block/sdfixed/removable", "0\n"}, + // removable with partitions + {"sys/block/sdpart/removable", "1\n"}, + {"sys/block/sdpart/sdpart1/partition", "1\n"}, + {"sys/block/sdpart/sdpart2/partition", "0\n"}, + {"sys/block/sdpart/sdpart3/partition", "1\n"}, + // removable but subdevices are not partitions? + {"sys/block/sdother/removable", "1\n"}, + {"sys/block/sdother/sdother1/partition", "0\n"}, + }) + + // do not mock mountinfo contents, we just want to observe whether we + // try to mount and umount the right stuff + + _, err := snap.Parser(snap.Client()).ParseArgs([]string{"auto-import"}) + c.Assert(err, IsNil) + c.Check(s.Stdout(), Equals, "") + c.Check(s.Stderr(), Equals, "") + c.Check(mountCmd.Calls(), DeepEquals, [][]string{ + append(mountStatic, "/dev/sdpart1", filepath.Join(rootdir, "/tmp/snapd-auto-import-mount-1")), + append(mountStatic, "/dev/sdpart3", filepath.Join(rootdir, "/tmp/snapd-auto-import-mount-2")), + append(mountStatic, "/dev/sdremovable", filepath.Join(rootdir, "/tmp/snapd-auto-import-mount-3")), + }) + c.Check(umounts, DeepEquals, []string{ + filepath.Join(rootdir, "/tmp/snapd-auto-import-mount-3"), + filepath.Join(rootdir, "/tmp/snapd-auto-import-mount-2"), + filepath.Join(rootdir, "/tmp/snapd-auto-import-mount-1"), + }) +} + +func (s *SnapSuite) TestAutoImportNoRemovable(c *C) { + restore := release.MockOnClassic(false) + defer restore() + + rootdir := c.MkDir() + dirs.SetRootDir(rootdir) + + var umounts []string + restore = snap.MockSyscallUmount(func(p string, _ int) error { + return fmt.Errorf("unexpected call") + }) + defer restore() + + restore = osutil.MockMountInfo(``) + defer restore() + + mountCmd := testutil.MockCommand(c, "mount", "exit 1") + defer mountCmd.Restore() + + snaptest.PopulateDir(rootdir, [][]string{ + // fixed disk + {"sys/block/sdfixed/removable", "0\n"}, + // removable but subdevices are not partitions? + {"sys/block/sdother/removable", "1\n"}, + {"sys/block/sdother/sdother1/partition", "0\n"}, + }) + + _, err := snap.Parser(snap.Client()).ParseArgs([]string{"auto-import"}) + c.Assert(err, IsNil) + c.Check(s.Stdout(), Equals, "") + c.Check(s.Stderr(), Equals, "") + c.Check(mountCmd.Calls(), HasLen, 0) + c.Check(umounts, HasLen, 0) +} + +func (s *SnapSuite) TestAutoImportFromMount(c *C) { + restore := release.MockOnClassic(false) + defer restore() + + restore = osutil.MockMountInfo(``) + defer restore() + + _, restoreLogger := logger.MockLogger() + defer restoreLogger() + + mountCmd := testutil.MockCommand(c, "mount", "") + + rootdir := c.MkDir() + dirs.SetRootDir(rootdir) + + var umounts []string + restore = snap.MockSyscallUmount(func(p string, _ int) error { + c.Assert(umounts, HasLen, 0) + umounts = append(umounts, p) + return nil + }) + defer restore() + + var tmpdircalls int + restore = snap.MockIoutilTempDir(func(where string, p string) (string, error) { + c.Check(where, Equals, "") + c.Assert(tmpdircalls, Equals, 0) + tmpdircalls++ + return filepath.Join(rootdir, fmt.Sprintf("/tmp/%s1", p)), nil + }) + defer restore() + + // do not mock mountinfo contents, we just want to observe whether we + // try to mount and umount the right stuff + + _, err := snap.Parser(snap.Client()).ParseArgs([]string{"auto-import", "--mount", "/dev/foobar"}) + c.Assert(err, IsNil) + c.Check(s.Stdout(), Equals, "") + c.Check(s.Stderr(), Equals, "") + c.Check(mountCmd.Calls(), DeepEquals, [][]string{ + append(mountStatic, "/dev/foobar", filepath.Join(rootdir, "/tmp/snapd-auto-import-mount-1")), + }) + c.Check(umounts, DeepEquals, []string{ + filepath.Join(rootdir, "/tmp/snapd-auto-import-mount-1"), + }) +} + +func (s *SnapSuite) TestAutoImportUC20CandidatesIgnoresSystemPartitions(c *C) { + + mountDirs := []string{ + "/writable/system-data/var/lib/snapd/seed", + "/var/lib/snapd/seed", + "/run/mnt/ubuntu-boot", + "/run/mnt/ubuntu-seed", + "/run/mnt/ubuntu-data", + "/mnt/real-device", + } + + rootDir := c.MkDir() + dirs.SetRootDir(rootDir) + defer func() { dirs.SetRootDir("") }() + + args := make([]interface{}, 0, len(mountDirs)+1) + args = append(args, dirs.GlobalRootDir) + // pretend there are auto-import.asserts on all of them + for _, dir := range mountDirs { + args = append(args, dir) + file := filepath.Join(rootDir, dir, "auto-import.assert") + c.Assert(os.MkdirAll(filepath.Dir(file), 0755), IsNil) + c.Assert(os.WriteFile(file, nil, 0644), IsNil) + } + + mockMountInfoFmtWithLoop := `24 0 8:18 / %[1]s%[2]s rw,relatime foo - ext3 /dev/meep2 rw,errors=remount-ro,data=ordered +24 0 8:18 / %[1]s%[3]s rw,relatime - ext3 /dev/meep2 rw,errors=remount-ro,data=ordered +24 0 8:18 / %[1]s%[4]s rw,relatime opt:1 - ext4 /dev/meep3 rw,errors=remount-ro,data=ordered +24 0 8:18 / %[1]s%[5]s rw,relatime opt:1 opt:2 - ext2 /dev/meep4 rw,errors=remount-ro,data=ordered +24 0 8:18 / %[1]s%[6]s rw,relatime opt:1 opt:2 - ext2 /dev/meep5 rw,errors=remount-ro,data=ordered +24 0 8:18 / %[1]s%[7]s rw,relatime opt:1 opt:2 - ext2 /dev/meep78 rw,errors=remount-ro,data=ordered` + + content := fmt.Sprintf(mockMountInfoFmtWithLoop, args...) + restore := osutil.MockMountInfo(content) + defer restore() + + l, err := snap.AutoImportCandidates() + c.Check(err, IsNil) + + // only device should be the /mnt/real-device one + c.Check(l, DeepEquals, []string{filepath.Join(rootDir, "/mnt/real-device", "auto-import.assert")}) +} + +func (s *SnapSuite) TestAutoImportAssertsManagedEmptyReply(c *C) { + restore := release.MockOnClassic(false) + defer restore() + + _, restore = logger.MockLogger() + defer restore() + + fakeAssertData := []byte("my-assertion") + + n := 0 + total := 2 + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch n { + case 0: + c.Check(r.Method, Equals, "POST") + c.Check(r.URL.Path, Equals, "/v2/assertions") + postData, err := io.ReadAll(r.Body) + c.Assert(err, IsNil) + c.Check(postData, DeepEquals, fakeAssertData) + fmt.Fprintln(w, `{"type": "sync", "result": {"ready": true, "status": "Done"}}`) + n++ + case 1: + c.Check(r.Method, Equals, "POST") + c.Check(r.URL.Path, Equals, "/v2/users") + postData, err := io.ReadAll(r.Body) + c.Assert(err, IsNil) + c.Check(string(postData), Equals, `{"action":"create","automatic":true}`) + + fmt.Fprintln(w, `{"type": "sync", "result": []}`) + n++ + default: + c.Fatalf("unexpected request: %v (expected %d got %d)", r, total, n) + } + }) + + fakeAssertsFn := filepath.Join(c.MkDir(), "auto-import.assert") + err := os.WriteFile(fakeAssertsFn, fakeAssertData, 0644) + c.Assert(err, IsNil) + + mockMountInfoFmt := `24 0 8:18 / %s rw,relatime shared:1 - ext4 /dev/sdb2 rw,errors=remount-ro,data=ordered` + content := fmt.Sprintf(mockMountInfoFmt, filepath.Dir(fakeAssertsFn)) + restore = osutil.MockMountInfo(content) + defer restore() + + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"auto-import"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + c.Check(s.Stdout(), Equals, ``) + c.Check(n, Equals, total) +} diff --git a/cmd/snap/cmd_booted.go b/cmd/snap/cmd_booted.go new file mode 100644 index 00000000..912d1e8b --- /dev/null +++ b/cmd/snap/cmd_booted.go @@ -0,0 +1,49 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2014-2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "fmt" + + "github.com/jessevdk/go-flags" +) + +type cmdBooted struct{} + +func init() { + cmd := addCommand("booted", + "Deprecated (hidden)", + "The booted command is only retained for backwards compatibility.", + func() flags.Commander { + return &cmdBooted{} + }, nil, nil) + cmd.hidden = true +} + +// WARNING: do not remove this command, older systems may still have a systemd +// snapd.firstboot.service job in /etc/systemd/system that we did not cleanup. +// So we need this sample command or those units will start failing. +func (x *cmdBooted) Execute(args []string) error { + if len(args) > 0 { + return ErrExtraArgs + } + fmt.Fprintf(Stderr, "booted command is deprecated\n") + return nil +} diff --git a/cmd/snap/cmd_buy.go b/cmd/snap/cmd_buy.go new file mode 100644 index 00000000..826f2cd8 --- /dev/null +++ b/cmd/snap/cmd_buy.go @@ -0,0 +1,137 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "fmt" + "strings" + + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/client" + "github.com/snapcore/snapd/i18n" +) + +var shortBuyHelp = i18n.G("Buy a snap") +var longBuyHelp = i18n.G(` +The buy command buys a snap from the store. +`) + +type cmdBuy struct { + clientMixin + Positional struct { + SnapName remoteSnapName + } `positional-args:"yes" required:"yes"` +} + +func init() { + cmd := addCommand("buy", shortBuyHelp, longBuyHelp, func() flags.Commander { + return &cmdBuy{} + }, map[string]string{}, []argDesc{{ + name: "", + // TRANSLATORS: This should not start with a lowercase letter. + desc: i18n.G("Snap name"), + }}) + cmd.hidden = true +} + +func (x *cmdBuy) Execute(args []string) error { + if len(args) > 0 { + return ErrExtraArgs + } + + return buySnap(x.client, string(x.Positional.SnapName)) +} + +func buySnap(cli *client.Client, snapName string) error { + user := cli.LoggedInUser() + if user == nil { + return fmt.Errorf(i18n.G("You need to be logged in to purchase software. Please run 'snap login' and try again.")) + } + + if strings.ContainsAny(snapName, ":*") { + return fmt.Errorf(i18n.G("cannot buy snap: invalid characters in name")) + } + + snap, resultInfo, err := cli.FindOne(snapName) + if err != nil { + return err + } + + opts := &client.BuyOptions{ + SnapID: snap.ID, + Currency: resultInfo.SuggestedCurrency, + } + + opts.Price, opts.Currency, err = getPrice(snap.Prices, opts.Currency) + if err != nil { + return fmt.Errorf(i18n.G("cannot buy snap: %v"), err) + } + + if snap.Status == "available" { + return fmt.Errorf(i18n.G("cannot buy snap: it has already been bought")) + } + + err = cli.ReadyToBuy() + if err != nil { + if e, ok := err.(*client.Error); ok { + switch e.Kind { + case client.ErrorKindNoPaymentMethods: + return fmt.Errorf(i18n.G(`You need to have a payment method associated with your account in order to buy a snap, please visit https://my.ubuntu.com/payment/edit to add one. + +Once you’ve added your payment details, you just need to run 'snap buy %s' again.`), snap.Name) + case client.ErrorKindTermsNotAccepted: + return fmt.Errorf(i18n.G(`In order to buy %q, you need to agree to the latest terms and conditions. Please visit https://my.ubuntu.com/payment/edit to do this. + +Once completed, return here and run 'snap buy %s' again.`), snap.Name, snap.Name) + } + } + return err + } + + // TRANSLATORS: %q, %q and %s are the snap name, developer, and price. Please wrap the translation at 80 characters. + fmt.Fprintf(Stdout, i18n.G(`Please re-enter your Ubuntu One password to purchase %q from %q +for %s. Press ctrl-c to cancel.`), snap.Name, snap.Publisher.Username, formatPrice(opts.Price, opts.Currency)) + fmt.Fprint(Stdout, "\n") + + err = requestLogin(cli, user.Email) + if err != nil { + return err + } + + _, err = cli.Buy(opts) + if err != nil { + if e, ok := err.(*client.Error); ok { + switch e.Kind { + case client.ErrorKindPaymentDeclined: + return fmt.Errorf(i18n.G(`Sorry, your payment method has been declined by the issuer. Please review your +payment details at https://my.ubuntu.com/payment/edit and try again.`)) + } + } + return err + } + + // TRANSLATORS: %q and %s are the same snap name. Please wrap the translation at 80 characters. + fmt.Fprintf(Stdout, i18n.G(`Thanks for purchasing %q. You may now install it on any of your devices +with 'snap install %s'.`), snapName, snapName) + fmt.Fprint(Stdout, "\n") + + return nil +} diff --git a/cmd/snap/cmd_buy_test.go b/cmd/snap/cmd_buy_test.go new file mode 100644 index 00000000..003ce990 --- /dev/null +++ b/cmd/snap/cmd_buy_test.go @@ -0,0 +1,462 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "encoding/json" + "fmt" + "net/http" + + "gopkg.in/check.v1" + + snap "github.com/snapcore/snapd/cmd/snap" +) + +type BuySnapSuite struct { + BaseSnapSuite +} + +var _ = check.Suite(&BuySnapSuite{}) + +type expectedURL struct { + Body string + Checker func(r *http.Request) + + callCount int +} + +type expectedMethod map[string]*expectedURL + +type expectedMethods map[string]*expectedMethod + +type buyTestMockSnapServer struct { + ExpectedMethods expectedMethods + + Checker *check.C +} + +func (s *buyTestMockSnapServer) serveHttp(w http.ResponseWriter, r *http.Request) { + method := s.ExpectedMethods[r.Method] + if method == nil || len(*method) == 0 { + s.Checker.Fatalf("unexpected HTTP method %s", r.Method) + } + + url := (*method)[r.URL.Path] + if url == nil { + s.Checker.Fatalf("unexpected URL %q", r.URL.Path) + } + + if url.Checker != nil { + url.Checker(r) + } + fmt.Fprintln(w, url.Body) + url.callCount++ +} + +func (s *buyTestMockSnapServer) checkCounts() { + for _, method := range s.ExpectedMethods { + for _, url := range *method { + s.Checker.Check(url.callCount, check.Equals, 1) + } + } +} + +func (s *BuySnapSuite) SetUpTest(c *check.C) { + s.BaseSnapSuite.SetUpTest(c) + s.Login(c) +} + +func (s *BuySnapSuite) TearDownTest(c *check.C) { + s.Logout(c) + s.BaseSnapSuite.TearDownTest(c) +} + +func (s *BuySnapSuite) TestBuyHelp(c *check.C) { + _, err := snap.Parser(snap.Client()).ParseArgs([]string{"buy"}) + c.Assert(err, check.NotNil) + c.Check(err.Error(), check.Equals, "the required argument `` was not provided") + c.Check(s.Stdout(), check.Equals, "") + c.Check(s.Stderr(), check.Equals, "") +} + +func (s *BuySnapSuite) TestBuyInvalidCharacters(c *check.C) { + _, err := snap.Parser(snap.Client()).ParseArgs([]string{"buy", "a:b"}) + c.Assert(err, check.NotNil) + c.Check(err.Error(), check.Equals, "cannot buy snap: invalid characters in name") + c.Check(s.Stdout(), check.Equals, "") + c.Check(s.Stderr(), check.Equals, "") + + _, err = snap.Parser(snap.Client()).ParseArgs([]string{"buy", "c*d"}) + c.Assert(err, check.NotNil) + c.Check(err.Error(), check.Equals, "cannot buy snap: invalid characters in name") + c.Check(s.Stdout(), check.Equals, "") + c.Check(s.Stderr(), check.Equals, "") +} + +const buyFreeSnapFailsFindJson = ` +{ + "type": "sync", + "status-code": 200, + "status": "OK", + "result": [ + { + "channel": "stable", + "confinement": "strict", + "description": "GNU hello prints a friendly greeting. This is part of the snapcraft tour at https://snapcraft.io/", + "developer": "canonical", + "publisher": { + "id": "canonical", + "username": "canonical", + "display-name": "Canonical", + "validation": "verified" + }, + "download-size": 65536, + "icon": "", + "id": "mVyGrEwiqSi5PugCwyH7WgpoQLemtTd6", + "name": "hello", + "private": false, + "resource": "/v2/snaps/hello", + "revision": "1", + "status": "available", + "summary": "GNU Hello, the \"hello world\" snap", + "type": "app", + "version": "2.10" + } + ], + "sources": [ + "store" + ], + "suggested-currency": "GBP" +} +` + +func (s *BuySnapSuite) TestBuyFreeSnapFails(c *check.C) { + mockServer := &buyTestMockSnapServer{ + ExpectedMethods: expectedMethods{ + "GET": &expectedMethod{ + "/v2/find": &expectedURL{ + Body: buyFreeSnapFailsFindJson, + }, + }, + }, + Checker: c, + } + defer mockServer.checkCounts() + s.RedirectClientToTestServer(mockServer.serveHttp) + + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"buy", "hello"}) + c.Assert(err, check.NotNil) + c.Check(err.Error(), check.Equals, "cannot buy snap: snap is free") + c.Assert(rest, check.DeepEquals, []string{"hello"}) + c.Check(s.Stdout(), check.Equals, "") + c.Check(s.Stderr(), check.Equals, "") +} + +const buySnapFindJson = ` +{ + "type": "sync", + "status-code": 200, + "status": "OK", + "result": [ + { + "channel": "stable", + "confinement": "strict", + "description": "GNU hello prints a friendly greeting. This is part of the snapcraft tour at https://snapcraft.io/", + "developer": "canonical", + "publisher": { + "id": "canonical", + "username": "canonical", + "display-name": "Canonical", + "validation": "verified" + }, + "download-size": 65536, + "icon": "", + "id": "mVyGrEwiqSi5PugCwyH7WgpoQLemtTd6", + "name": "hello", + "private": false, + "resource": "/v2/snaps/hello", + "revision": "1", + "status": "priced", + "summary": "GNU Hello, the \"hello world\" snap", + "type": "app", + "version": "2.10", + "prices": {"USD": 3.99, "GBP": 2.99} + } + ], + "sources": [ + "store" + ], + "suggested-currency": "GBP" +} +` + +func buySnapFindURL(c *check.C) *expectedURL { + return &expectedURL{ + Body: buySnapFindJson, + Checker: func(r *http.Request) { + c.Check(r.URL.Query().Get("name"), check.Equals, "hello") + }, + } +} + +const buyReadyJson = ` +{ + "type": "sync", + "status-code": 200, + "status": "OK", + "result": true, + "sources": [ + "store" + ], + "suggested-currency": "GBP" +} +` + +func buyReady(c *check.C) *expectedURL { + return &expectedURL{ + Body: buyReadyJson, + } +} + +const buySnapJson = ` +{ + "type": "sync", + "status-code": 200, + "status": "OK", + "result": { + "state": "Complete" + }, + "sources": [ + "store" + ], + "suggested-currency": "GBP" +} +` + +const loginJson = ` +{ + "type": "sync", + "status-code": 200, + "status": "OK", + "result": { + "id": 1, + "username": "username", + "email": "hello@mail.com", + "macaroon": "1234abcd", + "discharges": ["a", "b", "c"] + }, + "sources": [ + "store" + ] +} +` + +func (s *BuySnapSuite) TestBuySnapSuccess(c *check.C) { + mockServer := &buyTestMockSnapServer{ + ExpectedMethods: expectedMethods{ + "GET": &expectedMethod{ + "/v2/find": buySnapFindURL(c), + "/v2/buy/ready": buyReady(c), + }, + "POST": &expectedMethod{ + "/v2/login": &expectedURL{ + Body: loginJson, + }, + "/v2/buy": &expectedURL{ + Body: buySnapJson, + Checker: func(r *http.Request) { + var postData struct { + SnapID string `json:"snap-id"` + Price float64 `json:"price"` + Currency string `json:"currency"` + } + decoder := json.NewDecoder(r.Body) + err := decoder.Decode(&postData) + c.Assert(err, check.IsNil) + + c.Check(postData.SnapID, check.Equals, "mVyGrEwiqSi5PugCwyH7WgpoQLemtTd6") + c.Check(postData.Price, check.Equals, 2.99) + c.Check(postData.Currency, check.Equals, "GBP") + }, + }, + }, + }, + Checker: c, + } + defer mockServer.checkCounts() + s.RedirectClientToTestServer(mockServer.serveHttp) + + // Confirm the purchase. + s.password = "the password" + + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"buy", "hello"}) + c.Check(err, check.IsNil) + c.Check(rest, check.DeepEquals, []string{}) + c.Check(s.Stdout(), check.Equals, `Please re-enter your Ubuntu One password to purchase "hello" from "canonical" +for 2.99GBP. Press ctrl-c to cancel. +Password of "hello@mail.com": +Thanks for purchasing "hello". You may now install it on any of your devices +with 'snap install hello'. +`) + c.Check(s.Stderr(), check.Equals, "") +} + +const buySnapPaymentDeclinedJson = ` +{ + "type": "error", + "result": { + "message": "payment declined", + "kind": "payment-declined" + }, + "status-code": 400 +} +` + +func (s *BuySnapSuite) TestBuySnapPaymentDeclined(c *check.C) { + mockServer := &buyTestMockSnapServer{ + ExpectedMethods: expectedMethods{ + "GET": &expectedMethod{ + "/v2/find": buySnapFindURL(c), + "/v2/buy/ready": buyReady(c), + }, + "POST": &expectedMethod{ + "/v2/login": &expectedURL{ + Body: loginJson, + }, + "/v2/buy": &expectedURL{ + Body: buySnapPaymentDeclinedJson, + Checker: func(r *http.Request) { + var postData struct { + SnapID string `json:"snap-id"` + Price float64 `json:"price"` + Currency string `json:"currency"` + } + decoder := json.NewDecoder(r.Body) + err := decoder.Decode(&postData) + c.Assert(err, check.IsNil) + + c.Check(postData.SnapID, check.Equals, "mVyGrEwiqSi5PugCwyH7WgpoQLemtTd6") + c.Check(postData.Price, check.Equals, 2.99) + c.Check(postData.Currency, check.Equals, "GBP") + }, + }, + }, + }, + Checker: c, + } + defer mockServer.checkCounts() + s.RedirectClientToTestServer(mockServer.serveHttp) + + // Confirm the purchase. + s.password = "the password" + + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"buy", "hello"}) + c.Assert(err, check.NotNil) + c.Check(err.Error(), check.Equals, `Sorry, your payment method has been declined by the issuer. Please review your +payment details at https://my.ubuntu.com/payment/edit and try again.`) + c.Check(rest, check.DeepEquals, []string{"hello"}) + c.Check(s.Stdout(), check.Equals, `Please re-enter your Ubuntu One password to purchase "hello" from "canonical" +for 2.99GBP. Press ctrl-c to cancel. +Password of "hello@mail.com": +`) + c.Check(s.Stderr(), check.Equals, "") +} + +const readyToBuyNoPaymentMethodJson = ` +{ + "type": "error", + "result": { + "message": "no payment methods", + "kind": "no-payment-methods" + }, + "status-code": 400 +}` + +func (s *BuySnapSuite) TestBuySnapFailsNoPaymentMethod(c *check.C) { + mockServer := &buyTestMockSnapServer{ + ExpectedMethods: expectedMethods{ + "GET": &expectedMethod{ + "/v2/find": buySnapFindURL(c), + "/v2/buy/ready": &expectedURL{ + Body: readyToBuyNoPaymentMethodJson, + }, + }, + }, + Checker: c, + } + defer mockServer.checkCounts() + s.RedirectClientToTestServer(mockServer.serveHttp) + + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"buy", "hello"}) + c.Assert(err, check.NotNil) + c.Check(err.Error(), check.Equals, `You need to have a payment method associated with your account in order to buy a snap, please visit https://my.ubuntu.com/payment/edit to add one. + +Once you’ve added your payment details, you just need to run 'snap buy hello' again.`) + c.Check(rest, check.DeepEquals, []string{"hello"}) + c.Check(s.Stdout(), check.Equals, "") + c.Check(s.Stderr(), check.Equals, "") +} + +const readyToBuyNotAcceptedTermsJson = ` +{ + "type": "error", + "result": { + "message": "terms of service not accepted", + "kind": "terms-not-accepted" + }, + "status-code": 400 +}` + +func (s *BuySnapSuite) TestBuySnapFailsNotAcceptedTerms(c *check.C) { + mockServer := &buyTestMockSnapServer{ + ExpectedMethods: expectedMethods{ + "GET": &expectedMethod{ + "/v2/find": buySnapFindURL(c), + "/v2/buy/ready": &expectedURL{ + Body: readyToBuyNotAcceptedTermsJson, + }, + }, + }, + Checker: c, + } + defer mockServer.checkCounts() + s.RedirectClientToTestServer(mockServer.serveHttp) + + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"buy", "hello"}) + c.Assert(err, check.NotNil) + c.Check(err.Error(), check.Equals, `In order to buy "hello", you need to agree to the latest terms and conditions. Please visit https://my.ubuntu.com/payment/edit to do this. + +Once completed, return here and run 'snap buy hello' again.`) + c.Check(rest, check.DeepEquals, []string{"hello"}) + c.Check(s.Stdout(), check.Equals, "") + c.Check(s.Stderr(), check.Equals, "") +} + +func (s *BuySnapSuite) TestBuyFailsWithoutLogin(c *check.C) { + // We don't login here + s.Logout(c) + + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"buy", "hello"}) + c.Check(err, check.NotNil) + c.Check(err.Error(), check.Equals, "You need to be logged in to purchase software. Please run 'snap login' and try again.") + c.Check(rest, check.DeepEquals, []string{"hello"}) + c.Check(s.Stdout(), check.Equals, "") + c.Check(s.Stderr(), check.Equals, "") +} diff --git a/cmd/snap/cmd_can_manage_refreshes.go b/cmd/snap/cmd_can_manage_refreshes.go new file mode 100644 index 00000000..32b8855d --- /dev/null +++ b/cmd/snap/cmd_can_manage_refreshes.go @@ -0,0 +1,53 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "fmt" + + "github.com/jessevdk/go-flags" +) + +type cmdCanManageRefreshes struct { + clientMixin +} + +func init() { + cmd := addDebugCommand("can-manage-refreshes", + "(internal) return if refresh.schedule=managed can be used", + "(internal) return if refresh.schedule=managed can be used", + func() flags.Commander { + return &cmdCanManageRefreshes{} + }, nil, nil) + cmd.hidden = true +} + +func (x *cmdCanManageRefreshes) Execute(args []string) error { + if len(args) > 0 { + return ErrExtraArgs + } + + var resp bool + if err := x.client.Debug("can-manage-refreshes", nil, &resp); err != nil { + return err + } + fmt.Fprintf(Stdout, "%v\n", resp) + return nil +} diff --git a/cmd/snap/cmd_changes.go b/cmd/snap/cmd_changes.go new file mode 100644 index 00000000..2c53e040 --- /dev/null +++ b/cmd/snap/cmd_changes.go @@ -0,0 +1,210 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "fmt" + "regexp" + "sort" + + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/client" + "github.com/snapcore/snapd/i18n" +) + +var shortChangesHelp = i18n.G("List system changes") +var shortTasksHelp = i18n.G("List a change's tasks") +var longChangesHelp = i18n.G(` +The changes command displays a summary of system changes performed recently. +`) +var longTasksHelp = i18n.G(` +The tasks command displays a summary of tasks associated with an individual +change. +`) + +type cmdChanges struct { + clientMixin + timeMixin + Positional struct { + Snap string `positional-arg-name:""` + } `positional-args:"yes"` +} + +type cmdTasks struct { + timeMixin + changeIDMixin +} + +func init() { + addCommand("changes", shortChangesHelp, longChangesHelp, + func() flags.Commander { return &cmdChanges{} }, timeDescs, nil) + addCommand("tasks", shortTasksHelp, longTasksHelp, + func() flags.Commander { return &cmdTasks{} }, + changeIDMixinOptDesc.also(timeDescs), + changeIDMixinArgDesc).alias = "change" +} + +type changesByTime []*client.Change + +func (s changesByTime) Len() int { return len(s) } +func (s changesByTime) Less(i, j int) bool { return s[i].SpawnTime.Before(s[j].SpawnTime) } +func (s changesByTime) Swap(i, j int) { s[i], s[j] = s[j], s[i] } + +var allDigits = regexp.MustCompile(`^[0-9]+$`).MatchString + +func queryChanges(cli *client.Client, opts *client.ChangesOptions) ([]*client.Change, error) { + chgs, err := cli.Changes(opts) + if err != nil { + return nil, err + } + if err := warnMaintenance(cli); err != nil { + return nil, err + } + return chgs, nil +} + +func (c *cmdChanges) Execute(args []string) error { + if len(args) > 0 { + return ErrExtraArgs + } + + if allDigits(c.Positional.Snap) { + // TRANSLATORS: the %s is the argument given by the user to 'snap changes' + return fmt.Errorf(i18n.G(`'snap changes' command expects a snap name, try 'snap tasks %s'`), c.Positional.Snap) + } + + if c.Positional.Snap == "everything" { + fmt.Fprintln(Stdout, i18n.G("Yes, yes it does.")) + return nil + } + + opts := client.ChangesOptions{ + SnapName: c.Positional.Snap, + Selector: client.ChangesAll, + } + + changes, err := queryChanges(c.client, &opts) + if err != nil { + return err + } + + if len(changes) == 0 { + fmt.Fprintln(Stderr, i18n.G("no changes found")) + return nil + } + + sort.Sort(changesByTime(changes)) + + w := tabWriter() + + fmt.Fprintf(w, i18n.G("ID\tStatus\tSpawn\tReady\tSummary\n")) + for _, chg := range changes { + spawnTime := c.fmtTime(chg.SpawnTime) + readyTime := c.fmtTime(chg.ReadyTime) + if chg.ReadyTime.IsZero() { + readyTime = "-" + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", chg.ID, chg.Status, spawnTime, readyTime, chg.Summary) + } + + w.Flush() + fmt.Fprintln(Stdout) + + return nil +} + +func (c *cmdTasks) Execute([]string) error { + chid, err := c.GetChangeID() + if err != nil { + if err == noChangeFoundOK { + return nil + } + return err + } + + return c.showChange(chid) +} + +func queryChange(cli *client.Client, chid string) (*client.Change, error) { + chg, err := cli.Change(chid) + if err != nil { + return nil, err + } + if err := warnMaintenance(cli); err != nil { + return nil, err + } + return chg, nil +} + +func (c *cmdTasks) showChange(chid string) error { + chg, err := queryChange(c.client, chid) + if err != nil { + return err + } + + w := tabWriter() + + fmt.Fprintf(w, i18n.G("Status\tSpawn\tReady\tSummary\n")) + for _, t := range chg.Tasks { + spawnTime := c.fmtTime(t.SpawnTime) + readyTime := c.fmtTime(t.ReadyTime) + if t.ReadyTime.IsZero() { + readyTime = "-" + } + summary := t.Summary + if t.Status == "Doing" && t.Progress.Total > 1 { + summary = fmt.Sprintf("%s (%.2f%%)", summary, float64(t.Progress.Done)/float64(t.Progress.Total)*100.0) + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", t.Status, spawnTime, readyTime, summary) + } + + w.Flush() + + for _, t := range chg.Tasks { + if len(t.Log) == 0 { + continue + } + fmt.Fprintln(Stdout) + fmt.Fprintln(Stdout, line) + fmt.Fprintln(Stdout, t.Summary) + fmt.Fprintln(Stdout) + for _, line := range t.Log { + fmt.Fprintln(Stdout, line) + } + } + + fmt.Fprintln(Stdout) + + return nil +} + +const line = "......................................................................" + +func warnMaintenance(cli *client.Client) error { + if maintErr := cli.Maintenance(); maintErr != nil { + msg, err := errorToCmdMessage("", "", maintErr, nil) + if err != nil { + return err + } + fmt.Fprintf(Stderr, "WARNING: %s\n", msg) + } + return nil +} diff --git a/cmd/snap/cmd_changes_test.go b/cmd/snap/cmd_changes_test.go new file mode 100644 index 00000000..6d03812e --- /dev/null +++ b/cmd/snap/cmd_changes_test.go @@ -0,0 +1,252 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "fmt" + "net/http" + "strings" + + "gopkg.in/check.v1" + + snap "github.com/snapcore/snapd/cmd/snap" +) + +var mockChangeJSON = `{"type": "sync", "result": { + "id": "uno", + "kind": "foo", + "summary": "...", + "status": "Do", + "ready": false, + "spawn-time": "2016-04-21T01:02:03Z", + "ready-time": "2016-04-21T01:02:04Z", + "tasks": [{"kind": "bar", "summary": "some summary", "status": "Do", "progress": {"done": 0, "total": 1}, "spawn-time": "2016-04-21T01:02:03Z", "ready-time": "2016-04-21T01:02:04Z"}] +}}` + +func (s *SnapSuite) TestChangeSimple(c *check.C) { + n := 0 + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + if n < 2 { + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/changes/42") + fmt.Fprintln(w, mockChangeJSON) + } else { + c.Fatalf("expected to get 1 requests, now on %d", n+1) + } + + n++ + }) + expectedChange := `(?ms)Status +Spawn +Ready +Summary +Do +2016-04-21T01:02:03Z +2016-04-21T01:02:04Z +some summary +` + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"change", "--abs-time", "42"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + c.Check(s.Stdout(), check.Matches, expectedChange) + c.Check(s.Stderr(), check.Equals, "") + + rest, err = snap.Parser(snap.Client()).ParseArgs([]string{"tasks", "--abs-time", "42"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + c.Check(s.Stdout(), check.Matches, expectedChange) + c.Check(s.Stderr(), check.Equals, "") +} + +func (s *SnapSuite) TestChangeSimpleRebooting(c *check.C) { + n := 0 + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + if n < 2 { + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/changes/42") + fmt.Fprintln(w, strings.Replace(mockChangeJSON, `"type": "sync"`, `"type": "sync", "maintenance": {"kind": "system-restart", "message": "system is restarting"}`, 1)) + } else { + c.Fatalf("expected to get 1 requests, now on %d", n+1) + } + + n++ + }) + + _, err := snap.Parser(snap.Client()).ParseArgs([]string{"change", "42"}) + c.Assert(err, check.IsNil) + c.Check(s.Stderr(), check.Equals, "WARNING: snapd is about to reboot the system\n") +} + +var mockChangesJSON = `{"type": "sync", "result": [ + { + "id": "four", + "kind": "install-snap", + "summary": "...", + "status": "Do", + "ready": false, + "spawn-time": "2015-02-21T01:02:03Z", + "ready-time": "2015-02-21T01:02:04Z", + "tasks": [{"kind": "bar", "summary": "some summary", "status": "Do", "progress": {"done": 0, "total": 1}, "spawn-time": "2015-02-21T01:02:03Z", "ready-time": "2015-02-21T01:02:04Z"}] + }, + { + "id": "one", + "kind": "remove-snap", + "summary": "...", + "status": "Do", + "ready": false, + "spawn-time": "2016-03-21T01:02:03Z", + "ready-time": "2016-03-21T01:02:04Z", + "tasks": [{"kind": "bar", "summary": "some summary", "status": "Do", "progress": {"done": 0, "total": 1}, "spawn-time": "2016-03-21T01:02:03Z", "ready-time": "2016-03-21T01:02:04Z"}] + }, + { + "id": "two", + "kind": "install-snap", + "summary": "...", + "status": "Do", + "ready": false, + "spawn-time": "2016-04-21T01:02:03Z", + "ready-time": "2016-04-21T01:02:04Z", + "tasks": [{"kind": "bar", "summary": "some summary", "status": "Do", "progress": {"done": 0, "total": 1}, "spawn-time": "2016-04-21T01:02:03Z", "ready-time": "2016-04-21T01:02:04Z"}] + }, + { + "id": "three", + "kind": "install-snap", + "summary": "...", + "status": "Do", + "ready": false, + "spawn-time": "2016-01-21T01:02:03Z", + "ready-time": "2016-01-21T01:02:04Z", + "tasks": [{"kind": "bar", "summary": "some summary", "status": "Do", "progress": {"done": 0, "total": 1}, "spawn-time": "2016-01-21T01:02:03Z", "ready-time": "2016-01-21T01:02:04Z"}] + } +]}` + +func (s *SnapSuite) TestTasksLast(c *check.C) { + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + c.Check(r.Method, check.Equals, "GET") + if r.URL.Path == "/v2/changes" { + fmt.Fprintln(w, mockChangesJSON) + return + } + c.Assert(r.URL.Path, check.Equals, "/v2/changes/two") + fmt.Fprintln(w, mockChangeJSON) + }) + expectedChange := `(?ms)Status +Spawn +Ready +Summary +Do +2016-04-21T01:02:03Z +2016-04-21T01:02:04Z +some summary +` + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"tasks", "--abs-time", "--last=install"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + c.Check(s.Stdout(), check.Matches, expectedChange) + c.Check(s.Stderr(), check.Equals, "") + + _, err = snap.Parser(snap.Client()).ParseArgs([]string{"tasks", "--abs-time", "--last=foobar"}) + c.Assert(err, check.NotNil) + c.Assert(err, check.ErrorMatches, `no changes of type "foobar" found`) +} + +func (s *SnapSuite) TestTasksLastQuestionmark(c *check.C) { + n := 0 + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + n++ + c.Check(r.Method, check.Equals, "GET") + c.Assert(r.URL.Path, check.Equals, "/v2/changes") + switch n { + case 1, 2: + fmt.Fprintln(w, `{"type": "sync", "result": []}`) + case 3, 4: + fmt.Fprintln(w, mockChangesJSON) + default: + c.Errorf("expected 4 calls, now on %d", n) + } + }) + for i := 0; i < 2; i++ { + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"tasks", "--last=foobar?"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + c.Check(s.Stdout(), check.Matches, "") + c.Check(s.Stderr(), check.Equals, "") + + _, err = snap.Parser(snap.Client()).ParseArgs([]string{"tasks", "--last=foobar"}) + if i == 0 { + c.Assert(err, check.ErrorMatches, `no changes found`) + } else { + c.Assert(err, check.ErrorMatches, `no changes of type "foobar" found`) + } + } + + c.Check(n, check.Equals, 4) +} + +func (s *SnapSuite) TestTasksSyntaxError(c *check.C) { + _, err := snap.Parser(snap.Client()).ParseArgs([]string{"tasks", "--abs-time", "--last=install", "42"}) + c.Assert(err, check.NotNil) + c.Assert(err, check.ErrorMatches, `cannot use change ID and type together`) + + _, err = snap.Parser(snap.Client()).ParseArgs([]string{"tasks"}) + c.Assert(err, check.NotNil) + c.Assert(err, check.ErrorMatches, `please provide change ID or type with --last=`) +} + +var mockChangeInProgressJSON = `{"type": "sync", "result": { + "id": "uno", + "kind": "foo", + "summary": "...", + "status": "Do", + "ready": false, + "spawn-time": "2016-04-21T01:02:03Z", + "ready-time": "2016-04-21T01:02:04Z", + "tasks": [{"kind": "bar", "summary": "some summary", "status": "Doing", "progress": {"done": 50, "total": 100}, "spawn-time": "2016-04-21T01:02:03Z", "ready-time": "2016-04-21T01:02:04Z"}] +}}` + +func (s *SnapSuite) TestChangeProgress(c *check.C) { + n := 0 + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch n { + case 0: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/changes/42") + fmt.Fprintln(w, mockChangeInProgressJSON) + default: + c.Fatalf("expected to get 1 requests, now on %d", n+1) + } + + n++ + }) + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"change", "--abs-time", "42"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + c.Check(s.Stdout(), check.Matches, `(?ms)Status +Spawn +Ready +Summary +Doing +2016-04-21T01:02:03Z +2016-04-21T01:02:04Z +some summary \(50.00%\) +`) + c.Check(s.Stderr(), check.Equals, "") +} + +func (s *SnapSuite) TestNoChanges(c *check.C) { + n := 0 + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch n { + case 0: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/changes") + fmt.Fprintln(w, `{"type": "sync", "result": []}`) + default: + c.Fatalf("expected to get 1 requests, now on %d", n+1) + } + + n++ + }) + _, err := snap.Parser(snap.Client()).ParseArgs([]string{"changes"}) + c.Assert(err, check.IsNil) + c.Check(s.Stderr(), check.Equals, "no changes found\n") +} diff --git a/cmd/snap/cmd_confinement.go b/cmd/snap/cmd_confinement.go new file mode 100644 index 00000000..c6a4a3f6 --- /dev/null +++ b/cmd/snap/cmd_confinement.go @@ -0,0 +1,57 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "fmt" + + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/i18n" +) + +var shortConfinementHelp = i18n.G("Print the confinement mode the system operates in") +var longConfinementHelp = i18n.G(` +The confinement command will print the confinement mode (strict, +partial or none) the system operates in. +`) + +type cmdConfinement struct { + clientMixin +} + +func init() { + addDebugCommand("confinement", shortConfinementHelp, longConfinementHelp, func() flags.Commander { + return &cmdConfinement{} + }, nil, nil) +} + +func (cmd cmdConfinement) Execute(args []string) error { + if len(args) > 0 { + return ErrExtraArgs + } + + sysInfo, err := cmd.client.SysInfo() + if err != nil { + return err + } + fmt.Fprintf(Stdout, "%s\n", sysInfo.Confinement) + return nil +} diff --git a/cmd/snap/cmd_confinement_test.go b/cmd/snap/cmd_confinement_test.go new file mode 100644 index 00000000..b76e5f75 --- /dev/null +++ b/cmd/snap/cmd_confinement_test.go @@ -0,0 +1,39 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "fmt" + "net/http" + + . "gopkg.in/check.v1" + + snap "github.com/snapcore/snapd/cmd/snap" +) + +func (s *SnapSuite) TestConfinement(c *C) { + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, `{"type": "sync", "result": {"confinement": "strict"}}`) + }) + _, err := snap.Parser(snap.Client()).ParseArgs([]string{"debug", "confinement"}) + c.Assert(err, IsNil) + c.Assert(s.Stdout(), Equals, "strict\n") + c.Assert(s.Stderr(), Equals, "") +} diff --git a/cmd/snap/cmd_connect.go b/cmd/snap/cmd_connect.go new file mode 100644 index 00000000..b4e98975 --- /dev/null +++ b/cmd/snap/cmd_connect.go @@ -0,0 +1,93 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/i18n" +) + +type cmdConnect struct { + waitMixin + Positionals struct { + PlugSpec connectPlugSpec `required:"yes"` + SlotSpec connectSlotSpec + } `positional-args:"true"` +} + +var shortConnectHelp = i18n.G("Connect a plug to a slot") +var longConnectHelp = i18n.G(` +The connect command connects a plug to a slot. +It may be called in the following ways: + +$ snap connect : : + +Connects the provided plug to the given slot. + +$ snap connect : + +Connects the specific plug to the only slot in the provided snap that matches +the connected interface. If more than one potential slot exists, the command +fails. + +$ snap connect : + +Connects the provided plug to the slot in the core snap with a name matching +the plug name. +`) + +func init() { + addCommand("connect", shortConnectHelp, longConnectHelp, func() flags.Commander { + return &cmdConnect{} + }, waitDescs, []argDesc{ + // TRANSLATORS: This needs to begin with < and end with > + {name: i18n.G(":")}, + // TRANSLATORS: This needs to begin with < and end with > + {name: i18n.G(":")}, + }) +} + +func (x *cmdConnect) Execute(args []string) error { + if len(args) > 0 { + return ErrExtraArgs + } + + // snap connect [:] + if x.Positionals.PlugSpec.Snap != "" && x.Positionals.PlugSpec.Name == "" { + // Move the value of .Snap to .Name and keep .Snap empty + x.Positionals.PlugSpec.Name = x.Positionals.PlugSpec.Snap + x.Positionals.PlugSpec.Snap = "" + } + + id, err := x.client.Connect(x.Positionals.PlugSpec.Snap, x.Positionals.PlugSpec.Name, x.Positionals.SlotSpec.Snap, x.Positionals.SlotSpec.Name) + if err != nil { + return err + } + + if _, err := x.wait(id); err != nil { + if err == noWait { + return nil + } + return err + } + + return nil +} diff --git a/cmd/snap/cmd_connect_test.go b/cmd/snap/cmd_connect_test.go new file mode 100644 index 00000000..9e0c04bc --- /dev/null +++ b/cmd/snap/cmd_connect_test.go @@ -0,0 +1,329 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "fmt" + "net/http" + "os" + + "github.com/jessevdk/go-flags" + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/client" + . "github.com/snapcore/snapd/cmd/snap" +) + +func (s *SnapSuite) TestConnectHelp(c *C) { + msg := `Usage: + snap.test connect [connect-OPTIONS] : [:] + +The connect command connects a plug to a slot. +It may be called in the following ways: + +$ snap connect : : + +Connects the provided plug to the given slot. + +$ snap connect : + +Connects the specific plug to the only slot in the provided snap that matches +the connected interface. If more than one potential slot exists, the command +fails. + +$ snap connect : + +Connects the provided plug to the slot in the core snap with a name matching +the plug name. + +[connect command options] + --no-wait Do not wait for the operation to finish but just print + the change id. +` + s.testSubCommandHelp(c, "connect", msg) +} + +func (s *SnapSuite) TestConnectExplicitEverything(c *C) { + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v2/interfaces": + c.Check(r.Method, Equals, "POST") + c.Check(DecodedRequestBody(c, r), DeepEquals, map[string]interface{}{ + "action": "connect", + "plugs": []interface{}{ + map[string]interface{}{ + "snap": "producer", + "plug": "plug", + }, + }, + "slots": []interface{}{ + map[string]interface{}{ + "snap": "consumer", + "slot": "slot", + }, + }, + }) + w.WriteHeader(202) + fmt.Fprintln(w, `{"type":"async", "status-code": 202, "change": "zzz"}`) + case "/v2/changes/zzz": + c.Check(r.Method, Equals, "GET") + fmt.Fprintln(w, `{"type":"sync", "result":{"ready": true, "status": "Done"}}`) + default: + c.Fatalf("unexpected path %q", r.URL.Path) + } + }) + rest, err := Parser(Client()).ParseArgs([]string{"connect", "producer:plug", "consumer:slot"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) +} + +func (s *SnapSuite) TestConnectExplicitPlugImplicitSlot(c *C) { + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v2/interfaces": + c.Check(r.Method, Equals, "POST") + c.Check(DecodedRequestBody(c, r), DeepEquals, map[string]interface{}{ + "action": "connect", + "plugs": []interface{}{ + map[string]interface{}{ + "snap": "producer", + "plug": "plug", + }, + }, + "slots": []interface{}{ + map[string]interface{}{ + "snap": "consumer", + "slot": "", + }, + }, + }) + w.WriteHeader(202) + fmt.Fprintln(w, `{"type":"async", "status-code": 202, "change": "zzz"}`) + case "/v2/changes/zzz": + c.Check(r.Method, Equals, "GET") + fmt.Fprintln(w, `{"type":"sync", "result":{"ready": true, "status": "Done"}}`) + default: + c.Fatalf("unexpected path %q", r.URL.Path) + } + }) + rest, err := Parser(Client()).ParseArgs([]string{"connect", "producer:plug", "consumer"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) +} + +func (s *SnapSuite) TestConnectImplicitPlugExplicitSlot(c *C) { + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v2/interfaces": + c.Check(r.Method, Equals, "POST") + c.Check(DecodedRequestBody(c, r), DeepEquals, map[string]interface{}{ + "action": "connect", + "plugs": []interface{}{ + map[string]interface{}{ + "snap": "", + "plug": "plug", + }, + }, + "slots": []interface{}{ + map[string]interface{}{ + "snap": "consumer", + "slot": "slot", + }, + }, + }) + w.WriteHeader(202) + fmt.Fprintln(w, `{"type":"async", "status-code": 202, "change": "zzz"}`) + case "/v2/changes/zzz": + c.Check(r.Method, Equals, "GET") + fmt.Fprintln(w, `{"type":"sync", "result":{"ready": true, "status": "Done"}}`) + default: + c.Fatalf("unexpected path %q", r.URL.Path) + } + }) + rest, err := Parser(Client()).ParseArgs([]string{"connect", "plug", "consumer:slot"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) +} + +func (s *SnapSuite) TestConnectImplicitPlugImplicitSlot(c *C) { + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v2/interfaces": + c.Check(r.Method, Equals, "POST") + c.Check(DecodedRequestBody(c, r), DeepEquals, map[string]interface{}{ + "action": "connect", + "plugs": []interface{}{ + map[string]interface{}{ + "snap": "", + "plug": "plug", + }, + }, + "slots": []interface{}{ + map[string]interface{}{ + "snap": "consumer", + "slot": "", + }, + }, + }) + w.WriteHeader(202) + fmt.Fprintln(w, `{"type":"async", "status-code": 202, "change": "zzz"}`) + case "/v2/changes/zzz": + c.Check(r.Method, Equals, "GET") + fmt.Fprintln(w, `{"type":"sync", "result":{"ready": true, "status": "Done"}}`) + default: + c.Fatalf("unexpected path %q", r.URL.Path) + } + }) + rest, err := Parser(Client()).ParseArgs([]string{"connect", "plug", "consumer"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) +} + +var fortestingConnectionList = client.Connections{ + Slots: []client.Slot{ + { + Snap: "core", + Name: "x11", + Interface: "x11", + }, + { + Snap: "core", + Name: "core-support", + Interface: "core-support", + Connections: []client.PlugRef{ + { + Snap: "core", + Name: "core-support-plug", + }, + }, + }, + { + Snap: "wake-up-alarm", + Name: "toggle", + Interface: "bool-file", + Label: "Alarm toggle", + }, + { + Snap: "canonical-pi2", + Name: "pin-13", + Interface: "bool-file", + Label: "Pin 13", + Connections: []client.PlugRef{ + { + Snap: "keyboard-lights", + Name: "capslock-led", + }, + }, + }, + }, + Plugs: []client.Plug{ + { + Snap: "core", + Name: "core-support-plug", + Interface: "core-support", + Connections: []client.SlotRef{ + { + Snap: "core", + Name: "core-support", + }, + }, + }, + { + Snap: "core", + Name: "network-bind-plug", + Interface: "network-bind", + }, + { + Snap: "paste-daemon", + Name: "network-listening", + Interface: "network-listening", + Label: "Ability to be a network service", + }, + { + Snap: "potato", + Name: "frying", + Interface: "frying", + Label: "Ability to fry a network service", + }, + { + Snap: "keyboard-lights", + Name: "capslock-led", + Interface: "bool-file", + Label: "Capslock indicator LED", + Connections: []client.SlotRef{ + { + Snap: "canonical-pi2", + Name: "pin-13", + }, + }, + }, + }, +} + +func (s *SnapSuite) TestConnectCompletion(c *C) { + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v2/connections": + c.Assert(r.Method, Equals, "GET") + EncodeResponseBody(c, w, map[string]interface{}{ + "type": "sync", + "result": fortestingConnectionList, + }) + default: + c.Fatalf("unexpected path %q", r.URL.Path) + } + }) + os.Setenv("GO_FLAGS_COMPLETION", "verbose") + defer os.Unsetenv("GO_FLAGS_COMPLETION") + + expected := []flags.Completion{} + parser := Parser(Client()) + parser.CompletionHandler = func(obtained []flags.Completion) { + c.Check(obtained, DeepEquals, expected) + } + + expected = []flags.Completion{{Item: "core:"}, {Item: "paste-daemon:"}, {Item: "potato:"}} + _, err := parser.ParseArgs([]string{"connect", ""}) + c.Assert(err, IsNil) + + // connect's first argument can't start with : (only for the 2nd arg, the slot) + expected = nil + _, err = parser.ParseArgs([]string{"connect", ":"}) + c.Assert(err, IsNil) + + expected = []flags.Completion{{Item: "paste-daemon:network-listening", Description: "plug"}} + _, err = parser.ParseArgs([]string{"connect", "pa"}) + c.Assert(err, IsNil) + + expected = []flags.Completion{{Item: "core:"}, {Item: "wake-up-alarm:"}} + _, err = parser.ParseArgs([]string{"connect", "paste-daemon:network-listening", ""}) + c.Assert(err, IsNil) + + expected = []flags.Completion{{Item: "wake-up-alarm:toggle", Description: "slot"}} + _, err = parser.ParseArgs([]string{"connect", "paste-daemon:network-listening", "w"}) + c.Assert(err, IsNil) + + expected = []flags.Completion{{Item: ":x11", Description: "slot"}} + _, err = parser.ParseArgs([]string{"connect", "paste-daemon:network-listening", ":"}) + c.Assert(err, IsNil) + + c.Assert(s.Stdout(), Equals, "") + c.Assert(s.Stderr(), Equals, "") +} diff --git a/cmd/snap/cmd_connections.go b/cmd/snap/cmd_connections.go new file mode 100644 index 00000000..3e40a953 --- /dev/null +++ b/cmd/snap/cmd_connections.go @@ -0,0 +1,214 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2018 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "fmt" + "sort" + "strings" + + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/client" + "github.com/snapcore/snapd/i18n" +) + +type cmdConnections struct { + clientMixin + All bool `long:"all"` + Positionals struct { + Snap installedSnapName + } `positional-args:"true"` +} + +var shortConnectionsHelp = i18n.G("List interface connections") +var longConnectionsHelp = i18n.G(` +The connections command lists connections between plugs and slots +in the system. + +Unless is provided, the listing is for connected plugs and +slots for all snaps in the system. In this mode, pass --all to also +list unconnected plugs and slots. + +$ snap connections + +Lists connected and unconnected plugs and slots for the specified +snap. +`) + +func init() { + addCommand("connections", shortConnectionsHelp, longConnectionsHelp, func() flags.Commander { + return &cmdConnections{} + }, map[string]string{ + "all": i18n.G("Show connected and unconnected plugs and slots"), + }, []argDesc{{ + // TRANSLATORS: This needs to be wrapped in <>s. + name: "", + // TRANSLATORS: This should not start with a lowercase letter. + desc: i18n.G("Constrain listing to a specific snap"), + }}) +} + +func isSystemSnap(snap string) bool { + return snap == "core" || snap == "snapd" || snap == "system" +} + +func endpoint(snap, name string) string { + if isSystemSnap(snap) { + return ":" + name + } + return snap + ":" + name +} + +type connection struct { + slot string + plug string + interfaceName string + interfaceDeterminant string + manual bool + gadget bool +} + +func (cn connection) String() string { + opts := []string{} + if cn.manual { + opts = append(opts, "manual") + } + if cn.gadget { + opts = append(opts, "gadget") + } + if len(opts) == 0 { + return "-" + } + return strings.Join(opts, ",") +} + +type byConnectionData []connection + +func (b byConnectionData) Len() int { return len(b) } +func (b byConnectionData) Swap(i, j int) { b[i], b[j] = b[j], b[i] } +func (b byConnectionData) Less(i, j int) bool { + iCon, jCon := b[i], b[j] + if iCon.interfaceName != jCon.interfaceName { + return iCon.interfaceName < jCon.interfaceName + } + if iCon.plug != jCon.plug { + return iCon.plug < jCon.plug + } + return iCon.slot < jCon.slot +} + +func interfaceDeterminant(conn *client.Connection) string { + var value string + + switch conn.Interface { + case "content": + value, _ = conn.PlugAttrs["content"].(string) + if value == "" { + value, _ = conn.SlotAttrs["content"].(string) + } + } + if value == "" { + return "" + } + return fmt.Sprintf("[%v]", value) +} + +func (x *cmdConnections) Execute(args []string) error { + if len(args) > 0 { + return ErrExtraArgs + } + + opts := client.ConnectionOptions{ + All: x.All, + } + wanted := string(x.Positionals.Snap) + if wanted != "" { + if x.All { + // passing a snap name already implies --all, error out + // when it was passed explicitly + return fmt.Errorf(i18n.G("cannot use --all with snap name")) + } + // when asking for a single snap, include its disconnected plugs + // and slots + opts.Snap = wanted + opts.All = true + // print all slots + x.All = true + } + + connections, err := x.client.Connections(&opts) + if err != nil { + return err + } + if len(connections.Plugs) == 0 && len(connections.Slots) == 0 { + return nil + } + + annotatedConns := make([]connection, 0, len(connections.Established)+len(connections.Undesired)) + for _, conn := range connections.Established { + annotatedConns = append(annotatedConns, connection{ + plug: endpoint(conn.Plug.Snap, conn.Plug.Name), + slot: endpoint(conn.Slot.Snap, conn.Slot.Name), + manual: conn.Manual, + gadget: conn.Gadget, + interfaceName: conn.Interface, + interfaceDeterminant: interfaceDeterminant(&conn), + }) + } + + w := tabWriter() + fmt.Fprintln(w, i18n.G("Interface\tPlug\tSlot\tNotes")) + + for _, plug := range connections.Plugs { + if len(plug.Connections) == 0 && x.All { + annotatedConns = append(annotatedConns, connection{ + plug: endpoint(plug.Snap, plug.Name), + slot: "-", + interfaceName: plug.Interface, + }) + } + } + for _, slot := range connections.Slots { + if !isSystemSnap(wanted) && isSystemSnap(slot.Snap) { + // displaying unconnected system snap slots is boring, + // unless explicitly asked to show them + continue + } + if len(slot.Connections) == 0 && x.All { + annotatedConns = append(annotatedConns, connection{ + plug: "-", + slot: endpoint(slot.Snap, slot.Name), + interfaceName: slot.Interface, + }) + } + } + + sort.Sort(byConnectionData(annotatedConns)) + + for _, note := range annotatedConns { + fmt.Fprintf(w, "%s%s\t%s\t%s\t%s\n", note.interfaceName, note.interfaceDeterminant, note.plug, note.slot, note) + } + + if len(annotatedConns) > 0 { + w.Flush() + } + return nil +} diff --git a/cmd/snap/cmd_connections_test.go b/cmd/snap/cmd_connections_test.go new file mode 100644 index 00000000..11dcbbd6 --- /dev/null +++ b/cmd/snap/cmd_connections_test.go @@ -0,0 +1,853 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2018 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "fmt" + "io" + "net/http" + "net/url" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/client" + . "github.com/snapcore/snapd/cmd/snap" +) + +func (s *SnapSuite) TestConnectionsNoneConnected(c *C) { + result := client.Connections{} + query := url.Values{} + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + c.Check(r.Method, Equals, "GET") + c.Check(r.URL.Path, Equals, "/v2/connections") + c.Check(r.URL.Query(), DeepEquals, query) + body, err := io.ReadAll(r.Body) + c.Check(err, IsNil) + c.Check(body, DeepEquals, []byte{}) + EncodeResponseBody(c, w, map[string]interface{}{ + "type": "sync", + "result": result, + }) + }) + _, err := Parser(Client()).ParseArgs([]string{"connections"}) + c.Check(err, IsNil) + c.Assert(s.Stdout(), Equals, "") + c.Assert(s.Stderr(), Equals, "") + + s.ResetStdStreams() + + query = url.Values{ + "select": []string{"all"}, + } + _, err = Parser(Client()).ParseArgs([]string{"connections", "--all"}) + c.Check(err, IsNil) + c.Assert(s.Stdout(), Equals, "") + c.Assert(s.Stderr(), Equals, "") +} + +func (s *SnapSuite) TestConnectionsNotInstalled(c *C) { + query := url.Values{ + "snap": []string{"foo"}, + "select": []string{"all"}, + } + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + c.Check(r.Method, Equals, "GET") + c.Check(r.URL.Path, Equals, "/v2/connections") + c.Check(r.URL.Query(), DeepEquals, query) + body, err := io.ReadAll(r.Body) + c.Check(err, IsNil) + c.Check(body, DeepEquals, []byte{}) + fmt.Fprintln(w, `{"type": "error", "result": {"message": "not found", "value": "foo", "kind": "snap-not-found"}, "status-code": 404}`) + }) + _, err := Parser(Client()).ParseArgs([]string{"connections", "foo"}) + c.Check(err, ErrorMatches, `not found`) + c.Assert(s.Stdout(), Equals, "") + c.Assert(s.Stderr(), Equals, "") +} + +func (s *SnapSuite) TestConnectionsNoneConnectedPlugs(c *C) { + query := url.Values{ + "select": []string{"all"}, + } + result := client.Connections{ + Plugs: []client.Plug{ + { + Snap: "keyboard-lights", + Name: "capslock-led", + Interface: "leds", + }, + }, + } + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + c.Check(r.Method, Equals, "GET") + c.Check(r.URL.Path, Equals, "/v2/connections") + c.Check(r.URL.Query(), DeepEquals, query) + body, err := io.ReadAll(r.Body) + c.Check(err, IsNil) + c.Check(body, DeepEquals, []byte{}) + EncodeResponseBody(c, w, map[string]interface{}{ + "type": "sync", + "result": result, + }) + }) + + rest, err := Parser(Client()).ParseArgs([]string{"connections", "--all"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + expectedStdout := "" + + "Interface Plug Slot Notes\n" + + "leds keyboard-lights:capslock-led - -\n" + c.Assert(s.Stdout(), Equals, expectedStdout) + c.Assert(s.Stderr(), Equals, "") + + s.ResetStdStreams() + + query = url.Values{ + "select": []string{"all"}, + "snap": []string{"keyboard-lights"}, + } + + rest, err = Parser(Client()).ParseArgs([]string{"connections", "keyboard-lights"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + expectedStdout = "" + + "Interface Plug Slot Notes\n" + + "leds keyboard-lights:capslock-led - -\n" + c.Assert(s.Stdout(), Equals, expectedStdout) + c.Assert(s.Stderr(), Equals, "") +} + +func (s *SnapSuite) TestConnectionsNoneConnectedSlots(c *C) { + result := client.Connections{} + query := url.Values{} + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + c.Check(r.Method, Equals, "GET") + c.Check(r.URL.Path, Equals, "/v2/connections") + c.Check(r.URL.Query(), DeepEquals, query) + body, err := io.ReadAll(r.Body) + c.Check(err, IsNil) + c.Check(body, DeepEquals, []byte{}) + EncodeResponseBody(c, w, map[string]interface{}{ + "type": "sync", + "result": result, + }) + }) + _, err := Parser(Client()).ParseArgs([]string{"connections"}) + c.Check(err, IsNil) + c.Assert(s.Stdout(), Equals, "") + c.Assert(s.Stderr(), Equals, "") + + s.ResetStdStreams() + + query = url.Values{ + "select": []string{"all"}, + } + result = client.Connections{ + Slots: []client.Slot{ + { + Snap: "leds-provider", + Name: "capslock-led", + Interface: "leds", + }, + }, + } + rest, err := Parser(Client()).ParseArgs([]string{"connections", "--all"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + expectedStdout := "" + + "Interface Plug Slot Notes\n" + + "leds - leds-provider:capslock-led -\n" + c.Assert(s.Stdout(), Equals, expectedStdout) + c.Assert(s.Stderr(), Equals, "") +} + +func (s *SnapSuite) TestConnectionsSomeConnected(c *C) { + result := client.Connections{ + Established: []client.Connection{ + { + Plug: client.PlugRef{Snap: "keyboard-lights", Name: "capslock"}, + Slot: client.SlotRef{Snap: "leds-provider", Name: "capslock-led"}, + Interface: "leds", + Gadget: true, + }, { + Plug: client.PlugRef{Snap: "keyboard-lights", Name: "numlock"}, + Slot: client.SlotRef{Snap: "core", Name: "numlock-led"}, + Interface: "leds", + Manual: true, + }, { + Plug: client.PlugRef{Snap: "keyboard-lights", Name: "scrollock"}, + Slot: client.SlotRef{Snap: "core", Name: "scrollock-led"}, + Interface: "leds", + }, + }, + Plugs: []client.Plug{ + { + Snap: "keyboard-lights", + Name: "capslock", + Interface: "leds", + Connections: []client.SlotRef{{ + Snap: "leds-provider", + Name: "capslock-led", + }}, + }, { + Snap: "keyboard-lights", + Name: "numlock", + Interface: "leds", + Connections: []client.SlotRef{{ + Snap: "core", + Name: "numlock-led", + }}, + }, { + Snap: "keyboard-lights", + Name: "scrollock", + Interface: "leds", + Connections: []client.SlotRef{{ + Snap: "core", + Name: "scrollock-led", + }}, + }, + }, + Slots: []client.Slot{ + { + Snap: "core", + Name: "numlock-led", + Interface: "leds", + Connections: []client.PlugRef{{ + Snap: "keyuboard-lights", + Name: "numlock", + }}, + }, { + Snap: "core", + Name: "scrollock-led", + Interface: "leds", + Connections: []client.PlugRef{{ + Snap: "keyuboard-lights", + Name: "scrollock", + }}, + }, { + Snap: "leds-provider", + Name: "capslock-led", + Interface: "leds", + Connections: []client.PlugRef{{ + Snap: "keyuboard-lights", + Name: "capslock", + }}, + }, + }, + } + query := url.Values{} + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + c.Check(r.Method, Equals, "GET") + c.Check(r.URL.Path, Equals, "/v2/connections") + c.Check(r.URL.Query(), DeepEquals, query) + body, err := io.ReadAll(r.Body) + c.Check(err, IsNil) + c.Check(body, DeepEquals, []byte{}) + EncodeResponseBody(c, w, map[string]interface{}{ + "type": "sync", + "result": result, + }) + }) + rest, err := Parser(Client()).ParseArgs([]string{"connections"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + expectedStdout := "" + + "Interface Plug Slot Notes\n" + + "leds keyboard-lights:capslock leds-provider:capslock-led gadget\n" + + "leds keyboard-lights:numlock :numlock-led manual\n" + + "leds keyboard-lights:scrollock :scrollock-led -\n" + c.Assert(s.Stdout(), Equals, expectedStdout) + c.Assert(s.Stderr(), Equals, "") +} + +func (s *SnapSuite) TestConnectionsSomeDisconnected(c *C) { + result := client.Connections{ + Established: []client.Connection{ + { + Plug: client.PlugRef{Snap: "keyboard-lights", Name: "scrollock"}, + Slot: client.SlotRef{Snap: "core", Name: "scrollock-led"}, + Interface: "leds", + }, { + Plug: client.PlugRef{Snap: "keyboard-lights", Name: "capslock"}, + Slot: client.SlotRef{Snap: "leds-provider", Name: "capslock-led"}, + Interface: "leds", + }, + }, + Undesired: []client.Connection{ + { + Plug: client.PlugRef{Snap: "keyboard-lights", Name: "numlock"}, + Slot: client.SlotRef{Snap: "core", Name: "numlock-led"}, + Interface: "leds", + Manual: true, + }, + }, + Plugs: []client.Plug{ + { + Snap: "keyboard-lights", + Name: "capslock", + Interface: "leds", + Connections: []client.SlotRef{{ + Snap: "leds-provider", + Name: "capslock-led", + }}, + }, { + Snap: "keyboard-lights", + Name: "numlock", + Interface: "leds", + }, { + Snap: "keyboard-lights", + Name: "scrollock", + Interface: "leds", + Connections: []client.SlotRef{{ + Snap: "core", + Name: "scrollock-led", + }}, + }, + }, + Slots: []client.Slot{ + { + Snap: "core", + Name: "capslock-led", + Interface: "leds", + }, { + Snap: "core", + Name: "numlock-led", + Interface: "leds", + }, { + Snap: "core", + Name: "scrollock-led", + Interface: "leds", + Connections: []client.PlugRef{{ + Snap: "keyuboard-lights", + Name: "scrollock", + }}, + }, { + Snap: "leds-provider", + Name: "capslock-led", + Interface: "leds", + Connections: []client.PlugRef{{ + Snap: "keyuboard-lights", + Name: "capslock", + }}, + }, { + Snap: "leds-provider", + Name: "numlock-led", + Interface: "leds", + }, + }, + } + query := url.Values{ + "select": []string{"all"}, + } + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + c.Check(r.Method, Equals, "GET") + c.Check(r.URL.Path, Equals, "/v2/connections") + c.Check(r.URL.Query(), DeepEquals, query) + body, err := io.ReadAll(r.Body) + c.Check(err, IsNil) + c.Check(body, DeepEquals, []byte{}) + EncodeResponseBody(c, w, map[string]interface{}{ + "type": "sync", + "result": result, + }) + }) + + rest, err := Parser(Client()).ParseArgs([]string{"connections", "--all"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + expectedStdout := "" + + "Interface Plug Slot Notes\n" + + "leds - leds-provider:numlock-led -\n" + + "leds keyboard-lights:capslock leds-provider:capslock-led -\n" + + "leds keyboard-lights:numlock - -\n" + + "leds keyboard-lights:scrollock :scrollock-led -\n" + c.Assert(s.Stdout(), Equals, expectedStdout) + c.Assert(s.Stderr(), Equals, "") +} + +func (s *SnapSuite) TestConnectionsOnlyDisconnected(c *C) { + result := client.Connections{ + Undesired: []client.Connection{ + { + Plug: client.PlugRef{Snap: "keyboard-lights", Name: "numlock"}, + Slot: client.SlotRef{Snap: "leds-provider", Name: "numlock-led"}, + Interface: "leds", + Manual: true, + }, + }, + Slots: []client.Slot{ + { + Snap: "leds-provider", + Name: "capslock-led", + Interface: "leds", + }, { + Snap: "leds-provider", + Name: "numlock-led", + Interface: "leds", + }, + }, + } + query := url.Values{ + "snap": []string{"leds-provider"}, + "select": []string{"all"}, + } + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + c.Check(r.Method, Equals, "GET") + c.Check(r.URL.Path, Equals, "/v2/connections") + c.Check(r.URL.Query(), DeepEquals, query) + body, err := io.ReadAll(r.Body) + c.Check(err, IsNil) + c.Check(body, DeepEquals, []byte{}) + EncodeResponseBody(c, w, map[string]interface{}{ + "type": "sync", + "result": result, + }) + }) + + rest, err := Parser(Client()).ParseArgs([]string{"connections", "leds-provider"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + expectedStdout := "" + + "Interface Plug Slot Notes\n" + + "leds - leds-provider:capslock-led -\n" + + "leds - leds-provider:numlock-led -\n" + c.Assert(s.Stdout(), Equals, expectedStdout) + c.Assert(s.Stderr(), Equals, "") +} + +func (s *SnapSuite) TestConnectionsFiltering(c *C) { + result := client.Connections{} + query := url.Values{ + "select": []string{"all"}, + } + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + c.Check(r.Method, Equals, "GET") + c.Check(r.URL.Path, Equals, "/v2/connections") + c.Check(r.URL.Query(), DeepEquals, query) + body, err := io.ReadAll(r.Body) + c.Check(err, IsNil) + c.Check(body, DeepEquals, []byte{}) + EncodeResponseBody(c, w, map[string]interface{}{ + "type": "sync", + "result": result, + }) + }) + + query = url.Values{ + "select": []string{"all"}, + "snap": []string{"mouse-buttons"}, + } + rest, err := Parser(Client()).ParseArgs([]string{"connections", "mouse-buttons"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + + rest, err = Parser(Client()).ParseArgs([]string{"connections", "mouse-buttons", "--all"}) + c.Assert(err, ErrorMatches, "cannot use --all with snap name") + c.Assert(rest, DeepEquals, []string{"--all"}) +} + +func (s *SnapSuite) TestConnectionsSorting(c *C) { + result := client.Connections{ + Established: []client.Connection{ + { + Plug: client.PlugRef{Snap: "foo", Name: "plug"}, + Slot: client.SlotRef{Snap: "a-content-provider", Name: "data"}, + Interface: "content", + }, { + Plug: client.PlugRef{Snap: "foo", Name: "plug"}, + Slot: client.SlotRef{Snap: "b-content-provider", Name: "data"}, + Interface: "content", + }, { + Plug: client.PlugRef{Snap: "foo", Name: "desktop-plug"}, + Slot: client.SlotRef{Snap: "core", Name: "desktop"}, + Interface: "desktop", + }, { + Plug: client.PlugRef{Snap: "foo", Name: "x11-plug"}, + Slot: client.SlotRef{Snap: "core", Name: "x11"}, + Interface: "x11", + }, { + Plug: client.PlugRef{Snap: "foo", Name: "a-x11-plug"}, + Slot: client.SlotRef{Snap: "core", Name: "x11"}, + Interface: "x11", + }, { + Plug: client.PlugRef{Snap: "a-foo", Name: "plug"}, + Slot: client.SlotRef{Snap: "a-content-provider", Name: "data"}, + Interface: "content", + }, { + Plug: client.PlugRef{Snap: "keyboard-app", Name: "x11"}, + Slot: client.SlotRef{Snap: "core", Name: "x11"}, + Interface: "x11", + Manual: true, + }, + }, + Undesired: []client.Connection{ + { + Plug: client.PlugRef{Snap: "foo", Name: "plug"}, + Slot: client.SlotRef{Snap: "c-content-provider", Name: "data"}, + Interface: "content", + Manual: true, + }, + }, + Plugs: []client.Plug{ + { + Snap: "foo", + Name: "plug", + Interface: "content", + Connections: []client.SlotRef{{ + Snap: "a-content-provider", + Name: "data", + }, { + Snap: "b-content-provider", + Name: "data", + }}, + }, { + Snap: "foo", + Name: "desktop-plug", + Interface: "desktop", + Connections: []client.SlotRef{{ + Snap: "core", + Name: "desktop", + }}, + }, { + Snap: "foo", + Name: "x11-plug", + Interface: "x11", + Connections: []client.SlotRef{{ + Snap: "core", + Name: "x11", + }}, + }, { + Snap: "foo", + Name: "a-x11-plug", + Interface: "x11", + Connections: []client.SlotRef{{ + Snap: "core", + Name: "x11", + }}, + }, { + Snap: "a-foo", + Name: "plug", + Interface: "content", + Connections: []client.SlotRef{{ + Snap: "a-content-provider", + Name: "data", + }}, + }, { + Snap: "keyboard-app", + Name: "x11", + Interface: "x11", + Connections: []client.SlotRef{{ + Snap: "core", + Name: "x11", + }}, + }, { + Snap: "keyboard-lights", + Name: "numlock", + Interface: "leds", + }, + }, + Slots: []client.Slot{ + { + Snap: "c-content-provider", + Name: "data", + Interface: "content", + }, { + Snap: "a-content-provider", + Name: "data", + Interface: "content", + Connections: []client.PlugRef{{ + Snap: "foo", + Name: "plug", + }, { + Snap: "a-foo", + Name: "plug", + }}, + }, { + Snap: "b-content-provider", + Name: "data", + Interface: "content", + Connections: []client.PlugRef{{ + Snap: "foo", + Name: "plug", + }}, + }, { + Snap: "core", + Name: "x11", + Interface: "x11", + Connections: []client.PlugRef{{ + Snap: "foo", + Name: "a-x11-plug", + }, { + Snap: "foo", + Name: "x11-plug", + }, { + Snap: "keyboard-app", + Name: "x11", + }}, + }, { + Snap: "leds-provider", + Name: "numlock-led", + Interface: "leds", + }, + }, + } + query := url.Values{ + "select": []string{"all"}, + } + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + c.Check(r.Method, Equals, "GET") + c.Check(r.URL.Path, Equals, "/v2/connections") + c.Check(r.URL.Query(), DeepEquals, query) + body, err := io.ReadAll(r.Body) + c.Check(err, IsNil) + c.Check(body, DeepEquals, []byte{}) + EncodeResponseBody(c, w, map[string]interface{}{ + "type": "sync", + "result": result, + }) + }) + + rest, err := Parser(Client()).ParseArgs([]string{"connections", "--all"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + expectedStdout := "" + + "Interface Plug Slot Notes\n" + + "content - c-content-provider:data -\n" + + "content a-foo:plug a-content-provider:data -\n" + + "content foo:plug a-content-provider:data -\n" + + "content foo:plug b-content-provider:data -\n" + + "desktop foo:desktop-plug :desktop -\n" + + "leds - leds-provider:numlock-led -\n" + + "leds keyboard-lights:numlock - -\n" + + "x11 foo:a-x11-plug :x11 -\n" + + "x11 foo:x11-plug :x11 -\n" + + "x11 keyboard-app:x11 :x11 manual\n" + c.Assert(s.Stdout(), Equals, expectedStdout) + c.Assert(s.Stderr(), Equals, "") +} + +func (s *SnapSuite) TestConnectionsDefiningAttribute(c *C) { + result := client.Connections{ + Established: []client.Connection{ + { + Plug: client.PlugRef{Snap: "foo", Name: "a-plug"}, + Slot: client.SlotRef{Snap: "a-content-provider", Name: "data"}, + Interface: "content", + PlugAttrs: map[string]interface{}{ + "content": "plug-some-data", + "target": "$SNAP/foo", + }, + SlotAttrs: map[string]interface{}{ + "content": "slot-some-data", + "source": map[string]interface{}{ + "read": []string{"$SNAP/bar"}, + }, + }, + }, { + Plug: client.PlugRef{Snap: "foo", Name: "b-plug"}, + Slot: client.SlotRef{Snap: "b-content-provider", Name: "data"}, + Interface: "content", + PlugAttrs: map[string]interface{}{ + // no content attribute for plug, falls back to slot + "target": "$SNAP/foo", + }, + SlotAttrs: map[string]interface{}{ + "content": "slot-some-data", + "source": map[string]interface{}{ + "read": []string{"$SNAP/bar"}, + }, + }, + }, { + Plug: client.PlugRef{Snap: "foo", Name: "c-plug"}, + Slot: client.SlotRef{Snap: "c-content-provider", Name: "data"}, + Interface: "content", + PlugAttrs: map[string]interface{}{ + // no content attribute for plug + "target": "$SNAP/foo", + }, + SlotAttrs: map[string]interface{}{ + // no content attribute for slot either + "source": map[string]interface{}{ + "read": []string{"$SNAP/bar"}, + }, + }, + }, { + Plug: client.PlugRef{Snap: "foo", Name: "d-plug"}, + Slot: client.SlotRef{Snap: "d-content-provider", Name: "data"}, + Interface: "content", + // no attributes at all + }, { + Plug: client.PlugRef{Snap: "foo", Name: "desktop-plug"}, + Slot: client.SlotRef{Snap: "core", Name: "desktop"}, + // desktop interface does not have any defining attributes + Interface: "desktop", + PlugAttrs: map[string]interface{}{ + "this-is-ignored": "foo", + }, + SlotAttrs: map[string]interface{}{ + "this-is-ignored-too": "foo", + }, + }, + }, + Plugs: []client.Plug{ + { + Snap: "foo", + Name: "a-plug", + Interface: "content", + Connections: []client.SlotRef{{ + Snap: "a-content-provider", + Name: "data", + }}, + Attrs: map[string]interface{}{ + "content": "plug-some-data", + "target": "$SNAP/foo", + }, + }, { + Snap: "foo", + Name: "b-plug", + Interface: "content", + Connections: []client.SlotRef{{ + Snap: "b-content-provider", + Name: "data", + }}, + Attrs: map[string]interface{}{ + // no content attribute for plug, falls back to slot + "target": "$SNAP/foo", + }, + }, { + Snap: "foo", + Name: "c-plug", + Interface: "content", + Connections: []client.SlotRef{{ + Snap: "c-content-provider", + Name: "data", + }}, + Attrs: map[string]interface{}{ + // no content attribute for plug + "target": "$SNAP/foo", + }, + }, { + Snap: "foo", + Name: "d-plug", + Interface: "content", + Connections: []client.SlotRef{{ + Snap: "d-content-provider", + Name: "data", + }}, + }, { + Snap: "foo", + Name: "desktop-plug", + Interface: "desktop", + Connections: []client.SlotRef{{ + Snap: "core", + Name: "desktop", + }}, + }, + }, + Slots: []client.Slot{ + { + Snap: "a-content-provider", + Name: "data", + Interface: "content", + Connections: []client.PlugRef{{ + Snap: "foo", + Name: "a-plug", + }}, + Attrs: map[string]interface{}{ + "content": "slot-some-data", + "source": map[string]interface{}{ + "read": []string{"$SNAP/bar"}, + }, + }, + }, { + Snap: "b-content-provider", + Name: "data", + Interface: "content", + Connections: []client.PlugRef{{ + Snap: "foo", + Name: "a-plug", + }}, + Attrs: map[string]interface{}{ + "content": "slot-some-data", + "source": map[string]interface{}{ + "read": []string{"$SNAP/bar"}, + }, + }, + }, { + Snap: "c-content-provider", + Name: "data", + Interface: "content", + Connections: []client.PlugRef{{ + Snap: "foo", + Name: "a-plug", + }}, + Attrs: map[string]interface{}{ + "source": map[string]interface{}{ + "read": []string{"$SNAP/bar"}, + }, + }, + }, { + Snap: "a-content-provider", + Name: "data", + Interface: "content", + Connections: []client.PlugRef{{ + Snap: "foo", + Name: "a-plug", + }}, + }, { + Snap: "core", + Name: "desktop", + Interface: "desktop", + Connections: []client.PlugRef{{ + Snap: "foo", + Name: "desktop-plug", + }}, + }, + }, + } + query := url.Values{ + "select": []string{"all"}, + } + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + c.Check(r.Method, Equals, "GET") + c.Check(r.URL.Path, Equals, "/v2/connections") + c.Check(r.URL.Query(), DeepEquals, query) + body, err := io.ReadAll(r.Body) + c.Check(err, IsNil) + c.Check(body, DeepEquals, []byte{}) + EncodeResponseBody(c, w, map[string]interface{}{ + "type": "sync", + "result": result, + }) + }) + + rest, err := Parser(Client()).ParseArgs([]string{"connections", "--all"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + expectedStdout := "" + + "Interface Plug Slot Notes\n" + + "content[plug-some-data] foo:a-plug a-content-provider:data -\n" + + "content[slot-some-data] foo:b-plug b-content-provider:data -\n" + + "content foo:c-plug c-content-provider:data -\n" + + "content foo:d-plug d-content-provider:data -\n" + + "desktop foo:desktop-plug :desktop -\n" + c.Assert(s.Stdout(), Equals, expectedStdout) + c.Assert(s.Stderr(), Equals, "") +} diff --git a/cmd/snap/cmd_connectivity_check.go b/cmd/snap/cmd_connectivity_check.go new file mode 100644 index 00000000..b7e94985 --- /dev/null +++ b/cmd/snap/cmd_connectivity_check.go @@ -0,0 +1,63 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2018 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "fmt" + + "github.com/jessevdk/go-flags" +) + +type cmdConnectivityCheck struct { + clientMixin +} + +func init() { + addDebugCommand("connectivity", + "Check network connectivity status", + "The connectivity command checks the network connectivity of snapd.", + func() flags.Commander { + return &cmdConnectivityCheck{} + }, nil, nil) +} + +func (x *cmdConnectivityCheck) Execute(args []string) error { + if len(args) > 0 { + return ErrExtraArgs + } + + var status struct { + Unreachable []string + } + if err := x.client.DebugGet("connectivity", &status, nil); err != nil { + return err + } + + fmt.Fprintf(Stdout, "Connectivity status:\n") + if len(status.Unreachable) == 0 { + fmt.Fprintf(Stdout, " * PASS\n") + return nil + } + + for _, uri := range status.Unreachable { + fmt.Fprintf(Stdout, " * %s: unreachable\n", uri) + } + return fmt.Errorf("%v servers unreachable", len(status.Unreachable)) +} diff --git a/cmd/snap/cmd_connectivity_check_test.go b/cmd/snap/cmd_connectivity_check_test.go new file mode 100644 index 00000000..d775ceea --- /dev/null +++ b/cmd/snap/cmd_connectivity_check_test.go @@ -0,0 +1,84 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2018 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "fmt" + "io" + "net/http" + + "gopkg.in/check.v1" + + snap "github.com/snapcore/snapd/cmd/snap" +) + +func (s *SnapSuite) TestConnectivityHappy(c *check.C) { + n := 0 + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch n { + case 0: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/debug") + c.Check(r.URL.RawQuery, check.Equals, "aspect=connectivity") + data, err := io.ReadAll(r.Body) + c.Check(err, check.IsNil) + c.Check(data, check.HasLen, 0) + fmt.Fprintln(w, `{"type": "sync", "result": {}}`) + default: + c.Fatalf("expected to get 1 requests, now on %d", n+1) + } + + n++ + }) + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"debug", "connectivity"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + c.Check(s.Stdout(), check.Equals, `Connectivity status: + * PASS +`) + c.Check(s.Stderr(), check.Equals, "") +} + +func (s *SnapSuite) TestConnectivityUnhappy(c *check.C) { + n := 0 + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch n { + case 0: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/debug") + c.Check(r.URL.RawQuery, check.Equals, "aspect=connectivity") + data, err := io.ReadAll(r.Body) + c.Check(err, check.IsNil) + c.Check(data, check.HasLen, 0) + fmt.Fprintln(w, `{"type": "sync", "result": {"connectivity":false,"unreachable":["foo.bar.com"]}}`) + default: + c.Fatalf("expected to get 1 requests, now on %d", n+1) + } + + n++ + }) + _, err := snap.Parser(snap.Client()).ParseArgs([]string{"debug", "connectivity"}) + c.Assert(err, check.ErrorMatches, "1 servers unreachable") + // note that only the unreachable hosts are displayed + c.Check(s.Stdout(), check.Equals, `Connectivity status: + * foo.bar.com: unreachable +`) + c.Check(s.Stderr(), check.Equals, "") +} diff --git a/cmd/snap/cmd_create_cohort.go b/cmd/snap/cmd_create_cohort.go new file mode 100644 index 00000000..8779ef93 --- /dev/null +++ b/cmd/snap/cmd_create_cohort.go @@ -0,0 +1,85 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "github.com/jessevdk/go-flags" + "gopkg.in/yaml.v2" + + "github.com/snapcore/snapd/i18n" +) + +var shortCreateCohortHelp = i18n.G("Create cohort keys for a set of snaps") +var longCreateCohortHelp = i18n.G(` +The create-cohort command creates a set of cohort keys for a given set of snaps. + +A cohort is a view or snapshot of a snap's "channel map" at a given point in +time that fixes the set of revisions for the snap given other constraints +(e.g. channel or architecture). The cohort is then identified by an opaque +per-snap key that works across systems. Installations or refreshes of the snap +using a given cohort key would use a fixed revision for up to 90 days, after +which a new set of revisions would be fixed under that same cohort key and a +new 90 days window started. +`) + +type cmdCreateCohort struct { + clientMixin + Positional struct { + Snaps []anySnapName `positional-arg-name:"" required:"1"` + } `positional-args:"yes" required:"yes"` +} + +func init() { + addCommand("create-cohort", shortCreateCohortHelp, longCreateCohortHelp, func() flags.Commander { return &cmdCreateCohort{} }, nil, nil) +} + +// output should be YAML, so we use these two as helpers to get that done easy +type cohortInnerYAML struct { + CohortKey string `yaml:"cohort-key"` +} +type cohortOutYAML struct { + Cohorts map[string]cohortInnerYAML `yaml:"cohorts"` +} + +func (x *cmdCreateCohort) Execute(args []string) error { + if len(args) > 0 { + return ErrExtraArgs + } + + snaps := make([]string, len(x.Positional.Snaps)) + for i, s := range x.Positional.Snaps { + snaps[i] = string(s) + } + + cohorts, err := x.client.CreateCohorts(snaps) + if len(cohorts) == 0 || err != nil { + return err + } + + var out cohortOutYAML + out.Cohorts = make(map[string]cohortInnerYAML, len(cohorts)) + for k, v := range cohorts { + out.Cohorts[k] = cohortInnerYAML{v} + } + + enc := yaml.NewEncoder(Stdout) + defer enc.Close() + return enc.Encode(out) +} diff --git a/cmd/snap/cmd_create_cohort_test.go b/cmd/snap/cmd_create_cohort_test.go new file mode 100644 index 00000000..cf3f7623 --- /dev/null +++ b/cmd/snap/cmd_create_cohort_test.go @@ -0,0 +1,87 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "fmt" + "net/http" + + "gopkg.in/check.v1" + "gopkg.in/yaml.v2" + + snap "github.com/snapcore/snapd/cmd/snap" +) + +func (s *SnapSuite) TestCreateCohort(c *check.C) { + n := 0 + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + n++ + fmt.Fprintln(w, `{ +"type": "sync", +"status-code": 200, +"status": "OK", +"result": {"foo": "what", "bar": "this"}}`) + + }) + + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"create-cohort", "foo", "bar"}) + c.Assert(err, check.IsNil) + c.Check(rest, check.HasLen, 0) + + var v map[string]map[string]map[string]string + c.Assert(yaml.Unmarshal(s.stdout.Bytes(), &v), check.IsNil) + c.Check(v, check.DeepEquals, map[string]map[string]map[string]string{ + "cohorts": { + "foo": {"cohort-key": "what"}, + "bar": {"cohort-key": "this"}, + }, + }) + c.Check(n, check.Equals, 1) +} + +func (s *SnapSuite) TestCreateCohortNoSnaps(c *check.C) { + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + panic("shouldn't be called") + }) + _, err := snap.Parser(snap.Client()).ParseArgs([]string{"create-cohort"}) + c.Check(err, check.ErrorMatches, "the required argument .* was not provided") +} + +func (s *SnapSuite) TestCreateCohortNotFound(c *check.C) { + n := 0 + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + n++ + fmt.Fprintln(w, `{"type": "error", "result": {"message": "snap not found", "kind": "snap-not-found"}, "status-code": 404}`) + }) + _, err := snap.Parser(snap.Client()).ParseArgs([]string{"create-cohort", "foo", "bar"}) + c.Check(err, check.ErrorMatches, "cannot create cohorts: snap not found") + c.Check(n, check.Equals, 1) +} + +func (s *SnapSuite) TestCreateCohortError(c *check.C) { + n := 0 + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + n++ + fmt.Fprintln(w, `{"type": "error", "result": {"message": "something went wrong"}}`) + }) + _, err := snap.Parser(snap.Client()).ParseArgs([]string{"create-cohort", "foo", "bar"}) + c.Check(err, check.ErrorMatches, "cannot create cohorts: something went wrong") + c.Check(n, check.Equals, 1) +} diff --git a/cmd/snap/cmd_create_key.go b/cmd/snap/cmd_create_key.go new file mode 100644 index 00000000..ae86920b --- /dev/null +++ b/cmd/snap/cmd_create_key.go @@ -0,0 +1,75 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "fmt" + + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/asserts/signtool" + "github.com/snapcore/snapd/i18n" +) + +type cmdCreateKey struct { + Positional struct { + KeyName string + } `positional-args:"true"` +} + +func init() { + cmd := addCommand("create-key", + i18n.G("Create cryptographic key pair"), + i18n.G(` +The create-key command creates a cryptographic key pair that can be +used for signing assertions. +`), + func() flags.Commander { + return &cmdCreateKey{} + }, nil, []argDesc{{ + // TRANSLATORS: This needs to begin with < and end with > + name: i18n.G(""), + // TRANSLATORS: This should not start with a lowercase letter. + desc: i18n.G("Name of key to create; defaults to 'default'"), + }}) + cmd.hidden = true + cmd.completeHidden = true +} + +func (x *cmdCreateKey) Execute(args []string) error { + if len(args) > 0 { + return ErrExtraArgs + } + + keyName := x.Positional.KeyName + if keyName == "" { + keyName = "default" + } + if !asserts.IsValidAccountKeyName(keyName) { + return fmt.Errorf(i18n.G("key name %q is not valid; only ASCII letters, digits, and hyphens are allowed"), keyName) + } + + keypairMgr, err := signtool.GetKeypairManager() + if err != nil { + return err + } + return signtool.GenerateKey(keypairMgr, keyName) +} diff --git a/cmd/snap/cmd_create_key_test.go b/cmd/snap/cmd_create_key_test.go new file mode 100644 index 00000000..c4484577 --- /dev/null +++ b/cmd/snap/cmd_create_key_test.go @@ -0,0 +1,34 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + . "gopkg.in/check.v1" + + snap "github.com/snapcore/snapd/cmd/snap" +) + +func (s *SnapSuite) TestCreateKeyInvalidCharacters(c *C) { + _, err := snap.Parser(snap.Client()).ParseArgs([]string{"create-key", "a b"}) + c.Assert(err, NotNil) + c.Check(err.Error(), Equals, "key name \"a b\" is not valid; only ASCII letters, digits, and hyphens are allowed") + c.Check(s.Stdout(), Equals, "") + c.Check(s.Stderr(), Equals, "") +} diff --git a/cmd/snap/cmd_create_user.go b/cmd/snap/cmd_create_user.go new file mode 100644 index 00000000..9ae33185 --- /dev/null +++ b/cmd/snap/cmd_create_user.go @@ -0,0 +1,118 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "encoding/json" + "fmt" + + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/client" + "github.com/snapcore/snapd/i18n" +) + +var shortCreateUserHelp = i18n.G("Create a local system user") +var longCreateUserHelp = i18n.G(` +The create-user command creates a local system user with the username and SSH +keys registered on the store account identified by the provided email address. + +An account can be setup at https://login.ubuntu.com. +`) + +type cmdCreateUser struct { + clientMixin + Positional struct { + Email string + } `positional-args:"yes"` + + JSON bool `long:"json"` + Sudoer bool `long:"sudoer"` + Known bool `long:"known"` + ForceManaged bool `long:"force-managed"` +} + +func init() { + cmd := addCommand("create-user", shortCreateUserHelp, longCreateUserHelp, func() flags.Commander { return &cmdCreateUser{} }, + map[string]string{ + // TRANSLATORS: This should not start with a lowercase letter. + "json": i18n.G("Output results in JSON format"), + // TRANSLATORS: This should not start with a lowercase letter. + "sudoer": i18n.G("Grant sudo access to the created user"), + // TRANSLATORS: This should not start with a lowercase letter. + "known": i18n.G("Use known assertions for user creation"), + // TRANSLATORS: This should not start with a lowercase letter. + "force-managed": i18n.G("Force adding the user, even if the device is already managed"), + }, []argDesc{{ + // TRANSLATORS: This is a noun and it needs to begin with < and end with > + name: i18n.G(""), + // TRANSLATORS: This should not start with a lowercase letter (unless it's "login.ubuntu.com"). Also, note users on login.ubuntu.com can have multiple email addresses. + desc: i18n.G("An email of a user on login.ubuntu.com"), + }}) + cmd.hidden = true +} + +func (x *cmdCreateUser) Execute(args []string) error { + if len(args) > 0 { + return ErrExtraArgs + } + + options := client.CreateUserOptions{ + Email: x.Positional.Email, + Sudoer: x.Sudoer, + Known: x.Known, + ForceManaged: x.ForceManaged, + } + + var results []*client.CreateUserResult + var result *client.CreateUserResult + var err error + + if options.Email == "" && options.Known { + results, err = x.client.CreateUsers([]*client.CreateUserOptions{&options}) + } else { + result, err = x.client.CreateUser(&options) + if err == nil { + results = append(results, result) + } + } + + createErr := err + + // Print results regardless of error because some users may have been created. + if x.JSON { + var data []byte + if result != nil { + data, err = json.Marshal(result) + } else if len(results) > 0 { + data, err = json.Marshal(results) + } + if err != nil { + return err + } + fmt.Fprintf(Stdout, "%s\n", data) + } else { + for _, result := range results { + fmt.Fprintf(Stdout, i18n.G("created user %q\n"), result.Username) + } + } + + return createErr +} diff --git a/cmd/snap/cmd_create_user_test.go b/cmd/snap/cmd_create_user_test.go new file mode 100644 index 00000000..f0182b1a --- /dev/null +++ b/cmd/snap/cmd_create_user_test.go @@ -0,0 +1,152 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "encoding/json" + "fmt" + "net/http" + + "gopkg.in/check.v1" + + "github.com/snapcore/snapd/client" + snap "github.com/snapcore/snapd/cmd/snap" +) + +func makeCreateUserChecker(c *check.C, n *int, email string, sudoer, known bool) func(w http.ResponseWriter, r *http.Request) { + f := func(w http.ResponseWriter, r *http.Request) { + switch *n { + case 0: + c.Check(r.Method, check.Equals, "POST") + c.Check(r.URL.Path, check.Equals, "/v2/users") + var gotBody map[string]interface{} + dec := json.NewDecoder(r.Body) + err := dec.Decode(&gotBody) + c.Assert(err, check.IsNil) + + wantBody := map[string]interface{}{ + "action": "create", + } + if email != "" { + wantBody["email"] = "one@email.com" + } + if sudoer { + wantBody["sudoer"] = true + } + if known { + wantBody["known"] = true + } + c.Check(gotBody, check.DeepEquals, wantBody) + + if email == "" { + fmt.Fprintln(w, `{"type": "sync", "result": [{"username": "karl", "ssh-keys": ["a","b"]}]}`) + } else { + fmt.Fprintln(w, `{"type": "sync", "result": [{"username": "karl", "ssh-keys": ["a","b"]}]}`) + } + default: + c.Fatalf("got too many requests (now on %d)", *n+1) + } + + *n++ + } + return f +} + +func (s *SnapSuite) TestCreateUserNoSudoer(c *check.C) { + n := 0 + s.RedirectClientToTestServer(makeCreateUserChecker(c, &n, "one@email.com", false, false)) + + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"create-user", "one@email.com"}) + c.Assert(err, check.IsNil) + c.Check(rest, check.DeepEquals, []string{}) + c.Check(n, check.Equals, 1) + c.Assert(s.Stdout(), check.Equals, `created user "karl"`+"\n") + c.Assert(s.Stderr(), check.Equals, "") +} + +func (s *SnapSuite) TestCreateUserSudoer(c *check.C) { + n := 0 + s.RedirectClientToTestServer(makeCreateUserChecker(c, &n, "one@email.com", true, false)) + + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"create-user", "--sudoer", "one@email.com"}) + c.Assert(err, check.IsNil) + c.Check(rest, check.DeepEquals, []string{}) + c.Check(n, check.Equals, 1) + c.Assert(s.Stdout(), check.Equals, `created user "karl"`+"\n") + c.Assert(s.Stderr(), check.Equals, "") +} + +func (s *SnapSuite) TestCreateUserJSON(c *check.C) { + n := 0 + s.RedirectClientToTestServer(makeCreateUserChecker(c, &n, "one@email.com", false, false)) + + expectedResponse := &client.CreateUserResult{ + Username: "karl", + SSHKeys: []string{"a", "b"}, + } + actualResponse := &client.CreateUserResult{} + + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"create-user", "--json", "one@email.com"}) + c.Assert(err, check.IsNil) + c.Check(rest, check.DeepEquals, []string{}) + c.Check(n, check.Equals, 1) + json.Unmarshal(s.stdout.Bytes(), actualResponse) + c.Assert(actualResponse, check.DeepEquals, expectedResponse) + c.Assert(s.Stderr(), check.Equals, "") +} + +func (s *SnapSuite) TestCreateUserNoEmailJSON(c *check.C) { + n := 0 + s.RedirectClientToTestServer(makeCreateUserChecker(c, &n, "", false, true)) + + var expectedResponse = []*client.CreateUserResult{{ + Username: "karl", + SSHKeys: []string{"a", "b"}, + }} + var actualResponse []*client.CreateUserResult + + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"create-user", "--json", "--known"}) + c.Assert(err, check.IsNil) + c.Check(rest, check.DeepEquals, []string{}) + c.Check(n, check.Equals, 1) + json.Unmarshal(s.stdout.Bytes(), &actualResponse) + c.Assert(actualResponse, check.DeepEquals, expectedResponse) + c.Assert(s.Stderr(), check.Equals, "") +} + +func (s *SnapSuite) TestCreateUserKnown(c *check.C) { + n := 0 + s.RedirectClientToTestServer(makeCreateUserChecker(c, &n, "one@email.com", false, true)) + + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"create-user", "--known", "one@email.com"}) + c.Assert(err, check.IsNil) + c.Check(rest, check.DeepEquals, []string{}) + c.Check(n, check.Equals, 1) +} + +func (s *SnapSuite) TestCreateUserKnownNoEmail(c *check.C) { + n := 0 + s.RedirectClientToTestServer(makeCreateUserChecker(c, &n, "", false, true)) + + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"create-user", "--known"}) + c.Assert(err, check.IsNil) + c.Check(rest, check.DeepEquals, []string{}) + c.Check(n, check.Equals, 1) +} diff --git a/cmd/snap/cmd_debug.go b/cmd/snap/cmd_debug.go new file mode 100644 index 00000000..f7e11b27 --- /dev/null +++ b/cmd/snap/cmd_debug.go @@ -0,0 +1,34 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "github.com/snapcore/snapd/i18n" +) + +type cmdDebug struct{} + +var shortDebugHelp = i18n.G("Run debug commands") +var longDebugHelp = i18n.G(` +The debug command contains a selection of additional sub-commands. + +Debug commands can be removed without notice and may not work on +non-development systems. +`) diff --git a/cmd/snap/cmd_debug_bootvars.go b/cmd/snap/cmd_debug_bootvars.go new file mode 100644 index 00000000..ea59dfe3 --- /dev/null +++ b/cmd/snap/cmd_debug_bootvars.go @@ -0,0 +1,84 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "errors" + + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/boot" + "github.com/snapcore/snapd/i18n" + "github.com/snapcore/snapd/release" +) + +type cmdBootvarsGet struct { + UC20 bool `long:"uc20"` + RootDir string `long:"root-dir"` +} + +type cmdBootvarsSet struct { + RootDir string `long:"root-dir"` + Recovery bool `long:"recovery"` + Positional struct { + VarEqValue []string `positional-arg-name:"" required:"1"` + } `positional-args:"yes" required:"yes"` +} + +func init() { + cmdGet := addDebugCommand("boot-vars", + "(internal) obtain the snapd boot variables", + "(internal) obtain the snapd boot variables", + func() flags.Commander { + return &cmdBootvarsGet{} + }, map[string]string{ + "uc20": i18n.G("Whether to use UC20+ boot vars or not"), + "root-dir": i18n.G("Root directory to look for boot variables in"), + }, nil) + + cmdSet := addDebugCommand("set-boot-vars", + "(internal) set snapd boot variables", + "(internal) set snapd boot variables", + func() flags.Commander { + return &cmdBootvarsSet{} + }, map[string]string{ + "root-dir": i18n.G("Root directory to look for boot variables in (implies UC20+)"), + "recovery": i18n.G("Manipulate the recovery bootloader (implies UC20+)"), + }, nil) + + if release.OnClassic { + cmdGet.hidden = true + cmdSet.hidden = true + } +} + +func (x *cmdBootvarsGet) Execute(args []string) error { + if release.OnClassic { + return errors.New(`the "boot-vars" command is not available on classic systems`) + } + return boot.DebugDumpBootVars(Stdout, x.RootDir, x.UC20) +} + +func (x *cmdBootvarsSet) Execute(args []string) error { + if release.OnClassic { + return errors.New(`the "boot-vars" command is not available on classic systems`) + } + return boot.DebugSetBootVars(x.RootDir, x.Recovery, x.Positional.VarEqValue) +} diff --git a/cmd/snap/cmd_debug_bootvars_test.go b/cmd/snap/cmd_debug_bootvars_test.go new file mode 100644 index 00000000..ee321f03 --- /dev/null +++ b/cmd/snap/cmd_debug_bootvars_test.go @@ -0,0 +1,157 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "gopkg.in/check.v1" + + "github.com/snapcore/snapd/boot" + "github.com/snapcore/snapd/bootloader" + "github.com/snapcore/snapd/bootloader/bootloadertest" + snap "github.com/snapcore/snapd/cmd/snap" + "github.com/snapcore/snapd/release" +) + +func (s *SnapSuite) TestDebugBootvars(c *check.C) { + restore := release.MockOnClassic(false) + defer restore() + bloader := bootloadertest.Mock("mock", c.MkDir()) + bootloader.Force(bloader) + err := bloader.SetBootVars(map[string]string{ + "snap_mode": "try", + "unrelated": "thing", + "snap_core": "core18_1.snap", + "snap_try_core": "core18_2.snap", + "snap_kernel": "pc-kernel_3.snap", + "snap_try_kernel": "pc-kernel_4.snap", + }) + c.Assert(err, check.IsNil) + + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"debug", "boot-vars"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.HasLen, 0) + c.Check(s.Stdout(), check.Equals, `snap_mode=try +snap_core=core18_1.snap +snap_try_core=core18_2.snap +snap_kernel=pc-kernel_3.snap +snap_try_kernel=pc-kernel_4.snap +`) + c.Check(s.Stderr(), check.Equals, "") +} + +func (s *SnapSuite) TestDebugBootvarsNotOnClassic(c *check.C) { + restore := release.MockOnClassic(true) + defer restore() + _, err := snap.Parser(snap.Client()).ParseArgs([]string{"debug", "boot-vars"}) + c.Assert(err, check.ErrorMatches, `the "boot-vars" command is not available on classic systems`) +} + +func (s *SnapSuite) TestDebugSetBootvars(c *check.C) { + restore := release.MockOnClassic(false) + defer restore() + bloader := bootloadertest.Mock("mock", c.MkDir()) + bootloader.Force(bloader) + err := bloader.SetBootVars(map[string]string{ + "snap_mode": "try", + "unrelated": "thing", + "snap_core": "core18_1.snap", + "snap_try_core": "core18_2.snap", + "snap_kernel": "pc-kernel_3.snap", + "snap_try_kernel": "", + }) + c.Assert(err, check.IsNil) + + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"debug", "set-boot-vars", + "snap_mode=trying", "try_recovery_system=1234", "unrelated="}) + c.Assert(err, check.IsNil) + c.Check(rest, check.HasLen, 0) + c.Check(bloader.BootVars, check.DeepEquals, map[string]string{ + "snap_mode": "trying", + "unrelated": "", + "snap_core": "core18_1.snap", + "snap_try_core": "core18_2.snap", + "snap_kernel": "pc-kernel_3.snap", + "snap_try_kernel": "", + "try_recovery_system": "1234", + }) +} + +func (s *SnapSuite) TestDebugGetSetBootvarsWithParams(c *check.C) { + // the bootloader options are not intercepted by the mocks, so we can + // only observe the effect indirectly for boot-vars + + restore := release.MockOnClassic(false) + defer restore() + bloader := bootloadertest.Mock("mock", c.MkDir()) + bootloader.Force(bloader) + err := bloader.SetBootVars(map[string]string{ + "snapd_recovery_system": "1234", + "snapd_recovery_mode": "run", + "unrelated": "thing", + "snap_kernel": "pc-kernel_3.snap", + "recovery_system_status": "try", + "try_recovery_system": "9999", + "snapd_good_recovery_systems": "0000", + }) + c.Assert(err, check.IsNil) + + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"debug", "boot-vars", "--root-dir", boot.InitramfsUbuntuBootDir}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.HasLen, 0) + c.Check(s.Stdout(), check.Equals, `snapd_recovery_mode=run +snapd_recovery_system=1234 +snapd_recovery_kernel= +snap_kernel=pc-kernel_3.snap +snap_try_kernel= +kernel_status= +recovery_system_status=try +try_recovery_system=9999 +snapd_good_recovery_systems=0000 +snapd_extra_cmdline_args= +snapd_full_cmdline_args= +`) + c.Check(s.Stderr(), check.Equals, "") + s.ResetStdStreams() + + // and make sure that set does not blow up when passed a root dir + rest, err = snap.Parser(snap.Client()).ParseArgs([]string{"debug", "set-boot-vars", "--root-dir", boot.InitramfsUbuntuBootDir, "foo=bar"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.HasLen, 0) + + v, err := bloader.GetBootVars("foo") + c.Assert(err, check.IsNil) + c.Check(v, check.DeepEquals, map[string]string{ + "foo": "bar", + }) + // and make sure that set does not blow up when passed recover bootloader flag + rest, err = snap.Parser(snap.Client()).ParseArgs([]string{"debug", "set-boot-vars", "--recovery", "foo=recovery"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.HasLen, 0) + + v, err = bloader.GetBootVars("foo") + c.Assert(err, check.IsNil) + c.Check(v, check.DeepEquals, map[string]string{ + "foo": "recovery", + }) + + // but basic validity checks are still done + _, err = snap.Parser(snap.Client()).ParseArgs([]string{"debug", "set-boot-vars", "--recovery", "--root-dir", boot.InitramfsUbuntuBootDir, "foo=recovery"}) + c.Assert(err, check.ErrorMatches, "cannot use run bootloader root-dir with a recovery flag") +} diff --git a/cmd/snap/cmd_debug_disks.go b/cmd/snap/cmd_debug_disks.go new file mode 100644 index 00000000..09207d71 --- /dev/null +++ b/cmd/snap/cmd_debug_disks.go @@ -0,0 +1,60 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2021 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "encoding/json" + "fmt" + + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/gadget" +) + +type cmdDiskMapping struct { + clientMixin +} + +func init() { + cmd := addDebugCommand("disks", + "(internal) obtain all on-disk volume information as JSON", + "(internal) obtain all on-disk volume information as JSON", + func() flags.Commander { + return &cmdDiskMapping{} + }, nil, nil) + cmd.hidden = true +} + +func (x *cmdDiskMapping) Execute(args []string) error { + if len(args) > 0 { + return ErrExtraArgs + } + resp := []gadget.OnDiskVolume{} + if err := x.client.DebugGet("disks", &resp, nil); err != nil { + return err + } + b, err := json.Marshal(resp) + if err != nil { + return err + } + + fmt.Fprintf(Stdout, "%s\n", string(b)) + return nil +} diff --git a/cmd/snap/cmd_debug_gadget_disk_mapping.go b/cmd/snap/cmd_debug_gadget_disk_mapping.go new file mode 100644 index 00000000..c916a688 --- /dev/null +++ b/cmd/snap/cmd_debug_gadget_disk_mapping.go @@ -0,0 +1,60 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2021 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "encoding/json" + "fmt" + + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/gadget" +) + +type cmdGadgetDiskMapping struct { + clientMixin +} + +func init() { + cmd := addDebugCommand("gadget-disk-mapping", + "(internal) obtain the gadget disk mapping", + "(internal) obtain the gadget disk mapping", + func() flags.Commander { + return &cmdGadgetDiskMapping{} + }, nil, nil) + cmd.hidden = true +} + +func (x *cmdGadgetDiskMapping) Execute(args []string) error { + if len(args) > 0 { + return ErrExtraArgs + } + resp := map[string]gadget.DiskVolumeDeviceTraits{} + if err := x.client.DebugGet("gadget-disk-mapping", &resp, nil); err != nil { + return err + } + b, err := json.Marshal(resp) + if err != nil { + return err + } + + fmt.Fprintf(Stdout, "%s\n", string(b)) + return nil +} diff --git a/cmd/snap/cmd_debug_migrate.go b/cmd/snap/cmd_debug_migrate.go new file mode 100644 index 00000000..4c7bab6f --- /dev/null +++ b/cmd/snap/cmd_debug_migrate.go @@ -0,0 +1,80 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "errors" + "fmt" + + "github.com/jessevdk/go-flags" + "github.com/snapcore/snapd/i18n" + "github.com/snapcore/snapd/strutil" +) + +type cmdMigrateHome struct { + waitMixin + + Positional struct { + Snaps []string `positional-arg-name:"" required:"1"` + } `positional-args:"yes" required:"yes"` +} + +func init() { + addDebugCommand("migrate-home", + "Migrate snaps' directory to ~/Snap.", + "Migrate snaps' directory to ~/Snap.", + func() flags.Commander { + return &cmdMigrateHome{} + }, nil, nil) +} + +func (x *cmdMigrateHome) Execute(args []string) error { + chgID, err := x.client.MigrateSnapHome(x.Positional.Snaps) + if err != nil { + msg, err := errorToCmdMessage("", "migrate-home", err, nil) + if err != nil { + return err + } + fmt.Fprintln(Stderr, msg) + return nil + } + + chg, err := x.wait(chgID) + if err != nil { + return err + } + + var snaps []string + if err := chg.Get("snap-names", &snaps); err != nil { + return errors.New(`cannot get "snap-names" from change`) + } + + if len(snaps) == 0 { + return errors.New(`expected "migrate-home" change to have non-empty "snap-names"`) + } + + msg := fmt.Sprintf("%s's home directory was migrated to ~/Snap\n", snaps[0]) + if len(snaps) > 1 { + msg = fmt.Sprintf(i18n.G("%s migrated their home directories to ~/Snap\n"), strutil.Quoted(snaps)) + } + + fmt.Fprintf(Stdout, msg) + return nil +} diff --git a/cmd/snap/cmd_debug_migrate_test.go b/cmd/snap/cmd_debug_migrate_test.go new file mode 100644 index 00000000..c09210b8 --- /dev/null +++ b/cmd/snap/cmd_debug_migrate_test.go @@ -0,0 +1,160 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "fmt" + "net/http" + + snap "github.com/snapcore/snapd/cmd/snap" + "gopkg.in/check.v1" + . "gopkg.in/check.v1" +) + +type MigrateHomeSuite struct { + BaseSnapSuite +} + +var _ = check.Suite(&MigrateHomeSuite{}) + +// failRequest logs an error message, fails the test and returns a proper error +// to the client. Use this instead of panic() or c.Fatal() because those crash +// the server and leave the client hanging/retrying. +func failRequest(msg string, w http.ResponseWriter, c *C) { + c.Error(msg) + w.WriteHeader(400) + fmt.Fprintf(w, `{"type": "error", "status-code": 400, "result": {"message": %q}}`, msg) +} + +func serverWithChange(chgRsp string, c *C) func(w http.ResponseWriter, r *http.Request) { + var n int + return func(w http.ResponseWriter, r *http.Request) { + switch n { + case 0: + c.Check(r.Method, check.Equals, "POST") + c.Check(r.URL.Path, check.Equals, "/v2/debug") + c.Check(DecodedRequestBody(c, r), check.DeepEquals, map[string]interface{}{ + "action": "migrate-home", + "snaps": []interface{}{"foo"}, + }) + w.WriteHeader(202) + fmt.Fprintln(w, `{"type": "async", "status-code": 202, "result": {}, "change": "12"}`) + + case 1: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/changes/12") + fmt.Fprintf(w, chgRsp) + + default: + failRequest(fmt.Sprintf("server expected to get 2 requests, now on %d", n+1), w, c) + } + + n++ + } +} + +func (s *MigrateHomeSuite) TestMigrateHome(c *C) { + rsp := serverWithChange(`{"type": "sync", "result": {"ready": true, "status": "Done", "data": {"snap-names": ["foo"]}}}\n`, c) + s.RedirectClientToTestServer(rsp) + + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"debug", "migrate-home", "foo"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + c.Check(s.Stdout(), check.Equals, "foo's home directory was migrated to ~/Snap\n") + c.Check(s.Stderr(), check.Equals, "") +} + +func (s *MigrateHomeSuite) TestMigrateHomeManySnaps(c *C) { + n := 0 + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch n { + case 0: + c.Check(r.Method, check.Equals, "POST") + c.Check(r.URL.Path, check.Equals, "/v2/debug") + c.Check(DecodedRequestBody(c, r), check.DeepEquals, map[string]interface{}{ + "action": "migrate-home", + "snaps": []interface{}{"foo", "bar"}, + }) + w.WriteHeader(202) + fmt.Fprintln(w, `{"type": "async", "status-code": 202, "result": {}, "change": "12"}`) + + case 1: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/changes/12") + fmt.Fprintf(w, `{"type": "sync", "result": {"ready": true, "status": "Done", "data": {"snap-names": ["foo", "bar"]}}}\n`) + + default: + failRequest(fmt.Sprintf("server expected to get 2 requests, now on %d", n+1), w, c) + } + + n++ + }) + + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"debug", "migrate-home", "foo", "bar"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + c.Check(s.Stdout(), check.Equals, "\"foo\", \"bar\" migrated their home directories to ~/Snap\n") + c.Check(s.Stderr(), check.Equals, "") +} + +func (s *MigrateHomeSuite) TestMigrateHomeNoSnaps(c *C) { + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + failRequest("unexpected request on server", w, c) + }) + + _, err := snap.Parser(snap.Client()).ParseArgs([]string{"debug", "migrate-home"}) + c.Assert(err, check.ErrorMatches, "the required argument .* was not provided") + c.Check(s.Stdout(), check.Equals, "") + c.Check(s.Stderr(), check.Equals, "") +} + +func (s *MigrateHomeSuite) TestMigrateHomeServerError(c *C) { + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(500) + fmt.Fprintf(w, `{"type": "error", "status-code": 500, "result": {"message": "boom"}}`) + }) + + _, err := snap.Parser(snap.Client()).ParseArgs([]string{"debug", "migrate-home", "foo"}) + c.Assert(err, check.ErrorMatches, "boom") + c.Check(s.Stdout(), check.Equals, "") + c.Check(s.Stderr(), check.Equals, "") +} + +func (s *MigrateHomeSuite) TestMigrateHomeBadChangeNoSnaps(c *C) { + // broken change response: missing required "snap-names" + srv := serverWithChange(`{"type": "sync", "result": {"ready": true, "status": "Done", "data": {"snap-names": []}}}\n`, c) + s.RedirectClientToTestServer(srv) + + _, err := snap.Parser(snap.Client()).ParseArgs([]string{"debug", "migrate-home", "foo"}) + c.Assert(err, check.ErrorMatches, `expected "migrate-home" change to have non-empty "snap-names"`) + c.Check(s.Stdout(), check.Equals, "") + c.Check(s.Stderr(), check.Equals, "") +} + +func (s *MigrateHomeSuite) TestMigrateHomeBadChangeNoData(c *C) { + // broken change response: missing data + srv := serverWithChange(`{"type": "sync", "result": {"ready": true, "status": "Done"}}\n`, c) + s.RedirectClientToTestServer(srv) + + _, err := snap.Parser(snap.Client()).ParseArgs([]string{"debug", "migrate-home", "foo"}) + c.Assert(err, check.ErrorMatches, `cannot get "snap-names" from change`) + c.Check(s.Stdout(), check.Equals, "") + c.Check(s.Stderr(), check.Equals, "") +} diff --git a/cmd/snap/cmd_debug_model.go b/cmd/snap/cmd_debug_model.go new file mode 100644 index 00000000..b96c5bc8 --- /dev/null +++ b/cmd/snap/cmd_debug_model.go @@ -0,0 +1,54 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "fmt" + + "github.com/jessevdk/go-flags" +) + +type cmdGetModel struct { + clientMixin +} + +func init() { + cmd := addDebugCommand("model", + "(internal) obtain the active model assertion", + "(internal) obtain the active model assertion", + func() flags.Commander { + return &cmdGetModel{} + }, nil, nil) + cmd.hidden = true +} + +func (x *cmdGetModel) Execute(args []string) error { + if len(args) > 0 { + return ErrExtraArgs + } + var resp struct { + Model string `json:"model"` + } + if err := x.client.DebugGet("model", &resp, nil); err != nil { + return err + } + fmt.Fprintf(Stdout, "%s\n", resp.Model) + return nil +} diff --git a/cmd/snap/cmd_debug_model_test.go b/cmd/snap/cmd_debug_model_test.go new file mode 100644 index 00000000..6b3530a9 --- /dev/null +++ b/cmd/snap/cmd_debug_model_test.go @@ -0,0 +1,56 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "fmt" + "io" + "net/http" + + "gopkg.in/check.v1" + + snap "github.com/snapcore/snapd/cmd/snap" +) + +func (s *SnapSuite) TestGetModel(c *check.C) { + n := 0 + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch n { + case 0: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/debug") + c.Check(r.URL.RawQuery, check.Equals, "aspect=model") + data, err := io.ReadAll(r.Body) + c.Check(err, check.IsNil) + c.Check(string(data), check.Equals, "") + fmt.Fprintln(w, `{"type": "sync", "result": {"model": "some-model-json"}}`) + default: + c.Fatalf("expected to get 1 requests, now on %d", n+1) + } + + n++ + }) + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"debug", "model"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + c.Check(s.Stdout(), check.Equals, "some-model-json\n") + c.Check(s.Stderr(), check.Equals, "") + c.Check(n, check.Equals, 1) +} diff --git a/cmd/snap/cmd_debug_seeding.go b/cmd/snap/cmd_debug_seeding.go new file mode 100644 index 00000000..81b72191 --- /dev/null +++ b/cmd/snap/cmd_debug_seeding.go @@ -0,0 +1,163 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "time" + + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/interfaces" +) + +type cmdSeeding struct { + clientMixin + unicodeMixin +} + +func init() { + cmd := addDebugCommand("seeding", + "(internal) obtain seeding and preseeding details", + "(internal) obtain seeding and preseeding details", + func() flags.Commander { + return &cmdSeeding{} + }, nil, nil) + cmd.hidden = true +} + +func (x *cmdSeeding) Execute(args []string) error { + esc := x.getEscapes() + + if len(args) > 0 { + return ErrExtraArgs + } + var resp struct { + Seeded bool `json:"seeded,omitempty"` + Preseeded bool `json:"preseeded,omitempty"` + PreseedStartTime *time.Time `json:"preseed-start-time,omitempty"` + PreseedTime *time.Time `json:"preseed-time,omitempty"` + SeedStartTime *time.Time `json:"seed-start-time,omitempty"` + SeedRestartTime *time.Time `json:"seed-restart-time,omitempty"` + SeedTime *time.Time `json:"seed-time,omitempty"` + // use json.RawMessage to delay unmarshal'ing to the interfaces pkg + PreseedSystemKey *json.RawMessage `json:"preseed-system-key,omitempty"` + SeedRestartSystemKey *json.RawMessage `json:"seed-restart-system-key,omitempty"` + + SeedError string `json:"seed-error,omitempty"` + } + if err := x.client.DebugGet("seeding", &resp, nil); err != nil { + return err + } + + w := tabWriter() + + // show seeded and preseeded keys + fmt.Fprintf(w, "seeded:\t%v\n", resp.Seeded) + if resp.SeedError != "" { + // print seed-error + termWidth, _ := termSize() + termWidth -= 3 + if termWidth > 100 { + // any wider than this and it gets hard to read + termWidth = 100 + } + fmt.Fprintln(w, "seed-error: |") + // XXX: reuse/abuse + printDescr(w, resp.SeedError, termWidth) + } + + fmt.Fprintf(w, "preseeded:\t%v\n", resp.Preseeded) + + // calculate the time spent preseeding (if preseeded) and seeding + // for the preseeded case, we use the seed-restart-time as the start time + // to show how long we spent only after booting the preseeded image + + // if we are missing time values, we will default to showing "-" for the + // duration + seedDuration := esc.dash + if resp.Preseeded { + if resp.PreseedTime != nil && resp.PreseedStartTime != nil { + preseedDuration := resp.PreseedTime.Sub(*resp.PreseedStartTime).Round(time.Millisecond) + fmt.Fprintf(w, "image-preseeding:\t%v\n", preseedDuration) + } else { + fmt.Fprintf(w, "image-preseeding:\t%s\n", esc.dash) + } + + if resp.SeedTime != nil && resp.SeedRestartTime != nil { + seedDuration = fmt.Sprintf("%v", resp.SeedTime.Sub(*resp.SeedRestartTime).Round(time.Millisecond)) + } + } else if resp.SeedTime != nil && resp.SeedStartTime != nil { + seedDuration = fmt.Sprintf("%v", resp.SeedTime.Sub(*resp.SeedStartTime).Round(time.Millisecond)) + } + fmt.Fprintf(w, "seed-completion:\t%s\n", seedDuration) + + // we flush the tabwriter now because if we have more output, it will be + // the system keys, which are JSON and thus will never display cleanly in + // line with the other keys we did above + w.Flush() + + // only compare system-keys if preseeded and the system-keys exist + // they might not exist if this command is used on a system that was + // preseeded with an older version of snapd, i.e. while this feature is + // being rolled out, we may be preseeding images via old snapd deb, but with + // new snapd snap + if resp.Preseeded && resp.SeedRestartSystemKey != nil && resp.PreseedSystemKey != nil { + // only show them if they don't match, so first unmarshal them so we can + // properly compare them + + // we use raw json messages here so that the interfaces pkg can do the + // real unmarshalling to a real systemKey interface{} that can be + // compared with SystemKeysMatch, if we had instead unmarshalled here, + // we would have to remarshal the map[string]interface{} we got above + // and then pass those bytes back to the interfaces pkg which is awkward + seedSk, err := interfaces.UnmarshalJSONSystemKey(bytes.NewReader(*resp.SeedRestartSystemKey)) + if err != nil { + return err + } + + preseedSk, err := interfaces.UnmarshalJSONSystemKey(bytes.NewReader(*resp.PreseedSystemKey)) + if err != nil { + return err + } + + match, err := interfaces.SystemKeysMatch(preseedSk, seedSk) + if err != nil { + return err + } + if !match { + // mismatch, display the different keys + var preseedSkJSON, seedRestartSkJSON bytes.Buffer + json.Indent(&preseedSkJSON, *resp.PreseedSystemKey, "", " ") + fmt.Fprintf(Stdout, "preseed-system-key: ") + preseedSkJSON.WriteTo(Stdout) + fmt.Fprintln(Stdout, "") + + json.Indent(&seedRestartSkJSON, *resp.SeedRestartSystemKey, "", " ") + fmt.Fprintf(Stdout, "seed-restart-system-key: ") + seedRestartSkJSON.WriteTo(Stdout) + fmt.Fprintln(Stdout, "") + } + } + + return nil +} diff --git a/cmd/snap/cmd_debug_seeding_test.go b/cmd/snap/cmd_debug_seeding_test.go new file mode 100644 index 00000000..308ef5c5 --- /dev/null +++ b/cmd/snap/cmd_debug_seeding_test.go @@ -0,0 +1,493 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "fmt" + "io" + "net/http" + + . "gopkg.in/check.v1" + + snap "github.com/snapcore/snapd/cmd/snap" +) + +var newPreseedNewSnapdSameSysKey = ` +{ + "result": { + "preseed-start-time": "2020-07-24T21:41:33.838194712Z", + "preseed-system-key": { + "apparmor-features": [ + "caps", + "dbus", + "domain", + "file", + "mount", + "namespaces", + "network", + "network_v8", + "policy", + "ptrace", + "query", + "rlimit", + "signal" + ], + "apparmor-parser-features": [ + "unsafe" + ], + "apparmor-parser-mtime": 1589907589, + "build-id": "cb94e5eeee4cf7ecda53f8308a984cb155b55732", + "cgroup-version": "1", + "nfs-home": false, + "overlay-root": "", + "seccomp-compiler-version": "e6e309ad8aee052e5aa695dfaa040328ae1559c5 2.4.3 9b218ef9a4e508dd8a7f848095cb8875d10a4bf28428ad81fdc3f8dac89108f7 bpf-actlog", + "seccomp-features": [ + "allow", + "errno", + "kill_process", + "kill_thread", + "log", + "trace", + "trap", + "user_notif" + ], + "version": 10 + }, + "preseed-time": "2020-07-24T21:41:43.156401424Z", + "preseeded": true, + "seed-restart-system-key": { + "apparmor-features": [ + "caps", + "dbus", + "domain", + "file", + "mount", + "namespaces", + "network", + "network_v8", + "policy", + "ptrace", + "query", + "rlimit", + "signal" + ], + "apparmor-parser-features": [ + "unsafe" + ], + "apparmor-parser-mtime": 1589907589, + "build-id": "cb94e5eeee4cf7ecda53f8308a984cb155b55732", + "cgroup-version": "1", + "nfs-home": false, + "overlay-root": "", + "seccomp-compiler-version": "e6e309ad8aee052e5aa695dfaa040328ae1559c5 2.4.3 9b218ef9a4e508dd8a7f848095cb8875d10a4bf28428ad81fdc3f8dac89108f7 bpf-actlog", + "seccomp-features": [ + "allow", + "errno", + "kill_process", + "kill_thread", + "log", + "trace", + "trap", + "user_notif" + ], + "version": 10 + }, + "seed-restart-time": "2020-07-24T21:42:16.646098923Z", + "seed-start-time": "0001-01-01T00:00:00Z", + "seed-time": "2020-07-24T21:42:20.518607Z", + "seeded": true + }, + "status": "OK", + "status-code": 200, + "type": "sync" +}` + +var newPreseedNewSnapdDiffSysKey = ` +{ + "result": { + "preseed-start-time": "2020-07-24T21:41:33.838194712Z", + "preseed-system-key": { + "apparmor-features": [ + "caps", + "dbus", + "domain", + "file", + "mount", + "namespaces", + "network", + "network_v8", + "policy", + "ptrace", + "query", + "rlimit", + "signal" + ], + "apparmor-parser-features": [ + "unsafe" + ], + "apparmor-parser-mtime": 1589907589, + "build-id": "cb94e5eeee4cf7ecda53f8308a984cb155b55732", + "cgroup-version": "1", + "nfs-home": false, + "overlay-root": "", + "seccomp-compiler-version": "e6e309ad8aee052e5aa695dfaa040328ae1559c5 2.4.3 9b218ef9a4e508dd8a7f848095cb8875d10a4bf28428ad81fdc3f8dac89108f7 bpf-actlog", + "seccomp-features": [ + "allow", + "errno", + "kill_process", + "kill_thread", + "log", + "trace", + "trap", + "user_notif" + ], + "version": 10 + }, + "preseed-time": "2020-07-24T21:41:43.156401424Z", + "preseeded": true, + "seed-restart-system-key": { + "apparmor-features": [ + "caps", + "dbus", + "domain", + "file", + "mount", + "namespaces", + "network", + "policy", + "ptrace", + "query", + "rlimit", + "signal" + ], + "apparmor-parser-features": [ + "unsafe" + ], + "apparmor-parser-mtime": 1589907589, + "build-id": "cb94e5eeee4cf7ecda53f8308a984cb155b55732", + "cgroup-version": "1", + "nfs-home": false, + "overlay-root": "", + "seccomp-compiler-version": "e6e309ad8aee052e5aa695dfaa040328ae1559c5 2.4.3 9b218ef9a4e508dd8a7f848095cb8875d10a4bf28428ad81fdc3f8dac89108f7 bpf-actlog", + "seccomp-features": [ + "allow", + "errno", + "kill", + "log", + "trace", + "trap", + "user_notif" + ], + "version": 10 + }, + "seed-restart-time": "2020-07-24T21:42:16.646098923Z", + "seed-start-time": "0001-01-01T00:00:00Z", + "seed-time": "2020-07-24T21:42:20.518607Z", + "seeded": true + }, + "status": "OK", + "status-code": 200, + "type": "sync" +}` + +// a system that was not preseeded at all +var noPreseedingJSON = ` +{ + "result": { + "seed-time": "2019-07-04T19:16:10.548793375-05:00", + "seeded": true + }, + "status": "OK", + "status-code": 200, + "type": "sync" +}` + +var seedingError = `{ + "result": { + "preseed-start-time": "2020-07-24T21:41:33.838194712Z", + "preseed-time": "2020-07-24T21:41:43.156401424Z", + "preseeded": true, + "seed-error": "cannot perform the following tasks:\n- xxx" + }, + "status": "OK", + "status-code": 200, + "type": "sync" +}` + +// a system that was preseeded, but didn't record the new keys +// this is the case for a system that was preseeded and then seeded with an old +// snapd, but then is refreshed to a version of snapd that supports snap debug +// seeding, where we want to still have sensible output +var oldPreseedingJSON = `{ + "result": { + "preseed-start-time": "0001-01-01T00:00:00Z", + "preseed-time": "0001-01-01T00:00:00Z", + "seed-restart-time": "2019-07-04T19:14:10.548793375-05:00", + "seed-start-time": "0001-01-01T00:00:00Z", + "seed-time": "2019-07-04T19:16:10.548793375-05:00", + "seeded": true, + "preseeded": true + }, + "status": "OK", + "status-code": 200, + "type": "sync" +}` + +var stillSeeding = `{ + "result": { + "preseed-start-time": "2020-07-24T21:41:33.838194712Z", + "preseed-time": "2020-07-24T21:41:43.156401424Z", + "preseeded": true + }, + "status": "OK", + "status-code": 200, + "type": "sync" +}` + +var stillSeedingNoPreseed = `{ + "result": {}, + "status": "OK", + "status-code": 200, + "type": "sync" +}` + +func (s *SnapSuite) TestDebugSeeding(c *C) { + tt := []struct { + jsonResp string + expStdout string + expStderr string + expErr string + comment string + hasUnicode bool + }{ + { + jsonResp: newPreseedNewSnapdSameSysKey, + expStdout: ` +seeded: true +preseeded: true +image-preseeding: 9.318s +seed-completion: 3.873s +`[1:], + comment: "new preseed keys, same system-key", + }, + { + jsonResp: newPreseedNewSnapdDiffSysKey, + expStdout: ` +seeded: true +preseeded: true +image-preseeding: 9.318s +seed-completion: 3.873s +preseed-system-key: { + "apparmor-features": [ + "caps", + "dbus", + "domain", + "file", + "mount", + "namespaces", + "network", + "network_v8", + "policy", + "ptrace", + "query", + "rlimit", + "signal" + ], + "apparmor-parser-features": [ + "unsafe" + ], + "apparmor-parser-mtime": 1589907589, + "build-id": "cb94e5eeee4cf7ecda53f8308a984cb155b55732", + "cgroup-version": "1", + "nfs-home": false, + "overlay-root": "", + "seccomp-compiler-version": "e6e309ad8aee052e5aa695dfaa040328ae1559c5 2.4.3 9b218ef9a4e508dd8a7f848095cb8875d10a4bf28428ad81fdc3f8dac89108f7 bpf-actlog", + "seccomp-features": [ + "allow", + "errno", + "kill_process", + "kill_thread", + "log", + "trace", + "trap", + "user_notif" + ], + "version": 10 +} +seed-restart-system-key: { + "apparmor-features": [ + "caps", + "dbus", + "domain", + "file", + "mount", + "namespaces", + "network", + "policy", + "ptrace", + "query", + "rlimit", + "signal" + ], + "apparmor-parser-features": [ + "unsafe" + ], + "apparmor-parser-mtime": 1589907589, + "build-id": "cb94e5eeee4cf7ecda53f8308a984cb155b55732", + "cgroup-version": "1", + "nfs-home": false, + "overlay-root": "", + "seccomp-compiler-version": "e6e309ad8aee052e5aa695dfaa040328ae1559c5 2.4.3 9b218ef9a4e508dd8a7f848095cb8875d10a4bf28428ad81fdc3f8dac89108f7 bpf-actlog", + "seccomp-features": [ + "allow", + "errno", + "kill", + "log", + "trace", + "trap", + "user_notif" + ], + "version": 10 +} +`[1:], + comment: "new preseed keys, different system-key", + }, + { + jsonResp: noPreseedingJSON, + expStdout: ` +seeded: true +preseeded: false +seed-completion: -- +`[1:], + comment: "not preseeded no unicode", + }, + { + jsonResp: noPreseedingJSON, + expStdout: ` +seeded: true +preseeded: false +seed-completion: – +`[1:], + comment: "not preseeded", + hasUnicode: true, + }, + { + jsonResp: oldPreseedingJSON, + expStdout: ` +seeded: true +preseeded: true +image-preseeding: 0s +seed-completion: 2m0s +`[1:], + comment: "old preseeded json", + }, + { + jsonResp: stillSeeding, + expStdout: ` +seeded: false +preseeded: true +image-preseeding: 9.318s +seed-completion: -- +`[1:], + comment: "preseeded, still seeding no unicode", + }, + { + jsonResp: stillSeeding, + expStdout: ` +seeded: false +preseeded: true +image-preseeding: 9.318s +seed-completion: – +`[1:], + hasUnicode: true, + comment: "preseeded, still seeding", + }, + { + jsonResp: stillSeedingNoPreseed, + expStdout: ` +seeded: false +preseeded: false +seed-completion: -- +`[1:], + comment: "not preseeded, still seeding no unicode", + }, + { + jsonResp: stillSeedingNoPreseed, + expStdout: ` +seeded: false +preseeded: false +seed-completion: – +`[1:], + hasUnicode: true, + comment: "not preseeded, still seeding", + }, + { + jsonResp: seedingError, + expStdout: ` +seeded: false +seed-error: | + cannot perform the following tasks: + - xxx +preseeded: true +image-preseeding: 9.318s +seed-completion: -- +`[1:], + comment: "preseeded, error during seeding", + }, + } + + for _, t := range tt { + comment := Commentf(t.comment) + n := 0 + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + n++ + switch n { + case 1: + c.Assert(r.Method, Equals, "GET", comment) + c.Assert(r.URL.Path, Equals, "/v2/debug", comment) + c.Assert(r.URL.RawQuery, Equals, "aspect=seeding", comment) + data, err := io.ReadAll(r.Body) + c.Assert(err, IsNil, comment) + c.Assert(string(data), Equals, "", comment) + fmt.Fprintln(w, t.jsonResp) + default: + c.Fatalf("expected to get 1 request, now on %d", n) + } + }) + args := []string{"debug", "seeding"} + if t.hasUnicode { + args = append(args, "--unicode=always") + } + rest, err := snap.Parser(snap.Client()).ParseArgs(args) + if t.expErr != "" { + c.Assert(err, ErrorMatches, t.expErr, comment) + c.Assert(s.Stdout(), Equals, "", comment) + c.Assert(s.Stderr(), Equals, t.expStderr, comment) + continue + } + c.Assert(err, IsNil, comment) + c.Assert(rest, DeepEquals, []string{}, comment) + c.Assert(s.Stdout(), Equals, t.expStdout, comment) + c.Assert(s.Stderr(), Equals, "", comment) + c.Assert(n, Equals, 1, comment) + + s.ResetStdStreams() + } +} diff --git a/cmd/snap/cmd_debug_stacktraces.go b/cmd/snap/cmd_debug_stacktraces.go new file mode 100644 index 00000000..c7d2d13d --- /dev/null +++ b/cmd/snap/cmd_debug_stacktraces.go @@ -0,0 +1,48 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2021 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "fmt" + + "github.com/jessevdk/go-flags" +) + +type cmdGetStacktraces struct { + clientMixin +} + +func init() { + addDebugCommand("stacktraces", + "Obtain stacktraces of all snapd goroutines", + "Obtain stacktraces of all snapd goroutines.", + func() flags.Commander { + return &cmdGetStacktraces{} + }, nil, nil) +} + +func (x *cmdGetStacktraces) Execute(args []string) error { + var stacktraces string + if err := x.client.Debug("stacktraces", nil, &stacktraces); err != nil { + return err + } + fmt.Fprintf(Stdout, stacktraces) + return nil +} diff --git a/cmd/snap/cmd_debug_state.go b/cmd/snap/cmd_debug_state.go new file mode 100644 index 00000000..87ff1e69 --- /dev/null +++ b/cmd/snap/cmd_debug_state.go @@ -0,0 +1,577 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "errors" + "fmt" + "os" + "sort" + "strconv" + "strings" + "text/tabwriter" + + "gopkg.in/yaml.v2" + + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/i18n" + "github.com/snapcore/snapd/interfaces" + "github.com/snapcore/snapd/overlord/ifacestate/schema" + "github.com/snapcore/snapd/overlord/state" + "github.com/snapcore/snapd/strutil" +) + +type cmdDebugState struct { + timeMixin + + Changes bool `long:"changes"` + TaskID string `long:"task"` + ChangeID string `long:"change"` + Check bool `long:"check"` + + Connections bool `long:"connections"` + Connection string `long:"connection"` + + IsSeeded bool `long:"is-seeded"` + + // flags for --change=N output + DotOutput bool `long:"dot"` // XXX: mildly useful (too crowded in many cases), but let's have it just in case + // When inspecting errors/undone tasks, those in Hold state are usually irrelevant, make it possible to ignore them + NoHoldState bool `long:"no-hold"` + + Positional struct { + StateFilePath string `positional-args:"yes" positional-arg-name:""` + } `positional-args:"yes"` +} + +var cmdDebugStateShortHelp = i18n.G("Inspect a snapd state file.") +var cmdDebugStateLongHelp = i18n.G("Inspect a snapd state file, bypassing snapd API.") + +type byChangeSpawnTime []*state.Change + +func (c byChangeSpawnTime) Len() int { return len(c) } +func (c byChangeSpawnTime) Swap(i, j int) { c[i], c[j] = c[j], c[i] } +func (c byChangeSpawnTime) Less(i, j int) bool { return c[i].SpawnTime().Before(c[j].SpawnTime()) } + +func loadState(path string) (*state.State, error) { + if path == "" { + path = "state.json" + } + r, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("cannot read the state file: %s", err) + } + defer r.Close() + + return state.ReadState(nil, r) +} + +func init() { + addDebugCommand("state", cmdDebugStateShortHelp, cmdDebugStateLongHelp, func() flags.Commander { + return &cmdDebugState{} + }, timeDescs.also(map[string]string{ + // TRANSLATORS: This should not start with a lowercase letter. + "change": i18n.G("ID of the change to inspect"), + "task": i18n.G("ID of the task to inspect"), + "dot": i18n.G("Dot (graphviz) output"), + "no-hold": i18n.G("Omit tasks in 'Hold' state in the change output"), + "changes": i18n.G("List all changes"), + "connections": i18n.G("List all connections"), + "connection": i18n.G("Show details of the matching connections (snap or snap:plug,snap:slot or snap:plug-or-slot"), + "is-seeded": i18n.G("Output seeding status (true or false)"), + "check": i18n.G("Check change consistency"), + }), nil) +} + +type byLaneAndWaitTaskChain []*state.Task + +func (t byLaneAndWaitTaskChain) Len() int { return len(t) } +func (t byLaneAndWaitTaskChain) Swap(i, j int) { t[i], t[j] = t[j], t[i] } +func (t byLaneAndWaitTaskChain) Less(i, j int) bool { + if t[i].ID() == t[j].ID() { + return false + } + // cover the typical case (just one lane), and order by first lane + if t[i].Lanes()[0] == t[j].Lanes()[0] { + seenTasks := make(map[string]bool) + return t.waitChainSearch(t[i], t[j], seenTasks) + } + return t[i].Lanes()[0] < t[j].Lanes()[0] +} + +func (t *byLaneAndWaitTaskChain) waitChainSearch(startT, searchT *state.Task, seenTasks map[string]bool) bool { + if seenTasks[startT.ID()] { + return false + } + seenTasks[startT.ID()] = true + for _, cand := range startT.HaltTasks() { + if cand == searchT { + return true + } + if t.waitChainSearch(cand, searchT, seenTasks) { + return true + } + } + + return false +} + +func (c *cmdDebugState) writeDotOutput(st *state.State, changeID string) error { + st.Lock() + defer st.Unlock() + + chg := st.Change(changeID) + if chg == nil { + return fmt.Errorf("no such change: %s", changeID) + } + + fmt.Fprintf(Stdout, "digraph D{\n") + tasks := chg.Tasks() + for _, t := range tasks { + if c.NoHoldState && t.Status() == state.HoldStatus { + continue + } + fmt.Fprintf(Stdout, " %s [label=%q];\n", t.ID(), t.Kind()) + for _, wt := range t.WaitTasks() { + if c.NoHoldState && wt.Status() == state.HoldStatus { + continue + } + fmt.Fprintf(Stdout, " %s -> %s;\n", t.ID(), wt.ID()) + } + } + fmt.Fprintf(Stdout, "}\n") + + return nil +} + +func (c *cmdDebugState) showTasks(st *state.State, changeID string) error { + st.Lock() + defer st.Unlock() + + chg := st.Change(changeID) + if chg == nil { + return fmt.Errorf("no such change: %s", changeID) + } + + tasks := chg.Tasks() + sort.Sort(byLaneAndWaitTaskChain(tasks)) + + w := tabwriter.NewWriter(Stdout, 5, 3, 2, ' ', 0) + fmt.Fprintf(w, "Lanes\tID\tStatus\tSpawn\tReady\tKind\tSummary\n") + for _, t := range tasks { + if c.NoHoldState && t.Status() == state.HoldStatus { + continue + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + strutil.IntsToCommaSeparated(t.Lanes()), + t.ID(), + t.Status().String(), + c.fmtTime(t.SpawnTime()), + c.fmtTime(t.ReadyTime()), + t.Kind(), + t.Summary()) + } + + w.Flush() + + for _, t := range tasks { + logs := t.Log() + if len(logs) > 0 { + fmt.Fprintf(Stdout, "---\n") + fmt.Fprintf(Stdout, "%s %s\n", t.ID(), t.Summary()) + for _, log := range logs { + fmt.Fprintf(Stdout, " %s\n", log) + } + } + } + + return nil +} + +func (c *cmdDebugState) checkTasks(st *state.State, changeID string) error { + st.Lock() + defer st.Unlock() + + showAtMostTasks := 3 + formatAtMostTaskIDs := func(tasks []*state.Task) string { + var b strings.Builder + b.WriteRune('[') + atMostTasks := tasks + trimmed := false + if len(atMostTasks) > showAtMostTasks { + atMostTasks = tasks[:showAtMostTasks] + trimmed = true + } + for i, t := range atMostTasks { + b.WriteString(t.ID()) + if i < len(atMostTasks)-1 { + b.WriteRune(',') + } + } + if trimmed { + b.WriteString(",...") + } + b.WriteRune(']') + return b.String() + } + + chg := st.Change(changeID) + if chg == nil { + return fmt.Errorf("no such change: %s", changeID) + } + err := chg.CheckTaskDependencies() + if err != nil { + if tdcErr, ok := err.(*state.TaskDependencyCycleError); ok { + fmt.Fprintf(Stdout, "Detected task dependency cycle involving tasks:\n") + w := tabwriter.NewWriter(Stdout, 5, 3, 2, ' ', 0) + fmt.Fprintf(w, "Lanes\tID\tStatus\tSpawn\tReady\tKind\tSummary\tAfter\tBefore\n") + for _, tid := range tdcErr.IDs { + t := st.Task(tid) + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%v\t%v\n", + strutil.IntsToCommaSeparated(t.Lanes()), + t.ID(), + t.Status().String(), + c.fmtTime(t.SpawnTime()), + c.fmtTime(t.ReadyTime()), + t.Kind(), + t.Summary(), + formatAtMostTaskIDs(t.WaitTasks()), + formatAtMostTaskIDs(t.HaltTasks()), + ) + } + w.Flush() + } else { + return err + } + } + return nil +} + +func (c *cmdDebugState) showChanges(st *state.State) error { + st.Lock() + defer st.Unlock() + + changes := st.Changes() + sort.Sort(byChangeSpawnTime(changes)) + + w := tabwriter.NewWriter(Stdout, 5, 3, 2, ' ', 0) + fmt.Fprintf(w, "ID\tStatus\tSpawn\tReady\tLabel\tSummary\n") + for _, chg := range changes { + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", + chg.ID(), + chg.Status().String(), + c.fmtTime(chg.SpawnTime()), + c.fmtTime(chg.ReadyTime()), + chg.Kind(), + chg.Summary()) + } + w.Flush() + + return nil +} + +func (c *cmdDebugState) showIsSeeded(st *state.State) error { + st.Lock() + defer st.Unlock() + + var isSeeded bool + err := st.Get("seeded", &isSeeded) + if err != nil && !errors.Is(err, state.ErrNoState) { + return err + } + fmt.Fprintf(Stdout, "%v\n", isSeeded) + + return nil +} + +type connectionInfo struct { + PlugSnap string + PlugName string + SlotSnap string + SlotName string + + schema.ConnState +} + +type byPlug []*connectionInfo + +func (c byPlug) Len() int { return len(c) } +func (c byPlug) Swap(i, j int) { c[i], c[j] = c[j], c[i] } +func (c byPlug) Less(i, j int) bool { + a, b := c[i], c[j] + return a.PlugSnap < b.PlugSnap || (a.PlugSnap == b.PlugSnap && a.PlugName < b.PlugName) +} + +func (c *cmdDebugState) showConnectionDetails(st *state.State, connArg string) error { + st.Lock() + defer st.Unlock() + + p := strings.FieldsFunc(connArg, func(r rune) bool { + return r == ' ' || r == ',' + }) + + var plugMatch, slotMatch SnapAndName + if err := plugMatch.UnmarshalFlag(p[0]); err != nil { + return err + } + + if len(p) > 1 { + if err := slotMatch.UnmarshalFlag(p[1]); err != nil { + return err + } + } + + var conns map[string]*schema.ConnState + if err := st.Get("conns", &conns); err != nil && !errors.Is(err, state.ErrNoState) { + return err + } + + // sort by connection ID + connIDs := make([]string, 0, len(conns)) + for connID := range conns { + connIDs = append(connIDs, connID) + } + sort.Strings(connIDs) + + for _, connID := range connIDs { + connRef, err := interfaces.ParseConnRef(connID) + if err != nil { + return err + } + + refMatch := func(x SnapAndName, y interface{ String() string }) bool { + parts := strings.Split(y.String(), ":") + return len(parts) == 2 && x.Snap == parts[0] && x.Name == parts[1] + } + plug, slot := connRef.PlugRef, connRef.SlotRef + + switch { + // command invoked with 'snap:plug,snap:slot' + case slotMatch.Name != "" && slotMatch.Snap != "" && plugMatch.Snap != "" && plugMatch.Name != "": + // should match the connection exactly + if !refMatch(plugMatch, plug) || !refMatch(slotMatch, slot) { + continue + } + + // command invoked with 'snap:plug-or-slot' + case plugMatch.Snap != "" && plugMatch.Name != "" && slotMatch.Snap == "" && slotMatch.Name == "": + // should match either the connection's slot or plug + if !refMatch(plugMatch, plug) && !refMatch(plugMatch, slot) { + continue + } + + // command invoked with 'snap' only + case plugMatch.Snap != "" && plugMatch.Name == "" && slotMatch.Snap == "" && slotMatch.Name == "": + // should match one of the snap names + if plugMatch.Snap != slot.Snap && plugMatch.Snap != plug.Snap { + continue + } + + default: + return fmt.Errorf("invalid command with connection args: %s", connArg) + } + + conn := conns[connID] + + // the output of 'debug connection' is yaml + fmt.Fprintf(Stdout, "id: %s\n", connID) + out, err := yaml.Marshal(conn) + if err != nil { + return err + } + fmt.Fprintf(Stdout, "%s\n", out) + } + return nil +} + +func (c *cmdDebugState) showConnections(st *state.State) error { + st.Lock() + defer st.Unlock() + + var conns map[string]*schema.ConnState + if err := st.Get("conns", &conns); err != nil && !errors.Is(err, state.ErrNoState) { + return err + } + + all := make([]*connectionInfo, 0, len(conns)) + for connID, conn := range conns { + p := strings.Split(connID, " ") + if len(p) != 2 { + return fmt.Errorf("cannot parse connection ID %q", connID) + } + plug := strings.Split(p[0], ":") + slot := strings.Split(p[1], ":") + + c := &connectionInfo{ + PlugSnap: plug[0], + PlugName: plug[1], + SlotSnap: slot[0], + SlotName: slot[1], + ConnState: *conn, + } + all = append(all, c) + } + + sort.Sort(byPlug(all)) + + w := tabwriter.NewWriter(Stdout, 5, 3, 2, ' ', 0) + fmt.Fprintf(w, "Interface\tPlug\tSlot\tNotes\n") + for _, conn := range all { + var notes []string + if conn.Auto { + notes = append(notes, "auto") + } + if conn.Undesired { + notes = append(notes, "undesired") + } + if conn.ByGadget { + notes = append(notes, "by-gadget") + } + fmt.Fprintf(w, "%s\t%s:%s\t%s:%s\t%s\n", conn.Interface, conn.PlugSnap, conn.PlugName, conn.SlotSnap, conn.SlotName, strings.Join(notes, ",")) + } + w.Flush() + + return nil +} + +func (c *cmdDebugState) showTask(st *state.State, taskID string) error { + st.Lock() + defer st.Unlock() + + task := st.Task(taskID) + if task == nil { + return fmt.Errorf("no such task: %s", taskID) + } + + termWidth, _ := termSize() + termWidth -= 3 + if termWidth > 100 { + // any wider than this and it gets hard to read + termWidth = 100 + } + + // the output of 'debug task' is yaml'ish + fmt.Fprintf(Stdout, "id: %s\nkind: %s\nsummary: %s\nstatus: %s\n", + taskID, task.Kind(), + task.Summary(), + task.Status().String()) + log := task.Log() + if len(log) > 0 { + fmt.Fprintf(Stdout, "log: |\n") + for _, msg := range log { + if err := strutil.WordWrapPadded(Stdout, []rune(msg), " ", termWidth); err != nil { + break + } + } + fmt.Fprintln(Stdout) + } + + fmt.Fprintf(Stdout, "halt-tasks:") + if len(task.HaltTasks()) == 0 { + fmt.Fprintln(Stdout, " []") + } else { + fmt.Fprintln(Stdout) + for _, ht := range task.HaltTasks() { + fmt.Fprintf(Stdout, " - %s (%s)\n", ht.Kind(), ht.ID()) + } + } + + return nil +} + +func (c *cmdDebugState) Execute(args []string) error { + st, err := loadState(c.Positional.StateFilePath) + if err != nil { + return err + } + + // check valid combinations of args + var cmds []string + if c.Changes { + cmds = append(cmds, "--changes") + } + if c.ChangeID != "" { + cmds = append(cmds, "--change=") + } + if c.TaskID != "" { + cmds = append(cmds, "--task=") + } + if c.IsSeeded { + cmds = append(cmds, "--is-seeded") + } + if c.Connections { + cmds = append(cmds, "--connections") + } + if len(cmds) > 1 { + return fmt.Errorf("cannot use %s and %s together", cmds[0], cmds[1]) + } + + if c.IsSeeded { + return c.showIsSeeded(st) + } + + if c.DotOutput && c.ChangeID == "" { + return fmt.Errorf("--dot can only be used with --change=") + } + if c.NoHoldState && c.ChangeID == "" { + return fmt.Errorf("--no-hold can only be used with --change=") + } + if c.Check && c.ChangeID == "" { + return fmt.Errorf("--check can only be used with --change") + } + + if c.Changes { + return c.showChanges(st) + } + + if c.ChangeID != "" { + _, err := strconv.ParseInt(c.ChangeID, 0, 64) + if err != nil { + return fmt.Errorf("invalid change: %s", c.ChangeID) + } + if c.DotOutput { + return c.writeDotOutput(st, c.ChangeID) + } + if c.Check { + return c.checkTasks(st, c.ChangeID) + } + return c.showTasks(st, c.ChangeID) + } + + if c.TaskID != "" { + _, err := strconv.ParseInt(c.TaskID, 0, 64) + if err != nil { + return fmt.Errorf("invalid task: %s", c.TaskID) + } + return c.showTask(st, c.TaskID) + } + + if c.Connections { + return c.showConnections(st) + } + + if c.Connection != "" { + return c.showConnectionDetails(st, c.Connection) + } + + // show changes by default + return c.showChanges(st) +} diff --git a/cmd/snap/cmd_debug_state_test.go b/cmd/snap/cmd_debug_state_test.go new file mode 100644 index 00000000..b2899955 --- /dev/null +++ b/cmd/snap/cmd_debug_state_test.go @@ -0,0 +1,552 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "fmt" + "os" + "path/filepath" + "time" + + . "gopkg.in/check.v1" + + main "github.com/snapcore/snapd/cmd/snap" +) + +var stateJSON = []byte(` +{ + "last-task-id": 31, + "last-change-id": 10, + + "data": { + "snaps": {}, + "seeded": true + }, + "changes": { + "9": { + "id": "9", + "kind": "install-snap", + "summary": "install a snap", + "status": 0, + "data": {"snap-names": ["a"]}, + "task-ids": ["11","12"], + "spawn-time": "2009-11-10T23:00:00Z" + }, + "10": { + "id": "10", + "kind": "revert-snap", + "summary": "revert c snap", + "status": 0, + "data": {"snap-names": ["c"]}, + "task-ids": ["21","31"], + "spawn-time": "2009-11-10T23:00:10Z", + "ready-time": "2009-11-10T23:00:30Z" + } + }, + "tasks": { + "11": { + "id": "11", + "change": "9", + "kind": "download-snap", + "summary": "Download snap a from channel edge", + "status": 4, + "data": {"snap-setup": { + "channel": "edge", + "flags": 1 + }}, + "halt-tasks": ["12"] + }, + "12": {"id": "12", "change": "9", "kind": "some-other-task"}, + "21": { + "id": "21", + "change": "10", + "kind": "download-snap", + "summary": "Download snap b from channel beta", + "status": 4, + "data": {"snap-setup": { + "channel": "beta", + "flags": 2 + }}, + "halt-tasks": ["12"] + }, + "31": { + "id": "31", + "change": "10", + "kind": "prepare-snap", + "summary": "Prepare snap c", + "status": 4, + "data": {"snap-setup": { + "channel": "stable", + "flags": 1073741828 + }}, + "halt-tasks": ["12"], + "log": ["logline1", "logline2"] + } + } +} +`) + +var stateConnsJSON = []byte(` +{ + "data": { + "conns": { + "gnome-calculator:desktop-legacy core:desktop-legacy": { + "auto": true, + "interface": "desktop-legacy" + }, + "gnome-calculator:gtk-3-themes gtk-common-themes:gtk-3-themes": { + "auto": true, + "interface": "content", + "plug-static": { + "content": "gtk-3-themes", + "default-provider": "gtk-common-themes", + "target": "$SNAP/data-dir/themes" + }, + "slot-static": { + "content": "gtk-3-themes", + "source": { + "read": [ + "$SNAP/share/themes/Adwaita", + "$SNAP/share/themes/Materia-light-compact" + ] + } + } + }, + "gnome-calculator:icon-themes gtk-common-themes:icon-themes": { + "auto": true, + "interface": "content", + "plug-static": { + "content": "icon-themes", + "default-provider": "gtk-common-themes", + "target": "$SNAP/data-dir/icons" + }, + "slot-static": { + "content": "icon-themes", + "source": { + "read": [ + "$SNAP/share/icons/Adwaita", + "$SNAP/share/icons/elementary-xfce-darkest" + ] + } + } + }, + "gnome-calculator:network core:network": { + "auto": true, + "interface": "network" + }, + "gnome-calculator:x11 core:x11": { + "auto": true, + "interface": "x11" + }, + "vlc:x11 core:x11": { + "auto": true, + "interface": "x11" + }, + "vlc:network core:network": { + "auto": true, + "undesired": true, + "interface": "network" + }, + "some-snap:network core:network": { + "auto": true, + "by-gadget": true, + "interface": "network" + } + } + } +}`) + +var stateCyclesJSON = []byte(` +{ + "last-task-id": 14, + "last-change-id": 2, + + "data": { + "snaps": {}, + "seeded": true + }, + "changes": { + "1": { + "id": "1", + "kind": "install-snap", + "summary": "install a snap", + "status": 0, + "task-ids": ["11","12","13"] + } + }, + "tasks": { + "11": { + "id": "11", + "change": "1", + "kind": "foo", + "summary": "Foo task", + "status": 4, + "halt-tasks": ["13"], + "lanes": [1,2] + }, + "12": { + "id": "12", + "change": "1", + "kind": "bar", + "summary": "Bar task", + "halt-tasks": ["13"], + "lanes": [1] + }, + "13": { + "id": "13", + "change": "1", + "kind": "bar", + "summary": "Bar task", + "halt-tasks": ["11","12"], + "lanes": [2] + } + } +} +`) + +func (s *SnapSuite) TestDebugChanges(c *C) { + dir := c.MkDir() + stateFile := filepath.Join(dir, "test-state.json") + c.Assert(os.WriteFile(stateFile, stateJSON, 0644), IsNil) + + rest, err := main.Parser(main.Client()).ParseArgs([]string{"debug", "state", "--abs-time", "--changes", stateFile}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + c.Check(s.Stdout(), Matches, + "ID Status Spawn Ready Label Summary\n"+ + "9 Do 2009-11-10T23:00:00Z 0001-01-01T00:00:00Z install-snap install a snap\n"+ + "10 Done 2009-11-10T23:00:10Z 2009-11-10T23:00:30Z revert-snap revert c snap\n") + c.Check(s.Stderr(), Equals, "") +} + +func (s *SnapSuite) TestDebugChangesMissingState(c *C) { + _, err := main.Parser(main.Client()).ParseArgs([]string{"debug", "state", "--changes", "/missing-state.json"}) + c.Check(err, ErrorMatches, "cannot read the state file: open /missing-state.json: no such file or directory") +} + +func (s *SnapSuite) TestDebugTask(c *C) { + dir := c.MkDir() + stateFile := filepath.Join(dir, "test-state.json") + c.Assert(os.WriteFile(stateFile, stateJSON, 0644), IsNil) + + rest, err := main.Parser(main.Client()).ParseArgs([]string{"debug", "state", "--task=31", stateFile}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + c.Check(s.Stdout(), Equals, "id: 31\n"+ + "kind: prepare-snap\n"+ + "summary: Prepare snap c\n"+ + "status: Done\n"+ + "log: |\n"+ + " logline1\n"+ + " logline2\n"+ + "\n"+ + "halt-tasks:\n"+ + " - some-other-task (12)\n") + c.Check(s.Stderr(), Equals, "") +} + +func (s *SnapSuite) TestDebugTaskEmptyLists(c *C) { + dir := c.MkDir() + stateFile := filepath.Join(dir, "test-state.json") + c.Assert(os.WriteFile(stateFile, stateJSON, 0644), IsNil) + + rest, err := main.Parser(main.Client()).ParseArgs([]string{"debug", "state", "--task=12", stateFile}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + c.Check(s.Stdout(), Equals, "id: 12\n"+ + "kind: some-other-task\n"+ + "summary: \n"+ + "status: Do\n"+ + "halt-tasks: []\n") + c.Check(s.Stderr(), Equals, "") +} + +func (s *SnapSuite) TestDebugTaskMissingState(c *C) { + _, err := main.Parser(main.Client()).ParseArgs([]string{"debug", "state", "--task=1", "/missing-state.json"}) + c.Check(err, ErrorMatches, "cannot read the state file: open /missing-state.json: no such file or directory") +} + +func (s *SnapSuite) TestDebugTaskNoSuchTaskError(c *C) { + dir := c.MkDir() + stateFile := filepath.Join(dir, "test-state.json") + c.Assert(os.WriteFile(stateFile, stateJSON, 0644), IsNil) + + _, err := main.Parser(main.Client()).ParseArgs([]string{"debug", "state", "--task=99", stateFile}) + c.Check(err, ErrorMatches, "no such task: 99") +} + +func (s *SnapSuite) TestDebugTaskMutuallyExclusiveCommands(c *C) { + dir := c.MkDir() + stateFile := filepath.Join(dir, "test-state.json") + c.Assert(os.WriteFile(stateFile, stateJSON, 0644), IsNil) + + _, err := main.Parser(main.Client()).ParseArgs([]string{"debug", "state", "--task=99", "--changes", stateFile}) + c.Check(err, ErrorMatches, "cannot use --changes and --task= together") + + _, err = main.Parser(main.Client()).ParseArgs([]string{"debug", "state", "--changes", "--change=1", stateFile}) + c.Check(err, ErrorMatches, "cannot use --changes and --change= together") + + _, err = main.Parser(main.Client()).ParseArgs([]string{"debug", "state", "--change=1", "--task=1", stateFile}) + c.Check(err, ErrorMatches, "cannot use --change= and --task= together") + + _, err = main.Parser(main.Client()).ParseArgs([]string{"debug", "state", "--change=1", "--is-seeded", stateFile}) + c.Check(err, ErrorMatches, "cannot use --change= and --is-seeded together") +} + +func (s *SnapSuite) TestDebugTasks(c *C) { + dir := c.MkDir() + stateFile := filepath.Join(dir, "test-state.json") + c.Assert(os.WriteFile(stateFile, stateJSON, 0644), IsNil) + + rest, err := main.Parser(main.Client()).ParseArgs([]string{"debug", "state", "--abs-time", "--change=9", stateFile}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + c.Check(s.Stdout(), Matches, + "Lanes ID Status Spawn Ready Kind Summary\n"+ + "0 11 Done 0001-01-01T00:00:00Z 0001-01-01T00:00:00Z download-snap Download snap a from channel edge\n"+ + "0 12 Do 0001-01-01T00:00:00Z 0001-01-01T00:00:00Z some-other-task \n") + c.Check(s.Stderr(), Equals, "") +} + +func (s *SnapSuite) TestDebugTasksWithCycles(c *C) { + dir := c.MkDir() + stateFile := filepath.Join(dir, "test-state.json") + c.Assert(os.WriteFile(stateFile, stateCyclesJSON, 0644), IsNil) + + rest, err := main.Parser(main.Client()).ParseArgs([]string{"debug", "state", "--abs-time", "--change=1", stateFile}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + c.Check(s.Stdout(), Matches, + ""+ + "Lanes ID Status Spawn Ready Kind Summary\n"+ + "1 12 Do 0001-01-01T00:00:00Z 0001-01-01T00:00:00Z bar Bar task\n"+ + "1,2 11 Done 0001-01-01T00:00:00Z 0001-01-01T00:00:00Z foo Foo task\n"+ + "2 13 Do 0001-01-01T00:00:00Z 0001-01-01T00:00:00Z bar Bar task\n") + c.Check(s.Stderr(), Equals, "") +} + +func (s *SnapSuite) TestDebugCheckForCycles(c *C) { + // we use local time when printing times in a human-friendly format, which can + // break the comparison below + oldLoc := time.Local + time.Local = time.UTC + defer func() { + time.Local = oldLoc + }() + + dir := c.MkDir() + stateFile := filepath.Join(dir, "test-state.json") + c.Assert(os.WriteFile(stateFile, stateCyclesJSON, 0644), IsNil) + + rest, err := main.Parser(main.Client()).ParseArgs([]string{"debug", "state", "--check", "--change=1", stateFile}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + c.Check(s.Stdout(), Equals, ``+ + `Detected task dependency cycle involving tasks: +Lanes ID Status Spawn Ready Kind Summary After Before +1,2 11 Done 0001-01-01 0001-01-01 foo Foo task [] [13] +1 12 Do 0001-01-01 0001-01-01 bar Bar task [] [13] +2 13 Do 0001-01-01 0001-01-01 bar Bar task [] [11,12] +`) + c.Check(s.Stderr(), Equals, "") +} + +func (s *SnapSuite) TestDebugTasksMissingState(c *C) { + _, err := main.Parser(main.Client()).ParseArgs([]string{"debug", "state", "--change=1", "/missing-state.json"}) + c.Check(err, ErrorMatches, "cannot read the state file: open /missing-state.json: no such file or directory") +} + +func (s *SnapSuite) TestDebugIsSeededHappy(c *C) { + dir := c.MkDir() + stateFile := filepath.Join(dir, "test-state.json") + c.Assert(os.WriteFile(stateFile, stateJSON, 0644), IsNil) + + rest, err := main.Parser(main.Client()).ParseArgs([]string{"debug", "state", "--is-seeded", stateFile}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + c.Check(s.Stdout(), Matches, "true\n") + c.Check(s.Stderr(), Equals, "") +} + +func (s *SnapSuite) TestDebugIsSeededNo(c *C) { + dir := c.MkDir() + stateFile := filepath.Join(dir, "test-state.json") + c.Assert(os.WriteFile(stateFile, []byte("{}"), 0644), IsNil) + + rest, err := main.Parser(main.Client()).ParseArgs([]string{"debug", "state", "--is-seeded", stateFile}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + c.Check(s.Stdout(), Matches, "false\n") + c.Check(s.Stderr(), Equals, "") +} + +func (s *SnapSuite) TestDebugConnections(c *C) { + dir := c.MkDir() + stateFile := filepath.Join(dir, "test-state.json") + c.Assert(os.WriteFile(stateFile, stateConnsJSON, 0644), IsNil) + + rest, err := main.Parser(main.Client()).ParseArgs([]string{"debug", "state", "--connections", stateFile}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + c.Check(s.Stdout(), Matches, + "Interface Plug Slot Notes\n"+ + "desktop-legacy gnome-calculator:desktop-legacy core:desktop-legacy auto\n"+ + "content gnome-calculator:gtk-3-themes gtk-common-themes:gtk-3-themes auto\n"+ + "content gnome-calculator:icon-themes gtk-common-themes:icon-themes auto\n"+ + "network gnome-calculator:network core:network auto\n"+ + "x11 gnome-calculator:x11 core:x11 auto\n"+ + "network some-snap:network core:network auto,by-gadget\n"+ + "network vlc:network core:network auto,undesired\n"+ + "x11 vlc:x11 core:x11 auto\n") + c.Check(s.Stderr(), Equals, "") +} + +func (s *SnapSuite) TestDebugConnectionDetails(c *C) { + dir := c.MkDir() + stateFile := filepath.Join(dir, "test-state.json") + c.Assert(os.WriteFile(stateFile, stateConnsJSON, 0644), IsNil) + + for i, connArg := range []string{"gnome-calculator:gtk-3-themes", ",gtk-common-themes:gtk-3-themes"} { + s.ResetStdStreams() + rest, err := main.Parser(main.Client()).ParseArgs([]string{"debug", "state", fmt.Sprintf("--connection=%s", connArg), stateFile}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + c.Check(s.Stdout(), Matches, + "id: gnome-calculator:gtk-3-themes gtk-common-themes:gtk-3-themes\n"+ + "auto: true\n"+ + "by-gadget: false\n"+ + "interface: content\n"+ + "undesired: false\n"+ + "plug-static:\n"+ + " content: gtk-3-themes\n"+ + " default-provider: gtk-common-themes\n"+ + " target: \\$SNAP/data-dir/themes\n"+ + "slot-static:\n"+ + " content: gtk-3-themes\n"+ + " source:\n"+ + " read:\n"+ + " - \\$SNAP/share/themes/Adwaita\n"+ + " - \\$SNAP/share/themes/Materia-light-compact\n"+ + "\n", Commentf("#%d: %s", i, connArg)) + c.Check(s.Stderr(), Equals, "") + } +} + +func (s *SnapSuite) TestDebugConnectionPlugAndSlot(c *C) { + dir := c.MkDir() + stateFile := filepath.Join(dir, "test-state.json") + c.Assert(os.WriteFile(stateFile, stateConnsJSON, 0644), IsNil) + + connArg := "gnome-calculator:network,core:network" + rest, err := main.Parser(main.Client()).ParseArgs([]string{"debug", "state", fmt.Sprintf("--connection=%s", connArg), stateFile}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + c.Check(s.Stdout(), Matches, + "id: gnome-calculator:network core:network\n"+ + "auto: true\n"+ + "by-gadget: false\n"+ + "interface: network\n"+ + "undesired: false\n"+ + "\n", Commentf("#0: %s", connArg)) + c.Check(s.Stderr(), Equals, "") +} + +func (s *SnapSuite) TestDebugConnectionInvalidCombination(c *C) { + dir := c.MkDir() + stateFile := filepath.Join(dir, "test-state.json") + c.Assert(os.WriteFile(stateFile, stateConnsJSON, 0644), IsNil) + + connArg := "gnome-calculator,core:network" + _, err := main.Parser(main.Client()).ParseArgs([]string{"debug", "state", fmt.Sprintf("--connection=%s", connArg), stateFile}) + c.Assert(err, ErrorMatches, fmt.Sprintf("invalid command with connection args: %s", connArg)) + c.Check(s.Stdout(), Equals, "") +} + +func (s *SnapSuite) TestDebugConnectionDetailsMany(c *C) { + dir := c.MkDir() + stateFile := filepath.Join(dir, "test-state.json") + c.Assert(os.WriteFile(stateFile, stateConnsJSON, 0644), IsNil) + + rest, err := main.Parser(main.Client()).ParseArgs([]string{"debug", "state", "--connection=core", stateFile}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + c.Check(s.Stdout(), Matches, + "id: gnome-calculator:desktop-legacy core:desktop-legacy\n"+ + "auto: true\n"+ + "by-gadget: false\n"+ + "interface: desktop-legacy\n"+ + "undesired: false\n"+ + "\n"+ + "id: gnome-calculator:network core:network\n"+ + "auto: true\n"+ + "by-gadget: false\n"+ + "interface: network\n"+ + "undesired: false\n"+ + "\n"+ + "id: gnome-calculator:x11 core:x11\n"+ + "auto: true\n"+ + "by-gadget: false\n"+ + "interface: x11\n"+ + "undesired: false\n"+ + "\n"+ + "id: some-snap:network core:network\n"+ + "auto: true\n"+ + "by-gadget: true\n"+ + "interface: network\n"+ + "undesired: false\n"+ + "\n"+ + "id: vlc:network core:network\n"+ + "auto: true\n"+ + "by-gadget: false\n"+ + "interface: network\n"+ + "undesired: true\n"+ + "\n"+ + "id: vlc:x11 core:x11\n"+ + "auto: true\n"+ + "by-gadget: false\n"+ + "interface: x11\n"+ + "undesired: false\n"+ + "\n") + + c.Check(s.Stderr(), Equals, "") +} + +func (s *SnapSuite) TestDebugConnectionDetailsManySlotSide(c *C) { + dir := c.MkDir() + stateFile := filepath.Join(dir, "test-state.json") + c.Assert(os.WriteFile(stateFile, stateConnsJSON, 0644), IsNil) + + rest, err := main.Parser(main.Client()).ParseArgs([]string{"debug", "state", "--connection=core:x11", stateFile}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + c.Check(s.Stdout(), Matches, + "id: gnome-calculator:x11 core:x11\n"+ + "auto: true\n"+ + "by-gadget: false\n"+ + "interface: x11\n"+ + "undesired: false\n"+ + "\n"+ + "id: vlc:x11 core:x11\n"+ + "auto: true\n"+ + "by-gadget: false\n"+ + "interface: x11\n"+ + "undesired: false\n"+ + "\n") +} diff --git a/cmd/snap/cmd_debug_timings.go b/cmd/snap/cmd_debug_timings.go new file mode 100644 index 00000000..170593c7 --- /dev/null +++ b/cmd/snap/cmd_debug_timings.go @@ -0,0 +1,292 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "fmt" + "io" + "sort" + "strings" + "time" + + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/i18n" +) + +type cmdChangeTimings struct { + changeIDMixin + EnsureTag string `long:"ensure" choice:"auto-refresh" choice:"become-operational" choice:"refresh-catalogs" choice:"refresh-hints" choice:"seed" choice:"install-system"` + All bool `long:"all"` + StartupTag string `long:"startup" choice:"load-state" choice:"ifacemgr"` + Verbose bool `long:"verbose"` +} + +func init() { + addDebugCommand("timings", + i18n.G("Get the timings of the tasks of a change"), + i18n.G("The timings command displays details about the time each task runs."), + func() flags.Commander { + return &cmdChangeTimings{} + }, changeIDMixinOptDesc.also(map[string]string{ + "ensure": i18n.G("Show timings for a change related to the given Ensure activity (one of: auto-refresh, become-operational, refresh-catalogs, refresh-hints, seed)"), + "all": i18n.G("Show timings for all executions of the given Ensure or startup activity, not just the latest"), + "startup": i18n.G("Show timings for the startup of given subsystem (one of: load-state, ifacemgr)"), + // TRANSLATORS: This should not start with a lowercase letter. + "verbose": i18n.G("Show more information"), + }), changeIDMixinArgDesc) +} + +type Timing struct { + Level int `json:"level,omitempty"` + Label string `json:"label,omitempty"` + Summary string `json:"summary,omitempty"` + Duration time.Duration `json:"duration,omitempty"` +} + +func formatDuration(dur time.Duration) string { + return fmt.Sprintf("%dms", dur/time.Millisecond) +} + +func printTiming(w io.Writer, verbose bool, nestLevel int, id, status, doingTimeStr, undoingTimeStr, label, summary string) { + // don't display id for nesting>1, instead show nesting indicator + if nestLevel > 0 { + id = strings.Repeat(" ", nestLevel) + "^" + } + // Duration formats to 17m14.342s or 2.038s or 970ms, so with + // 11 chars we can go up to 59m59.999s + if verbose { + fmt.Fprintf(w, "%s\t%s\t%11s\t%11s\t%s\t%s\n", id, status, doingTimeStr, undoingTimeStr, label, strings.Repeat(" ", 2*nestLevel)+summary) + } else { + fmt.Fprintf(w, "%s\t%s\t%11s\t%11s\t%s\n", id, status, doingTimeStr, undoingTimeStr, strings.Repeat(" ", 2*nestLevel)+summary) + } +} + +func printTaskTiming(w io.Writer, t *Timing, verbose, doing bool) { + var doingTimeStr, undoingTimeStr string + if doing { + doingTimeStr = formatDuration(t.Duration) + undoingTimeStr = "-" + } else { + doingTimeStr = "-" + undoingTimeStr = formatDuration(t.Duration) + } + printTiming(w, verbose, t.Level+1, "", "", doingTimeStr, undoingTimeStr, t.Label, t.Summary) +} + +// sortTimingsTasks sorts tasks from changeTimings by lane and ready time with special treatment of lane 0 tasks: +// - tasks from lanes >0 are grouped by lanes and sorted by ready time. +// - tasks from lane 0 are sorted by ready time and inserted before and after other lanes based on the min/max +// ready times of non-zero lanes. +// - tasks from lane 0 with ready time between non-zero lane tasks are not really expected in our system and will +// appear after non-zero lane tasks. +func sortTimingsTasks(timings map[string]changeTimings) []string { + tasks := make([]string, 0, len(timings)) + + var minReadyTime time.Time + // determine min ready time from all non-zero lane tasks + for taskID, taskData := range timings { + if taskData.Lane > 0 { + if minReadyTime.IsZero() { + minReadyTime = taskData.ReadyTime + } + if taskData.ReadyTime.Before(minReadyTime) { + minReadyTime = taskData.ReadyTime + } + } + tasks = append(tasks, taskID) + } + + sort.Slice(tasks, func(i, j int) bool { + t1 := timings[tasks[i]] + t2 := timings[tasks[j]] + if t1.Lane != t2.Lane { + // if either t1 or t2 is from lane 0, then it comes before or after non-zero lane tasks + if t1.Lane == 0 { + return t1.ReadyTime.Before(minReadyTime) + } + if t2.Lane == 0 { + return !t2.ReadyTime.Before(minReadyTime) + } + // different lanes (but neither of them is 0), order by lane + return t1.Lane < t2.Lane + } + + // same lane - order by ready time + return t1.ReadyTime.Before(t2.ReadyTime) + }) + + return tasks +} + +func (x *cmdChangeTimings) printChangeTimings(w io.Writer, timing *timingsData) error { + tasks := sortTimingsTasks(timing.ChangeTimings) + + for _, taskID := range tasks { + chgTiming := timing.ChangeTimings[taskID] + doingTime := formatDuration(timing.ChangeTimings[taskID].DoingTime) + if chgTiming.DoingTime == 0 { + doingTime = "-" + } + undoingTime := formatDuration(timing.ChangeTimings[taskID].UndoingTime) + if chgTiming.UndoingTime == 0 { + undoingTime = "-" + } + + printTiming(w, x.Verbose, 0, taskID, chgTiming.Status, doingTime, undoingTime, chgTiming.Kind, chgTiming.Summary) + for _, nested := range timing.ChangeTimings[taskID].DoingTimings { + showDoing := true + printTaskTiming(w, &nested, x.Verbose, showDoing) + } + for _, nested := range timing.ChangeTimings[taskID].UndoingTimings { + showDoing := false + printTaskTiming(w, &nested, x.Verbose, showDoing) + } + } + + return nil +} + +func (x *cmdChangeTimings) printEnsureTimings(w io.Writer, timings []*timingsData) error { + for _, td := range timings { + printTiming(w, x.Verbose, 0, x.EnsureTag, "", formatDuration(td.TotalDuration), "-", "", "") + for _, t := range td.EnsureTimings { + printTiming(w, x.Verbose, t.Level+1, "", "", formatDuration(t.Duration), "-", t.Label, t.Summary) + } + + // change is optional for ensure timings + if td.ChangeID != "" { + x.printChangeTimings(w, td) + } + } + return nil +} + +func (x *cmdChangeTimings) printStartupTimings(w io.Writer, timings []*timingsData) error { + for _, td := range timings { + printTiming(w, x.Verbose, 0, x.StartupTag, "", formatDuration(td.TotalDuration), "-", "", "") + for _, t := range td.StartupTimings { + printTiming(w, x.Verbose, t.Level+1, "", "", formatDuration(t.Duration), "-", t.Label, t.Summary) + } + } + return nil +} + +type changeTimings struct { + Status string `json:"status,omitempty"` + Kind string `json:"kind,omitempty"` + Summary string `json:"summary,omitempty"` + Lane int `json:"lane,omitempty"` + ReadyTime time.Time `json:"ready-time,omitempty"` + DoingTime time.Duration `json:"doing-time,omitempty"` + UndoingTime time.Duration `json:"undoing-time,omitempty"` + DoingTimings []Timing `json:"doing-timings,omitempty"` + UndoingTimings []Timing `json:"undoing-timings,omitempty"` +} + +type timingsData struct { + ChangeID string `json:"change-id"` + EnsureTimings []Timing `json:"ensure-timings,omitempty"` + StartupTimings []Timing `json:"startup-timings,omitempty"` + TotalDuration time.Duration `json:"total-duration,omitempty"` + // ChangeTimings are indexed by task id + ChangeTimings map[string]changeTimings `json:"change-timings,omitempty"` +} + +func (x *cmdChangeTimings) checkConflictingFlags() error { + var i int + for _, opt := range []string{string(x.Positional.ID), x.StartupTag, x.EnsureTag} { + if opt != "" { + i++ + if i > 1 { + return fmt.Errorf("cannot use change id, 'startup' or 'ensure' together") + } + } + } + + if x.All && (x.Positional.ID != "" || x.LastChangeType != "") { + return fmt.Errorf("cannot use 'all' with change id or 'last'") + } + return nil +} + +func (x *cmdChangeTimings) Execute(args []string) error { + if len(args) > 0 { + return ErrExtraArgs + } + + if err := x.checkConflictingFlags(); err != nil { + return err + } + + var chgid string + var err error + + if x.EnsureTag == "" && x.StartupTag == "" { + if x.Positional.ID == "" && x.LastChangeType == "" { + // GetChangeID() below checks for empty change ID / --last, check them early here to provide more helpful error message + return fmt.Errorf("please provide change ID or type with --last=, or query for --ensure= or --startup=") + } + + // GetChangeID takes care of --last=... if change ID was not specified by the user + chgid, err = x.GetChangeID() + if err != nil { + return err + } + } + + // gather debug timings first + var timings []*timingsData + var allEnsures string + if x.All { + allEnsures = "true" + } else { + allEnsures = "false" + } + if err := x.client.DebugGet("change-timings", &timings, map[string]string{"change-id": chgid, "ensure": x.EnsureTag, "all": allEnsures, "startup": x.StartupTag}); err != nil { + return err + } + + w := tabWriter() + if x.Verbose { + fmt.Fprintf(w, "ID\tStatus\t%11s\t%11s\tLabel\tSummary\n", "Doing", "Undoing") + } else { + fmt.Fprintf(w, "ID\tStatus\t%11s\t%11s\tSummary\n", "Doing", "Undoing") + } + + // If a specific change was requested, we expect exactly one timingsData element. + // If "ensure" activity was requested, we may get multiple elements (for multiple executions of the ensure) + if chgid != "" && len(timings) > 0 { + x.printChangeTimings(w, timings[0]) + } + + if x.EnsureTag != "" { + x.printEnsureTimings(w, timings) + } + + if x.StartupTag != "" { + x.printStartupTimings(w, timings) + } + + w.Flush() + fmt.Fprintln(Stdout) + + return nil +} diff --git a/cmd/snap/cmd_debug_timings_test.go b/cmd/snap/cmd_debug_timings_test.go new file mode 100644 index 00000000..8d05384d --- /dev/null +++ b/cmd/snap/cmd_debug_timings_test.go @@ -0,0 +1,348 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "fmt" + "net/http" + "strings" + "time" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/cmd/snap" +) + +type timingsCmdArgs struct { + args, stdout, stderr, error string +} + +var timingsTests = []timingsCmdArgs{{ + args: "debug timings", + error: "please provide change ID or type with --last=, or query for --ensure= or --startup=", +}, { + args: "debug timings --ensure=seed 9", + error: "cannot use change id, 'startup' or 'ensure' together", +}, { + args: "debug timings --ensure=seed --startup=ifacemgr", + error: "cannot use change id, 'startup' or 'ensure' together", +}, { + args: "debug timings --last=install --all", + error: "cannot use 'all' with change id or 'last'", +}, { + args: "debug timings --last=remove", + error: `no changes of type "remove" found`, +}, { + args: "debug timings --startup=load-state 9", + error: "cannot use change id, 'startup' or 'ensure' together", +}, { + args: "debug timings --all 9", + error: "cannot use 'all' with change id or 'last'", +}, { + args: "debug timings --last=install", + stdout: "ID Status Doing Undoing Summary\n" + + "40 Doing 910ms - lane 0 task bar summary\n" + + " ^ 1ms - foo summary\n" + + " ^ 1ms - bar summary\n" + + "41 Done 210ms - lane 1 task baz summary\n" + + "42 Done 310ms - lane 1 task boo summary\n" + + "43 Done 310ms - lane 0 task doh summary\n\n", +}, { + args: "debug timings 1", + stdout: "ID Status Doing Undoing Summary\n" + + "40 Doing 910ms - lane 0 task bar summary\n" + + " ^ 1ms - foo summary\n" + + " ^ 1ms - bar summary\n" + + "41 Done 210ms - lane 1 task baz summary\n" + + "42 Done 310ms - lane 1 task boo summary\n" + + "43 Done 310ms - lane 0 task doh summary\n\n", +}, { + args: "debug timings 1 --verbose", + stdout: "ID Status Doing Undoing Label Summary\n" + + "40 Doing 910ms - bar lane 0 task bar summary\n" + + " ^ 1ms - foo foo summary\n" + + " ^ 1ms - bar bar summary\n" + + "41 Done 210ms - baz lane 1 task baz summary\n" + + "42 Done 310ms - boo lane 1 task boo summary\n" + + "43 Done 310ms - doh lane 0 task doh summary\n\n", +}, { + args: "debug timings --ensure=seed", + stdout: "ID Status Doing Undoing Summary\n" + + "seed 8ms - \n" + + " ^ 8ms - baz summary\n" + + " ^ 8ms - booze summary\n" + + "40 Doing 910ms - task bar summary\n" + + " ^ 1ms - foo summary\n" + + " ^ 1ms - bar summary\n\n", +}, { + args: "debug timings --ensure=seed --all", + stdout: "ID Status Doing Undoing Summary\n" + + "seed 8ms - \n" + + " ^ 8ms - bar summary 1\n" + + " ^ 8ms - bar summary 2\n" + + "40 Doing 910ms - task bar summary\n" + + " ^ 1ms - foo summary\n" + + " ^ 1ms - bar summary\n" + + "seed 7ms - \n" + + " ^ 7ms - baz summary 2\n" + + "60 Doing 910ms - task bar summary\n" + + " ^ 1ms - foo summary\n" + + " ^ 1ms - bar summary\n\n", +}, { + args: "debug timings --ensure=seed --all --verbose", + stdout: "ID Status Doing Undoing Label Summary\n" + + "seed 8ms - \n" + + " ^ 8ms - abc bar summary 1\n" + + " ^ 8ms - abc bar summary 2\n" + + "40 Doing 910ms - bar task bar summary\n" + + " ^ 1ms - foo foo summary\n" + + " ^ 1ms - bar bar summary\n" + + "seed 7ms - \n" + + " ^ 7ms - ghi baz summary 2\n" + + "60 Doing 910ms - bar task bar summary\n" + + " ^ 1ms - foo foo summary\n" + + " ^ 1ms - bar bar summary\n\n", +}, { + args: "debug timings --startup=ifacemgr", + stdout: "ID Status Doing Undoing Summary\n" + + "ifacemgr 8ms - \n" + + " ^ 8ms - baz summary\n" + + " ^ 8ms - booze summary\n\n", +}, { + args: "debug timings --startup=ifacemgr --all", + stdout: "ID Status Doing Undoing Summary\n" + + "ifacemgr 8ms - \n" + + " ^ 8ms - baz summary\n" + + "ifacemgr 9ms - \n" + + " ^ 9ms - baz summary\n\n", +}, { + args: "debug timings 2", + stdout: "ID Status Doing Undoing Summary\n" + + "41 Undone - 210ms lane 0 task bar summary\n\n", +}, +} + +func (s *SnapSuite) TestGetDebugTimings(c *C) { + s.mockCmdTimingsAPI(c) + + restore := main.MockIsStdinTTY(true) + defer restore() + + for _, test := range timingsTests { + s.stdout.Truncate(0) + s.stderr.Truncate(0) + + c.Logf("Test: %s", test.args) + + _, err := main.Parser(main.Client()).ParseArgs(strings.Fields(test.args)) + if test.error != "" { + c.Check(err, ErrorMatches, test.error) + } else { + c.Check(err, IsNil) + c.Check(s.Stderr(), Equals, test.stderr) + c.Check(s.Stdout(), Equals, test.stdout) + } + } +} + +func (s *SnapSuite) mockCmdTimingsAPI(c *C) { + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, Equals, "GET") + + if r.URL.Path == "/v2/debug" { + q := r.URL.Query() + aspect := q.Get("aspect") + c.Assert(aspect, Equals, "change-timings") + + changeID := q.Get("change-id") + ensure := q.Get("ensure") + startup := q.Get("startup") + all := q.Get("all") + + switch { + case changeID == "1": + // lane 0 and lane 1 tasks, interleaved + fmt.Fprintln(w, `{"type":"sync","status-code":200,"status":"OK","result":[ + {"change-id":"1", "change-timings":{ + "41":{"doing-time":210000000, "status": "Done", "lane": 1, "ready-time": "2016-04-22T01:02:04Z", "kind": "baz", "summary": "lane 1 task baz summary"}, + "43":{"doing-time":310000000, "status": "Done", "ready-time": "2016-04-25T01:02:04Z", "kind": "doh", "summary": "lane 0 task doh summary"}, + "40":{"doing-time":910000000, "status": "Doing", "ready-time": "2016-04-20T00:00:00Z", "kind": "bar", "summary": "lane 0 task bar summary", + "doing-timings":[ + {"label":"foo", "summary": "foo summary", "duration": 1000001}, + {"level":1, "label":"bar", "summary": "bar summary", "duration": 1000002} + ]}, + "42":{"doing-time":310000000, "status": "Done", "lane": 1, "ready-time": "2016-04-23T01:02:04Z", "kind": "boo", "summary": "lane 1 task boo summary"} + }}]}`) + case changeID == "2": + // lane 0 tasks, interleaved + fmt.Fprintln(w, `{"type":"sync","status-code":200,"status":"OK","result":[ + {"change-id":"1", "change-timings":{ + "41":{"undoing-time":210000000, "status": "Undone", "lane": 0, "ready-time": "2016-04-22T01:02:04Z", "kind": "baz", "summary": "lane 0 task bar summary"} + }}]}`) + case ensure == "seed" && all == "false": + fmt.Fprintln(w, `{"type":"sync","status-code":200,"status":"OK","result":[ + {"change-id":"1", + "total-duration": 8000002, + "ensure-timings": [ + {"label":"baz", "summary": "baz summary", "duration": 8000001}, + {"level":1, "label":"booze", "summary": "booze summary", "duration": 8000002} + ], + "change-timings":{ + "40":{"doing-time":910000000, "status": "Doing", "kind": "bar", "summary": "task bar summary", + "doing-timings":[ + {"label":"foo", "summary": "foo summary", "duration": 1000001}, + {"level":1, "label":"bar", "summary": "bar summary", "duration": 1000002} + ]}}}]}`) + case ensure == "seed" && all == "true": + fmt.Fprintln(w, `{"type":"sync","status-code":200,"status":"OK","result":[ + {"change-id":"1", + "total-duration": 8000002, + "ensure-timings": [ + {"label":"abc", "summary": "bar summary 1", "duration": 8000001}, + {"label":"abc", "summary": "bar summary 2", "duration": 8000002} + ], + "change-timings":{ + "40":{"doing-time":910000000, "status": "Doing", "kind": "bar", "summary": "task bar summary", + "doing-timings":[ + {"label":"foo", "summary": "foo summary", "duration": 1000001}, + {"level":1, "label":"bar", "summary": "bar summary", "duration": 1000002} + ]}}}, + {"change-id":"2", + "total-duration": 7000002, + "ensure-timings": [{"label":"ghi", "summary": "baz summary 2", "duration": 7000002}], + "change-timings":{ + "60":{"doing-time":910000000, "status": "Doing", "kind": "bar", "summary": "task bar summary", + "doing-timings":[ + {"label":"foo", "summary": "foo summary", "duration": 1000001}, + {"level":1, "label":"bar", "summary": "bar summary", "duration": 1000002} + ]}}}]}`) + case startup == "ifacemgr" && all == "false": + fmt.Fprintln(w, `{"type":"sync","status-code":200,"status":"OK","result":[ + {"total-duration": 8000002, "startup-timings": [ + {"label":"baz", "summary": "baz summary", "duration": 8000001}, + {"level":1, "label":"booze", "summary": "booze summary", "duration": 8000002} + ]}]}`) + case startup == "ifacemgr" && all == "true": + fmt.Fprintln(w, `{"type":"sync","status-code":200,"status":"OK","result":[ + {"total-duration": 8000002, "startup-timings": [ + {"label":"baz", "summary": "baz summary", "duration": 8000001} + ]}, + {"total-duration": 9000002, "startup-timings": [ + {"label":"baz", "summary": "baz summary", "duration": 9000001} + ]}]}`) + default: + c.Errorf("unexpected request: %s, %s, %s", changeID, ensure, all) + } + return + } + + // request for all changes on --last=... + if r.URL.Path == "/v2/changes" { + fmt.Fprintln(w, `{"type":"sync","status-code":200,"status":"OK","result":[{ + "id": "1", + "kind": "install-snap", + "summary": "a", + "status": "Doing", + "ready": false, + "spawn-time": "2016-04-21T01:02:03Z", + "ready-time": "2016-04-21T01:02:04Z", + "tasks": [{"id":"99", "kind": "bar", "summary": ".", "status": "Doing", "progress": {"done": 0, "total": 1}, "spawn-time": "2016-04-21T01:02:03Z", "ready-time": "2016-04-21T01:02:04Z"}] + }]}`) + return + } + c.Errorf("unexpected path %q", r.URL.Path) + }) +} + +type TaskDef struct { + TaskID string + Lane int + ReadyTime time.Time +} + +func (s *SnapSuite) TestSortTimingsTasks(c *C) { + mkTime := func(timeStr string) time.Time { + t, err := time.Parse(time.RFC3339, timeStr) + c.Assert(err, IsNil) + return t + } + + testData := []struct { + ChangeTimings map[string]main.ChangeTimings + Expected []string + }{{ + // nothing to do + ChangeTimings: map[string]main.ChangeTimings{}, + Expected: []string{}, + }, { + ChangeTimings: map[string]main.ChangeTimings{ + // tasks in lane 0 only + "1": {ReadyTime: mkTime("2019-04-21T00:00:00Z")}, + "2": {ReadyTime: mkTime("2019-05-21T00:00:00Z")}, + "3": {ReadyTime: mkTime("2019-02-21T00:00:00Z")}, + "4": {ReadyTime: mkTime("2019-03-21T00:00:00Z")}, + "5": {ReadyTime: mkTime("2019-01-21T00:00:00Z")}, + }, + Expected: []string{"5", "3", "4", "1", "2"}, + }, { + // task in lane 1 with a task in lane 0 before and after it + ChangeTimings: map[string]main.ChangeTimings{ + "1": {Lane: 1, ReadyTime: mkTime("2019-01-21T00:00:00Z")}, + "2": {Lane: 0, ReadyTime: mkTime("2019-01-20T00:00:00Z")}, + "3": {Lane: 0, ReadyTime: mkTime("2019-01-22T00:00:00Z")}, + }, + Expected: []string{"2", "1", "3"}, + }, { + // tasks in lane 1 only + ChangeTimings: map[string]main.ChangeTimings{ + "1": {Lane: 1, ReadyTime: mkTime("2019-01-21T00:00:00Z")}, + "2": {Lane: 1, ReadyTime: mkTime("2019-01-20T00:00:00Z")}, + "3": {Lane: 1, ReadyTime: mkTime("2019-01-16T00:00:00Z")}, + }, + Expected: []string{"3", "2", "1"}, + }, { + // tasks in lanes 0, 1, 2 with tasks from line 0 before and after lanes 1, 2 + ChangeTimings: map[string]main.ChangeTimings{ + "1": {Lane: 1, ReadyTime: mkTime("2019-01-21T00:00:00Z")}, + "2": {Lane: 0, ReadyTime: mkTime("2019-01-19T00:00:00Z")}, + "3": {Lane: 2, ReadyTime: mkTime("2019-01-20T00:00:00Z")}, + "4": {Lane: 0, ReadyTime: mkTime("2019-01-25T00:00:00Z")}, + "5": {Lane: 1, ReadyTime: mkTime("2019-01-20T00:00:00Z")}, + "6": {Lane: 2, ReadyTime: mkTime("2019-01-21T00:00:00Z")}, + "7": {Lane: 0, ReadyTime: mkTime("2019-01-18T00:00:00Z")}, + "8": {Lane: 0, ReadyTime: mkTime("2019-01-27T00:00:00Z")}, + }, + Expected: []string{"7", "2", "5", "1", "3", "6", "4", "8"}, + }, { + // pathological case: lane 0 tasks have ready-time between lane 1 tasks + ChangeTimings: map[string]main.ChangeTimings{ + "1": {Lane: 1, ReadyTime: mkTime("2019-01-20T00:00:00Z")}, + "2": {Lane: 1, ReadyTime: mkTime("2019-01-30T00:00:00Z")}, + "3": {Lane: 0, ReadyTime: mkTime("2019-01-27T00:00:00Z")}, + "4": {Lane: 0, ReadyTime: mkTime("2019-01-25T00:00:00Z")}, + }, + Expected: []string{"1", "2", "4", "3"}, + }} + + for _, data := range testData { + tasks := main.SortTimingsTasks(data.ChangeTimings) + c.Check(tasks, DeepEquals, data.Expected) + } +} diff --git a/cmd/snap/cmd_debug_validate_seed.go b/cmd/snap/cmd_debug_validate_seed.go new file mode 100644 index 00000000..68e8fa55 --- /dev/null +++ b/cmd/snap/cmd_debug_validate_seed.go @@ -0,0 +1,56 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2019-2023 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/interfaces/builtin" + "github.com/snapcore/snapd/seed" + "github.com/snapcore/snapd/snap" +) + +type cmdValidateSeed struct { + Positionals struct { + SeedYamlPath flags.Filename `positional-arg-name:""` + } `positional-args:"true" required:"true"` +} + +func init() { + cmd := addDebugCommand("validate-seed", + "(internal) validate seed.yaml", + "(internal) validate seed.yaml", + func() flags.Commander { + return &cmdValidateSeed{} + }, nil, nil) + cmd.hidden = true +} + +func (x *cmdValidateSeed) Execute(args []string) error { + if len(args) > 0 { + return ErrExtraArgs + } + // plug/slot sanitization is disabled (no-op) by default at the package + // level for "snap" command, for seed package use here however we want + // real validation. + snap.SanitizePlugsSlots = builtin.SanitizePlugsSlots + + return seed.ValidateFromYaml(string(x.Positionals.SeedYamlPath)) +} diff --git a/cmd/snap/cmd_debug_validate_seed_test.go b/cmd/snap/cmd_debug_validate_seed_test.go new file mode 100644 index 00000000..795cba38 --- /dev/null +++ b/cmd/snap/cmd_debug_validate_seed_test.go @@ -0,0 +1,45 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2019-2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "os" + "path/filepath" + + . "gopkg.in/check.v1" + + snap "github.com/snapcore/snapd/cmd/snap" +) + +func (s *SnapSuite) TestDebugValidateCannotValidate(c *C) { + tmpf := filepath.Join(c.MkDir(), "seed.yaml") + err := os.WriteFile(tmpf, []byte(` +snaps: + - + name: core + channel: stable + file: core_6673.snap +`), 0644) + c.Assert(err, IsNil) + + _, err = snap.Parser(snap.Client()).ParseArgs([]string{"debug", "validate-seed", tmpf}) + c.Assert(err, ErrorMatches, `cannot validate seed: + - no seed assertions`) +} diff --git a/cmd/snap/cmd_delete_key.go b/cmd/snap/cmd_delete_key.go new file mode 100644 index 00000000..3dc1a78d --- /dev/null +++ b/cmd/snap/cmd_delete_key.go @@ -0,0 +1,75 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "fmt" + + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/asserts/signtool" + "github.com/snapcore/snapd/i18n" +) + +type cmdDeleteKey struct { + Positional struct { + KeyName keyName + } `positional-args:"true" required:"true"` +} + +func init() { + cmd := addCommand("delete-key", + i18n.G("Delete cryptographic key pair"), + i18n.G(` +The delete-key command deletes the local cryptographic key pair with +the given name. +`), + func() flags.Commander { + return &cmdDeleteKey{} + }, nil, []argDesc{{ + // TRANSLATORS: This needs to begin with < and end with > + name: i18n.G(""), + // TRANSLATORS: This should not start with a lowercase letter. + desc: i18n.G("Name of key to delete"), + }}) + cmd.hidden = true + cmd.completeHidden = true +} + +func (x *cmdDeleteKey) Execute(args []string) error { + if len(args) > 0 { + return ErrExtraArgs + } + + keypairMgr, err := signtool.GetKeypairManager() + if err != nil { + return err + } + keyName := string(x.Positional.KeyName) + err = keypairMgr.DeleteByName(keyName) + if _, ok := err.(*asserts.ExternalUnsupportedOpError); ok { + return fmt.Errorf(i18n.G("cannot delete external keypair manager key via snap command, use the appropriate external procedure")) + } + if err != nil { + return fmt.Errorf("cannot delete key named %q: %v", keyName, err) + } + return nil +} diff --git a/cmd/snap/cmd_delete_key_test.go b/cmd/snap/cmd_delete_key_test.go new file mode 100644 index 00000000..67e4c912 --- /dev/null +++ b/cmd/snap/cmd_delete_key_test.go @@ -0,0 +1,95 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "encoding/json" + "os" + + . "gopkg.in/check.v1" + + snap "github.com/snapcore/snapd/cmd/snap" + "github.com/snapcore/snapd/testutil" +) + +// XXX: share this helper with signtool tests? +func mockNopExtKeyMgr(c *C) (pgm *testutil.MockCmd, restore func()) { + os.Setenv("SNAPD_EXT_KEYMGR", "keymgr") + pgm = testutil.MockCommand(c, "keymgr", ` +if [ "$1" == "features" ]; then + echo '{"signing":["RSA-PKCS"] , "public-keys":["DER"]}' + exit 0 +fi +exit 1 +`) + r := func() { + pgm.Restore() + os.Unsetenv("SNAPD_EXT_KEYMGR") + } + + return pgm, r +} + +func (s *SnapKeysSuite) TestDeleteKeyRequiresName(c *C) { + _, err := snap.Parser(snap.Client()).ParseArgs([]string{"delete-key"}) + c.Assert(err, NotNil) + c.Check(err.Error(), Equals, "the required argument `` was not provided") + c.Check(s.Stdout(), Equals, "") + c.Check(s.Stderr(), Equals, "") +} + +func (s *SnapKeysSuite) TestDeleteKeyNonexistent(c *C) { + _, err := snap.Parser(snap.Client()).ParseArgs([]string{"delete-key", "nonexistent"}) + c.Assert(err, NotNil) + c.Check(err.Error(), Equals, `cannot delete key named "nonexistent": cannot find key pair in GPG keyring`) + c.Check(s.Stdout(), Equals, "") + c.Check(s.Stderr(), Equals, "") +} + +func (s *SnapKeysSuite) TestDeleteKey(c *C) { + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"delete-key", "another"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + c.Check(s.Stdout(), Equals, "") + c.Check(s.Stderr(), Equals, "") + _, err = snap.Parser(snap.Client()).ParseArgs([]string{"keys", "--json"}) + c.Assert(err, IsNil) + expectedResponse := []snap.Key{ + { + Name: "default", + Sha3_384: "g4Pks54W_US4pZuxhgG_RHNAf_UeZBBuZyGRLLmMj1Do3GkE_r_5A5BFjx24ZwVJ", + }, + } + var obtainedResponse []snap.Key + json.Unmarshal(s.stdout.Bytes(), &obtainedResponse) + c.Check(obtainedResponse, DeepEquals, expectedResponse) + c.Check(s.Stderr(), Equals, "") +} + +func (s *SnapKeysSuite) TestDeleteKeyExternalUnsupported(c *C) { + _, restore := mockNopExtKeyMgr(c) + defer restore() + + _, err := snap.Parser(snap.Client()).ParseArgs([]string{"delete-key", "key"}) + c.Assert(err, NotNil) + c.Check(err.Error(), Equals, "cannot delete external keypair manager key via snap command, use the appropriate external procedure") + c.Check(s.Stdout(), Equals, "") + c.Check(s.Stderr(), Equals, "") +} diff --git a/cmd/snap/cmd_disconnect.go b/cmd/snap/cmd_disconnect.go new file mode 100644 index 00000000..83dcf587 --- /dev/null +++ b/cmd/snap/cmd_disconnect.go @@ -0,0 +1,105 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "fmt" + + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/client" + "github.com/snapcore/snapd/i18n" +) + +type cmdDisconnect struct { + waitMixin + Forget bool `long:"forget"` + Positionals struct { + Offer disconnectSlotOrPlugSpec `required:"true"` + Use disconnectSlotSpec + } `positional-args:"true"` +} + +var shortDisconnectHelp = i18n.G("Disconnect a plug from a slot") +var longDisconnectHelp = i18n.G(` +The disconnect command disconnects a plug from a slot. +It may be called in the following ways: + +$ snap disconnect : : + +Disconnects the specific plug from the specific slot. + +$ snap disconnect : + +Disconnects everything from the provided plug or slot. +The snap name may be omitted for the core snap. + +When an automatic connection is manually disconnected, its disconnected state +is retained after a snap refresh. The --forget flag can be added to the +disconnect command to reset this behaviour, and consequently re-enable +an automatic reconnection after a snap refresh. +`) + +func init() { + addCommand("disconnect", shortDisconnectHelp, longDisconnectHelp, func() flags.Commander { + return &cmdDisconnect{} + }, waitDescs.also(map[string]string{"forget": "Forget remembered state about the given connection."}), []argDesc{ + // TRANSLATORS: This needs to begin with < and end with > + {name: i18n.G(":")}, + // TRANSLATORS: This needs to begin with < and end with > + {name: i18n.G(":")}, + }) +} + +func (x *cmdDisconnect) Execute(args []string) error { + if len(args) > 0 { + return ErrExtraArgs + } + + offer := x.Positionals.Offer.SnapAndNameStrict + use := x.Positionals.Use.SnapAndNameStrict + + // snap disconnect : + // snap disconnect + if use.Snap == "" && use.Name == "" { + // Swap Offer and Use around + offer, use = use, offer + } + + opts := &client.DisconnectOptions{Forget: x.Forget} + id, err := x.client.Disconnect(offer.Snap, offer.Name, use.Snap, use.Name, opts) + if err != nil { + if client.IsInterfacesUnchangedError(err) { + fmt.Fprintf(Stdout, i18n.G("No connections to disconnect")) + fmt.Fprintf(Stdout, "\n") + return nil + } + return err + } + + if _, err := x.wait(id); err != nil { + if err == noWait { + return nil + } + return err + } + + return nil +} diff --git a/cmd/snap/cmd_disconnect_test.go b/cmd/snap/cmd_disconnect_test.go new file mode 100644 index 00000000..695110d3 --- /dev/null +++ b/cmd/snap/cmd_disconnect_test.go @@ -0,0 +1,270 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "fmt" + "net/http" + "os" + + "github.com/jessevdk/go-flags" + . "gopkg.in/check.v1" + + . "github.com/snapcore/snapd/cmd/snap" +) + +func (s *SnapSuite) TestDisconnectHelp(c *C) { + msg := `Usage: + snap.test disconnect [disconnect-OPTIONS] : [:] + +The disconnect command disconnects a plug from a slot. +It may be called in the following ways: + +$ snap disconnect : : + +Disconnects the specific plug from the specific slot. + +$ snap disconnect : + +Disconnects everything from the provided plug or slot. +The snap name may be omitted for the core snap. + +When an automatic connection is manually disconnected, its disconnected state +is retained after a snap refresh. The --forget flag can be added to the +disconnect command to reset this behaviour, and consequently re-enable +an automatic reconnection after a snap refresh. + +[disconnect command options] + --no-wait Do not wait for the operation to finish but just print + the change id. + --forget Forget remembered state about the given connection. +` + s.testSubCommandHelp(c, "disconnect", msg) +} + +func (s *SnapSuite) TestDisconnectExplicitEverything(c *C) { + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v2/interfaces": + c.Check(r.Method, Equals, "POST") + c.Check(DecodedRequestBody(c, r), DeepEquals, map[string]interface{}{ + "action": "disconnect", + "plugs": []interface{}{ + map[string]interface{}{ + "snap": "producer", + "plug": "plug", + }, + }, + "slots": []interface{}{ + map[string]interface{}{ + "snap": "consumer", + "slot": "slot", + }, + }, + }) + w.WriteHeader(202) + fmt.Fprintln(w, `{"type":"async", "status-code": 202, "change": "zzz"}`) + case "/v2/changes/zzz": + c.Check(r.Method, Equals, "GET") + fmt.Fprintln(w, `{"type":"sync", "result":{"ready": true, "status": "Done"}}`) + default: + c.Fatalf("unexpected path %q", r.URL.Path) + } + }) + rest, err := Parser(Client()).ParseArgs([]string{"disconnect", "producer:plug", "consumer:slot"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + c.Assert(s.Stdout(), Equals, "") + c.Assert(s.Stderr(), Equals, "") +} + +func (s *SnapSuite) TestDisconnectWithForgetFlag(c *C) { + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v2/interfaces": + c.Check(r.Method, Equals, "POST") + c.Check(DecodedRequestBody(c, r), DeepEquals, map[string]interface{}{ + "action": "disconnect", + "forget": true, + "plugs": []interface{}{ + map[string]interface{}{ + "snap": "consumer", + "plug": "plug", + }, + }, + "slots": []interface{}{ + map[string]interface{}{ + "snap": "producer", + "slot": "slot", + }, + }, + }) + w.WriteHeader(202) + fmt.Fprintln(w, `{"type":"async", "status-code": 202, "change": "zzz"}`) + case "/v2/changes/zzz": + c.Check(r.Method, Equals, "GET") + fmt.Fprintln(w, `{"type":"sync", "result":{"ready": true, "status": "Done"}}`) + default: + c.Fatalf("unexpected path %q", r.URL.Path) + } + }) + rest, err := Parser(Client()).ParseArgs([]string{"disconnect", "--forget", "consumer:plug", "producer:slot"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + c.Assert(s.Stdout(), Equals, "") + c.Assert(s.Stderr(), Equals, "") +} + +func (s *SnapSuite) TestDisconnectEverythingFromSpecificSlot(c *C) { + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v2/interfaces": + c.Check(r.Method, Equals, "POST") + c.Check(DecodedRequestBody(c, r), DeepEquals, map[string]interface{}{ + "action": "disconnect", + "plugs": []interface{}{ + map[string]interface{}{ + "snap": "", + "plug": "", + }, + }, + "slots": []interface{}{ + map[string]interface{}{ + "snap": "consumer", + "slot": "slot", + }, + }, + }) + w.WriteHeader(202) + fmt.Fprintln(w, `{"type":"async", "status-code": 202, "change": "zzz"}`) + case "/v2/changes/zzz": + c.Check(r.Method, Equals, "GET") + fmt.Fprintln(w, `{"type":"sync", "result":{"ready": true, "status": "Done"}}`) + default: + c.Fatalf("unexpected path %q", r.URL.Path) + } + }) + rest, err := Parser(Client()).ParseArgs([]string{"disconnect", "consumer:slot"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + c.Assert(s.Stdout(), Equals, "") + c.Assert(s.Stderr(), Equals, "") +} + +func (s *SnapSuite) TestDisconnectEverythingFromSpecificSnapPlugOrSlot(c *C) { + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v2/interfaces": + c.Check(r.Method, Equals, "POST") + c.Check(DecodedRequestBody(c, r), DeepEquals, map[string]interface{}{ + "action": "disconnect", + "plugs": []interface{}{ + map[string]interface{}{ + "snap": "", + "plug": "", + }, + }, + "slots": []interface{}{ + map[string]interface{}{ + "snap": "consumer", + "slot": "plug-or-slot", + }, + }, + }) + w.WriteHeader(202) + fmt.Fprintln(w, `{"type":"async", "status-code": 202, "change": "zzz"}`) + case "/v2/changes/zzz": + c.Check(r.Method, Equals, "GET") + fmt.Fprintln(w, `{"type":"sync", "result":{"ready": true, "status": "Done"}}`) + default: + c.Fatalf("unexpected path %q", r.URL.Path) + } + }) + rest, err := Parser(Client()).ParseArgs([]string{"disconnect", "consumer:plug-or-slot"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + c.Assert(s.Stdout(), Equals, "") + c.Assert(s.Stderr(), Equals, "") +} + +func (s *SnapSuite) TestDisconnectEverythingFromSpecificSnap(c *C) { + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + c.Fatalf("expected nothing to reach the server") + }) + rest, err := Parser(Client()).ParseArgs([]string{"disconnect", "consumer"}) + c.Assert(err, ErrorMatches, `invalid value: "consumer" \(want snap:name or :name\)`) + c.Assert(rest, DeepEquals, []string{"consumer"}) + c.Assert(s.Stdout(), Equals, "") + c.Assert(s.Stderr(), Equals, "") +} + +func (s *SnapSuite) TestDisconnectCompletion(c *C) { + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v2/connections": + c.Assert(r.Method, Equals, "GET") + EncodeResponseBody(c, w, map[string]interface{}{ + "type": "sync", + "result": fortestingConnectionList, + }) + default: + c.Fatalf("unexpected path %q", r.URL.Path) + } + }) + os.Setenv("GO_FLAGS_COMPLETION", "verbose") + defer os.Unsetenv("GO_FLAGS_COMPLETION") + + expected := []flags.Completion{} + parser := Parser(Client()) + parser.CompletionHandler = func(obtained []flags.Completion) { + c.Check(obtained, DeepEquals, expected) + } + + expected = []flags.Completion{{Item: "canonical-pi2:"}, {Item: "core:"}, {Item: "keyboard-lights:"}} + _, err := parser.ParseArgs([]string{"disconnect", ""}) + c.Assert(err, IsNil) + + expected = []flags.Completion{{Item: "canonical-pi2:pin-13", Description: "slot"}} + _, err = parser.ParseArgs([]string{"disconnect", "ca"}) + c.Assert(err, IsNil) + + expected = []flags.Completion{{Item: ":core-support", Description: "slot"}, {Item: ":core-support-plug", Description: "plug"}} + _, err = parser.ParseArgs([]string{"disconnect", ":"}) + c.Assert(err, IsNil) + + expected = []flags.Completion{{Item: "keyboard-lights:capslock-led", Description: "plug"}} + _, err = parser.ParseArgs([]string{"disconnect", "k"}) + c.Assert(err, IsNil) + + expected = []flags.Completion{{Item: "canonical-pi2:"}, {Item: "core:"}} + _, err = parser.ParseArgs([]string{"disconnect", "keyboard-lights:capslock-led", ""}) + c.Assert(err, IsNil) + + expected = []flags.Completion{{Item: "canonical-pi2:pin-13", Description: "slot"}} + _, err = parser.ParseArgs([]string{"disconnect", "keyboard-lights:capslock-led", "ca"}) + c.Assert(err, IsNil) + + expected = []flags.Completion{{Item: ":core-support", Description: "slot"}} + _, err = parser.ParseArgs([]string{"disconnect", ":core-support-plug", ":"}) + c.Assert(err, IsNil) + + c.Assert(s.Stdout(), Equals, "") + c.Assert(s.Stderr(), Equals, "") +} diff --git a/cmd/snap/cmd_download.go b/cmd/snap/cmd_download.go new file mode 100644 index 00000000..01e46158 --- /dev/null +++ b/cmd/snap/cmd_download.go @@ -0,0 +1,186 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016-2023 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/asserts/sysdb" + "github.com/snapcore/snapd/i18n" + "github.com/snapcore/snapd/image" + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/store/tooling" +) + +type cmdDownload struct { + channelMixin + Revision string `long:"revision"` + Basename string `long:"basename"` + TargetDir string `long:"target-directory"` + + CohortKey string `long:"cohort"` + Positional struct { + Snap remoteSnapName + } `positional-args:"true" required:"true"` +} + +var shortDownloadHelp = i18n.G("Download the given snap") +var longDownloadHelp = i18n.G(` +The download command downloads the given snap and its supporting assertions +to the current directory with .snap and .assert file extensions, respectively. +`) + +func init() { + addCommand("download", shortDownloadHelp, longDownloadHelp, func() flags.Commander { + return &cmdDownload{} + }, channelDescs.also(map[string]string{ + // TRANSLATORS: This should not start with a lowercase letter. + "revision": i18n.G("Download the given revision of a snap"), + // TRANSLATORS: This should not start with a lowercase letter. + "cohort": i18n.G("Download from the given cohort"), + // TRANSLATORS: This should not start with a lowercase letter. + "basename": i18n.G("Use this basename for the snap and assertion files (defaults to _)"), + // TRANSLATORS: This should not start with a lowercase letter. + "target-directory": i18n.G("Download to this directory (defaults to the current directory)"), + }), []argDesc{{ + name: "", + // TRANSLATORS: This should not start with a lowercase letter. + desc: i18n.G("Snap name"), + }}) +} + +func fetchSnapAssertionsDirect(tsto *tooling.ToolingStore, snapPath string, snapInfo *snap.Info) (string, error) { + db, err := asserts.OpenDatabase(&asserts.DatabaseConfig{ + Backstore: asserts.NewMemoryBackstore(), + Trusted: sysdb.Trusted(), + }) + if err != nil { + return "", err + } + + assertPath := strings.TrimSuffix(snapPath, filepath.Ext(snapPath)) + ".assert" + w, err := os.Create(assertPath) + if err != nil { + return "", fmt.Errorf(i18n.G("cannot create assertions file: %v"), err) + } + defer w.Close() + + encoder := asserts.NewEncoder(w) + save := func(a asserts.Assertion) error { + return encoder.Encode(a) + } + f := tsto.AssertionFetcher(db, save) + + _, err = image.FetchAndCheckSnapAssertions(snapPath, snapInfo, nil, f, db) + return assertPath, err +} + +func printInstallHint(assertPath, snapPath string) { + // simplify paths + wd, _ := os.Getwd() + if p, err := filepath.Rel(wd, assertPath); err == nil { + assertPath = p + } + if p, err := filepath.Rel(wd, snapPath); err == nil { + snapPath = p + } + // add a hint what to do with the downloaded snap (LP:1676707) + fmt.Fprintf(Stdout, i18n.G(`Install the snap with: + snap ack %s + snap install %s +`), assertPath, snapPath) +} + +// for testing +var downloadDirect = downloadDirectImpl + +func downloadDirectImpl(snapName string, revision snap.Revision, dlOpts tooling.DownloadSnapOptions) error { + tsto, err := tooling.NewToolingStore() + if err != nil { + return err + } + tsto.Stdout = Stdout + + fmt.Fprintf(Stdout, i18n.G("Fetching snap %q\n"), snapName) + dlSnap, err := tsto.DownloadSnap(snapName, dlOpts) + if err != nil { + return err + } + + fmt.Fprintf(Stdout, i18n.G("Fetching assertions for %q\n"), snapName) + assertPath, err := fetchSnapAssertionsDirect(tsto, dlSnap.Path, dlSnap.Info) + if err != nil { + return err + } + printInstallHint(assertPath, dlSnap.Path) + return nil +} + +func (x *cmdDownload) downloadFromStore(snapName string, revision snap.Revision) error { + dlOpts := tooling.DownloadSnapOptions{ + TargetDir: x.TargetDir, + Basename: x.Basename, + Channel: x.Channel, + CohortKey: x.CohortKey, + Revision: revision, + // if something goes wrong, don't force it to start over again + LeavePartialOnError: true, + } + return downloadDirect(snapName, revision, dlOpts) +} + +func (x *cmdDownload) Execute(args []string) error { + if strings.ContainsRune(x.Basename, filepath.Separator) { + return fmt.Errorf(i18n.G("cannot specify a path in basename (use --target-dir for that)")) + } + if err := x.setChannelFromCommandline(); err != nil { + return err + } + + if len(args) > 0 { + return ErrExtraArgs + } + + var revision snap.Revision + if x.Revision == "" { + revision = snap.R(0) + } else { + if x.Channel != "" { + return fmt.Errorf(i18n.G("cannot specify both channel and revision")) + } + if x.CohortKey != "" { + return fmt.Errorf(i18n.G("cannot specify both cohort and revision")) + } + var err error + revision, err = snap.ParseRevision(x.Revision) + if err != nil { + return err + } + } + + snapName := string(x.Positional.Snap) + return x.downloadFromStore(snapName, revision) +} diff --git a/cmd/snap/cmd_download_test.go b/cmd/snap/cmd_download_test.go new file mode 100644 index 00000000..08fb24da --- /dev/null +++ b/cmd/snap/cmd_download_test.go @@ -0,0 +1,130 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "fmt" + "os" + "path/filepath" + + "gopkg.in/check.v1" + + snapCmd "github.com/snapcore/snapd/cmd/snap" + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/store/tooling" +) + +// these only cover errors that happen before hitting the network, +// because we're not (yet!) mocking the tooling store + +func (s *SnapSuite) TestDownloadBadBasename(c *check.C) { + _, err := snapCmd.Parser(snapCmd.Client()).ParseArgs([]string{ + "download", "--basename=/foo", "a-snap", + }) + + c.Check(err, check.ErrorMatches, "cannot specify a path in basename .use --target-dir for that.") +} + +func (s *SnapSuite) TestDownloadBadChannelCombo(c *check.C) { + _, err := snapCmd.Parser(snapCmd.Client()).ParseArgs([]string{ + "download", "--beta", "--channel=foo", "a-snap", + }) + + c.Check(err, check.ErrorMatches, "Please specify a single channel") +} + +func (s *SnapSuite) TestDownloadCohortAndRevision(c *check.C) { + _, err := snapCmd.Parser(snapCmd.Client()).ParseArgs([]string{ + "download", "--cohort=what", "--revision=1234", "a-snap", + }) + + c.Check(err, check.ErrorMatches, "cannot specify both cohort and revision") +} + +func (s *SnapSuite) TestDownloadChannelAndRevision(c *check.C) { + _, err := snapCmd.Parser(snapCmd.Client()).ParseArgs([]string{ + "download", "--beta", "--revision=1234", "a-snap", + }) + + c.Check(err, check.ErrorMatches, "cannot specify both channel and revision") +} + +func (s *SnapSuite) TestPrintInstalHint(c *check.C) { + snapCmd.PrintInstallHint("foo_1.assert", "foo_1.snap") + c.Check(s.Stdout(), check.Equals, `Install the snap with: + snap ack foo_1.assert + snap install foo_1.snap +`) + s.stdout.Reset() + + cwd, err := os.Getwd() + c.Assert(err, check.IsNil) + as := filepath.Join(cwd, "some-dir/foo_1.assert") + sn := filepath.Join(cwd, "some-dir/foo_1.snap") + snapCmd.PrintInstallHint(as, sn) + c.Check(s.Stdout(), check.Equals, `Install the snap with: + snap ack some-dir/foo_1.assert + snap install some-dir/foo_1.snap +`) +} + +func (s *SnapSuite) TestDownloadDirect(c *check.C) { + var n int + restore := snapCmd.MockDownloadDirect(func(snapName string, revision snap.Revision, dlOpts tooling.DownloadSnapOptions) error { + c.Check(snapName, check.Equals, "a-snap") + c.Check(revision, check.Equals, snap.R(0)) + c.Check(dlOpts.Basename, check.Equals, "some-base-name") + c.Check(dlOpts.TargetDir, check.Equals, "some-target-dir") + c.Check(dlOpts.Channel, check.Equals, "some-channel") + c.Check(dlOpts.CohortKey, check.Equals, "some-cohort") + n++ + return nil + }) + defer restore() + + // check that a direct download got issued + _, err := snapCmd.Parser(snapCmd.Client()).ParseArgs([]string{ + "download", + "--target-directory=some-target-dir", + "--basename=some-base-name", + "--channel=some-channel", + "--cohort=some-cohort", + "a-snap"}, + ) + c.Assert(err, check.IsNil) + c.Check(n, check.Equals, 1) +} + +func (s *SnapSuite) TestDownloadDirectErrors(c *check.C) { + var n int + restore := snapCmd.MockDownloadDirect(func(snapName string, revision snap.Revision, dlOpts tooling.DownloadSnapOptions) error { + n++ + return fmt.Errorf("some-error") + }) + defer restore() + + // check that a direct download got issued + _, err := snapCmd.Parser(snapCmd.Client()).ParseArgs([]string{ + "download", + "a-snap"}, + ) + c.Assert(err, check.ErrorMatches, "some-error") + c.Check(n, check.Equals, 1) +} diff --git a/cmd/snap/cmd_ensure_state_soon.go b/cmd/snap/cmd_ensure_state_soon.go new file mode 100644 index 00000000..8e8476f4 --- /dev/null +++ b/cmd/snap/cmd_ensure_state_soon.go @@ -0,0 +1,46 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "github.com/jessevdk/go-flags" +) + +type cmdEnsureStateSoon struct { + clientMixin +} + +func init() { + cmd := addDebugCommand("ensure-state-soon", + "(internal) trigger an ensure run in the state engine", + "(internal) trigger an ensure run in the state engine", + func() flags.Commander { + return &cmdEnsureStateSoon{} + }, nil, nil) + cmd.hidden = true +} + +func (x *cmdEnsureStateSoon) Execute(args []string) error { + if len(args) > 0 { + return ErrExtraArgs + } + + return x.client.Debug("ensure-state-soon", nil, nil) +} diff --git a/cmd/snap/cmd_ensure_state_soon_test.go b/cmd/snap/cmd_ensure_state_soon_test.go new file mode 100644 index 00000000..1f3193eb --- /dev/null +++ b/cmd/snap/cmd_ensure_state_soon_test.go @@ -0,0 +1,55 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "fmt" + "io" + "net/http" + + "gopkg.in/check.v1" + + snap "github.com/snapcore/snapd/cmd/snap" +) + +func (s *SnapSuite) TestEnsureStateSoon(c *check.C) { + n := 0 + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch n { + case 0: + c.Check(r.Method, check.Equals, "POST") + c.Check(r.URL.Path, check.Equals, "/v2/debug") + c.Check(r.URL.RawQuery, check.Equals, "") + data, err := io.ReadAll(r.Body) + c.Check(err, check.IsNil) + c.Check(data, check.DeepEquals, []byte(`{"action":"ensure-state-soon"}`)) + fmt.Fprintln(w, `{"type": "sync", "result": true}`) + default: + c.Fatalf("expected to get 1 requests, now on %d", n+1) + } + + n++ + }) + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"debug", "ensure-state-soon"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + c.Check(s.Stdout(), check.Equals, "") + c.Check(s.Stderr(), check.Equals, "") +} diff --git a/cmd/snap/cmd_export_key.go b/cmd/snap/cmd_export_key.go new file mode 100644 index 00000000..39898508 --- /dev/null +++ b/cmd/snap/cmd_export_key.go @@ -0,0 +1,104 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2021 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "fmt" + "time" + + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/asserts/signtool" + "github.com/snapcore/snapd/i18n" +) + +type cmdExportKey struct { + Account string `long:"account"` + Positional struct { + KeyName keyName + } `positional-args:"true"` +} + +func init() { + cmd := addCommand("export-key", + i18n.G("Export cryptographic public key"), + i18n.G(` +The export-key command exports a public key assertion body that may be +imported by other systems. +`), + func() flags.Commander { + return &cmdExportKey{} + }, map[string]string{ + "account": i18n.G("Format public key material as a request for an account-key for this account-id"), + }, []argDesc{{ + // TRANSLATORS: This needs to begin with < and end with > + name: i18n.G(""), + // TRANSLATORS: This should not start with a lowercase letter. + desc: i18n.G("Name of key to export"), + }}) + cmd.hidden = true +} + +func (x *cmdExportKey) Execute(args []string) error { + if len(args) > 0 { + return ErrExtraArgs + } + + keyName := string(x.Positional.KeyName) + if keyName == "" { + keyName = "default" + } + + keypairMgr, err := signtool.GetKeypairManager() + if err != nil { + return err + } + if x.Account != "" { + privKey, err := keypairMgr.GetByName(keyName) + if err != nil { + return fmt.Errorf("cannot find key named %q: %v", keyName, err) + } + pubKey := privKey.PublicKey() + headers := map[string]interface{}{ + "account-id": x.Account, + "name": keyName, + "public-key-sha3-384": pubKey.ID(), + "since": time.Now().UTC().Format(time.RFC3339), + // XXX: To support revocation, we need to check for matching known assertions and set a suitable revision if we find one. + } + body, err := asserts.EncodePublicKey(pubKey) + if err != nil { + return err + } + assertion, err := asserts.SignWithoutAuthority(asserts.AccountKeyRequestType, headers, body, privKey) + if err != nil { + return err + } + fmt.Fprint(Stdout, string(asserts.Encode(assertion))) + } else { + encoded, err := keypairMgr.Export(keyName) + if err != nil { + return fmt.Errorf("cannot export key named %q: %v", keyName, err) + } + fmt.Fprintf(Stdout, "%s\n", encoded) + } + return nil +} diff --git a/cmd/snap/cmd_export_key_test.go b/cmd/snap/cmd_export_key_test.go new file mode 100644 index 00000000..1bd18fad --- /dev/null +++ b/cmd/snap/cmd_export_key_test.go @@ -0,0 +1,84 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "time" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/asserts/assertstest" + snap "github.com/snapcore/snapd/cmd/snap" +) + +func (s *SnapKeysSuite) TestExportKeyNonexistent(c *C) { + _, err := snap.Parser(snap.Client()).ParseArgs([]string{"export-key", "nonexistent"}) + c.Assert(err, NotNil) + c.Check(err.Error(), Equals, "cannot export key named \"nonexistent\": cannot find key pair in GPG keyring") + c.Check(s.Stdout(), Equals, "") + c.Check(s.Stderr(), Equals, "") +} + +func (s *SnapKeysSuite) TestExportKeyDefault(c *C) { + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"export-key"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + pubKey, err := asserts.DecodePublicKey(s.stdout.Bytes()) + c.Assert(err, IsNil) + c.Check(pubKey.ID(), Equals, "g4Pks54W_US4pZuxhgG_RHNAf_UeZBBuZyGRLLmMj1Do3GkE_r_5A5BFjx24ZwVJ") + c.Check(s.Stderr(), Equals, "") +} + +func (s *SnapKeysSuite) TestExportKeyNonDefault(c *C) { + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"export-key", "another"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + pubKey, err := asserts.DecodePublicKey(s.stdout.Bytes()) + c.Assert(err, IsNil) + c.Check(pubKey.ID(), Equals, "DVQf1U4mIsuzlQqAebjjTPYtYJ-GEhJy0REuj3zvpQYTZ7EJj7adBxIXLJ7Vmk3L") + c.Check(s.Stderr(), Equals, "") +} + +func (s *SnapKeysSuite) TestExportKeyAccount(c *C) { + storeSigning := assertstest.NewStoreStack("canonical", nil) + manager := asserts.NewGPGKeypairManager() + assertstest.NewAccount(storeSigning, "developer1", nil, "") + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"export-key", "another", "--account=developer1"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + assertion, err := asserts.Decode(s.stdout.Bytes()) + c.Assert(err, IsNil) + c.Check(assertion.Type(), Equals, asserts.AccountKeyRequestType) + c.Check(assertion.Revision(), Equals, 0) + c.Check(assertion.HeaderString("account-id"), Equals, "developer1") + c.Check(assertion.HeaderString("name"), Equals, "another") + c.Check(assertion.HeaderString("public-key-sha3-384"), Equals, "DVQf1U4mIsuzlQqAebjjTPYtYJ-GEhJy0REuj3zvpQYTZ7EJj7adBxIXLJ7Vmk3L") + since, err := time.Parse(time.RFC3339, assertion.HeaderString("since")) + c.Assert(err, IsNil) + zone, offset := since.Zone() + c.Check(zone, Equals, "UTC") + c.Check(offset, Equals, 0) + c.Check(s.Stderr(), Equals, "") + privKey, err := manager.Get(assertion.HeaderString("public-key-sha3-384")) + c.Assert(err, IsNil) + err = asserts.SignatureCheck(assertion, privKey.PublicKey()) + c.Assert(err, IsNil) +} diff --git a/cmd/snap/cmd_find.go b/cmd/snap/cmd_find.go new file mode 100644 index 00000000..69c087af --- /dev/null +++ b/cmd/snap/cmd_find.go @@ -0,0 +1,275 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016-2018 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "bufio" + "errors" + "fmt" + "os" + "sort" + "strings" + + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/client" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/i18n" + "github.com/snapcore/snapd/logger" + "github.com/snapcore/snapd/strutil" +) + +var shortFindHelp = i18n.G("Find packages to install") +var longFindHelp = i18n.G(` +The find command queries the store for available packages. + +With the --private flag, which requires the user to be logged-in to the store +(see 'snap help login'), it instead searches for private snaps that the user +has developer access to, either directly or through the store's collaboration +feature. + +A green check mark (given color and unicode support) after a publisher name +indicates that the publisher has been verified. +`) + +func getPrice(prices map[string]float64, currency string) (float64, string, error) { + // If there are no prices, then the snap is free + if len(prices) == 0 { + // TRANSLATORS: free as in gratis + return 0, "", errors.New(i18n.G("snap is free")) + } + + // Look up the price by currency code + val, ok := prices[currency] + + // Fall back to dollars + if !ok { + currency = "USD" + val, ok = prices["USD"] + } + + // If there aren't even dollars, grab the first currency, + // ordered alphabetically by currency code + if !ok { + currency = "ZZZ" + for c, v := range prices { + if c < currency { + currency, val = c, v + } + } + } + + return val, currency, nil +} + +type SectionName string + +func (s SectionName) Complete(match string) []flags.Completion { + if ret, err := completeFromSortedFile(dirs.SnapSectionsFile, match); err == nil { + return ret + } + + cli := mkClient() + sections, err := cli.Sections() + if err != nil { + return nil + } + ret := make([]flags.Completion, 0, len(sections)) + for _, s := range sections { + if strings.HasPrefix(s, match) { + ret = append(ret, flags.Completion{Item: s}) + } + } + return ret +} + +func cachedSections() (sections []string, err error) { + cachedSections, err := os.Open(dirs.SnapSectionsFile) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + defer cachedSections.Close() + + r := bufio.NewScanner(cachedSections) + for r.Scan() { + sections = append(sections, r.Text()) + } + if r.Err() != nil { + return nil, r.Err() + } + + return sections, nil +} + +func getSections(cli *client.Client) (sections []string, err error) { + // try loading from cached sections file + sections, err = cachedSections() + if err != nil { + return nil, err + } + if sections != nil { + return sections, nil + } + // fallback to listing from the daemon + return cli.Sections() +} + +func showSections(cli *client.Client) error { + sections, err := getSections(cli) + if err != nil { + return err + } + sort.Strings(sections) + + fmt.Fprintf(Stdout, i18n.G("No section specified. Available sections:\n")) + for _, sec := range sections { + fmt.Fprintf(Stdout, " * %s\n", sec) + } + fmt.Fprintf(Stdout, i18n.G("Please try 'snap find --section='\n")) + return nil +} + +type cmdFind struct { + clientMixin + Private bool `long:"private"` + Narrow bool `long:"narrow"` + Section SectionName `long:"section" optional:"true" optional-value:"show-all-sections-please" default:"no-section-specified" default-mask:"-"` + Positional struct { + Query []string + } `positional-args:"yes"` + colorMixin +} + +func init() { + addCommand("find", shortFindHelp, longFindHelp, func() flags.Commander { + return &cmdFind{} + }, colorDescs.also(map[string]string{ + // TRANSLATORS: This should not start with a lowercase letter. + "private": i18n.G("Search private snaps."), + // TRANSLATORS: This should not start with a lowercase letter. + "narrow": i18n.G("Only search for snaps in “stable”."), + // TRANSLATORS: This should not start with a lowercase letter. + "section": i18n.G("Restrict the search to a given section."), + }), []argDesc{{ + // TRANSLATORS: This needs to begin with < and end with > + name: i18n.G(""), + }}).alias = "search" + +} + +func (x *cmdFind) Execute(args []string) error { + if len(args) > 0 { + return ErrExtraArgs + } + + // LP: 1740605 + query := strings.Join(x.Positional.Query, " ") + if strings.TrimSpace(query) == "" { + query = "" + } + + // section will be: + // - "show-all-sections-please" if the user specified --section + // without any argument + // - "no-section-specified" if "--section" was not specified on + // the commandline at all + switch x.Section { + case "show-all-sections-please": + return showSections(x.client) + case "no-section-specified": + x.Section = "" + } + + // magic! `snap find` returns the featured snaps + showFeatured := (query == "" && x.Section == "") + if showFeatured { + x.Section = "featured" + } + + if x.Section != "" && x.Section != "featured" { + sections, err := cachedSections() + if err != nil { + return err + } + if !strutil.ListContains(sections, string(x.Section)) { + // try the store just in case it was added in the last 24 hours + sections, err = x.client.Sections() + if err != nil { + return err + } + if !strutil.ListContains(sections, string(x.Section)) { + // TRANSLATORS: the %q is the (quoted) name of the section the user entered + return fmt.Errorf(i18n.G("No matching section %q, use --section to list existing sections"), x.Section) + } + } + } + + opts := &client.FindOptions{ + Query: query, + Section: string(x.Section), + Private: x.Private, + } + + if !x.Narrow { + opts.Scope = "wide" + } + + snaps, resInfo, err := x.client.Find(opts) + if e, ok := err.(*client.Error); ok && (e.Kind == client.ErrorKindNetworkTimeout || e.Kind == client.ErrorKindDNSFailure) { + logger.Debugf("cannot list snaps: %v", e) + return fmt.Errorf("unable to contact snap store") + } + if err != nil { + return err + } + if len(snaps) == 0 { + if x.Section == "" { + // TRANSLATORS: the %q is the (quoted) query the user entered + fmt.Fprintf(Stderr, i18n.G("No matching snaps for %q\n"), opts.Query) + } else { + // TRANSLATORS: the first %q is the (quoted) query, the + // second %q is the (quoted) name of the section the + // user entered + fmt.Fprintf(Stderr, i18n.G("No matching snaps for %q in section %q\n"), opts.Query, x.Section) + } + return nil + } + + // show featured header *after* we checked for errors from the find + if showFeatured { + fmt.Fprint(Stdout, i18n.G("No search term specified. Here are some interesting snaps:\n\n")) + } + + esc := x.getEscapes() + w := tabWriter() + // TRANSLATORS: the %s is to insert a filler escape sequence (please keep it flush to the column header, with no extra spaces) + fmt.Fprintf(w, i18n.G("Name\tVersion\tPublisher%s\tNotes\tSummary\n"), fillerPublisher(esc)) + for _, snap := range snaps { + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", snap.Name, snap.Version, shortPublisher(esc, snap.Publisher), NotesFromRemote(snap, resInfo), snap.Summary) + } + w.Flush() + if showFeatured { + fmt.Fprint(Stdout, i18n.G("\nProvide a search term for more specific results.\n")) + } + return nil +} diff --git a/cmd/snap/cmd_find_test.go b/cmd/snap/cmd_find_test.go new file mode 100644 index 00000000..b041200b --- /dev/null +++ b/cmd/snap/cmd_find_test.go @@ -0,0 +1,700 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "fmt" + "net/http" + "net/url" + "os" + "path" + + "github.com/jessevdk/go-flags" + "gopkg.in/check.v1" + + snap "github.com/snapcore/snapd/cmd/snap" + "github.com/snapcore/snapd/dirs" +) + +const findJSON = ` +{ + "type": "sync", + "status-code": 200, + "status": "OK", + "result": [ + { + "channel": "stable", + "confinement": "strict", + "description": "GNU hello prints a friendly greeting. This is part of the snapcraft tour at https://snapcraft.io/", + "developer": "canonical", + "publisher": { + "id": "canonical", + "username": "canonical", + "display-name": "Canonical", + "validation": "verified" + }, + "download-size": 65536, + "icon": "", + "id": "mVyGrEwiqSi5PugCwyH7WgpoQLemtTd6", + "name": "hello", + "private": false, + "resource": "/v2/snaps/hello", + "revision": "1", + "status": "available", + "summary": "GNU Hello, the \"hello world\" snap", + "type": "app", + "version": "2.10" + }, + { + "channel": "stable", + "confinement": "strict", + "description": "This is a simple hello world example.", + "developer": "canonical", + "publisher": { + "id": "canonical", + "username": "canonical", + "display-name": "Canonical", + "validation": "verified" + }, + "download-size": 20480, + "icon": "", + "id": "buPKUD3TKqCOgLEjjHx5kSiCpIs5cMuQ", + "name": "hello-world", + "private": false, + "resource": "/v2/snaps/hello-world", + "revision": "26", + "status": "available", + "summary": "Hello world example", + "type": "app", + "version": "6.1" + }, + { + "channel": "stable", + "confinement": "strict", + "description": "1.0GB", + "developer": "noise", + "publisher": { + "id": "noise-id", + "username": "noise", + "display-name": "Bret", + "validation": "unproven" + }, + "download-size": 512004096, + "icon": "", + "id": "asXOGCreK66DIAdyXmucwspTMgqA4rne", + "name": "hello-huge", + "private": false, + "resource": "/v2/snaps/hello-huge", + "revision": "1", + "status": "available", + "summary": "a really big snap", + "type": "app", + "version": "1.0" + } + ], + "sources": [ + "store" + ], + "suggested-currency": "GBP" +} +` + +func (s *SnapSuite) TestFindSnapName(c *check.C) { + n := 0 + + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch n { + case 0: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/find") + q := r.URL.Query() + c.Check(q, check.DeepEquals, url.Values{ + "q": {"hello"}, + "scope": {"wide"}, + }) + fmt.Fprint(w, findJSON) + default: + c.Fatalf("expected to get 1 request, now on %d", n+1) + } + n++ + }) + + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"find", "hello"}) + + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + + c.Check(s.Stdout(), check.Matches, `Name +Version +Publisher +Notes +Summary +hello +2.10 +canonical\*\* +- +GNU Hello, the "hello world" snap +hello-world +6.1 +canonical\*\* +- +Hello world example +hello-huge +1.0 +noise +- +a really big snap +`) + c.Check(s.Stderr(), check.Equals, "") + + s.ResetStdStreams() +} + +const findHelloWorldJSON = ` +{ + "type": "sync", + "status-code": 200, + "status": "OK", + "result": [ + { + "channel": "stable", + "confinement": "strict", + "description": "GNU hello prints a friendly greeting. This is part of the snapcraft tour at https://snapcraft.io/", + "developer": "canonical", + "publisher": { + "id": "canonical", + "username": "canonical", + "display-name": "Canonical", + "validation": "verified" + }, + "download-size": 65536, + "icon": "", + "id": "mVyGrEwiqSi5PugCwyH7WgpoQLemtTd6", + "name": "hello", + "private": false, + "resource": "/v2/snaps/hello", + "revision": "1", + "status": "available", + "summary": "GNU Hello, the \"hello world\" snap", + "type": "app", + "version": "2.10" + }, + { + "channel": "stable", + "confinement": "strict", + "description": "This is a simple hello world example.", + "developer": "canonical", + "publisher": { + "id": "canonical", + "username": "canonical", + "display-name": "Canonical", + "validation": "verified" + }, + "download-size": 20480, + "icon": "", + "id": "buPKUD3TKqCOgLEjjHx5kSiCpIs5cMuQ", + "name": "hello-world", + "private": false, + "resource": "/v2/snaps/hello-world", + "revision": "26", + "status": "available", + "summary": "Hello world example", + "type": "app", + "version": "6.1" + } + ], + "sources": [ + "store" + ], + "suggested-currency": "GBP" +} +` + +func (s *SnapSuite) TestFindSnapNameAggregateTerms(c *check.C) { + n := 0 + + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch n { + case 0, 1: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/find") + q := r.URL.Query() + c.Check(q, check.DeepEquals, url.Values{ + "q": {"hello world"}, + "scope": {"wide"}, + }) + fmt.Fprint(w, findHelloWorldJSON) + default: + c.Fatalf("expected to get 2 requests, now on %d", n+1) + } + n++ + }) + + // search terms will become one string + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"find", "hello", "world"}) + + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + + stdout := s.Stdout() + c.Check(s.Stdout(), check.Matches, `Name +Version +Publisher +Notes +Summary +hello +2.10 +canonical\*\* +- +GNU Hello, the "hello world" snap +hello-world +6.1 +canonical\*\* +- +Hello world example +`) + c.Check(s.Stderr(), check.Equals, "") + + s.ResetStdStreams() + + // search terms are already joined in the command line + rest, err = snap.Parser(snap.Client()).ParseArgs([]string{"find", "hello world"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + // with same output + c.Check(s.Stdout(), check.Equals, stdout) + c.Check(s.Stderr(), check.Equals, "") + + s.ResetStdStreams() +} + +const findHelloJSON = ` +{ + "type": "sync", + "status-code": 200, + "status": "OK", + "result": [ + { + "channel": "stable", + "confinement": "strict", + "description": "GNU hello prints a friendly greeting. This is part of the snapcraft tour at https://snapcraft.io/", + "developer": "canonical", + "publisher": { + "id": "canonical", + "username": "canonical", + "display-name": "Canonical", + "validation": "verified" + }, + "download-size": 65536, + "icon": "", + "id": "mVyGrEwiqSi5PugCwyH7WgpoQLemtTd6", + "name": "hello", + "private": false, + "resource": "/v2/snaps/hello", + "revision": "1", + "status": "available", + "summary": "GNU Hello, the \"hello world\" snap", + "type": "app", + "version": "2.10" + }, + { + "channel": "stable", + "confinement": "strict", + "description": "1.0GB", + "developer": "noise", + "publisher": { + "id": "noise-id", + "username": "noise", + "display-name": "Bret", + "validation": "unproven" + }, + "download-size": 512004096, + "icon": "", + "id": "asXOGCreK66DIAdyXmucwspTMgqA4rne", + "name": "hello-huge", + "private": false, + "resource": "/v2/snaps/hello-huge", + "revision": "1", + "status": "available", + "summary": "a really big snap", + "type": "app", + "version": "1.0" + } + ], + "sources": [ + "store" + ], + "suggested-currency": "GBP" +} +` + +func (s *SnapSuite) TestFindHello(c *check.C) { + n := 0 + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch n { + case 0: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/find") + q := r.URL.Query() + c.Check(q, check.HasLen, 2) + c.Check(q.Get("q"), check.Equals, "hello") + c.Check(q.Get("scope"), check.Equals, "wide") + fmt.Fprint(w, findHelloJSON) + default: + c.Fatalf("expected to get 1 requests, now on %d", n+1) + } + + n++ + }) + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"find", "hello"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + c.Check(s.Stdout(), check.Matches, `Name +Version +Publisher +Notes +Summary +hello +2.10 +canonical\*\* +- +GNU Hello, the "hello world" snap +hello-huge +1.0 +noise +- +a really big snap +`) + c.Check(s.Stderr(), check.Equals, "") +} + +func (s *SnapSuite) TestFindHelloNarrow(c *check.C) { + n := 0 + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch n { + case 0: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/find") + q := r.URL.Query() + c.Check(q, check.HasLen, 1) + c.Check(q.Get("q"), check.Equals, "hello") + fmt.Fprint(w, findHelloJSON) + default: + c.Fatalf("expected to get 1 requests, now on %d", n+1) + } + + n++ + }) + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"find", "--narrow", "hello"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + c.Check(s.Stdout(), check.Matches, `Name +Version +Publisher +Notes +Summary +hello +2.10 +canonical\*\* +- +GNU Hello, the "hello world" snap +hello-huge +1.0 +noise +- +a really big snap +`) + c.Check(s.Stderr(), check.Equals, "") +} + +const findPricedJSON = ` +{ + "type": "sync", + "status-code": 200, + "status": "OK", + "result": [ + { + "channel": "stable", + "confinement": "strict", + "description": "GNU hello prints a friendly greeting. This is part of the snapcraft tour at https://snapcraft.io/", + "developer": "canonical", + "publisher": { + "id": "canonical", + "username": "canonical", + "display-name": "Canonical", + "validation": "verified" + }, + "download-size": 65536, + "icon": "", + "id": "mVyGrEwiqSi5PugCwyH7WgpoQLemtTd6", + "name": "hello", + "prices": {"GBP": 1.99, "USD": 2.99}, + "private": false, + "resource": "/v2/snaps/hello", + "revision": "1", + "status": "priced", + "summary": "GNU Hello, the \"hello world\" snap", + "type": "app", + "version": "2.10", + "license": "Proprietary" + } + ], + "sources": [ + "store" + ], + "suggested-currency": "GBP" +} +` + +func (s *SnapSuite) TestFindPriced(c *check.C) { + n := 0 + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch n { + case 0: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/find") + fmt.Fprint(w, findPricedJSON) + default: + c.Fatalf("expected to get 1 requests, now on %d", n+1) + } + + n++ + }) + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"find", "hello"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + c.Check(s.Stdout(), check.Matches, `Name +Version +Publisher +Notes +Summary +hello +2.10 +canonical\*\* +1.99GBP +GNU Hello, the "hello world" snap +`) + c.Check(s.Stderr(), check.Equals, "") +} + +const findPricedAndBoughtJSON = ` +{ + "type": "sync", + "status-code": 200, + "status": "OK", + "result": [ + { + "channel": "stable", + "confinement": "strict", + "description": "GNU hello prints a friendly greeting. This is part of the snapcraft tour at https://snapcraft.io/", + "developer": "canonical", + "publisher": { + "id": "canonical", + "username": "canonical", + "display-name": "Canonical", + "validation": "verified" + }, + "download-size": 65536, + "icon": "", + "id": "mVyGrEwiqSi5PugCwyH7WgpoQLemtTd6", + "name": "hello", + "prices": {"GBP": 1.99, "USD": 2.99}, + "private": false, + "resource": "/v2/snaps/hello", + "revision": "1", + "status": "available", + "summary": "GNU Hello, the \"hello world\" snap", + "type": "app", + "version": "2.10" + } + ], + "sources": [ + "store" + ], + "suggested-currency": "GBP" +} +` + +func (s *SnapSuite) TestFindPricedAndBought(c *check.C) { + n := 0 + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch n { + case 0: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/find") + fmt.Fprint(w, findPricedAndBoughtJSON) + default: + c.Fatalf("expected to get 1 requests, now on %d", n+1) + } + + n++ + }) + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"find", "hello"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + c.Check(s.Stdout(), check.Matches, `Name +Version +Publisher +Notes +Summary +hello +2.10 +canonical\*\* +bought +GNU Hello, the "hello world" snap +`) + c.Check(s.Stderr(), check.Equals, "") +} + +func (s *SnapSuite) TestFindNothingMeansFeaturedSection(c *check.C) { + n := 0 + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch n { + case 0: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/find") + c.Check(r.URL.Query().Get("section"), check.Equals, "featured") + fmt.Fprint(w, findJSON) + default: + c.Fatalf("expected to get 1 request, now on %d", n+1) + } + n++ + }) + + _, err := snap.Parser(snap.Client()).ParseArgs([]string{"find"}) + c.Assert(err, check.IsNil) + c.Check(s.Stderr(), check.Equals, "") + c.Check(n, check.Equals, 1) +} + +func (s *SnapSuite) TestSectionCompletion(c *check.C) { + n := 0 + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch n { + case 0, 1: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/sections") + EncodeResponseBody(c, w, map[string]interface{}{ + "type": "sync", + "result": []string{"foo", "bar", "baz"}, + }) + default: + c.Fatalf("expected to get 2 requests, now on #%d", n+1) + } + n++ + }) + + c.Check(snap.SectionName("").Complete(""), check.DeepEquals, []flags.Completion{ + {Item: "foo"}, + {Item: "bar"}, + {Item: "baz"}, + }) + + c.Check(snap.SectionName("").Complete("f"), check.DeepEquals, []flags.Completion{ + {Item: "foo"}, + }) +} + +const findNetworkTimeoutErrorJSON = ` +{ + "type": "error", + "result": { + "message": "Get https://search.apps.ubuntu.com/api/v1/snaps/search?confinement=strict%2Cclassic&fields=anon_download_url%2Carchitecture%2Cchannel%2Cdownload_sha3_384%2Csummary%2Cdescription%2Cdeltas%2Cbinary_filesize%2Cdownload_url%2Cepoch%2Cicon_url%2Clast_updated%2Cpackage_name%2Cprices%2Cpublisher%2Cratings_average%2Crevision%2Cscreenshot_urls%2Csnap_id%2Csupport_url%2Ccontact%2Ctitle%2Ccontent%2Cversion%2Corigin%2Cdeveloper_id%2Cprivate%2Cconfinement%2Cchannel_maps_list&q=test: net/http: request canceled while waiting for connection (Client.Timeout exceeded while awaiting headers)", + "kind": "network-timeout" + }, + "status-code": 400 +} +` + +func (s *SnapSuite) TestFindNetworkTimeoutError(c *check.C) { + n := 0 + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch n { + case 0: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/find") + fmt.Fprint(w, findNetworkTimeoutErrorJSON) + default: + c.Fatalf("expected to get 1 requests, now on %d", n+1) + } + + n++ + }) + _, err := snap.Parser(snap.Client()).ParseArgs([]string{"find", "hello"}) + c.Assert(err, check.ErrorMatches, `unable to contact snap store`) + c.Check(s.Stdout(), check.Equals, "") +} + +func (s *SnapSuite) TestFindSnapSectionOverview(c *check.C) { + n := 0 + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch n { + case 0, 1: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/sections") + EncodeResponseBody(c, w, map[string]interface{}{ + "type": "sync", + "result": []string{"sec2", "sec1"}, + }) + default: + c.Fatalf("expected to get 2 requests, now on #%d", n+1) + } + n++ + }) + + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"find", "--section"}) + + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + + c.Check(s.Stdout(), check.Equals, `No section specified. Available sections: + * sec1 + * sec2 +Please try 'snap find --section=' +`) + c.Check(s.Stderr(), check.Equals, "") + + s.ResetStdStreams() +} + +func (s *SnapSuite) TestFindSnapInvalidSection(c *check.C) { + n := 0 + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch n { + case 0: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/sections") + EncodeResponseBody(c, w, map[string]interface{}{ + "type": "sync", + "result": []string{"sec1"}, + }) + default: + c.Fatalf("expected to get 1 request, now on %d", n+1) + } + + n++ + }) + _, err := snap.Parser(snap.Client()).ParseArgs([]string{"find", "--section=foobar", "hello"}) + c.Assert(err, check.ErrorMatches, `No matching section "foobar", use --section to list existing sections`) +} + +func (s *SnapSuite) TestFindSnapNotFoundInSection(c *check.C) { + n := 0 + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch n { + case 0: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/sections") + EncodeResponseBody(c, w, map[string]interface{}{ + "type": "sync", + "result": []string{"foobar"}, + }) + case 1: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/find") + v, ok := r.URL.Query()["section"] + c.Check(ok, check.Equals, true) + c.Check(v, check.DeepEquals, []string{"foobar"}) + EncodeResponseBody(c, w, map[string]interface{}{ + "type": "sync", + "result": []string{}, + }) + default: + c.Fatalf("expected to get 2 requests, now on #%d", n+1) + } + n++ + }) + + _, err := snap.Parser(snap.Client()).ParseArgs([]string{"find", "--section=foobar", "hello"}) + c.Assert(err, check.IsNil) + c.Check(s.Stderr(), check.Equals, "No matching snaps for \"hello\" in section \"foobar\"\n") + c.Check(s.Stdout(), check.Equals, "") + + s.ResetStdStreams() +} + +func (s *SnapSuite) TestFindSnapCachedSection(c *check.C) { + numHits := 0 + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + numHits++ + c.Check(numHits, check.Equals, 1) + c.Check(r.URL.Path, check.Equals, "/v2/sections") + EncodeResponseBody(c, w, map[string]interface{}{ + "type": "sync", + "result": []string{"sec1", "sec2", "sec3"}, + }) + }) + + os.MkdirAll(path.Dir(dirs.SnapSectionsFile), 0755) + os.WriteFile(dirs.SnapSectionsFile, []byte("sec1\nsec2\nsec3"), 0644) + + _, err := snap.Parser(snap.Client()).ParseArgs([]string{"find", "--section=foobar", "hello"}) + c.Logf("stdout: %s", s.Stdout()) + c.Assert(err, check.ErrorMatches, `No matching section "foobar", use --section to list existing sections`) + + s.ResetStdStreams() + + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"find", "--section"}) + + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + + c.Check(s.Stdout(), check.Equals, `No section specified. Available sections: + * sec1 + * sec2 + * sec3 +Please try 'snap find --section=' +`) + + s.ResetStdStreams() + c.Check(numHits, check.Equals, 1) +} diff --git a/cmd/snap/cmd_first_boot.go b/cmd/snap/cmd_first_boot.go new file mode 100644 index 00000000..8970b7bf --- /dev/null +++ b/cmd/snap/cmd_first_boot.go @@ -0,0 +1,49 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2014-2015 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "fmt" + + "github.com/jessevdk/go-flags" +) + +type cmdInternalFirstBoot struct{} + +func init() { + cmd := addCommand("firstboot", + "Deprecated (hidden)", + "The firstboot command is only retained for backwards compatibility.", + func() flags.Commander { + return &cmdInternalFirstBoot{} + }, nil, nil) + cmd.hidden = true +} + +// WARNING: do not remove this command, older systems may still have systemd +// snapd.firstboot.service job in /etc/systemd/system that we did not cleanup. +// so we need this sample command or those units will start failing. +func (x *cmdInternalFirstBoot) Execute(args []string) error { + if len(args) > 0 { + return ErrExtraArgs + } + fmt.Fprintf(Stderr, "firstboot command is deprecated\n") + return nil +} diff --git a/cmd/snap/cmd_get.go b/cmd/snap/cmd_get.go new file mode 100644 index 00000000..fc501441 --- /dev/null +++ b/cmd/snap/cmd_get.go @@ -0,0 +1,295 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/features" + "github.com/snapcore/snapd/i18n" +) + +var shortGetHelp = i18n.G("Print configuration options") +var longGetHelp = i18n.G(` +The get command prints configuration options for the provided snap. + + $ snap get snap-name username + frank + +If multiple option names are provided, the corresponding values are returned: + + $ snap get snap-name username password + Key Value + username frank + password ... + +Nested values may be retrieved via a dotted path: + + $ snap get snap-name author.name + frank +`) + +var longAspectGetHelp = i18n.G(` +If the first argument passed into get is an aspect identifier matching the +format //, get will use the aspects configuration +API. In this case, the command returns the data retrieved from the requested +dot-separated aspect paths. +`) + +type cmdGet struct { + clientMixin + Positional struct { + Snap installedSnapName `required:"yes"` + Keys []string + } `positional-args:"yes"` + + Typed bool `short:"t"` + Document bool `short:"d"` + List bool `short:"l"` +} + +func init() { + if err := validateAspectFeatureFlag(); err == nil { + longGetHelp += longAspectGetHelp + } + + addCommand("get", shortGetHelp, longGetHelp, func() flags.Commander { return &cmdGet{} }, + map[string]string{ + // TRANSLATORS: This should not start with a lowercase letter. + "d": i18n.G("Always return document, even with single key"), + // TRANSLATORS: This should not start with a lowercase letter. + "l": i18n.G("Always return list, even with single key"), + // TRANSLATORS: This should not start with a lowercase letter. + "t": i18n.G("Strict typing with nulls and quoted strings"), + }, []argDesc{ + { + name: "", + // TRANSLATORS: This should not start with a lowercase letter. + desc: i18n.G("The snap whose conf is being requested"), + }, + { + // TRANSLATORS: This needs to begin with < and end with > + name: i18n.G(""), + // TRANSLATORS: This should not start with a lowercase letter. + desc: i18n.G("Key of interest within the configuration"), + }, + }) +} + +type ConfigValue struct { + Path string + Value interface{} +} + +type byConfigPath []ConfigValue + +func (s byConfigPath) Len() int { return len(s) } +func (s byConfigPath) Swap(i, j int) { s[i], s[j] = s[j], s[i] } +func (s byConfigPath) Less(i, j int) bool { + other := s[j].Path + for k, c := range s[i].Path { + if len(other) <= k { + return false + } + + switch { + case c == rune(other[k]): + continue + case c == '.': + return true + case other[k] == '.' || c > rune(other[k]): + return false + } + return true + } + return true +} + +func sortByPath(config []ConfigValue) { + sort.Sort(byConfigPath(config)) +} + +func flattenConfig(cfg map[string]interface{}, root bool) (values []ConfigValue) { + const docstr = "{...}" + for k, v := range cfg { + if input, ok := v.(map[string]interface{}); ok { + if root { + values = append(values, ConfigValue{k, docstr}) + } else { + for kk, vv := range input { + p := k + "." + kk + if _, ok := vv.(map[string]interface{}); ok { + values = append(values, ConfigValue{p, docstr}) + } else { + values = append(values, ConfigValue{p, vv}) + } + } + } + } else { + values = append(values, ConfigValue{k, v}) + } + } + sortByPath(values) + return values +} + +func rootRequested(confKeys []string) bool { + return len(confKeys) == 0 +} + +// outputJson will be used when the user requested "document" output via +// the "-d" commandline switch. +func (c *cmdGet) outputJson(conf interface{}) error { + bytes, err := json.MarshalIndent(conf, "", "\t") + if err != nil { + return err + } + + fmt.Fprintln(Stdout, string(bytes)) + return nil +} + +// outputList will be used when the user requested list output via the +// "-l" commandline switch. +func (x *cmdGet) outputList(conf map[string]interface{}) error { + if rootRequested(x.Positional.Keys) && len(conf) == 0 { + return fmt.Errorf("snap %q has no configuration", x.Positional.Snap) + } + + w := tabWriter() + defer w.Flush() + + fmt.Fprintf(w, "Key\tValue\n") + values := flattenConfig(conf, rootRequested(x.Positional.Keys)) + for _, v := range values { + fmt.Fprintf(w, "%s\t%v\n", v.Path, v.Value) + } + return nil +} + +// outputDefault will be used when no commandline switch to override the +// output where used. The output follows the following rules: +// - a single key with a string value is printed directly +// - multiple keys are printed as a list to the terminal (if there is one) +// or as json if there is no terminal +// - the option "typed" is honored +func (x *cmdGet) outputDefault(conf map[string]interface{}, snapName string, confKeys []string) error { + if rootRequested(confKeys) && len(conf) == 0 { + return fmt.Errorf("snap %q has no configuration", snapName) + } + + var confToPrint interface{} = conf + + if len(confKeys) == 1 { + // if single key was requested, then just output the + // value unless it's a map, in which case it will be + // printed as a list below. + if _, ok := conf[confKeys[0]].(map[string]interface{}); !ok { + confToPrint = conf[confKeys[0]] + } + } + + // conf looks like a map + if cfg, ok := confToPrint.(map[string]interface{}); ok { + if isStdinTTY { + return x.outputList(cfg) + } + + // TODO: remove this conditional and the warning below + // after a transition period. + fmt.Fprintf(Stderr, i18n.G(`WARNING: The output of 'snap get' will become a list with columns - use -d or -l to force the output format.\n`)) + return x.outputJson(confToPrint) + } + + if s, ok := confToPrint.(string); ok && !x.Typed { + fmt.Fprintln(Stdout, s) + return nil + } + + if confToPrint != nil || x.Typed { + return x.outputJson(confToPrint) + } + + fmt.Fprintln(Stdout, "") + return nil + +} + +func (x *cmdGet) Execute(args []string) error { + if len(args) > 0 { + // TRANSLATORS: the %s is the list of extra arguments + return fmt.Errorf(i18n.G("too many arguments: %s"), strings.Join(args, " ")) + } + + if x.Document && x.Typed { + return fmt.Errorf("cannot use -d and -t together") + } + + if x.Document && x.List { + return fmt.Errorf("cannot use -d and -l together") + } + + snapName := string(x.Positional.Snap) + confKeys := x.Positional.Keys + + var conf map[string]interface{} + var err error + if isAspectID(snapName) { + if err := validateAspectFeatureFlag(); err != nil { + return err + } + + // first argument is an aspectID, use the aspects API + aspectID := snapName + if err := validateAspectID(aspectID); err != nil { + return err + } + + conf, err = x.client.AspectGet(aspectID, confKeys) + } else { + conf, err = x.client.Conf(snapName, confKeys) + } + + if err != nil { + return err + } + + switch { + case x.Document: + return x.outputJson(conf) + case x.List: + return x.outputList(conf) + default: + return x.outputDefault(conf, snapName, confKeys) + } +} + +func validateAspectFeatureFlag() error { + if !features.AspectsConfiguration.IsEnabled() { + _, confName := features.AspectsConfiguration.ConfigOption() + return fmt.Errorf(`aspect-based configuration is disabled: you must set '%s' to true`, confName) + } + return nil +} diff --git a/cmd/snap/cmd_get_base_declaration.go b/cmd/snap/cmd_get_base_declaration.go new file mode 100644 index 00000000..6669cf36 --- /dev/null +++ b/cmd/snap/cmd_get_base_declaration.go @@ -0,0 +1,70 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "fmt" + + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/i18n" +) + +type cmdGetBaseDeclaration struct { + get bool + clientMixin +} + +func init() { + cmd := addDebugCommand("get-base-declaration", + "(internal) obtain the base declaration for all interfaces (deprecated)", + "(internal) obtain the base declaration for all interfaces (deprecated)", + func() flags.Commander { + return &cmdGetBaseDeclaration{get: true} + }, nil, nil) + cmd.hidden = true + + cmd = addDebugCommand("base-declaration", + "(internal) obtain the base declaration for all interfaces", + "(internal) obtain the base declaration for all interfaces", + func() flags.Commander { + return &cmdGetBaseDeclaration{} + }, nil, nil) + cmd.hidden = true +} + +func (x *cmdGetBaseDeclaration) Execute(args []string) error { + if len(args) > 0 { + return ErrExtraArgs + } + var resp struct { + BaseDeclaration string `json:"base-declaration"` + } + var err error + err = x.client.DebugGet("base-declaration", &resp, nil) + if err != nil { + return err + } + fmt.Fprintf(Stdout, "%s\n", resp.BaseDeclaration) + if x.get { + fmt.Fprintf(Stderr, i18n.G("'snap debug get-base-declaration' is deprecated; use 'snap debug base-declaration'.")) + } + return nil +} diff --git a/cmd/snap/cmd_get_base_declaration_test.go b/cmd/snap/cmd_get_base_declaration_test.go new file mode 100644 index 00000000..d2a1fe36 --- /dev/null +++ b/cmd/snap/cmd_get_base_declaration_test.go @@ -0,0 +1,80 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "fmt" + "io" + "net/http" + + "gopkg.in/check.v1" + + snap "github.com/snapcore/snapd/cmd/snap" +) + +func (s *SnapSuite) TestGetBaseDeclaration(c *check.C) { + n := 0 + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch n { + case 0: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/debug") + c.Check(r.URL.RawQuery, check.Equals, "aspect=base-declaration") + data, err := io.ReadAll(r.Body) + c.Check(err, check.IsNil) + c.Check(data, check.HasLen, 0) + fmt.Fprintln(w, `{"type": "sync", "result": {"base-declaration": "hello"}}`) + default: + c.Fatalf("expected to get 1 requests, now on %d", n+1) + } + + n++ + }) + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"debug", "get-base-declaration"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + c.Check(s.Stdout(), check.Equals, "hello\n") + c.Check(s.Stderr(), check.Equals, `'snap debug get-base-declaration' is deprecated; use 'snap debug base-declaration'.`) +} + +func (s *SnapSuite) TestBaseDeclaration(c *check.C) { + n := 0 + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch n { + case 0: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/debug") + c.Check(r.URL.RawQuery, check.Equals, "aspect=base-declaration") + data, err := io.ReadAll(r.Body) + c.Check(err, check.IsNil) + c.Check(data, check.HasLen, 0) + fmt.Fprintln(w, `{"type": "sync", "result": {"base-declaration": "hello"}}`) + default: + c.Fatalf("expected to get 1 requests, now on %d", n+1) + } + + n++ + }) + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"debug", "base-declaration"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + c.Check(s.Stdout(), check.Equals, "hello\n") + c.Check(s.Stderr(), check.Equals, "") +} diff --git a/cmd/snap/cmd_get_test.go b/cmd/snap/cmd_get_test.go new file mode 100644 index 00000000..02edb060 --- /dev/null +++ b/cmd/snap/cmd_get_test.go @@ -0,0 +1,460 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "fmt" + "net/http" + "strings" + + "gopkg.in/check.v1" + . "gopkg.in/check.v1" + + snapset "github.com/snapcore/snapd/cmd/snap" + "github.com/snapcore/snapd/strutil" +) + +type getCmdArgs struct { + args, stdout, stderr, error string + isTerminal bool +} + +var getTests = []getCmdArgs{{ + args: "get snap-name --foo", + error: ".*unknown flag.*foo.*", +}, { + args: "get snapname test-key1", + stdout: "test-value1\n", +}, { + args: "get snapname test-key2", + stdout: "2\n", +}, { + args: "get snapname missing-key", + stdout: "\n", +}, { + args: "get -t snapname test-key1", + stdout: "\"test-value1\"\n", +}, { + args: "get -t snapname test-key2", + stdout: "2\n", +}, { + args: "get -t snapname missing-key", + stdout: "null\n", +}, { + args: "get -d snapname test-key1", + stdout: "{\n\t\"test-key1\": \"test-value1\"\n}\n", +}, { + args: "get -l snapname test-key1", + stdout: "Key Value\ntest-key1 test-value1\n", +}, { + args: "get snapname -l test-key1 test-key2", + stdout: "Key Value\ntest-key1 test-value1\ntest-key2 2\n", +}, { + args: "get snapname document", + stderr: `WARNING: The output of 'snap get' will become a list with columns - use -d or -l to force the output format.\n`, + stdout: "{\n\t\"document\": {\n\t\t\"key1\": \"value1\",\n\t\t\"key2\": \"value2\"\n\t}\n}\n", +}, { + isTerminal: true, + args: "get snapname document", + stdout: "Key Value\ndocument.key1 value1\ndocument.key2 value2\n", +}, { + args: "get snapname -d test-key1 test-key2", + stdout: "{\n\t\"test-key1\": \"test-value1\",\n\t\"test-key2\": 2\n}\n", +}, { + args: "get snapname -l document", + stdout: "Key Value\ndocument.key1 value1\ndocument.key2 value2\n", +}, { + args: "get -d snapname document", + stdout: "{\n\t\"document\": {\n\t\t\"key1\": \"value1\",\n\t\t\"key2\": \"value2\"\n\t}\n}\n", +}, { + args: "get -l snapname", + stdout: "Key Value\nbar 100\nfoo {...}\n", +}, { + args: "get snapname -l test-key3 test-key4", + stdout: "Key Value\ntest-key3.a 1\ntest-key3.b 2\ntest-key3-a 9\ntest-key4.a 3\ntest-key4.b 4\n", +}, { + args: "get -d snapname", + stdout: "{\n\t\"bar\": 100,\n\t\"foo\": {\n\t\t\"key1\": \"value1\",\n\t\t\"key2\": \"value2\"\n\t}\n}\n", +}, { + isTerminal: true, + args: "get snapname test-key1 test-key2", + stdout: "Key Value\ntest-key1 test-value1\ntest-key2 2\n", +}, { + isTerminal: false, + args: "get snapname test-key1 test-key2", + stdout: "{\n\t\"test-key1\": \"test-value1\",\n\t\"test-key2\": 2\n}\n", + stderr: `WARNING: The output of 'snap get' will become a list with columns - use -d or -l to force the output format.\n`, +}, +} + +func (s *SnapSuite) runTests(cmds []getCmdArgs, c *C) { + for _, test := range cmds { + s.stdout.Truncate(0) + s.stderr.Truncate(0) + + c.Logf("Test: %s", test.args) + + restore := snapset.MockIsStdinTTY(test.isTerminal) + defer restore() + + _, err := snapset.Parser(snapset.Client()).ParseArgs(strings.Fields(test.args)) + if test.error != "" { + c.Check(err, ErrorMatches, test.error) + } else { + c.Check(err, IsNil) + c.Check(s.Stderr(), Equals, test.stderr) + c.Check(s.Stdout(), Equals, test.stdout) + } + } +} + +func (s *SnapSuite) TestSnapGetTests(c *C) { + s.mockGetConfigServer(c) + s.runTests(getTests, c) +} + +var getNoConfigTests = []getCmdArgs{{ + args: "get -l snapname", + error: `snap "snapname" has no configuration`, +}, { + args: "get snapname", + error: `snap "snapname" has no configuration`, +}, { + args: "get -d snapname", + stdout: "{}\n", +}} + +func (s *SnapSuite) TestSnapGetNoConfiguration(c *C) { + s.mockGetEmptyConfigServer(c) + s.runTests(getNoConfigTests, c) +} + +func (s *SnapSuite) TestSortByPath(c *C) { + values := []snapset.ConfigValue{ + {Path: "test-key3.b"}, + {Path: "a"}, + {Path: "test-key3.a"}, + {Path: "a.b.c"}, + {Path: "test-key4.a"}, + {Path: "test-key4.b"}, + {Path: "a-b"}, + {Path: "zzz"}, + {Path: "aa"}, + {Path: "test-key3-a"}, + {Path: "a.b"}, + } + snapset.SortByPath(values) + + expected := []string{ + "a", + "a.b", + "a.b.c", + "a-b", + "aa", + "test-key3.a", + "test-key3.b", + "test-key3-a", + "test-key4.a", + "test-key4.b", + "zzz", + } + + c.Assert(values, HasLen, len(expected)) + + for i, e := range expected { + c.Assert(values[i].Path, Equals, e) + } +} + +func (s *SnapSuite) mockGetConfigServer(c *C) { + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v2/snaps/snapname/conf" { + c.Errorf("unexpected path %q", r.URL.Path) + return + } + + c.Check(r.Method, Equals, "GET") + + query := r.URL.Query() + switch query.Get("keys") { + case "test-key1": + fmt.Fprintln(w, `{"type":"sync", "status-code": 200, "result": {"test-key1":"test-value1"}}`) + case "test-key2": + fmt.Fprintln(w, `{"type":"sync", "status-code": 200, "result": {"test-key2":2}}`) + case "test-key1,test-key2": + fmt.Fprintln(w, `{"type":"sync", "status-code": 200, "result": {"test-key1":"test-value1","test-key2":2}}`) + case "test-key3,test-key4": + fmt.Fprintln(w, `{"type":"sync", "status-code": 200, "result": {"test-key3":{"a":1,"b":2},"test-key3-a":9,"test-key4":{"a":3,"b":4}}}`) + case "missing-key": + fmt.Fprintln(w, `{"type":"sync", "status-code": 200, "result": {}}`) + case "document": + fmt.Fprintln(w, `{"type":"sync", "status-code": 200, "result": {"document":{"key1":"value1","key2":"value2"}}}`) + case "": + fmt.Fprintln(w, `{"type":"sync", "status-code": 200, "result": {"foo":{"key1":"value1","key2":"value2"},"bar":100}}`) + default: + c.Errorf("unexpected keys %q", query.Get("keys")) + } + }) +} + +func (s *SnapSuite) mockGetEmptyConfigServer(c *C) { + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v2/snaps/snapname/conf" { + c.Errorf("unexpected path %q", r.URL.Path) + return + } + + c.Check(r.Method, Equals, "GET") + + fmt.Fprintln(w, `{"type":"sync", "status-code": 200, "result": {}}`) + }) +} + +const syncResp = `{ + "type": "sync", + "status-code": 200, + "status": "OK", + "result": %s +}` + +func (s *aspectsSuite) TestAspectGet(c *C) { + restore := snapset.MockIsStdinTTY(true) + defer restore() + + restore = s.mockAspectsFlag(c) + defer restore() + + var reqs int + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch reqs { + case 0: + c.Check(r.Method, Equals, "GET") + c.Check(r.URL.Path, Equals, "/v2/aspects/foo/bar/baz") + + q := r.URL.Query() + fields := strutil.CommaSeparatedList(q.Get("fields")) + c.Check(fields, DeepEquals, []string{"abc"}) + + w.WriteHeader(200) + fmt.Fprintf(w, syncResp, `{"abc": "cba"}`) + default: + err := fmt.Errorf("expected to get 1 request, now on %d (%v)", reqs+1, r) + w.WriteHeader(500) + fmt.Fprintf(w, `{"type": "error", "result": {"message": %q}}`, err) + c.Error(err) + } + + reqs++ + }) + + rest, err := snapset.Parser(snapset.Client()).ParseArgs([]string{"get", "foo/bar/baz", "abc"}) + c.Assert(err, IsNil) + c.Assert(rest, HasLen, 0) + c.Check(s.Stdout(), Equals, "cba\n") + c.Check(s.Stderr(), Equals, "") +} + +func (s *aspectsSuite) TestAspectGetAsDocument(c *C) { + restore := snapset.MockIsStdinTTY(true) + defer restore() + + restore = s.mockAspectsFlag(c) + defer restore() + + var reqs int + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch reqs { + case 0: + c.Check(r.Method, Equals, "GET") + c.Check(r.URL.Path, Equals, "/v2/aspects/foo/bar/baz") + + q := r.URL.Query() + fields := strutil.CommaSeparatedList(q.Get("fields")) + c.Check(fields, DeepEquals, []string{"abc"}) + + w.WriteHeader(200) + fmt.Fprintf(w, syncResp, `{"abc": "cba"}`) + default: + err := fmt.Errorf("expected to get 1 request, now on %d (%v)", reqs+1, r) + w.WriteHeader(500) + fmt.Fprintf(w, `{"type": "error", "result": {"message": %q}}`, err) + c.Error(err) + } + + reqs++ + }) + + rest, err := snapset.Parser(snapset.Client()).ParseArgs([]string{"get", "-d", "foo/bar/baz", "abc"}) + c.Assert(err, IsNil) + c.Assert(rest, HasLen, 0) + + c.Check(s.Stdout(), Equals, `{ + "abc": "cba" +} +`) + c.Check(s.Stderr(), Equals, "") +} + +func (s *aspectsSuite) TestAspectGetMany(c *C) { + restore := snapset.MockIsStdinTTY(true) + defer restore() + + restore = s.mockAspectsFlag(c) + defer restore() + + var reqs int + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch reqs { + case 0: + c.Check(r.Method, Equals, "GET") + c.Check(r.URL.Path, Equals, "/v2/aspects/foo/bar/baz") + + q := r.URL.Query() + fields := strutil.CommaSeparatedList(q.Get("fields")) + c.Check(fields, DeepEquals, []string{"abc", "xyz"}) + + w.WriteHeader(200) + fmt.Fprintf(w, syncResp, `{"abc": 1, "xyz": false}`) + default: + err := fmt.Errorf("expected to get 1 request, now on %d (%v)", reqs+1, r) + w.WriteHeader(500) + fmt.Fprintf(w, `{"type": "error", "result": {"message": %q}}`, err) + c.Error(err) + } + + reqs++ + }) + + rest, err := snapset.Parser(snapset.Client()).ParseArgs([]string{"get", "foo/bar/baz", "abc", "xyz"}) + c.Assert(err, IsNil) + c.Assert(rest, HasLen, 0) + c.Check(s.Stdout(), Equals, + `Key Value +abc 1 +xyz false +`) + c.Check(s.Stderr(), Equals, "") +} + +func (s *aspectsSuite) TestAspectGetManyAsDocument(c *C) { + restore := snapset.MockIsStdinTTY(true) + defer restore() + + restore = s.mockAspectsFlag(c) + defer restore() + + var reqs int + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch reqs { + case 0: + c.Check(r.Method, Equals, "GET") + c.Check(r.URL.Path, Equals, "/v2/aspects/foo/bar/baz") + + q := r.URL.Query() + fields := strutil.CommaSeparatedList(q.Get("fields")) + c.Check(fields, DeepEquals, []string{"abc", "xyz"}) + + w.WriteHeader(200) + fmt.Fprintf(w, syncResp, `{"abc": 1, "xyz": false}`) + default: + err := fmt.Errorf("expected to get 1 request, now on %d (%v)", reqs+1, r) + w.WriteHeader(500) + fmt.Fprintf(w, `{"type": "error", "result": {"message": %q}}`, err) + c.Error(err) + } + + reqs++ + }) + + rest, err := snapset.Parser(snapset.Client()).ParseArgs([]string{"get", "-d", "foo/bar/baz", "abc", "xyz"}) + c.Assert(err, IsNil) + c.Assert(rest, HasLen, 0) + + c.Check(s.Stdout(), Equals, `{ + "abc": 1, + "xyz": false +} +`) + c.Check(s.Stderr(), Equals, "") +} + +func (s *aspectsSuite) TestAspectGetInvalidAspectID(c *check.C) { + restore := s.mockAspectsFlag(c) + defer restore() + + _, err := snapset.Parser(snapset.Client()).ParseArgs([]string{"get", "foo//bar", "foo=bar"}) + c.Assert(err, NotNil) + c.Check(err.Error(), Equals, "aspect identifier must conform to format: //") +} + +func (s *aspectsSuite) TestAspectGetDisabledFlag(c *check.C) { + var reqs int + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch reqs { + default: + err := fmt.Errorf("expected to get no requests, now on %d (%v)", reqs+1, r) + w.WriteHeader(500) + fmt.Fprintf(w, `{"type": "error", "result": {"message": %q}}`, err) + c.Error(err) + } + + reqs++ + }) + + _, err := snapset.Parser(snapset.Client()).ParseArgs([]string{"get", "foo/bar/baz", "abc"}) + c.Assert(err, check.ErrorMatches, "aspect-based configuration is disabled: you must set 'experimental.aspects-configuration' to true") +} + +func (s *aspectsSuite) TestAspectGetNoFields(c *check.C) { + restore := s.mockAspectsFlag(c) + defer restore() + + var reqs int + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch reqs { + case 0: + c.Check(r.Method, Equals, "GET") + c.Check(r.URL.Path, Equals, "/v2/aspects/foo/bar/baz") + + fields := r.URL.Query().Get("fields") + c.Check(fields, Equals, "") + + w.WriteHeader(200) + fmt.Fprintf(w, syncResp, `{"abc": 1, "xyz": false}`) + default: + err := fmt.Errorf("expected to get 1 request, now on %d (%v)", reqs+1, r) + w.WriteHeader(500) + fmt.Fprintf(w, `{"type": "error", "result": {"message": %q}}`, err) + c.Error(err) + } + + reqs++ + }) + + rest, err := snapset.Parser(snapset.Client()).ParseArgs([]string{"get", "foo/bar/baz"}) + c.Assert(err, IsNil) + c.Assert(rest, HasLen, 0) + + c.Check(s.Stdout(), Equals, `{ + "abc": 1, + "xyz": false +} +`) +} diff --git a/cmd/snap/cmd_handle_link.go b/cmd/snap/cmd_handle_link.go new file mode 100644 index 00000000..7e57e0ad --- /dev/null +++ b/cmd/snap/cmd_handle_link.go @@ -0,0 +1,91 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2018 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "fmt" + "os" + "syscall" + "time" + + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/i18n" + "github.com/snapcore/snapd/usersession/userd/ui" +) + +type cmdHandleLink struct { + waitMixin + + Positional struct { + Uri string `positional-arg-name:""` + } `positional-args:"yes" required:"yes"` +} + +func init() { + cmd := addCommand("handle-link", + i18n.G("Handle a snap:// URI"), + i18n.G("The handle-link command installs the snap-store snap and then invokes it."), + func() flags.Commander { + return &cmdHandleLink{} + }, nil, nil) + cmd.hidden = true +} + +func (x *cmdHandleLink) ensureSnapStoreInstalled() error { + // If the snap-store snap is installed, our work is done + if _, _, err := x.client.Snap("snap-store"); err == nil { + return nil + } + + dialog, err := ui.New() + if err != nil { + return err + } + answeredYes := dialog.YesNo( + i18n.G("Install snap-aware Snap Store snap?"), + i18n.G("The Snap Store is required to open snaps from a web browser."), + &ui.DialogOptions{ + Timeout: 5 * time.Minute, + Footer: i18n.G("This dialog will close automatically after 5 minutes of inactivity."), + }) + if !answeredYes { + return fmt.Errorf(i18n.G("Snap Store required")) + } + + changeID, err := x.client.Install("snap-store", nil) + if err != nil { + return err + } + _, err = x.wait(changeID) + if err != nil && err != noWait { + return err + } + return nil +} + +func (x *cmdHandleLink) Execute([]string) error { + if err := x.ensureSnapStoreInstalled(); err != nil { + return err + } + + argv := []string{"snap", "run", "snap-store", x.Positional.Uri} + return syscall.Exec("/proc/self/exe", argv, os.Environ()) +} diff --git a/cmd/snap/cmd_help.go b/cmd/snap/cmd_help.go new file mode 100644 index 00000000..942f1a4e --- /dev/null +++ b/cmd/snap/cmd_help.go @@ -0,0 +1,387 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016-2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "bytes" + "fmt" + "io" + "regexp" + "strings" + "unicode/utf8" + + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/i18n" +) + +var shortHelpHelp = i18n.G("Show help about a command") +var longHelpHelp = i18n.G(` +The help command displays information about snap commands. +`) + +// addHelp adds --help like what go-flags would do for us, but hidden +func addHelp(parser *flags.Parser) error { + var help struct { + ShowHelp func() error `short:"h" long:"help"` + } + help.ShowHelp = func() error { + // this function is called via --help (or -h). In that + // case, parser.Command.Active should be the command + // on which help is being requested (like "snap foo + // --help", active is foo), or nil in the toplevel. + if parser.Command.Active == nil { + // this means *either* a bare 'snap --help', + // *or* 'snap --help command' + // + // If we return nil in the first case go-flags + // will throw up an ErrCommandRequired on its + // own, but in the second case it'll go on to + // run the command, which is very unexpected. + // + // So we force the ErrCommandRequired here. + + // toplevel --help gets handled via ErrCommandRequired + return &flags.Error{Type: flags.ErrCommandRequired} + } + // not toplevel, so ask for regular help + return &flags.Error{Type: flags.ErrHelp} + } + hlpgrp, err := parser.AddGroup("Help Options", "", &help) + if err != nil { + return err + } + hlpgrp.Hidden = true + hlp := parser.FindOptionByLongName("help") + hlp.Description = i18n.G("Show this help message") + hlp.Hidden = true + + return nil +} + +type cmdHelp struct { + All bool `long:"all"` + Manpage bool `long:"man" hidden:"true"` + Positional struct { + // TODO: find a way to make Command tab-complete + Subs []string `positional-arg-name:""` + } `positional-args:"yes"` + parser *flags.Parser +} + +func init() { + addCommand("help", shortHelpHelp, longHelpHelp, func() flags.Commander { return &cmdHelp{} }, + map[string]string{ + // TRANSLATORS: This should not start with a lowercase letter. + "all": i18n.G("Show a short summary of all commands"), + // TRANSLATORS: This should not start with a lowercase letter. + "man": i18n.G("Generate the manpage"), + }, nil) +} + +func (cmd *cmdHelp) setParser(parser *flags.Parser) { + cmd.parser = parser +} + +// manfixer is a hackish way to fix drawbacks in the generated manpage: +// - no way to get it into section 8 +// - duplicated TP lines that break older groff (e.g. 14.04), lp:1814767 +type manfixer struct { + bytes.Buffer + done bool +} + +func (w *manfixer) Write(buf []byte) (int, error) { + if !w.done { + w.done = true + if bytes.HasPrefix(buf, []byte(".TH snap 1 ")) { + // io.Writer.Write must not modify the buffer, even temporarily + n, _ := w.Buffer.Write(buf[:9]) + w.Buffer.Write([]byte{'8'}) + m, err := w.Buffer.Write(buf[10:]) + return n + m + 1, err + } + } + return w.Buffer.Write(buf) +} + +var tpRegexp = regexp.MustCompile(`(?m)(?:^\.TP\n)+`) + +func (w *manfixer) flush() { + str := tpRegexp.ReplaceAllLiteralString(w.Buffer.String(), ".TP\n") + io.Copy(Stdout, strings.NewReader(str)) +} + +func manExtend(out io.Writer) { + out.Write([]byte(` +.SH NOTES +.IP " 1. " 4 +Online documentation +.RS 4 +\%https://docs.snapcraft.io +.RE +.SH BUGS +.sp +Please report all bugs with \fI\%https://bugs.launchpad.net/snapd/+filebug\fP +`)) +} + +func (cmd cmdHelp) Execute(args []string) error { + if len(args) > 0 { + return ErrExtraArgs + } + if cmd.Manpage { + // you shouldn't try to to combine --man with --all nor a + // subcommand, but --man is hidden so no real need to check. + out := &manfixer{} + cmd.parser.WriteManPage(out) + manExtend(out) + out.flush() + return nil + } + if cmd.All { + if len(cmd.Positional.Subs) > 0 { + return fmt.Errorf(i18n.G("help accepts a command, or '--all', but not both.")) + } + printLongHelp(cmd.parser) + return nil + } + + var subcmd = cmd.parser.Command + for _, subname := range cmd.Positional.Subs { + subcmd = subcmd.Find(subname) + if subcmd == nil { + sug := "snap help" + if x := cmd.parser.Command.Active; x != nil && x.Name != "help" { + sug = "snap help " + x.Name + } + // TRANSLATORS: %q is the command the user entered; %s is 'snap help' or 'snap help ' + return fmt.Errorf(i18n.G("unknown command %q, see '%s'."), subname, sug) + } + // this makes "snap help foo" work the same as "snap foo --help" + cmd.parser.Command.Active = subcmd + } + if subcmd != cmd.parser.Command { + return &flags.Error{Type: flags.ErrHelp} + } + return &flags.Error{Type: flags.ErrCommandRequired} +} + +type helpCategory struct { + Label string + // Other is set if the category Commands should be listed + // together under "... Other" in the `snap help` list. + Other bool + Description string + // Commands list commands belonging to the category that should + // be listed under both `snap help` and "snap help --all`. + Commands []string + // AllOnlyCommands list commands belonging to the category that should + // be listed only under "snap help --all`. + AllOnlyCommands []string +} + +// helpCategories helps us by grouping commands +var helpCategories = []helpCategory{ + { + Label: i18n.G("Basics"), + Description: i18n.G("basic snap management"), + Commands: []string{"find", "info", "install", "remove", "list"}, + }, { + Label: i18n.G("...more"), + Description: i18n.G("slightly more advanced snap management"), + Commands: []string{"refresh", "revert", "switch", "disable", "enable", "create-cohort"}, + }, { + Label: i18n.G("History"), + Description: i18n.G("manage system change transactions"), + Commands: []string{"changes", "tasks", "abort", "watch"}, + }, { + Label: i18n.G("Daemons"), + Description: i18n.G("manage services"), + Commands: []string{"services", "start", "stop", "restart", "logs"}, + }, { + Label: i18n.G("Permissions"), + Description: i18n.G("manage permissions"), + Commands: []string{"connections", "interface", "connect", "disconnect"}, + }, { + Label: i18n.G("Configuration"), + Description: i18n.G("system administration and configuration"), + Commands: []string{"get", "set", "unset", "wait"}, + }, { + Label: i18n.G("App Aliases"), + Description: i18n.G("manage aliases"), + Commands: []string{"alias", "aliases", "unalias", "prefer"}, + }, { + Label: i18n.G("Account"), + Description: i18n.G("authentication to snapd and the snap store"), + Commands: []string{"login", "logout", "whoami"}, + }, { + Label: i18n.G("Snapshots"), + Description: i18n.G("archives of snap data"), + Commands: []string{"saved", "save", "check-snapshot", "restore", "forget"}, + AllOnlyCommands: []string{"export-snapshot", "import-snapshot"}, + }, { + Label: i18n.G("Device"), + Description: i18n.G("manage device"), + Commands: []string{"model", "remodel", "reboot", "recovery"}, + }, { + Label: i18n.G("Warnings"), + Other: true, + Description: i18n.G("manage warnings"), + Commands: []string{"warnings", "okay"}, + }, { + Label: i18n.G("Assertions"), + Other: true, + Description: i18n.G("manage assertions"), + Commands: []string{"known", "ack"}, + }, { + Label: i18n.G("Introspection"), + Other: true, + Description: i18n.G("introspection and debugging of snapd"), + Commands: []string{"version"}, + AllOnlyCommands: []string{"debug"}, + }, { + Label: i18n.G("Development"), + Description: i18n.G("developer-oriented features"), + Commands: []string{"download", "pack", "run", "try"}, + AllOnlyCommands: []string{"prepare-image"}, + }, { + Label: i18n.G("Quota Groups"), + Description: i18n.G("Manage quota groups for snaps"), + Commands: []string{"set-quota", "remove-quota", "quotas", "quota"}, + }, { + Label: i18n.G("Validation Sets"), + Description: i18n.G("Manage validation sets"), + Commands: []string{"validate"}, + }, +} + +var ( + longSnapDescription = strings.TrimSpace(i18n.G(` +The snap command lets you install, configure, refresh and remove snaps. +Snaps are packages that work across many different Linux distributions, +enabling secure delivery and operation of the latest apps and utilities. +`)) + snapUsage = i18n.G("Usage: snap [...]") + snapHelpCategoriesIntro = i18n.G("Commonly used commands can be classified as follows:") + snapHelpAllIntro = i18n.G("Commands can be classified as follows:") + snapHelpAllFooter = i18n.G("For more information about a command, run 'snap help '.") + snapHelpFooter = i18n.G("For a short summary of all commands, run 'snap help --all'.") +) + +func printHelpHeader(cmdsIntro string) { + fmt.Fprintln(Stdout, longSnapDescription) + fmt.Fprintln(Stdout) + fmt.Fprintln(Stdout, snapUsage) + fmt.Fprintln(Stdout) + fmt.Fprintln(Stdout, cmdsIntro) +} + +func printHelpAllFooter() { + fmt.Fprintln(Stdout) + fmt.Fprintln(Stdout, snapHelpAllFooter) +} + +func printHelpFooter() { + printHelpAllFooter() + fmt.Fprintln(Stdout, snapHelpFooter) +} + +// this is called when the Execute returns a flags.Error with ErrCommandRequired +func printShortHelp() { + printHelpHeader(snapHelpCategoriesIntro) + maxLen := utf8.RuneCountInString("... Other") + var otherCommands []string + var develCateg *helpCategory + for _, categ := range helpCategories { + if categ.Other { + otherCommands = append(otherCommands, categ.Commands...) + continue + } + if categ.Label == "Development" { + develCateg = &categ + } + if l := utf8.RuneCountInString(categ.Label); l > maxLen { + maxLen = l + } + } + + fmt.Fprintln(Stdout) + for _, categ := range helpCategories { + // Other and Development will come last + if categ.Other || categ.Label == "Development" || len(categ.Commands) == 0 { + continue + } + fmt.Fprintf(Stdout, "%*s: %s\n", maxLen+2, categ.Label, strings.Join(categ.Commands, ", ")) + } + // ... Other + if len(otherCommands) > 0 { + fmt.Fprintf(Stdout, "%*s: %s\n", maxLen+2, "... Other", strings.Join(otherCommands, ", ")) + } + // Development last + if develCateg != nil && len(develCateg.Commands) > 0 { + fmt.Fprintf(Stdout, "%*s: %s\n", maxLen+2, "Development", strings.Join(develCateg.Commands, ", ")) + } + printHelpFooter() +} + +// this is "snap help --all" +func printLongHelp(parser *flags.Parser) { + printHelpHeader(snapHelpAllIntro) + maxLen := 0 + for _, categ := range helpCategories { + for _, command := range categ.Commands { + if l := len(command); l > maxLen { + maxLen = l + } + } + for _, command := range categ.AllOnlyCommands { + if l := len(command); l > maxLen { + maxLen = l + } + } + } + + // flags doesn't have a LookupCommand? + commands := parser.Commands() + cmdLookup := make(map[string]*flags.Command, len(commands)) + for _, cmd := range commands { + cmdLookup[cmd.Name] = cmd + } + + listCmds := func(cmds []string) { + for _, name := range cmds { + cmd := cmdLookup[name] + if cmd == nil { + fmt.Fprintf(Stderr, "??? Cannot find command %q mentioned in help categories, please report!\n", name) + } else { + fmt.Fprintf(Stdout, " %*s %s\n", -maxLen, name, cmd.ShortDescription) + } + } + } + + for _, categ := range helpCategories { + fmt.Fprintln(Stdout) + fmt.Fprintf(Stdout, " %s (%s):\n", categ.Label, categ.Description) + listCmds(categ.Commands) + listCmds(categ.AllOnlyCommands) + } + printHelpAllFooter() +} diff --git a/cmd/snap/cmd_help_test.go b/cmd/snap/cmd_help_test.go new file mode 100644 index 00000000..4431163a --- /dev/null +++ b/cmd/snap/cmd_help_test.go @@ -0,0 +1,224 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "bytes" + "fmt" + "os" + "reflect" + "regexp" + "strings" + + "github.com/jessevdk/go-flags" + "gopkg.in/check.v1" + + snap "github.com/snapcore/snapd/cmd/snap" +) + +func (s *SnapSuite) TestHelpPrintsHelp(c *check.C) { + origArgs := os.Args + defer func() { os.Args = origArgs }() + + for _, cmdLine := range [][]string{ + {"snap"}, + {"snap", "help"}, + {"snap", "--help"}, + {"snap", "-h"}, + {"snap", "--help", "install"}, + } { + s.ResetStdStreams() + + os.Args = cmdLine + comment := check.Commentf("%q", cmdLine) + + err := snap.RunMain() + c.Assert(err, check.IsNil, comment) + c.Check(s.Stdout(), check.Matches, "(?s)"+strings.Join([]string{ + snap.LongSnapDescription, + "", + regexp.QuoteMeta(snap.SnapUsage), + "", + snap.SnapHelpCategoriesIntro, + ".*", "", + snap.SnapHelpAllFooter, + snap.SnapHelpFooter, + }, "\n")+`\s*`, comment) + c.Check(s.Stderr(), check.Equals, "", comment) + } +} + +func (s *SnapSuite) TestHelpAllPrintsLongHelp(c *check.C) { + origArgs := os.Args + defer func() { os.Args = origArgs }() + + os.Args = []string{"snap", "help", "--all"} + + err := snap.RunMain() + c.Assert(err, check.IsNil) + c.Check(s.Stdout(), check.Matches, "(?sm)"+strings.Join([]string{ + snap.LongSnapDescription, + "", + regexp.QuoteMeta(snap.SnapUsage), + "", + snap.SnapHelpAllIntro, + "", ".*", "", + snap.SnapHelpAllFooter, + }, "\n")+`\s*`) + c.Check(s.Stderr(), check.Equals, "") +} + +func nonHiddenCommands() map[string]bool { + parser := snap.Parser(snap.Client()) + commands := parser.Commands() + names := make(map[string]bool, len(commands)) + for _, cmd := range commands { + if cmd.Hidden { + continue + } + names[cmd.Name] = true + } + return names +} + +// Helper that checks if goflags is old. The check for EnvNamespace is +// arbitrary, it just happened that support for this got added right after +// the v1.4.0 release with commit 1c38ed7. +func goFlagsFromBefore20200331() bool { + v := reflect.ValueOf(flags.Group{}) + f := v.FieldByName("EnvNamespace") + return !f.IsValid() +} + +func (s *SnapSuite) testSubCommandHelp(c *check.C, sub, expected string) { + // Skip --help output tests for older versions of + // go-flags. Notably v1.4.0 from debian-sid will fail because + // the formating is slightly different. Note that the check here + // is not precise i.e. this is not the commit that added the change + // that changed the help output but this change is easy to test for + // with reflect and in practice this is fine. + if goFlagsFromBefore20200331() { + c.Skip("go flags too old") + } + + parser := snap.Parser(snap.Client()) + rest, err := parser.ParseArgs([]string{sub, "--help"}) + c.Assert(err, check.DeepEquals, &flags.Error{Type: flags.ErrHelp}) + c.Assert(rest, check.HasLen, 0) + var buf bytes.Buffer + parser.WriteHelp(&buf) + c.Check(buf.String(), check.Equals, expected) +} + +func (s *SnapSuite) TestSubCommandHelpPrintsHelp(c *check.C) { + origArgs := os.Args + defer func() { os.Args = origArgs }() + + for cmd := range nonHiddenCommands() { + s.ResetStdStreams() + os.Args = []string{"snap", cmd, "--help"} + + err := snap.RunMain() + comment := check.Commentf("%q", cmd) + c.Assert(err, check.IsNil, comment) + // regexp matches "Usage: snap " plus an arbitrary + // number of [] plus an arbitrary number of + // <> optionally ending in ellipsis + c.Check(s.Stdout(), check.Matches, fmt.Sprintf(`(?sm)Usage:\s+snap %s(?: \[[^][]+\])*(?:(?: <[^<>]+>(?:[.:]<[^<>]+>)?)+(?:\.\.\.)?)?(?: \[[^][]+\])?$.*`, cmd), comment) + c.Check(s.Stderr(), check.Equals, "", comment) + } +} + +func (s *SnapSuite) TestHelpCategories(c *check.C) { + // non-hidden commands that are not expected to appear in the help summary + excluded := []string{ + "help", + } + all := nonHiddenCommands() + categorised := make(map[string]bool, len(all)+len(excluded)) + for _, cmd := range excluded { + categorised[cmd] = true + } + seen := make(map[string]string, len(all)) + seenCmds := func(cmds []string, label string) { + for _, cmd := range cmds { + categorised[cmd] = true + if seen[cmd] != "" { + c.Errorf("duplicated: %q in %q and %q", cmd, seen[cmd], label) + } + seen[cmd] = label + } + } + for _, categ := range snap.HelpCategories { + seenCmds(categ.Commands, categ.Label) + seenCmds(categ.AllOnlyCommands, categ.Label) + } + for cmd := range all { + if !categorised[cmd] { + c.Errorf("uncategorised: %q", cmd) + } + } + for cmd := range categorised { + if !all[cmd] { + c.Errorf("unknown (hidden?): %q", cmd) + } + } +} + +func (s *SnapSuite) TestHelpCommandAllFails(c *check.C) { + origArgs := os.Args + defer func() { os.Args = origArgs }() + os.Args = []string{"snap", "help", "interfaces", "--all"} + + err := snap.RunMain() + c.Assert(err, check.ErrorMatches, "help accepts a command, or '--all', but not both.") +} + +func (s *SnapSuite) TestManpageInSection8(c *check.C) { + origArgs := os.Args + defer func() { os.Args = origArgs }() + os.Args = []string{"snap", "help", "--man"} + + err := snap.RunMain() + c.Assert(err, check.IsNil) + + c.Check(s.Stdout(), check.Matches, `\.TH snap 8 (?s).*`) +} + +func (s *SnapSuite) TestManpageNoDoubleTP(c *check.C) { + origArgs := os.Args + defer func() { os.Args = origArgs }() + os.Args = []string{"snap", "help", "--man"} + + err := snap.RunMain() + c.Assert(err, check.IsNil) + + c.Check(s.Stdout(), check.Not(check.Matches), `(?s).*(?m-s)^\.TP\n\.TP$(?s-m).*`) + +} + +func (s *SnapSuite) TestBadSub(c *check.C) { + origArgs := os.Args + defer func() { os.Args = origArgs }() + os.Args = []string{"snap", "debug", "brotato"} + + err := snap.RunMain() + c.Assert(err, check.ErrorMatches, `unknown command "brotato", see 'snap help debug'.`) +} diff --git a/cmd/snap/cmd_info.go b/cmd/snap/cmd_info.go new file mode 100644 index 00000000..5999727e --- /dev/null +++ b/cmd/snap/cmd_info.go @@ -0,0 +1,703 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016-2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "fmt" + "io" + "path/filepath" + "sort" + "strconv" + "strings" + "text/tabwriter" + "time" + "unicode" + + "github.com/jessevdk/go-flags" + "gopkg.in/yaml.v2" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/client" + "github.com/snapcore/snapd/client/clientutil" + "github.com/snapcore/snapd/i18n" + "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/snap/snapfile" + "github.com/snapcore/snapd/snap/squashfs" + "github.com/snapcore/snapd/strutil" +) + +type infoCmd struct { + clientMixin + colorMixin + timeMixin + + Verbose bool `long:"verbose"` + Positional struct { + Snaps []anySnapName `positional-arg-name:"" required:"1"` + } `positional-args:"yes" required:"yes"` +} + +var shortInfoHelp = i18n.G("Show detailed information about snaps") +var longInfoHelp = i18n.G(` +The info command shows detailed information about snaps. + +The snaps can be specified by name or by path; names are looked for both in the +store and in the installed snaps; paths can refer to a .snap file, or to a +directory that contains an unpacked snap suitable for 'snap try' (an example +of this would be the 'prime' directory snapcraft produces). +`) + +func init() { + addCommand("info", + shortInfoHelp, + longInfoHelp, + func() flags.Commander { + return &infoCmd{} + }, colorDescs.also(timeDescs).also(map[string]string{ + // TRANSLATORS: This should not start with a lowercase letter. + "verbose": i18n.G("Include more details on the snap (expanded notes, base, etc.)"), + }), nil) +} + +func clientSnapFromPath(path string) (*client.Snap, error) { + snapf, err := snapfile.Open(path) + if err != nil { + return nil, err + } + info, err := snap.ReadInfoFromSnapFile(snapf, nil) + if err != nil { + return nil, err + } + + direct, err := clientutil.ClientSnapFromSnapInfo(info, nil) + if err != nil { + return nil, err + } + + return direct, nil +} + +func norm(path string) string { + path = filepath.Clean(path) + if osutil.IsDirectory(path) { + path = path + "/" + } + + return path +} + +// wrapFlow wraps the text using yaml's flow style, allowing indent +// characters for the first line. +func wrapFlow(out io.Writer, text []rune, indent string, termWidth int) error { + return strutil.WordWrap(out, text, indent, " ", termWidth) +} + +func quotedIfNeeded(raw string) []rune { + // simplest way of checking to see if it needs quoting is to try + raw = strings.TrimSpace(raw) + type T struct { + S string + } + if len(raw) == 0 { + raw = `""` + } else if err := yaml.UnmarshalStrict([]byte("s: "+raw), &T{}); err != nil { + raw = strconv.Quote(raw) + } + return []rune(raw) +} + +// printDescr formats a given string (typically a snap description) +// in a user friendly way. +// +// The rules are (intentionally) very simple: +// - trim trailing whitespace +// - word wrap at "max" chars preserving line indent +// - keep \n intact and break there +func printDescr(w io.Writer, descr string, termWidth int) error { + var err error + descr = strings.TrimRightFunc(descr, unicode.IsSpace) + for _, line := range strings.Split(descr, "\n") { + err = strutil.WordWrapPadded(w, []rune(line), " ", termWidth) + if err != nil { + break + } + } + return err +} + +type writeflusher interface { + io.Writer + Flush() error +} + +type infoWriter struct { + // fields that are set every iteration + theSnap *client.Snap + diskSnap *client.Snap + localSnap *client.Snap + remoteSnap *client.Snap + resInfo *client.ResultInfo + path string + // fields that don't change and so can be set once + writeflusher + esc *escapes + termWidth int + fmtTime func(time.Time) string + absTime bool + verbose bool +} + +func (iw *infoWriter) setupDiskSnap(path string, diskSnap *client.Snap) { + iw.localSnap, iw.remoteSnap, iw.resInfo = nil, nil, nil + iw.path = path + iw.diskSnap = diskSnap + iw.theSnap = diskSnap +} + +func (iw *infoWriter) setupSnap(localSnap, remoteSnap *client.Snap, resInfo *client.ResultInfo) { + iw.path, iw.diskSnap = "", nil + iw.localSnap = localSnap + iw.remoteSnap = remoteSnap + iw.resInfo = resInfo + if localSnap != nil { + iw.theSnap = localSnap + } else { + iw.theSnap = remoteSnap + } +} + +func (iw *infoWriter) maybePrintPrice() { + if iw.resInfo == nil { + return + } + price, currency, err := getPrice(iw.remoteSnap.Prices, iw.resInfo.SuggestedCurrency) + if err != nil { + return + } + fmt.Fprintf(iw, "price:\t%s\n", formatPrice(price, currency)) +} + +func (iw *infoWriter) maybePrintType() { + // XXX: using literals here until we reshuffle snap & client properly + // (and os->core rename happens, etc) + t := iw.theSnap.Type + switch t { + case "", "app", "application": + return + case "os": + t = "core" + } + + fmt.Fprintf(iw, "type:\t%s\n", t) +} + +func (iw *infoWriter) maybePrintID() { + if iw.theSnap.ID != "" { + fmt.Fprintf(iw, "snap-id:\t%s\n", iw.theSnap.ID) + } +} + +func (iw *infoWriter) maybePrintHealth() { + if iw.localSnap == nil { + return + } + health := iw.localSnap.Health + if health == nil { + if !iw.verbose { + return + } + health = &client.SnapHealth{ + Status: "unknown", + Message: "health has not been set", + } + } + if health.Status == "okay" && !iw.verbose { + return + } + + fmt.Fprintln(iw, "health:") + fmt.Fprintf(iw, " status:\t%s\n", health.Status) + if health.Message != "" { + strutil.WordWrap(iw, quotedIfNeeded(health.Message), " message:\t", " ", iw.termWidth) + } + if health.Code != "" { + fmt.Fprintf(iw, " code:\t%s\n", health.Code) + } + if !health.Timestamp.IsZero() { + fmt.Fprintf(iw, " checked:\t%s\n", iw.fmtTime(health.Timestamp)) + } + if !health.Revision.Unset() { + fmt.Fprintf(iw, " revision:\t%s\n", health.Revision) + } + iw.Flush() +} + +func (iw *infoWriter) maybePrintTrackingChannel() { + if iw.localSnap == nil { + return + } + if iw.localSnap.TrackingChannel == "" { + return + } + fmt.Fprintf(iw, "tracking:\t%s\n", iw.localSnap.TrackingChannel) +} + +func (iw *infoWriter) maybePrintRefreshInfo() { + if iw.localSnap == nil { + return + } + + if iw.localSnap.InstallDate != nil && !iw.localSnap.InstallDate.IsZero() { + fmt.Fprintf(iw, "refresh-date:\t%s\n", iw.fmtTime(*iw.localSnap.InstallDate)) + } + + maybePrintHold := func(key string, hold *time.Time) { + if hold == nil || hold.Before(timeNow()) { + return + } + + longTime := timeNow().Add(100 * 365 * 24 * time.Hour) + if hold.After(longTime) { + fmt.Fprintf(iw, "%s:\tforever\n", key) + } else { + fmt.Fprintf(iw, "%s:\t%s\n", key, iw.fmtTime(*hold)) + } + } + + maybePrintHold("hold", iw.localSnap.Hold) + maybePrintHold("hold-by-gating", iw.localSnap.GatingHold) +} + +func (iw *infoWriter) maybePrintChinfo() { + if iw.diskSnap != nil { + return + } + chInfos := channelInfos{ + chantpl: "%s%s:\t%s %s%*s %*s %s\n", + releasedfmt: "2006-01-02", + esc: iw.esc, + } + if iw.absTime { + chInfos.releasedfmt = time.RFC3339 + } + if iw.remoteSnap != nil && iw.remoteSnap.Channels != nil && iw.remoteSnap.Tracks != nil { + iw.Flush() + chInfos.chantpl = "%s%s:\t%s\t%s\t%*s\t%*s\t%s\n" + chInfos.addFromRemote(iw.remoteSnap) + } + if iw.localSnap != nil { + chInfos.addFromLocal(iw.localSnap) + } + chInfos.dump(iw) +} + +func (iw *infoWriter) maybePrintBase() { + if iw.verbose && iw.theSnap.Base != "" { + fmt.Fprintf(iw, "base:\t%s\n", iw.theSnap.Base) + } +} + +func (iw *infoWriter) maybePrintPath() { + if iw.path != "" { + fmt.Fprintf(iw, "path:\t%q\n", iw.path) + } +} + +func (iw *infoWriter) printName() { + fmt.Fprintf(iw, "name:\t%s\n", iw.theSnap.Name) +} + +func (iw *infoWriter) printSummary() { + wrapFlow(iw, quotedIfNeeded(iw.theSnap.Summary), "summary:\t", iw.termWidth) +} + +func (iw *infoWriter) maybePrintStoreURL() { + storeURL := "" + // XXX: store-url for local snaps comes from aux data, but that gets + // updated only when the snap is refreshed, be smart and poke remote + // snap info if available + switch { + case iw.theSnap.StoreURL != "": + storeURL = iw.theSnap.StoreURL + case iw.remoteSnap != nil && iw.remoteSnap.StoreURL != "": + storeURL = iw.remoteSnap.StoreURL + } + if storeURL == "" { + return + } + fmt.Fprintf(iw, "store-url:\t%s\n", storeURL) +} + +func (iw *infoWriter) maybePrintPublisher() { + if iw.diskSnap != nil { + // snaps read from disk won't have a publisher + return + } + fmt.Fprintf(iw, "publisher:\t%s\n", longPublisher(iw.esc, iw.theSnap.Publisher)) +} + +func (iw *infoWriter) maybePrintStandaloneVersion() { + if iw.diskSnap == nil { + // snaps not read from disk will have version information shown elsewhere + return + } + version := iw.diskSnap.Version + if version == "" { + version = iw.esc.dash + } + // NotesFromRemote might be better called NotesFromNotInstalled but that's nasty + fmt.Fprintf(iw, "version:\t%s %s\n", version, NotesFromRemote(iw.diskSnap, nil)) +} + +func (iw *infoWriter) maybePrintBuildDate() { + if iw.diskSnap == nil { + return + } + if osutil.IsDirectory(iw.path) { + return + } + buildDate := squashfs.BuildDate(iw.path) + if buildDate.IsZero() { + return + } + fmt.Fprintf(iw, "build-date:\t%s\n", iw.fmtTime(buildDate)) +} + +func (iw *infoWriter) maybePrintLinks() { + contact := strings.TrimPrefix(iw.theSnap.Contact, "mailto:") + if contact != "" { + fmt.Fprintf(iw, "contact:\t%s\n", contact) + } + if !iw.verbose || len(iw.theSnap.Links) == 0 { + return + } + links := iw.theSnap.Links + fmt.Fprintln(iw, "links:") + linkKeys := make([]string, 0, len(iw.theSnap.Links)) + for k := range links { + linkKeys = append(linkKeys, k) + } + sort.Strings(linkKeys) + for _, k := range linkKeys { + fmt.Fprintf(iw, " %s:\n", k) + for _, v := range links[k] { + fmt.Fprintf(iw, " - %s\n", v) + } + } +} + +func (iw *infoWriter) printLicense() { + license := iw.theSnap.License + if license == "" { + license = "unset" + } + fmt.Fprintf(iw, "license:\t%s\n", license) +} + +func (iw *infoWriter) printDescr() { + fmt.Fprintln(iw, "description: |") + printDescr(iw, iw.theSnap.Description, iw.termWidth) +} + +func (iw *infoWriter) maybePrintCommands() { + if len(iw.theSnap.Apps) == 0 { + return + } + + commands := make([]string, 0, len(iw.theSnap.Apps)) + for _, app := range iw.theSnap.Apps { + if app.IsService() { + continue + } + + cmdStr := snap.JoinSnapApp(iw.theSnap.Name, app.Name) + commands = append(commands, cmdStr) + } + if len(commands) == 0 { + return + } + + fmt.Fprintf(iw, "commands:\n") + for _, cmd := range commands { + fmt.Fprintf(iw, " - %s\n", cmd) + } +} + +func (iw *infoWriter) maybePrintServices() { + if len(iw.theSnap.Apps) == 0 { + return + } + + services := make([]string, 0, len(iw.theSnap.Apps)) + for _, app := range iw.theSnap.Apps { + if !app.IsService() { + continue + } + + var active, enabled string + if app.Active { + active = "active" + } else { + active = "inactive" + } + if app.Enabled { + enabled = "enabled" + } else { + enabled = "disabled" + } + services = append(services, fmt.Sprintf(" %s:\t%s, %s, %s", snap.JoinSnapApp(iw.theSnap.Name, app.Name), app.Daemon, enabled, active)) + } + if len(services) == 0 { + return + } + + fmt.Fprintf(iw, "services:\n") + for _, svc := range services { + fmt.Fprintln(iw, svc) + } +} + +func (iw *infoWriter) maybePrintNotes() { + if !iw.verbose { + return + } + fmt.Fprintln(iw, "notes:\t") + fmt.Fprintf(iw, " private:\t%t\n", iw.theSnap.Private) + fmt.Fprintf(iw, " confinement:\t%s\n", iw.theSnap.Confinement) + if iw.localSnap == nil { + return + } + jailMode := iw.localSnap.Confinement == client.DevModeConfinement && !iw.localSnap.DevMode + fmt.Fprintf(iw, " devmode:\t%t\n", iw.localSnap.DevMode) + fmt.Fprintf(iw, " jailmode:\t%t\n", jailMode) + fmt.Fprintf(iw, " trymode:\t%t\n", iw.localSnap.TryMode) + fmt.Fprintf(iw, " enabled:\t%t\n", iw.localSnap.Status == client.StatusActive) + if iw.localSnap.Broken == "" { + fmt.Fprintf(iw, " broken:\t%t\n", false) + } else { + fmt.Fprintf(iw, " broken:\t%t (%s)\n", true, iw.localSnap.Broken) + } + + fmt.Fprintf(iw, " ignore-validation:\t%t\n", iw.localSnap.IgnoreValidation) +} + +func (iw *infoWriter) maybePrintCohortKey() { + if !iw.verbose { + return + } + if iw.localSnap == nil { + return + } + coh := iw.localSnap.CohortKey + if coh == "" { + return + } + if isStdoutTTY { + // 15 is 1 + the length of "refresh-date: " + coh = strutil.ElliptLeft(iw.localSnap.CohortKey, iw.termWidth-15) + } + fmt.Fprintf(iw, "cohort:\t%s\n", coh) +} + +func (iw *infoWriter) maybePrintSum() { + if !iw.verbose { + return + } + if iw.diskSnap == nil { + // TODO: expose the sha via /v2/snaps and /v2/find + return + } + if osutil.IsDirectory(iw.path) { + // no sha3_384 of a directory :-) + return + } + sha3_384, _, _ := asserts.SnapFileSHA3_384(iw.path) + if sha3_384 == "" { + return + } + fmt.Fprintf(iw, "sha3-384:\t%s\n", sha3_384) +} + +var channelRisks = []string{"stable", "candidate", "beta", "edge"} + +type channelInfo struct { + indent, name, version, released, revision, size, notes string +} + +type channelInfos struct { + channels []*channelInfo + maxRevLen, maxSizeLen int + releasedfmt, chantpl string + needsHeader bool + esc *escapes +} + +func (chInfos *channelInfos) add(indent, name, version string, revision snap.Revision, released time.Time, size int64, notes *Notes) { + chInfo := &channelInfo{ + indent: indent, + name: name, + version: version, + revision: fmt.Sprintf("(%s)", revision), + size: strutil.SizeToStr(size), + notes: notes.String(), + } + if !released.IsZero() { + chInfo.released = released.Format(chInfos.releasedfmt) + } + if len(chInfo.revision) > chInfos.maxRevLen { + chInfos.maxRevLen = len(chInfo.revision) + } + if len(chInfo.size) > chInfos.maxSizeLen { + chInfos.maxSizeLen = len(chInfo.size) + } + chInfos.channels = append(chInfos.channels, chInfo) +} + +func (chInfos *channelInfos) addFromLocal(local *client.Snap) { + chInfos.add("", "installed", local.Version, local.Revision, time.Time{}, local.InstalledSize, NotesFromLocal(local)) +} + +func (chInfos *channelInfos) addOpenChannel(name, version string, revision snap.Revision, released time.Time, size int64, notes *Notes) { + chInfos.add(" ", name, version, revision, released, size, notes) +} + +func (chInfos *channelInfos) addClosedChannel(name string, trackHasOpenChannel bool) { + chInfo := &channelInfo{indent: " ", name: name} + if trackHasOpenChannel { + chInfo.version = chInfos.esc.uparrow + } else { + chInfo.version = chInfos.esc.dash + } + + chInfos.channels = append(chInfos.channels, chInfo) +} + +func (chInfos *channelInfos) addFromRemote(remote *client.Snap) { + // order by tracks + for _, tr := range remote.Tracks { + trackHasOpenChannel := false + for _, risk := range channelRisks { + chName := fmt.Sprintf("%s/%s", tr, risk) + ch, ok := remote.Channels[chName] + if ok { + chInfos.addOpenChannel(chName, ch.Version, ch.Revision, ch.ReleasedAt, ch.Size, NotesFromChannelSnapInfo(ch)) + trackHasOpenChannel = true + } else { + chInfos.addClosedChannel(chName, trackHasOpenChannel) + } + } + } + chInfos.needsHeader = len(chInfos.channels) > 0 +} + +func (chInfos *channelInfos) dump(w io.Writer) { + if chInfos.needsHeader { + fmt.Fprintln(w, "channels:") + } + for _, c := range chInfos.channels { + fmt.Fprintf(w, chInfos.chantpl, c.indent, c.name, c.version, c.released, chInfos.maxRevLen, c.revision, chInfos.maxSizeLen, c.size, c.notes) + } +} + +func (x *infoCmd) Execute([]string) error { + termWidth, _ := termSize() + termWidth -= 3 + if termWidth > 100 { + // any wider than this and it gets hard to read + termWidth = 100 + } + + esc := x.getEscapes() + w := tabwriter.NewWriter(Stdout, 2, 2, 1, ' ', 0) + iw := &infoWriter{ + writeflusher: w, + esc: esc, + termWidth: termWidth, + verbose: x.Verbose, + fmtTime: x.fmtTime, + absTime: x.AbsTime, + } + + noneOK := true + for i, snapName := range x.Positional.Snaps { + snapName := string(snapName) + if i > 0 { + fmt.Fprintln(w, "---") + } + if snapName == "system" { + fmt.Fprintln(w, "system: You can't have it.") + continue + } + + if diskSnap, err := clientSnapFromPath(snapName); err == nil { + iw.setupDiskSnap(norm(snapName), diskSnap) + } else { + remoteSnap, resInfo, _ := x.client.FindOne(snap.InstanceSnap(snapName)) + localSnap, _, _ := x.client.Snap(snapName) + iw.setupSnap(localSnap, remoteSnap, resInfo) + } + // note diskSnap == nil, or localSnap == nil and remoteSnap == nil + + if iw.theSnap == nil { + if len(x.Positional.Snaps) == 1 { + w.Flush() + return fmt.Errorf("no snap found for %q", snapName) + } + + fmt.Fprintf(w, fmt.Sprintf(i18n.G("warning:\tno snap found for %q\n"), snapName)) + continue + } + noneOK = false + + iw.maybePrintPath() + iw.printName() + iw.printSummary() + iw.maybePrintHealth() + iw.maybePrintPublisher() + iw.maybePrintStoreURL() + iw.maybePrintStandaloneVersion() + iw.maybePrintBuildDate() + iw.maybePrintLinks() + iw.printLicense() + iw.maybePrintPrice() + iw.printDescr() + iw.maybePrintCommands() + iw.maybePrintServices() + iw.maybePrintNotes() + // stops the notes etc trying to be aligned with channels + iw.Flush() + iw.maybePrintType() + iw.maybePrintBase() + iw.maybePrintSum() + iw.maybePrintID() + iw.maybePrintCohortKey() + iw.maybePrintTrackingChannel() + iw.maybePrintRefreshInfo() + iw.maybePrintChinfo() + } + w.Flush() + + if noneOK { + return fmt.Errorf(i18n.G("no valid snaps given")) + } + + return nil +} diff --git a/cmd/snap/cmd_info_test.go b/cmd/snap/cmd_info_test.go new file mode 100644 index 00000000..a21b5070 --- /dev/null +++ b/cmd/snap/cmd_info_test.go @@ -0,0 +1,1341 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016-2022 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "bytes" + "fmt" + "net/http" + "os" + "path/filepath" + "time" + + "gopkg.in/check.v1" + + "github.com/snapcore/snapd/client" + snap "github.com/snapcore/snapd/cmd/snap" + snaplib "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/snap/snaptest" + "github.com/snapcore/snapd/snap/squashfs" + "github.com/snapcore/snapd/timeutil" +) + +var cmdAppInfos = []client.AppInfo{{Name: "app1"}, {Name: "app2"}} +var svcAppInfos = []client.AppInfo{ + { + Name: "svc1", + Daemon: "simple", + Enabled: false, + Active: true, + }, + { + Name: "svc2", + Daemon: "simple", + Enabled: true, + Active: false, + }, +} + +var mixedAppInfos = append(append([]client.AppInfo(nil), cmdAppInfos...), svcAppInfos...) + +type infoSuite struct { + BaseSnapSuite +} + +var _ = check.Suite(&infoSuite{}) + +type flushBuffer struct{ bytes.Buffer } + +func (*flushBuffer) Flush() error { return nil } + +func isoDateTimeToLocalDate(c *check.C, textualTime string) string { + t, err := time.Parse(time.RFC3339Nano, textualTime) + c.Assert(err, check.IsNil) + return t.Local().Format("2006-01-02") +} + +func (s *infoSuite) TestMaybePrintServices(c *check.C) { + var buf flushBuffer + iw := snap.NewInfoWriter(&buf) + for _, infos := range [][]client.AppInfo{svcAppInfos, mixedAppInfos} { + buf.Reset() + snap.SetupDiskSnap(iw, "", &client.Snap{Name: "foo", Apps: infos}) + snap.MaybePrintServices(iw) + + c.Check(buf.String(), check.Equals, `services: + foo.svc1: simple, disabled, active + foo.svc2: simple, enabled, inactive +`) + } +} + +func (s *infoSuite) TestMaybePrintServicesNoServices(c *check.C) { + var buf flushBuffer + iw := snap.NewInfoWriter(&buf) + for _, infos := range [][]client.AppInfo{cmdAppInfos, nil} { + buf.Reset() + snap.SetupDiskSnap(iw, "", &client.Snap{Name: "foo", Apps: infos}) + snap.MaybePrintServices(iw) + c.Check(buf.String(), check.Equals, "") + } +} + +func (s *infoSuite) TestMaybePrintCommands(c *check.C) { + var buf flushBuffer + iw := snap.NewInfoWriter(&buf) + for _, infos := range [][]client.AppInfo{cmdAppInfos, mixedAppInfos} { + buf.Reset() + snap.SetupDiskSnap(iw, "", &client.Snap{Name: "foo", Apps: infos}) + snap.MaybePrintCommands(iw) + + c.Check(buf.String(), check.Equals, `commands: + - foo.app1 + - foo.app2 +`) + } +} + +func (s *infoSuite) TestMaybePrintCommandsNoCommands(c *check.C) { + var buf flushBuffer + iw := snap.NewInfoWriter(&buf) + for _, infos := range [][]client.AppInfo{svcAppInfos, nil} { + buf.Reset() + snap.SetupDiskSnap(iw, "", &client.Snap{Name: "foo", Apps: infos}) + snap.MaybePrintCommands(iw) + + c.Check(buf.String(), check.Equals, "") + } +} + +func (infoSuite) TestPrintType(c *check.C) { + var buf flushBuffer + iw := snap.NewInfoWriter(&buf) + for from, to := range map[string]string{ + "": "", + "app": "", + "application": "", + "gadget": "type:\tgadget\n", + "core": "type:\tcore\n", + "os": "type:\tcore\n", + } { + buf.Reset() + snap.SetupDiskSnap(iw, "", &client.Snap{Type: from}) + snap.MaybePrintType(iw) + c.Check(buf.String(), check.Equals, to, check.Commentf("%q", from)) + } +} + +func (infoSuite) TestPrintSummary(c *check.C) { + var buf flushBuffer + iw := snap.NewInfoWriter(&buf) + for from, to := range map[string]string{ + "": `""`, // empty results in quoted empty + "foo": "foo", // plain text results in unquoted + "two words": "two words", // ...even when multi-word + "{": `"{"`, // but yaml-breaking is quoted + "very long text": "very long\n text", // too-long text gets split (TODO: split with tabbed indent to preserve alignment) + } { + buf.Reset() + snap.SetupDiskSnap(iw, "", &client.Snap{Summary: from}) + snap.PrintSummary(iw) + c.Check(buf.String(), check.Equals, "summary:\t"+to+"\n", check.Commentf("%q", from)) + } +} + +func (s *infoSuite) TestMaybePrintPublisher(c *check.C) { + acct := &snaplib.StoreAccount{ + Validation: "verified", + Username: "team-potato", + DisplayName: "Team Potato", + } + + type T struct { + diskSnap, localSnap *client.Snap + expected string + } + + var buf flushBuffer + iw := snap.NewInfoWriter(&buf) + for i, t := range []T{ + {&client.Snap{}, nil, ""}, // nothing output for on-disk snap + {nil, &client.Snap{}, "publisher:\t--\n"}, // from-snapd snap with no publisher is explicit + {nil, &client.Snap{Publisher: acct}, "publisher:\tTeam Potato*\n"}, + } { + buf.Reset() + if t.diskSnap == nil { + snap.SetupSnap(iw, t.localSnap, nil, nil) + } else { + snap.SetupDiskSnap(iw, "", t.diskSnap) + } + snap.MaybePrintPublisher(iw) + c.Check(buf.String(), check.Equals, t.expected, check.Commentf("%d", i)) + } +} + +func (s *infoSuite) TestMaybePrintNotes(c *check.C) { + type T struct { + localSnap, diskSnap *client.Snap + expected string + } + + var buf flushBuffer + iw := snap.NewInfoWriter(&buf) + for i, t := range []T{ + { + nil, + &client.Snap{Private: true, Confinement: "devmode"}, + "notes:\t\n" + + " private:\ttrue\n" + + " confinement:\tdevmode\n", + }, { + &client.Snap{Private: true, Confinement: "devmode"}, + nil, + "notes:\t\n" + + " private:\ttrue\n" + + " confinement:\tdevmode\n" + + " devmode:\tfalse\n" + + " jailmode:\ttrue\n" + + " trymode:\tfalse\n" + + " enabled:\tfalse\n" + + " broken:\tfalse\n" + + " ignore-validation:\tfalse\n", + }, { + &client.Snap{Private: true, Confinement: "devmode", Broken: "ouch"}, + nil, + "notes:\t\n" + + " private:\ttrue\n" + + " confinement:\tdevmode\n" + + " devmode:\tfalse\n" + + " jailmode:\ttrue\n" + + " trymode:\tfalse\n" + + " enabled:\tfalse\n" + + " broken:\ttrue (ouch)\n" + + " ignore-validation:\tfalse\n", + }, + } { + buf.Reset() + snap.SetVerbose(iw, false) + if t.diskSnap == nil { + snap.SetupSnap(iw, t.localSnap, nil, nil) + } else { + snap.SetupDiskSnap(iw, "", t.diskSnap) + } + snap.MaybePrintNotes(iw) + c.Check(buf.String(), check.Equals, "", check.Commentf("%d/false", i)) + + buf.Reset() + snap.SetVerbose(iw, true) + snap.MaybePrintNotes(iw) + c.Check(buf.String(), check.Equals, t.expected, check.Commentf("%d/true", i)) + } +} + +func (s *infoSuite) TestMaybePrintStandaloneVersion(c *check.C) { + var buf flushBuffer + iw := snap.NewInfoWriter(&buf) + + // no disk snap -> no version + snap.MaybePrintStandaloneVersion(iw) + c.Check(buf.String(), check.Equals, "") + + for version, expected := range map[string]string{ + "": "--", + "4.2": "4.2", + } { + buf.Reset() + snap.SetupDiskSnap(iw, "", &client.Snap{Version: version}) + snap.MaybePrintStandaloneVersion(iw) + c.Check(buf.String(), check.Equals, "version:\t"+expected+" -\n", check.Commentf("%q", version)) + + buf.Reset() + snap.SetupDiskSnap(iw, "", &client.Snap{Version: version, Confinement: "devmode"}) + snap.MaybePrintStandaloneVersion(iw) + c.Check(buf.String(), check.Equals, "version:\t"+expected+" devmode\n", check.Commentf("%q", version)) + } +} + +func (s *infoSuite) TestMaybePrintBuildDate(c *check.C) { + var buf flushBuffer + iw := snap.NewInfoWriter(&buf) + // some prep + dir := c.MkDir() + arbfile := filepath.Join(dir, "arb") + c.Assert(os.WriteFile(arbfile, nil, 0600), check.IsNil) + filename := filepath.Join(c.MkDir(), "foo.snap") + diskSnap := squashfs.New(filename) + c.Assert(diskSnap.Build(dir, nil), check.IsNil) + buildDate := diskSnap.BuildDate().Format(time.Kitchen) + + // no disk snap -> no build date + snap.MaybePrintBuildDate(iw) + c.Check(buf.String(), check.Equals, "") + + // path is directory -> no build date + buf.Reset() + snap.SetupDiskSnap(iw, dir, &client.Snap{}) + snap.MaybePrintBuildDate(iw) + c.Check(buf.String(), check.Equals, "") + + // not actually a snap -> no build date + buf.Reset() + snap.SetupDiskSnap(iw, arbfile, &client.Snap{}) + snap.MaybePrintBuildDate(iw) + c.Check(buf.String(), check.Equals, "") + + // disk snap -> get build date + buf.Reset() + snap.SetupDiskSnap(iw, filename, &client.Snap{}) + snap.MaybePrintBuildDate(iw) + c.Check(buf.String(), check.Equals, "build-date:\t"+buildDate+"\n") +} + +func (s *infoSuite) TestMaybePrintSum(c *check.C) { + var buf flushBuffer + // some prep + dir := c.MkDir() + filename := filepath.Join(c.MkDir(), "foo.snap") + diskSnap := squashfs.New(filename) + c.Assert(diskSnap.Build(dir, nil), check.IsNil) + iw := snap.NewInfoWriter(&buf) + snap.SetVerbose(iw, true) + + // no disk snap -> no checksum + snap.MaybePrintSum(iw) + c.Check(buf.String(), check.Equals, "") + + // path is directory -> no checksum + buf.Reset() + snap.SetupDiskSnap(iw, dir, &client.Snap{}) + snap.MaybePrintSum(iw) + c.Check(buf.String(), check.Equals, "") + + // disk snap and verbose -> get checksum + buf.Reset() + snap.SetupDiskSnap(iw, filename, &client.Snap{}) + snap.MaybePrintSum(iw) + c.Check(buf.String(), check.Matches, "sha3-384:\t\\S+\n") + + // disk snap but not verbose -> no checksum + buf.Reset() + snap.SetVerbose(iw, false) + snap.MaybePrintSum(iw) + c.Check(buf.String(), check.Equals, "") +} + +func (s *infoSuite) TestMaybePrintLinksContact(c *check.C) { + var buf flushBuffer + iw := snap.NewInfoWriter(&buf) + + for contact, expected := range map[string]string{ + "mailto:joe@example.com": "contact:\tjoe@example.com\n", + // gofmt 1.9 being silly + "foo": "contact:\tfoo\n", + "": "", + } { + buf.Reset() + snap.SetupDiskSnap(iw, "", &client.Snap{Contact: contact}) + snap.MaybePrintLinks(iw) + c.Check(buf.String(), check.Equals, expected, check.Commentf("%q", contact)) + } +} + +func (s *infoSuite) TestMaybePrintHoldingInfo(c *check.C) { + var buf flushBuffer + iw := snap.NewInfoWriterWithFmtTime(&buf, timeutil.Human) + instant, err := time.Parse(time.RFC3339, "2000-01-01T00:00:00Z") + c.Assert(err, check.IsNil) + + restore := snap.MockTimeNow(func() time.Time { + return instant + }) + defer restore() + + restore = timeutil.MockTimeNow(func() time.Time { + return instant + }) + defer restore() + + // ensure timezone is UTC, otherwise test runs in other timezones would fail + oldLocal := time.Local + time.Local = time.UTC + defer func() { + time.Local = oldLocal + }() + + for _, holdKind := range []string{"hold", "hold-by-gating"} { + for hold, expected := range map[string]string{ + "": "", + "0001-01-01T00:00:00Z": "", + "1999-01-01T00:00:00Z": "", + "2000-01-01T11:30:00Z": fmt.Sprintf("%s:\ttoday at 11:30 UTC\n", holdKind), + "2000-01-02T12:00:00Z": fmt.Sprintf("%s:\ttomorrow at 12:00 UTC\n", holdKind), + "2000-02-01T00:00:00Z": fmt.Sprintf("%s:\tin 31 days, at 00:00 UTC\n", holdKind), + "2099-01-01T00:00:00Z": fmt.Sprintf("%s:\t2099-01-01\n", holdKind), + "2100-01-01T00:00:00Z": fmt.Sprintf("%s:\tforever\n", holdKind), + } { + buf.Reset() + + var holdTime *time.Time + if hold != "" { + t, err := time.Parse(time.RFC3339, hold) + c.Assert(err, check.IsNil) + holdTime = &t + } + + switch holdKind { + case "hold": + snap.SetupSnap(iw, &client.Snap{Hold: holdTime}, nil, nil) + case "hold-by-gating": + snap.SetupSnap(iw, &client.Snap{GatingHold: holdTime}, nil, nil) + default: + c.Fatalf("unknown hold field: %s", holdKind) + } + + snap.MaybePrintRefreshInfo(iw) + iw.Flush() + cmt := check.Commentf("expected %q but got %q", expected, buf.String()) + c.Assert(buf.String(), check.Equals, expected, cmt) + } + } +} + +func (s *infoSuite) TestMaybePrintHoldingNonUTCLocalTime(c *check.C) { + var buf flushBuffer + iw := snap.NewInfoWriterWithFmtTime(&buf, timeutil.Human) + instant, err := time.Parse(time.RFC3339, "2000-01-01T00:00:00Z") + c.Assert(err, check.IsNil) + + restore := snap.MockTimeNow(func() time.Time { + return instant + }) + defer restore() + + restore = timeutil.MockTimeNow(func() time.Time { + return instant + }) + defer restore() + + hold := "2000-01-05T10:00:00Z" + holdTime, err := time.Parse(time.RFC3339, hold) + c.Assert(err, check.IsNil) + + // mock a local timezone other than UTC + oldLocal := time.Local + time.Local = time.FixedZone("UTC+4", 4*60*60) + defer func() { + time.Local = oldLocal + }() + + snap.SetupSnap(iw, &client.Snap{Hold: &holdTime}, nil, nil) + + snap.MaybePrintRefreshInfo(iw) + iw.Flush() + c.Assert(buf.String(), check.Equals, "hold:\tin 4 days, at 14:00 UTC+4\n") +} + +func (s *infoSuite) TestMaybePrintLinksVerbose(c *check.C) { + var buf flushBuffer + iw := snap.NewInfoWriter(&buf) + snap.SetVerbose(iw, true) + + const contact = "mailto:joe@example.com" + const website1 = "http://example.com/www1" + const website2 = "http://example.com/www2" + snap.SetupDiskSnap(iw, "", &client.Snap{ + Links: map[string][]string{ + "contact": {contact}, + "website": {website1, website2}, + }, + Contact: contact, + Website: website1, + }) + + snap.MaybePrintLinks(iw) + c.Check(buf.String(), check.Equals, "contact:\tjoe@example.com\n"+ + `links: + contact: + - mailto:joe@example.com + website: + - http://example.com/www1 + - http://example.com/www2 +`) +} + +func (s *infoSuite) TestMaybePrintBase(c *check.C) { + var buf flushBuffer + iw := snap.NewInfoWriter(&buf) + dSnap := &client.Snap{} + snap.SetupDiskSnap(iw, "", dSnap) + + // no verbose -> no base + snap.SetVerbose(iw, false) + snap.MaybePrintBase(iw) + c.Check(buf.String(), check.Equals, "") + buf.Reset() + + // no base -> no base :) + snap.SetVerbose(iw, true) + snap.MaybePrintBase(iw) + c.Check(buf.String(), check.Equals, "") + buf.Reset() + + // base + verbose -> base + dSnap.Base = "xyzzy" + snap.MaybePrintBase(iw) + c.Check(buf.String(), check.Equals, "base:\txyzzy\n") + buf.Reset() +} + +func (s *infoSuite) TestMaybePrintPath(c *check.C) { + var buf flushBuffer + iw := snap.NewInfoWriter(&buf) + dSnap := &client.Snap{} + + // no path -> no path + snap.SetupDiskSnap(iw, "", dSnap) + snap.MaybePrintPath(iw) + c.Check(buf.String(), check.Equals, "") + buf.Reset() + + // path -> path (quoted!) + snap.SetupDiskSnap(iw, "xyzzy", dSnap) + snap.MaybePrintPath(iw) + c.Check(buf.String(), check.Equals, "path:\t\"xyzzy\"\n") + buf.Reset() +} + +func (s *infoSuite) TestClientSnapFromPath(c *check.C) { + // minimal validity check + fn := snaptest.MakeTestSnapWithFiles(c, ` +name: some-snap +version: 9 +`, nil) + dSnap, err := snap.ClientSnapFromPath(fn) + c.Assert(err, check.IsNil) + c.Check(dSnap.Version, check.Equals, "9") +} + +func (s *infoSuite) TestInfoPricedNarrowTerminal(c *check.C) { + defer snap.MockTermSize(func() (int, int) { return 44, 25 })() + + n := 0 + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch n { + case 0: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/find") + fmt.Fprint(w, findPricedJSON) + case 1: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/snaps/hello") + fmt.Fprintln(w, "{}") + default: + c.Fatalf("expected to get 1 requests, now on %d (%v)", n+1, r) + } + + n++ + }) + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"info", "hello"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + c.Check(s.Stdout(), check.Equals, ` +name: hello +summary: GNU Hello, the "hello world" + snap +publisher: Canonical** +license: Proprietary +price: 1.99GBP +description: | + GNU hello prints a friendly greeting. + This is part of the snapcraft tour at + https://snapcraft.io/ +snap-id: mVyGrEwiqSi5PugCwyH7WgpoQLemtTd6 +`[1:]) + c.Check(s.Stderr(), check.Equals, "") +} + +func (s *infoSuite) TestInfoPriced(c *check.C) { + n := 0 + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch n { + case 0: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/find") + fmt.Fprint(w, findPricedJSON) + case 1: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/snaps/hello") + fmt.Fprintln(w, "{}") + default: + c.Fatalf("expected to get 1 requests, now on %d (%v)", n+1, r) + } + + n++ + }) + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"info", "hello"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + c.Check(s.Stdout(), check.Equals, `name: hello +summary: GNU Hello, the "hello world" snap +publisher: Canonical** +license: Proprietary +price: 1.99GBP +description: | + GNU hello prints a friendly greeting. This is part of the snapcraft tour at + https://snapcraft.io/ +snap-id: mVyGrEwiqSi5PugCwyH7WgpoQLemtTd6 +`) + c.Check(s.Stderr(), check.Equals, "") +} + +// only used for results on /v2/find +const mockInfoJSON = ` +{ + "type": "sync", + "status-code": 200, + "status": "OK", + "result": [ + { + "channel": "stable", + "confinement": "strict", + "description": "GNU hello prints a friendly greeting. This is part of the snapcraft tour at https://snapcraft.io/", + "developer": "canonical", + "publisher": { + "id": "canonical", + "username": "canonical", + "display-name": "Canonical", + "validation": "verified" + }, + "download-size": 65536, + "icon": "", + "id": "mVyGrEwiqSi5PugCwyH7WgpoQLemtTd6", + "name": "hello", + "private": false, + "resource": "/v2/snaps/hello", + "revision": "1", + "status": "available", + "summary": "The GNU Hello snap", + "type": "app", + "version": "2.10", + "license": "MIT" + } + ], + "sources": [ + "store" + ], + "suggested-currency": "GBP" +} +` + +const mockInfoJSONWithChannels = ` +{ + "type": "sync", + "status-code": 200, + "status": "OK", + "result": [ + { + "channel": "stable", + "confinement": "strict", + "description": "GNU hello prints a friendly greeting. This is part of the snapcraft tour at https://snapcraft.io/", + "developer": "canonical", + "publisher": { + "id": "canonical", + "username": "canonical", + "display-name": "Canonical", + "validation": "verified" + }, + "download-size": 65536, + "icon": "", + "id": "mVyGrEwiqSi5PugCwyH7WgpoQLemtTd6", + "name": "hello", + "private": false, + "resource": "/v2/snaps/hello", + "revision": "1", + "status": "available", + "summary": "The GNU Hello snap", + "store-url": "https://snapcraft.io/hello", + "type": "app", + "version": "2.10", + "license": "MIT", + "channels": { + "1/stable": { + "revision": "1", + "version": "2.10", + "channel": "1/stable", + "size": 65536, + "released-at": "2018-12-18T15:16:56.723501Z" + } + }, + "tracks": ["1"] + } + ], + "sources": [ + "store" + ], + "suggested-currency": "GBP" +} +` + +func (s *infoSuite) TestInfoUnquoted(c *check.C) { + n := 0 + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch n { + case 0: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/find") + fmt.Fprint(w, mockInfoJSON) + case 1: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/snaps/hello") + fmt.Fprint(w, "{}") + default: + c.Fatalf("expected to get 2 requests, now on %d (%v)", n+1, r) + } + + n++ + }) + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"info", "hello"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + c.Check(s.Stdout(), check.Equals, `name: hello +summary: The GNU Hello snap +publisher: Canonical** +license: MIT +description: | + GNU hello prints a friendly greeting. This is part of the snapcraft tour at + https://snapcraft.io/ +snap-id: mVyGrEwiqSi5PugCwyH7WgpoQLemtTd6 +`) + c.Check(s.Stderr(), check.Equals, "") +} + +// only used for /v2/snaps/hello +const mockInfoJSONOtherLicense = ` +{ + "type": "sync", + "status-code": 200, + "status": "OK", + "result": { + "channel": "stable", + "confinement": "strict", + "description": "GNU hello prints a friendly greeting. This is part of the snapcraft tour at https://snapcraft.io/", + "developer": "canonical", + "publisher": { + "id": "canonical", + "username": "canonical", + "display-name": "Canonical", + "validation": "verified" + }, + "health": {"revision": "1", "status": "blocked", "message": "please configure the grawflit", "timestamp": "2019-05-13T16:27:01.475851677+01:00"}, + "id": "mVyGrEwiqSi5PugCwyH7WgpoQLemtTd6", + "install-date": "2006-01-02T22:04:07.123456789Z", + "installed-size": 1024, + "name": "hello", + "private": false, + "resource": "/v2/snaps/hello", + "revision": "1", + "status": "available", + "summary": "The GNU Hello snap", + "type": "app", + "version": "2.10", + "license": "BSD-3", + "tracking-channel": "beta" + } +} +` +const mockInfoJSONNoLicense = ` +{ + "type": "sync", + "status-code": 200, + "status": "OK", + "result": { + "channel": "stable", + "confinement": "strict", + "description": "GNU hello prints a friendly greeting. This is part of the snapcraft tour at https://snapcraft.io/", + "developer": "canonical", + "publisher": { + "id": "canonical", + "username": "canonical", + "display-name": "Canonical", + "validation": "verified" + }, + "id": "mVyGrEwiqSi5PugCwyH7WgpoQLemtTd6", + "install-date": "2006-01-02T22:04:07.123456789Z", + "installed-size": 1024, + "name": "hello", + "private": false, + "resource": "/v2/snaps/hello", + "revision": "100", + "status": "available", + "summary": "The GNU Hello snap", + "type": "app", + "version": "2.10", + "license": "", + "tracking-channel": "beta" + } +} +` + +func (s *infoSuite) TestInfoWithLocalDifferentLicense(c *check.C) { + n := 0 + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch n { + case 0: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/find") + fmt.Fprint(w, mockInfoJSON) + case 1: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/snaps/hello") + fmt.Fprint(w, mockInfoJSONOtherLicense) + default: + c.Fatalf("expected to get 2 requests, now on %d (%v)", n+1, r) + } + + n++ + }) + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"info", "--abs-time", "hello"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + c.Check(s.Stdout(), check.Equals, ` +name: hello +summary: The GNU Hello snap +health: + status: blocked + message: please configure the grawflit + checked: 2019-05-13T16:27:01+01:00 + revision: 1 +publisher: Canonical** +license: BSD-3 +description: | + GNU hello prints a friendly greeting. This is part of the snapcraft tour at + https://snapcraft.io/ +snap-id: mVyGrEwiqSi5PugCwyH7WgpoQLemtTd6 +tracking: beta +refresh-date: 2006-01-02T22:04:07Z +installed: 2.10 (1) 1kB disabled,blocked +`[1:]) + c.Check(s.Stderr(), check.Equals, "") +} + +func (s *infoSuite) TestInfoNotFound(c *check.C) { + n := 0 + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch n % 2 { + case 0: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/find") + case 1: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/snaps/x") + } + w.WriteHeader(404) + fmt.Fprintln(w, `{"type":"error","status-code":404,"status":"Not Found","result":{"message":"No.","kind":"snap-not-found","value":"x"}}`) + + n++ + }) + _, err := snap.Parser(snap.Client()).ParseArgs([]string{"info", "--verbose", "/x"}) + c.Check(err, check.ErrorMatches, `no snap found for "/x"`) + c.Check(s.Stdout(), check.Equals, "") + c.Check(s.Stderr(), check.Equals, "") +} + +func (s *infoSuite) TestInfoWithLocalNoLicense(c *check.C) { + n := 0 + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch n { + case 0: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/find") + fmt.Fprint(w, mockInfoJSON) + case 1: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/snaps/hello") + fmt.Fprint(w, mockInfoJSONNoLicense) + default: + c.Fatalf("expected to get 2 requests, now on %d (%v)", n+1, r) + } + + n++ + }) + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"info", "--abs-time", "hello"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + c.Check(s.Stdout(), check.Equals, `name: hello +summary: The GNU Hello snap +publisher: Canonical** +license: unset +description: | + GNU hello prints a friendly greeting. This is part of the snapcraft tour at + https://snapcraft.io/ +snap-id: mVyGrEwiqSi5PugCwyH7WgpoQLemtTd6 +tracking: beta +refresh-date: 2006-01-02T22:04:07Z +installed: 2.10 (100) 1kB disabled +`) + c.Check(s.Stderr(), check.Equals, "") +} + +func (s *infoSuite) TestInfoWithChannelsAndLocal(c *check.C) { + n := 0 + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch n { + case 0, 2, 4: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/find") + fmt.Fprint(w, mockInfoJSONWithChannels) + case 1, 3, 5: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/snaps/hello") + fmt.Fprint(w, mockInfoJSONNoLicense) + default: + c.Fatalf("expected to get 6 requests, now on %d (%v)", n+1, r) + } + + n++ + }) + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"info", "--abs-time", "hello"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + c.Check(s.Stdout(), check.Equals, `name: hello +summary: The GNU Hello snap +publisher: Canonical** +store-url: https://snapcraft.io/hello +license: unset +description: | + GNU hello prints a friendly greeting. This is part of the snapcraft tour at + https://snapcraft.io/ +snap-id: mVyGrEwiqSi5PugCwyH7WgpoQLemtTd6 +tracking: beta +refresh-date: 2006-01-02T22:04:07Z +channels: + 1/stable: 2.10 2018-12-18T15:16:56Z (1) 65kB - + 1/candidate: ^ + 1/beta: ^ + 1/edge: ^ +installed: 2.10 (100) 1kB disabled +`) + c.Check(s.Stderr(), check.Equals, "") + c.Check(n, check.Equals, 2) + + // now the same but without abs-time + s.ResetStdStreams() + rest, err = snap.Parser(snap.Client()).ParseArgs([]string{"info", "hello"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + refreshDate := isoDateTimeToLocalDate(c, "2006-01-02T22:04:07.123456789Z") + c.Check(s.Stdout(), check.Equals, fmt.Sprintf(`name: hello +summary: The GNU Hello snap +publisher: Canonical** +store-url: https://snapcraft.io/hello +license: unset +description: | + GNU hello prints a friendly greeting. This is part of the snapcraft tour at + https://snapcraft.io/ +snap-id: mVyGrEwiqSi5PugCwyH7WgpoQLemtTd6 +tracking: beta +refresh-date: %s +channels: + 1/stable: 2.10 2018-12-18 (1) 65kB - + 1/candidate: ^ + 1/beta: ^ + 1/edge: ^ +installed: 2.10 (100) 1kB disabled +`, refreshDate)) + c.Check(s.Stderr(), check.Equals, "") + c.Check(n, check.Equals, 4) + + // now the same but with unicode on + s.ResetStdStreams() + rest, err = snap.Parser(snap.Client()).ParseArgs([]string{"info", "--unicode=always", "hello"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + c.Check(s.Stdout(), check.Equals, fmt.Sprintf(`name: hello +summary: The GNU Hello snap +publisher: Canonical✓ +store-url: https://snapcraft.io/hello +license: unset +description: | + GNU hello prints a friendly greeting. This is part of the snapcraft tour at + https://snapcraft.io/ +snap-id: mVyGrEwiqSi5PugCwyH7WgpoQLemtTd6 +tracking: beta +refresh-date: %s +channels: + 1/stable: 2.10 2018-12-18 (1) 65kB - + 1/candidate: ↑ + 1/beta: ↑ + 1/edge: ↑ +installed: 2.10 (100) 1kB disabled +`, refreshDate)) + c.Check(s.Stderr(), check.Equals, "") + c.Check(n, check.Equals, 6) +} + +func (s *infoSuite) TestInfoHumanTimes(c *check.C) { + // checks that tiemutil.Human is called when no --abs-time is given + restore := snap.MockTimeutilHuman(func(time.Time) string { return "TOTALLY NOT A ROBOT" }) + defer restore() + + n := 0 + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch n { + case 0: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/find") + fmt.Fprintln(w, "{}") + case 1: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/snaps/hello") + fmt.Fprint(w, mockInfoJSONNoLicense) + default: + c.Fatalf("expected to get 2 requests, now on %d (%v)", n+1, r) + } + + n++ + }) + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"info", "hello"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + c.Check(s.Stdout(), check.Equals, `name: hello +summary: The GNU Hello snap +publisher: Canonical** +license: unset +description: | + GNU hello prints a friendly greeting. This is part of the snapcraft tour at + https://snapcraft.io/ +snap-id: mVyGrEwiqSi5PugCwyH7WgpoQLemtTd6 +tracking: beta +refresh-date: TOTALLY NOT A ROBOT +installed: 2.10 (100) 1kB disabled +`) + c.Check(s.Stderr(), check.Equals, "") +} + +func (infoSuite) TestDescr(c *check.C) { + for k, v := range map[string]string{ + "": " \n", + `one: + * two three four five six + * seven height nine ten +`: ` one: + * two three four + five six + * seven height + nine ten +`, + "abcdefghijklm nopqrstuvwxyz ABCDEFGHIJKLMNOPQR STUVWXYZ": ` + abcdefghijklm + nopqrstuvwxyz + ABCDEFGHIJKLMNOPQR + STUVWXYZ +`[1:], + // not much we can do when it won't fit + "abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ": ` + abcdefghijklmnopqr + stuvwxyz + ABCDEFGHIJKLMNOPQR + STUVWXYZ +`[1:], + } { + var buf bytes.Buffer + snap.PrintDescr(&buf, k, 20) + c.Check(buf.String(), check.Equals, v, check.Commentf("%q", k)) + } +} + +func (infoSuite) TestMaybePrintCohortKey(c *check.C) { + type T struct { + snap *client.Snap + verbose bool + expected string + } + + tests := []T{ + {snap: nil, verbose: false, expected: ""}, + {snap: nil, verbose: true, expected: ""}, + {snap: &client.Snap{}, verbose: false, expected: ""}, + {snap: &client.Snap{}, verbose: true, expected: ""}, + {snap: &client.Snap{CohortKey: "some-cohort-key"}, verbose: false, expected: ""}, + {snap: &client.Snap{CohortKey: "some-cohort-key"}, verbose: true, expected: "cohort:\t…-key\n"}, + } + + var buf flushBuffer + iw := snap.NewInfoWriter(&buf) + defer snap.MockIsStdoutTTY(true)() + + for i, t := range tests { + buf.Reset() + snap.SetupSnap(iw, t.snap, nil, nil) + snap.SetVerbose(iw, t.verbose) + snap.MaybePrintCohortKey(iw) + c.Check(buf.String(), check.Equals, t.expected, check.Commentf("tty:true/%d", i)) + } + // now the same but without a tty -> the last test should no longer ellipt + tests[len(tests)-1].expected = "cohort:\tsome-cohort-key\n" + snap.MockIsStdoutTTY(false) + for i, t := range tests { + buf.Reset() + snap.SetupSnap(iw, t.snap, nil, nil) + snap.SetVerbose(iw, t.verbose) + snap.MaybePrintCohortKey(iw) + c.Check(buf.String(), check.Equals, t.expected, check.Commentf("tty:false/%d", i)) + } +} + +func (infoSuite) TestMaybePrintHealth(c *check.C) { + type T struct { + snap *client.Snap + verbose bool + expected string + } + + goodHealth := &client.SnapHealth{Status: "okay"} + t0 := time.Date(1970, 1, 1, 10, 24, 0, 0, time.UTC) + badHealth := &client.SnapHealth{ + Status: "waiting", + Message: "godot should be here any moment now", + Code: "godot-is-a-lie", + Revision: snaplib.R("42"), + Timestamp: t0, + } + + tests := []T{ + {snap: nil, verbose: false, expected: ""}, + {snap: nil, verbose: true, expected: ""}, + {snap: &client.Snap{}, verbose: false, expected: ""}, + {snap: &client.Snap{}, verbose: true, expected: `health: + status: unknown + message: health + has not been set +`}, + {snap: &client.Snap{Health: goodHealth}, verbose: false, expected: ``}, + {snap: &client.Snap{Health: goodHealth}, verbose: true, expected: `health: + status: okay +`}, + {snap: &client.Snap{Health: badHealth}, verbose: false, expected: `health: + status: waiting + message: godot + should be here + any moment now + code: godot-is-a-lie + checked: 10:24AM + revision: 42 +`}, + {snap: &client.Snap{Health: badHealth}, verbose: true, expected: `health: + status: waiting + message: godot + should be here + any moment now + code: godot-is-a-lie + checked: 10:24AM + revision: 42 +`}, + } + + var buf flushBuffer + iw := snap.NewInfoWriter(&buf) + defer snap.MockIsStdoutTTY(false)() + + for i, t := range tests { + buf.Reset() + snap.SetupSnap(iw, t.snap, nil, nil) + snap.SetVerbose(iw, t.verbose) + snap.MaybePrintHealth(iw) + c.Check(buf.String(), check.Equals, t.expected, check.Commentf("%d", i)) + } +} + +func (infoSuite) TestBug1828425(c *check.C) { + const s = `This is a description + that has + lines + too deeply + indented. +` + var buf bytes.Buffer + err := snap.PrintDescr(&buf, s, 30) + c.Assert(err, check.IsNil) + c.Check(buf.String(), check.Equals, ` This is a description + that has + lines + too deeply + indented. +`) +} + +const mockInfoJSONParallelInstance = ` +{ + "type": "sync", + "status-code": 200, + "status": "OK", + "result": { + "channel": "stable", + "confinement": "strict", + "description": "GNU hello prints a friendly greeting. This is part of the snapcraft tour at https://snapcraft.io/", + "developer": "canonical", + "publisher": { + "id": "canonical", + "username": "canonical", + "display-name": "Canonical", + "validation": "verified" + }, + "id": "mVyGrEwiqSi5PugCwyH7WgpoQLemtTd6", + "install-date": "2006-01-02T22:04:07.123456789Z", + "installed-size": 1024, + "name": "hello_foo", + "private": false, + "revision": "100", + "status": "available", + "summary": "The GNU Hello snap", + "type": "app", + "version": "2.10", + "license": "", + "tracking-channel": "beta" + } +} +` + +func (s *infoSuite) TestInfoParllelInstance(c *check.C) { + n := 0 + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch n { + case 0: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/find") + q := r.URL.Query() + // asks for the instance snap + c.Check(q.Get("name"), check.Equals, "hello") + fmt.Fprint(w, mockInfoJSONWithChannels) + case 1: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/snaps/hello_foo") + fmt.Fprint(w, mockInfoJSONParallelInstance) + default: + c.Fatalf("expected to get 2 requests, now on %d (%v)", n+1, r) + } + + n++ + }) + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"info", "hello_foo"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + refreshDate := isoDateTimeToLocalDate(c, "2006-01-02T22:04:07.123456789Z") + // make sure local and remote info is combined in the output + c.Check(s.Stdout(), check.Equals, fmt.Sprintf(`name: hello_foo +summary: The GNU Hello snap +publisher: Canonical** +store-url: https://snapcraft.io/hello +license: unset +description: | + GNU hello prints a friendly greeting. This is part of the snapcraft tour at + https://snapcraft.io/ +snap-id: mVyGrEwiqSi5PugCwyH7WgpoQLemtTd6 +tracking: beta +refresh-date: %s +channels: + 1/stable: 2.10 2018-12-18 (1) 65kB - + 1/candidate: ^ + 1/beta: ^ + 1/edge: ^ +installed: 2.10 (100) 1kB disabled +`, refreshDate)) + c.Check(s.Stderr(), check.Equals, "") +} + +const mockInfoJSONWithStoreURL = ` +{ + "type": "sync", + "status-code": 200, + "status": "OK", + "result": { + "channel": "stable", + "confinement": "strict", + "description": "GNU hello prints a friendly greeting. This is part of the snapcraft tour at https://snapcraft.io/", + "developer": "canonical", + "publisher": { + "id": "canonical", + "username": "canonical", + "display-name": "Canonical", + "validation": "verified" + }, + "id": "mVyGrEwiqSi5PugCwyH7WgpoQLemtTd6", + "install-date": "2006-01-02T22:04:07.123456789Z", + "installed-size": 1024, + "name": "hello", + "private": false, + "revision": "100", + "status": "available", + "store-url": "https://snapcraft.io/hello", + "summary": "The GNU Hello snap", + "type": "app", + "version": "2.10", + "license": "", + "tracking-channel": "beta" + } +} +` + +func (s *infoSuite) TestInfoStoreURL(c *check.C) { + n := 0 + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch n { + case 0: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/find") + q := r.URL.Query() + // asks for the instance snap + c.Check(q.Get("name"), check.Equals, "hello") + fmt.Fprint(w, mockInfoJSONWithChannels) + case 1: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/snaps/hello") + fmt.Fprint(w, mockInfoJSONWithStoreURL) + default: + c.Fatalf("expected to get 2 requests, now on %d (%v)", n+1, r) + } + + n++ + }) + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"info", "hello"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + refreshDate := isoDateTimeToLocalDate(c, "2006-01-02T22:04:07.123456789Z") + // make sure local and remote info is combined in the output + c.Check(s.Stdout(), check.Equals, fmt.Sprintf(`name: hello +summary: The GNU Hello snap +publisher: Canonical** +store-url: https://snapcraft.io/hello +license: unset +description: | + GNU hello prints a friendly greeting. This is part of the snapcraft tour at + https://snapcraft.io/ +snap-id: mVyGrEwiqSi5PugCwyH7WgpoQLemtTd6 +tracking: beta +refresh-date: %s +channels: + 1/stable: 2.10 2018-12-18 (1) 65kB - + 1/candidate: ^ + 1/beta: ^ + 1/edge: ^ +installed: 2.10 (100) 1kB disabled +`, refreshDate)) + c.Check(s.Stderr(), check.Equals, "") +} diff --git a/cmd/snap/cmd_interface.go b/cmd/snap/cmd_interface.go new file mode 100644 index 00000000..82a2132f --- /dev/null +++ b/cmd/snap/cmd_interface.go @@ -0,0 +1,190 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2017 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "encoding/json" + "fmt" + "io" + "sort" + "text/tabwriter" + + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/client" + "github.com/snapcore/snapd/i18n" +) + +type cmdInterface struct { + clientMixin + ShowAttrs bool `long:"attrs"` + ShowAll bool `long:"all"` + Positionals struct { + Interface interfaceName `skip-help:"true"` + } `positional-args:"true"` +} + +var shortInterfaceHelp = i18n.G("Show details of snap interfaces") +var longInterfaceHelp = i18n.G(` +The interface command shows details of snap interfaces. + +If no interface name is provided, a list of interface names with at least +one connection is shown, or a list of all interfaces if --all is provided. +`) + +func init() { + addCommand("interface", shortInterfaceHelp, longInterfaceHelp, func() flags.Commander { + return &cmdInterface{} + }, map[string]string{ + // TRANSLATORS: This should not start with a lowercase letter. + "attrs": i18n.G("Show interface attributes"), + // TRANSLATORS: This should not start with a lowercase letter. + "all": i18n.G("Include unused interfaces"), + }, []argDesc{{ + // TRANSLATORS: This needs to begin with < and end with > + name: i18n.G(""), + // TRANSLATORS: This should not start with a lowercase letter. + desc: i18n.G("Show details of a specific interface"), + }}) +} + +func (x *cmdInterface) Execute(args []string) error { + if len(args) > 0 { + return ErrExtraArgs + } + + if x.Positionals.Interface != "" { + // Show one interface in detail. + name := string(x.Positionals.Interface) + ifaces, err := x.client.Interfaces(&client.InterfaceOptions{ + Names: []string{name}, + Doc: true, + Plugs: true, + Slots: true, + }) + if err != nil { + return err + } + if len(ifaces) == 0 { + return fmt.Errorf(i18n.G("no such interface")) + } + x.showOneInterface(ifaces[0]) + } else { + // Show an overview of available interfaces. + ifaces, err := x.client.Interfaces(&client.InterfaceOptions{ + Connected: !x.ShowAll, + }) + if err != nil { + return err + } + if len(ifaces) == 0 { + if x.ShowAll { + return fmt.Errorf(i18n.G("no interfaces found")) + } + return fmt.Errorf(i18n.G("no interfaces currently connected")) + } + x.showManyInterfaces(ifaces) + } + return nil +} + +func (x *cmdInterface) showOneInterface(iface *client.Interface) { + w := tabwriter.NewWriter(Stdout, 2, 2, 1, ' ', 0) + defer w.Flush() + + fmt.Fprintf(w, "name:\t%s\n", iface.Name) + if iface.Summary != "" { + fmt.Fprintf(w, "summary:\t%s\n", iface.Summary) + } + if iface.DocURL != "" { + fmt.Fprintf(w, "documentation:\t%s\n", iface.DocURL) + } + if len(iface.Plugs) > 0 { + fmt.Fprintf(w, "plugs:\n") + for _, plug := range iface.Plugs { + var labelPart string + if plug.Label != "" { + labelPart = fmt.Sprintf(" (%s)", plug.Label) + } + if plug.Name == iface.Name { + fmt.Fprintf(w, " - %s%s", plug.Snap, labelPart) + } else { + fmt.Fprintf(w, ` - %s:%s%s`, plug.Snap, plug.Name, labelPart) + } + // Print a colon which will make the snap:plug element a key-value + // yaml object so that we can write the attributes. + if len(plug.Attrs) > 0 && x.ShowAttrs { + fmt.Fprintf(w, ":\n") + x.showAttrs(w, plug.Attrs, " ") + } else { + fmt.Fprintf(w, "\n") + } + } + } + if len(iface.Slots) > 0 { + fmt.Fprintf(w, "slots:\n") + for _, slot := range iface.Slots { + var labelPart string + if slot.Label != "" { + labelPart = fmt.Sprintf(" (%s)", slot.Label) + } + if slot.Name == iface.Name { + fmt.Fprintf(w, " - %s%s", slot.Snap, labelPart) + } else { + fmt.Fprintf(w, ` - %s:%s%s`, slot.Snap, slot.Name, labelPart) + } + // Print a colon which will make the snap:slot element a key-value + // yaml object so that we can write the attributes. + if len(slot.Attrs) > 0 && x.ShowAttrs { + fmt.Fprintf(w, ":\n") + x.showAttrs(w, slot.Attrs, " ") + } else { + fmt.Fprintf(w, "\n") + } + } + } +} + +func (x *cmdInterface) showManyInterfaces(infos []*client.Interface) { + w := tabWriter() + defer w.Flush() + fmt.Fprintln(w, i18n.G("Name\tSummary")) + for _, iface := range infos { + fmt.Fprintf(w, "%s\t%s\n", iface.Name, iface.Summary) + } +} + +func (x *cmdInterface) showAttrs(w io.Writer, attrs map[string]interface{}, indent string) { + if len(attrs) == 0 { + return + } + names := make([]string, 0, len(attrs)) + for name := range attrs { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + value := attrs[name] + switch value.(type) { + case string, bool, json.Number: + fmt.Fprintf(w, "%s %s:\t%v\n", indent, name, value) + } + } +} diff --git a/cmd/snap/cmd_interface_test.go b/cmd/snap/cmd_interface_test.go new file mode 100644 index 00000000..a4b0ce45 --- /dev/null +++ b/cmd/snap/cmd_interface_test.go @@ -0,0 +1,287 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "io" + "net/http" + "os" + + "github.com/jessevdk/go-flags" + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/client" + . "github.com/snapcore/snapd/cmd/snap" +) + +func (s *SnapSuite) TestInterfaceHelp(c *C) { + msg := `Usage: + snap.test interface [interface-OPTIONS] [] + +The interface command shows details of snap interfaces. + +If no interface name is provided, a list of interface names with at least +one connection is shown, or a list of all interfaces if --all is provided. + +[interface command options] + --attrs Show interface attributes + --all Include unused interfaces + +[interface command arguments] + : Show details of a specific interface +` + s.testSubCommandHelp(c, "interface", msg) +} + +func (s *SnapSuite) TestInterfaceListEmpty(c *C) { + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + c.Check(r.Method, Equals, "GET") + c.Check(r.URL.Path, Equals, "/v2/interfaces") + c.Check(r.URL.RawQuery, Equals, "select=connected") + body, err := io.ReadAll(r.Body) + c.Check(err, IsNil) + c.Check(body, DeepEquals, []byte{}) + EncodeResponseBody(c, w, map[string]interface{}{ + "type": "sync", + "result": []*client.Interface{}, + }) + }) + rest, err := Parser(Client()).ParseArgs([]string{"interface"}) + c.Assert(err, ErrorMatches, "no interfaces currently connected") + c.Assert(rest, DeepEquals, []string{"interface"}) + c.Assert(s.Stdout(), Equals, "") + c.Assert(s.Stderr(), Equals, "") +} + +func (s *SnapSuite) TestInterfaceListAllEmpty(c *C) { + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + c.Check(r.Method, Equals, "GET") + c.Check(r.URL.Path, Equals, "/v2/interfaces") + c.Check(r.URL.RawQuery, Equals, "select=all") + body, err := io.ReadAll(r.Body) + c.Check(err, IsNil) + c.Check(body, DeepEquals, []byte{}) + EncodeResponseBody(c, w, map[string]interface{}{ + "type": "sync", + "result": []*client.Interface{}, + }) + }) + rest, err := Parser(Client()).ParseArgs([]string{"interface", "--all"}) + c.Assert(err, ErrorMatches, "no interfaces found") + c.Assert(rest, DeepEquals, []string{"--all"}) // XXX: feels like a bug in go-flags. + c.Assert(s.Stdout(), Equals, "") + c.Assert(s.Stderr(), Equals, "") +} + +func (s *SnapSuite) TestInterfaceList(c *C) { + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + c.Check(r.Method, Equals, "GET") + c.Check(r.URL.Path, Equals, "/v2/interfaces") + c.Check(r.URL.RawQuery, Equals, "select=connected") + body, err := io.ReadAll(r.Body) + c.Check(err, IsNil) + c.Check(body, DeepEquals, []byte{}) + EncodeResponseBody(c, w, map[string]interface{}{ + "type": "sync", + "result": []*client.Interface{{ + Name: "network", + Summary: "allows access to the network", + }, { + Name: "network-bind", + Summary: "allows providing services on the network", + }}, + }) + }) + rest, err := Parser(Client()).ParseArgs([]string{"interface"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + expectedStdout := "" + + "Name Summary\n" + + "network allows access to the network\n" + + "network-bind allows providing services on the network\n" + c.Assert(s.Stdout(), Equals, expectedStdout) + c.Assert(s.Stderr(), Equals, "") +} + +func (s *SnapSuite) TestInterfaceListAll(c *C) { + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + c.Check(r.Method, Equals, "GET") + c.Check(r.URL.Path, Equals, "/v2/interfaces") + c.Check(r.URL.RawQuery, Equals, "select=all") + body, err := io.ReadAll(r.Body) + c.Check(err, IsNil) + c.Check(body, DeepEquals, []byte{}) + EncodeResponseBody(c, w, map[string]interface{}{ + "type": "sync", + "result": []*client.Interface{{ + Name: "network", + Summary: "allows access to the network", + }, { + Name: "network-bind", + Summary: "allows providing services on the network", + }, { + Name: "unused", + Summary: "just an unused interface, nothing to see here", + }}, + }) + }) + rest, err := Parser(Client()).ParseArgs([]string{"interface", "--all"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + expectedStdout := "" + + "Name Summary\n" + + "network allows access to the network\n" + + "network-bind allows providing services on the network\n" + + "unused just an unused interface, nothing to see here\n" + c.Assert(s.Stdout(), Equals, expectedStdout) + c.Assert(s.Stderr(), Equals, "") +} + +func (s *SnapSuite) TestInterfaceDetails(c *C) { + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + c.Check(r.Method, Equals, "GET") + c.Check(r.URL.Path, Equals, "/v2/interfaces") + c.Check(r.URL.RawQuery, Equals, "doc=true&names=network&plugs=true&select=all&slots=true") + body, err := io.ReadAll(r.Body) + c.Check(err, IsNil) + c.Check(body, DeepEquals, []byte{}) + EncodeResponseBody(c, w, map[string]interface{}{ + "type": "sync", + "result": []*client.Interface{{ + Name: "network", + Summary: "allows access to the network", + DocURL: "http://example.org/about-the-network-interface", + Plugs: []client.Plug{ + {Snap: "deepin-music", Name: "network"}, + {Snap: "http", Name: "network"}, + }, + Slots: []client.Slot{{Snap: "system", Name: "network"}}, + }}, + }) + }) + rest, err := Parser(Client()).ParseArgs([]string{"interface", "network"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + expectedStdout := "" + + "name: network\n" + + "summary: allows access to the network\n" + + "documentation: http://example.org/about-the-network-interface\n" + + "plugs:\n" + + " - deepin-music\n" + + " - http\n" + + "slots:\n" + + " - system\n" + c.Assert(s.Stdout(), Equals, expectedStdout) + c.Assert(s.Stderr(), Equals, "") +} + +func (s *SnapSuite) TestInterfaceDetailsAndAttrs(c *C) { + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + c.Check(r.Method, Equals, "GET") + c.Check(r.URL.Path, Equals, "/v2/interfaces") + c.Check(r.URL.RawQuery, Equals, "doc=true&names=serial-port&plugs=true&select=all&slots=true") + body, err := io.ReadAll(r.Body) + c.Check(err, IsNil) + c.Check(body, DeepEquals, []byte{}) + EncodeResponseBody(c, w, map[string]interface{}{ + "type": "sync", + "result": []*client.Interface{{ + Name: "serial-port", + Summary: "allows providing or using a specific serial port", + Plugs: []client.Plug{ + {Snap: "minicom", Name: "serial-port"}, + }, + Slots: []client.Slot{{ + Snap: "gizmo-gadget", + Name: "debug-serial-port", + Label: "serial port for debugging", + Attrs: map[string]interface{}{ + "header": "pin-array", + "location": "internal", + "path": "/dev/ttyS0", + "number": 1, + }, + }}, + }}, + }) + }) + rest, err := Parser(Client()).ParseArgs([]string{"interface", "--attrs", "serial-port"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + expectedStdout := "" + + "name: serial-port\n" + + "summary: allows providing or using a specific serial port\n" + + "plugs:\n" + + " - minicom\n" + + "slots:\n" + + " - gizmo-gadget:debug-serial-port (serial port for debugging):\n" + + " header: pin-array\n" + + " location: internal\n" + + " number: 1\n" + + " path: /dev/ttyS0\n" + c.Assert(s.Stdout(), Equals, expectedStdout) + c.Assert(s.Stderr(), Equals, "") +} + +func (s *SnapSuite) TestInterfaceCompletion(c *C) { + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, Equals, "GET") + c.Check(r.URL.Path, Equals, "/v2/interfaces") + c.Check(r.URL.RawQuery, Equals, "select=all") + EncodeResponseBody(c, w, map[string]interface{}{ + "type": "sync", + "result": []*client.Interface{{ + Name: "network", + Summary: "allows access to the network", + }, { + Name: "network-bind", + Summary: "allows providing services on the network", + }}, + }) + }) + os.Setenv("GO_FLAGS_COMPLETION", "verbose") + defer os.Unsetenv("GO_FLAGS_COMPLETION") + + expected := []flags.Completion{} + parser := Parser(Client()) + parser.CompletionHandler = func(obtained []flags.Completion) { + c.Check(obtained, DeepEquals, expected) + } + + expected = []flags.Completion{ + {Item: "network", Description: "allows access to the network"}, + {Item: "network-bind", Description: "allows providing services on the network"}, + } + _, err := parser.ParseArgs([]string{"interface", ""}) + c.Assert(err, IsNil) + + expected = []flags.Completion{ + {Item: "network-bind", Description: "allows providing services on the network"}, + } + _, err = parser.ParseArgs([]string{"interface", "network-"}) + c.Assert(err, IsNil) + + expected = []flags.Completion{} + _, err = parser.ParseArgs([]string{"interface", "bogus"}) + c.Assert(err, IsNil) + + c.Assert(s.Stdout(), Equals, "") + c.Assert(s.Stderr(), Equals, "") +} diff --git a/cmd/snap/cmd_interfaces.go b/cmd/snap/cmd_interfaces.go new file mode 100644 index 00000000..b0e965ba --- /dev/null +++ b/cmd/snap/cmd_interfaces.go @@ -0,0 +1,181 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "fmt" + + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/client" + "github.com/snapcore/snapd/i18n" +) + +type cmdInterfaces struct { + clientMixin + Interface string `short:"i"` + Positionals struct { + Query interfacesSlotOrPlugSpec `skip-help:"true"` + } `positional-args:"true"` +} + +var shortInterfacesHelp = i18n.G("List interfaces' slots and plugs") +var longInterfacesHelp = i18n.G(` +The interfaces command lists interfaces available in the system. + +By default all slots and plugs, used and offered by all snaps, are displayed. + +$ snap interfaces : + +Lists only the specified slot or plug. + +$ snap interfaces + +Lists the slots offered and plugs used by the specified snap. + +$ snap interfaces -i= [] + +Filters the complete output so only plugs and/or slots matching the provided +details are listed. + +NOTE this command is deprecated and has been replaced with the 'connections' + command. +`) + +func init() { + cmd := addCommand("interfaces", shortInterfacesHelp, longInterfacesHelp, func() flags.Commander { + return &cmdInterfaces{} + }, map[string]string{ + // TRANSLATORS: This should not start with a lowercase letter. + "i": i18n.G("Constrain listing to specific interfaces"), + }, []argDesc{{ + // TRANSLATORS: This needs to begin with < and end with > + name: i18n.G(":"), + // TRANSLATORS: This should not start with a lowercase letter. + desc: i18n.G("Constrain listing to a specific snap or snap:name"), + }}) + cmd.hidden = true +} + +var interfacesDeprecationNotice = i18n.G("'snap interfaces' is deprecated; use 'snap connections'.") + +func (x *cmdInterfaces) Execute(args []string) error { + if len(args) > 0 { + return ErrExtraArgs + } + + opts := client.ConnectionOptions{ + All: true, + Snap: x.Positionals.Query.Snap, + } + ifaces, err := x.client.Connections(&opts) + if err != nil { + return err + } + if len(ifaces.Plugs) == 0 && len(ifaces.Slots) == 0 { + return fmt.Errorf(i18n.G("no interfaces found")) + } + + defer fmt.Fprintln(Stderr, "\n"+fill(interfacesDeprecationNotice, 0)) + + w := tabWriter() + defer w.Flush() + fmt.Fprintln(w, i18n.G("Slot\tPlug")) + + wantedSnap := x.Positionals.Query.Snap + for _, slot := range ifaces.Slots { + if wantedSnap != "" { + var ok bool + if wantedSnap == slot.Snap { + ok = true + } + // Normally snap nicknames are handled internally in the snapd + // layer. This specific command is an exception as it does + // client-side filtering. As a special case, when the user asked + // for the snap "core" but we see the "system" nickname or the + // "snapd" snap, treat that as a match. + // + // The system nickname was returned in 2.35. + // The snapd snap is returned by 2.36+ if snapd snap is installed + // and is the host for implicit interfaces. + if (wantedSnap == "core" || wantedSnap == "snapd" || wantedSnap == "system") && (slot.Snap == "core" || slot.Snap == "snapd" || slot.Snap == "system") { + ok = true + } + + for i := 0; i < len(slot.Connections) && !ok; i++ { + if wantedSnap == slot.Connections[i].Snap { + ok = true + } + } + if !ok { + continue + } + } + if x.Positionals.Query.Name != "" && x.Positionals.Query.Name != slot.Name { + continue + } + if x.Interface != "" && slot.Interface != x.Interface { + continue + } + // There are two special snaps, the "core" and "snapd" snaps are + // abbreviated to an empty snap name. The "system" snap name is still + // here in case we talk to older snapd for some reason. + if slot.Snap == "core" || slot.Snap == "snapd" || slot.Snap == "system" { + fmt.Fprintf(w, ":%s\t", slot.Name) + } else { + fmt.Fprintf(w, "%s:%s\t", slot.Snap, slot.Name) + } + for i := 0; i < len(slot.Connections); i++ { + if i > 0 { + fmt.Fprint(w, ",") + } + if slot.Connections[i].Name != slot.Name { + fmt.Fprintf(w, "%s:%s", slot.Connections[i].Snap, slot.Connections[i].Name) + } else { + fmt.Fprintf(w, "%s", slot.Connections[i].Snap) + } + } + // Display visual indicator for disconnected slots + if len(slot.Connections) == 0 { + fmt.Fprint(w, "-") + } + fmt.Fprintf(w, "\n") + } + // Plugs are treated differently. Since the loop above already printed each connected + // plug, the loop below focuses on printing just the disconnected plugs. + for _, plug := range ifaces.Plugs { + if wantedSnap != "" { + if wantedSnap != plug.Snap { + continue + } + } + if x.Positionals.Query.Name != "" && x.Positionals.Query.Name != plug.Name { + continue + } + if x.Interface != "" && plug.Interface != x.Interface { + continue + } + // Display visual indicator for disconnected plugs. + if len(plug.Connections) == 0 { + fmt.Fprintf(w, "-\t%s:%s\n", plug.Snap, plug.Name) + } + } + return nil +} diff --git a/cmd/snap/cmd_interfaces_test.go b/cmd/snap/cmd_interfaces_test.go new file mode 100644 index 00000000..9ee28b9a --- /dev/null +++ b/cmd/snap/cmd_interfaces_test.go @@ -0,0 +1,674 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "io" + "net/http" + "os" + + "github.com/jessevdk/go-flags" + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/client" + . "github.com/snapcore/snapd/cmd/snap" + "github.com/snapcore/snapd/testutil" +) + +func (s *SnapSuite) TestInterfacesZeroSlotsOnePlug(c *C) { + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + c.Check(r.Method, Equals, "GET") + c.Check(r.URL.Path, Equals, "/v2/connections") + body, err := io.ReadAll(r.Body) + c.Check(err, IsNil) + c.Check(body, DeepEquals, []byte{}) + EncodeResponseBody(c, w, map[string]interface{}{ + "type": "sync", + "result": client.Connections{ + Plugs: []client.Plug{ + { + Snap: "keyboard-lights", + Name: "capslock-led", + }, + }, + }, + }) + }) + rest, err := Parser(Client()).ParseArgs([]string{"interfaces"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + expectedStdout := "" + + "Slot Plug\n" + + "- keyboard-lights:capslock-led\n" + c.Assert(s.Stdout(), Equals, expectedStdout) + c.Assert(s.Stderr(), testutil.EqualsWrapped, InterfacesDeprecationNotice) +} + +func (s *SnapSuite) TestInterfacesZeroPlugsOneSlot(c *C) { + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + c.Check(r.Method, Equals, "GET") + c.Check(r.URL.Path, Equals, "/v2/connections") + body, err := io.ReadAll(r.Body) + c.Check(err, IsNil) + c.Check(body, DeepEquals, []byte{}) + EncodeResponseBody(c, w, map[string]interface{}{ + "type": "sync", + "result": client.Connections{ + Slots: []client.Slot{ + { + Snap: "canonical-pi2", + Name: "pin-13", + Interface: "bool-file", + Label: "Pin 13", + }, + }, + }, + }) + }) + rest, err := Parser(Client()).ParseArgs([]string{"interfaces"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + expectedStdout := "" + + "Slot Plug\n" + + "canonical-pi2:pin-13 -\n" + c.Assert(s.Stdout(), Equals, expectedStdout) + c.Assert(s.Stderr(), testutil.EqualsWrapped, InterfacesDeprecationNotice) +} + +func (s *SnapSuite) TestInterfacesOneSlotOnePlug(c *C) { + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + c.Check(r.Method, Equals, "GET") + c.Check(r.URL.Path, Equals, "/v2/connections") + body, err := io.ReadAll(r.Body) + c.Check(err, IsNil) + c.Check(body, DeepEquals, []byte{}) + EncodeResponseBody(c, w, map[string]interface{}{ + "type": "sync", + "result": client.Connections{ + Slots: []client.Slot{ + { + Snap: "canonical-pi2", + Name: "pin-13", + Interface: "bool-file", + Label: "Pin 13", + Connections: []client.PlugRef{ + { + Snap: "keyboard-lights", + Name: "capslock-led", + }, + }, + }, + }, + Plugs: []client.Plug{ + { + Snap: "keyboard-lights", + Name: "capslock-led", + Interface: "bool-file", + Label: "Capslock indicator LED", + Connections: []client.SlotRef{ + { + Snap: "canonical-pi2", + Name: "pin-13", + }, + }, + }, + }, + }, + }) + }) + rest, err := Parser(Client()).ParseArgs([]string{"interfaces"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + expectedStdout := "" + + "Slot Plug\n" + + "canonical-pi2:pin-13 keyboard-lights:capslock-led\n" + c.Assert(s.Stdout(), Equals, expectedStdout) + c.Assert(s.Stderr(), testutil.EqualsWrapped, InterfacesDeprecationNotice) + + s.ResetStdStreams() + // should be the same + rest, err = Parser(Client()).ParseArgs([]string{"interfaces", "canonical-pi2"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + c.Assert(s.Stdout(), Equals, expectedStdout) + c.Assert(s.Stderr(), testutil.EqualsWrapped, InterfacesDeprecationNotice) + s.ResetStdStreams() + // and the same again + rest, err = Parser(Client()).ParseArgs([]string{"interfaces", "keyboard-lights"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + c.Assert(s.Stdout(), Equals, expectedStdout) + c.Assert(s.Stderr(), testutil.EqualsWrapped, InterfacesDeprecationNotice) +} + +func (s *SnapSuite) TestInterfacesTwoPlugs(c *C) { + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + c.Check(r.Method, Equals, "GET") + c.Check(r.URL.Path, Equals, "/v2/connections") + body, err := io.ReadAll(r.Body) + c.Check(err, IsNil) + c.Check(body, DeepEquals, []byte{}) + EncodeResponseBody(c, w, map[string]interface{}{ + "type": "sync", + "result": client.Connections{ + Slots: []client.Slot{ + { + Snap: "canonical-pi2", + Name: "pin-13", + Interface: "bool-file", + Label: "Pin 13", + Connections: []client.PlugRef{ + { + Snap: "keyboard-lights", + Name: "capslock-led", + }, + { + Snap: "keyboard-lights", + Name: "scrollock-led", + }, + }, + }, + }, + }, + }) + }) + rest, err := Parser(Client()).ParseArgs([]string{"interfaces"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + expectedStdout := "" + + "Slot Plug\n" + + "canonical-pi2:pin-13 keyboard-lights:capslock-led,keyboard-lights:scrollock-led\n" + c.Assert(s.Stdout(), Equals, expectedStdout) + c.Assert(s.Stderr(), testutil.EqualsWrapped, InterfacesDeprecationNotice) +} + +func (s *SnapSuite) TestInterfacesPlugsWithCommonName(c *C) { + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + c.Check(r.Method, Equals, "GET") + c.Check(r.URL.Path, Equals, "/v2/connections") + body, err := io.ReadAll(r.Body) + c.Check(err, IsNil) + c.Check(body, DeepEquals, []byte{}) + EncodeResponseBody(c, w, map[string]interface{}{ + "type": "sync", + "result": client.Connections{ + Slots: []client.Slot{ + { + Snap: "canonical-pi2", + Name: "network-listening", + Interface: "network-listening", + Label: "Ability to be a network service", + Connections: []client.PlugRef{ + { + Snap: "paste-daemon", + Name: "network-listening", + }, + { + Snap: "time-daemon", + Name: "network-listening", + }, + }, + }, + }, + Plugs: []client.Plug{ + { + Snap: "paste-daemon", + Name: "network-listening", + Interface: "network-listening", + Label: "Ability to be a network service", + Connections: []client.SlotRef{ + { + Snap: "canonical-pi2", + Name: "network-listening", + }, + }, + }, + { + Snap: "time-daemon", + Name: "network-listening", + Interface: "network-listening", + Label: "Ability to be a network service", + Connections: []client.SlotRef{ + { + Snap: "canonical-pi2", + Name: "network-listening", + }, + }, + }, + }, + }, + }) + }) + rest, err := Parser(Client()).ParseArgs([]string{"interfaces"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + expectedStdout := "" + + "Slot Plug\n" + + "canonical-pi2:network-listening paste-daemon,time-daemon\n" + c.Assert(s.Stdout(), Equals, expectedStdout) + c.Assert(s.Stderr(), testutil.EqualsWrapped, InterfacesDeprecationNotice) +} + +func (s *SnapSuite) TestInterfacesOsSnapSlots(c *C) { + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + c.Check(r.Method, Equals, "GET") + c.Check(r.URL.Path, Equals, "/v2/connections") + body, err := io.ReadAll(r.Body) + c.Check(err, IsNil) + c.Check(body, DeepEquals, []byte{}) + EncodeResponseBody(c, w, map[string]interface{}{ + "type": "sync", + "result": client.Connections{ + Slots: []client.Slot{ + { + Snap: "system", + Name: "network-listening", + Interface: "network-listening", + Label: "Ability to be a network service", + Connections: []client.PlugRef{ + { + Snap: "paste-daemon", + Name: "network-listening", + }, + { + Snap: "time-daemon", + Name: "network-listening", + }, + }, + }, + }, + Plugs: []client.Plug{ + { + Snap: "paste-daemon", + Name: "network-listening", + Interface: "network-listening", + Label: "Ability to be a network service", + Connections: []client.SlotRef{ + { + Snap: "system", + Name: "network-listening", + }, + }, + }, + { + Snap: "time-daemon", + Name: "network-listening", + Interface: "network-listening", + Label: "Ability to be a network service", + Connections: []client.SlotRef{ + { + Snap: "system", + Name: "network-listening", + }, + }, + }, + }, + }, + }) + }) + rest, err := Parser(Client()).ParseArgs([]string{"interfaces"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + expectedStdout := "" + + "Slot Plug\n" + + ":network-listening paste-daemon,time-daemon\n" + c.Assert(s.Stdout(), Equals, expectedStdout) + c.Assert(s.Stderr(), testutil.EqualsWrapped, InterfacesDeprecationNotice) +} + +func (s *SnapSuite) TestInterfacesTwoSlotsAndFiltering(c *C) { + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + c.Check(r.Method, Equals, "GET") + c.Check(r.URL.Path, Equals, "/v2/connections") + body, err := io.ReadAll(r.Body) + c.Check(err, IsNil) + c.Check(body, DeepEquals, []byte{}) + EncodeResponseBody(c, w, map[string]interface{}{ + "type": "sync", + "result": client.Connections{ + Slots: []client.Slot{ + { + Snap: "canonical-pi2", + Name: "debug-console", + Interface: "serial-port", + Label: "Serial port on the expansion header", + Connections: []client.PlugRef{ + { + Snap: "core", + Name: "debug-console", + }, + }, + }, + { + Snap: "canonical-pi2", + Name: "pin-13", + Interface: "bool-file", + Label: "Pin 13", + Connections: []client.PlugRef{ + { + Snap: "keyboard-lights", + Name: "capslock-led", + }, + }, + }, + }, + }, + }) + }) + rest, err := Parser(Client()).ParseArgs([]string{"interfaces", "-i=serial-port"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + expectedStdout := "" + + "Slot Plug\n" + + "canonical-pi2:debug-console core\n" + c.Assert(s.Stdout(), Equals, expectedStdout) + c.Assert(s.Stderr(), testutil.EqualsWrapped, InterfacesDeprecationNotice) +} + +func (s *SnapSuite) TestInterfacesOfSpecificSnap(c *C) { + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + c.Check(r.Method, Equals, "GET") + c.Check(r.URL.Path, Equals, "/v2/connections") + body, err := io.ReadAll(r.Body) + c.Check(err, IsNil) + c.Check(body, DeepEquals, []byte{}) + EncodeResponseBody(c, w, map[string]interface{}{ + "type": "sync", + "result": client.Connections{ + Slots: []client.Slot{ + { + Snap: "cheese", + Name: "photo-trigger", + Interface: "bool-file", + Label: "Photo trigger", + }, + { + Snap: "wake-up-alarm", + Name: "toggle", + Interface: "bool-file", + Label: "Alarm toggle", + }, + { + Snap: "wake-up-alarm", + Name: "snooze", + Interface: "bool-file", + Label: "Alarm snooze", + }, + }, + }, + }) + }) + rest, err := Parser(Client()).ParseArgs([]string{"interfaces", "wake-up-alarm"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + expectedStdout := "" + + "Slot Plug\n" + + "wake-up-alarm:toggle -\n" + + "wake-up-alarm:snooze -\n" + c.Assert(s.Stdout(), Equals, expectedStdout) + c.Assert(s.Stderr(), testutil.EqualsWrapped, InterfacesDeprecationNotice) +} + +func (s *SnapSuite) TestInterfacesOfSystemNicknameSnap(c *C) { + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + c.Check(r.Method, Equals, "GET") + c.Check(r.URL.Path, Equals, "/v2/connections") + body, err := io.ReadAll(r.Body) + c.Check(err, IsNil) + c.Check(body, DeepEquals, []byte{}) + EncodeResponseBody(c, w, map[string]interface{}{ + "type": "sync", + "result": client.Connections{ + Slots: []client.Slot{ + { + Snap: "system", + Name: "core-support", + Interface: "some-iface", + Connections: []client.PlugRef{{Snap: "core", Name: "core-support-plug"}}, + }, { + Snap: "foo", + Name: "foo-slot", + Interface: "foo-slot-iface", + }, + }, + Plugs: []client.Plug{ + { + Snap: "core", + Name: "core-support-plug", + Interface: "some-iface", + Connections: []client.SlotRef{{Snap: "system", Name: "core-support"}}, + }, + }, + }, + }) + }) + rest, err := Parser(Client()).ParseArgs([]string{"interfaces", "system"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + expectedStdout := "" + + "Slot Plug\n" + + ":core-support core:core-support-plug\n" + c.Assert(s.Stdout(), Equals, expectedStdout) + c.Assert(s.Stderr(), testutil.EqualsWrapped, InterfacesDeprecationNotice) + + s.ResetStdStreams() + + // when called with system nickname we get the same output + rest, err = Parser(Client()).ParseArgs([]string{"interfaces", "system"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + expectedStdoutSystem := "" + + "Slot Plug\n" + + ":core-support core:core-support-plug\n" + c.Assert(s.Stdout(), Equals, expectedStdoutSystem) + c.Assert(s.Stderr(), testutil.EqualsWrapped, InterfacesDeprecationNotice) +} + +func (s *SnapSuite) TestInterfacesOfSpecificSnapAndSlot(c *C) { + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + c.Check(r.Method, Equals, "GET") + c.Check(r.URL.Path, Equals, "/v2/connections") + body, err := io.ReadAll(r.Body) + c.Check(err, IsNil) + c.Check(body, DeepEquals, []byte{}) + EncodeResponseBody(c, w, map[string]interface{}{ + "type": "sync", + "result": client.Connections{ + Slots: []client.Slot{ + { + Snap: "cheese", + Name: "photo-trigger", + Interface: "bool-file", + Label: "Photo trigger", + }, + { + Snap: "wake-up-alarm", + Name: "toggle", + Interface: "bool-file", + Label: "Alarm toggle", + }, + { + Snap: "wake-up-alarm", + Name: "snooze", + Interface: "bool-file", + Label: "Alarm snooze", + }, + }, + }, + }) + }) + rest, err := Parser(Client()).ParseArgs([]string{"interfaces", "wake-up-alarm:snooze"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + expectedStdout := "" + + "Slot Plug\n" + + "wake-up-alarm:snooze -\n" + c.Assert(s.Stdout(), Equals, expectedStdout) + c.Assert(s.Stderr(), testutil.EqualsWrapped, InterfacesDeprecationNotice) +} + +func (s *SnapSuite) TestInterfacesNothingAtAll(c *C) { + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + c.Check(r.Method, Equals, "GET") + c.Check(r.URL.Path, Equals, "/v2/connections") + body, err := io.ReadAll(r.Body) + c.Check(err, IsNil) + c.Check(body, DeepEquals, []byte{}) + EncodeResponseBody(c, w, map[string]interface{}{ + "type": "sync", + "result": client.Connections{}, + }) + }) + rest, err := Parser(Client()).ParseArgs([]string{"interfaces"}) + c.Assert(err, ErrorMatches, "no interfaces found") + // XXX: not sure why this is returned, I guess that's what happens when a + // command Execute returns an error. + c.Assert(rest, DeepEquals, []string{"interfaces"}) + c.Assert(s.Stdout(), Equals, "") + c.Assert(s.Stderr(), Equals, "") +} + +func (s *SnapSuite) TestInterfacesOfSpecificType(c *C) { + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + c.Check(r.Method, Equals, "GET") + c.Check(r.URL.Path, Equals, "/v2/connections") + body, err := io.ReadAll(r.Body) + c.Check(err, IsNil) + c.Check(body, DeepEquals, []byte{}) + EncodeResponseBody(c, w, map[string]interface{}{ + "type": "sync", + "result": client.Connections{ + Slots: []client.Slot{ + { + Snap: "cheese", + Name: "photo-trigger", + Interface: "bool-file", + Label: "Photo trigger", + }, + { + Snap: "wake-up-alarm", + Name: "toggle", + Interface: "bool-file", + Label: "Alarm toggle", + }, + { + Snap: "wake-up-alarm", + Name: "snooze", + Interface: "bool-file", + Label: "Alarm snooze", + }, + }, + }, + }) + }) + rest, err := Parser(Client()).ParseArgs([]string{"interfaces", "-i", "bool-file"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + expectedStdout := "" + + "Slot Plug\n" + + "cheese:photo-trigger -\n" + + "wake-up-alarm:toggle -\n" + + "wake-up-alarm:snooze -\n" + c.Assert(s.Stdout(), Equals, expectedStdout) + c.Assert(s.Stderr(), testutil.EqualsWrapped, InterfacesDeprecationNotice) +} + +func (s *SnapSuite) TestInterfacesCompletion(c *C) { + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v2/connections": + c.Assert(r.Method, Equals, "GET") + EncodeResponseBody(c, w, map[string]interface{}{ + "type": "sync", + "result": fortestingConnectionList, + }) + default: + c.Fatalf("unexpected path %q", r.URL.Path) + } + }) + os.Setenv("GO_FLAGS_COMPLETION", "verbose") + defer os.Unsetenv("GO_FLAGS_COMPLETION") + + expected := []flags.Completion{} + parser := Parser(Client()) + parser.CompletionHandler = func(obtained []flags.Completion) { + c.Check(obtained, DeepEquals, expected) + } + + expected = []flags.Completion{{Item: "canonical-pi2:"}, {Item: "core:"}, {Item: "keyboard-lights:"}, {Item: "paste-daemon:"}, {Item: "potato:"}, {Item: "wake-up-alarm:"}} + _, err := parser.ParseArgs([]string{"interfaces", ""}) + c.Assert(err, IsNil) + + expected = []flags.Completion{{Item: "paste-daemon:network-listening", Description: "plug"}} + _, err = parser.ParseArgs([]string{"interfaces", "pa"}) + c.Assert(err, IsNil) + + expected = []flags.Completion{{Item: "wake-up-alarm:toggle", Description: "slot"}} + _, err = parser.ParseArgs([]string{"interfaces", "wa"}) + c.Assert(err, IsNil) + + c.Assert(s.Stdout(), Equals, "") + c.Assert(s.Stderr(), Equals, "") +} + +func (s *SnapSuite) TestInterfacesCoreNicknamedSystem(c *C) { + s.checkConnectionsSystemCoreRemapping(c, "core", "system") +} + +func (s *SnapSuite) TestInterfacesSnapdNicknamedSystem(c *C) { + s.checkConnectionsSystemCoreRemapping(c, "snapd", "system") +} + +func (s *SnapSuite) TestInterfacesSnapdNicknamedCore(c *C) { + s.checkConnectionsSystemCoreRemapping(c, "snapd", "core") +} + +func (s *SnapSuite) TestInterfacesCoreSnap(c *C) { + s.checkConnectionsSystemCoreRemapping(c, "core", "core") +} + +func (s *SnapSuite) checkConnectionsSystemCoreRemapping(c *C, apiSnapName, cliSnapName string) { + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + c.Check(r.Method, Equals, "GET") + c.Check(r.URL.Path, Equals, "/v2/connections") + body, err := io.ReadAll(r.Body) + c.Check(err, IsNil) + c.Check(body, DeepEquals, []byte{}) + EncodeResponseBody(c, w, map[string]interface{}{ + "type": "sync", + "result": client.Connections{ + Slots: []client.Slot{ + { + Snap: apiSnapName, + Name: "network", + }, + }, + }, + }) + }) + rest, err := Parser(Client()).ParseArgs([]string{"interfaces", cliSnapName}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + expectedStdout := "" + + "Slot Plug\n" + + ":network -\n" + c.Assert(s.Stdout(), Equals, expectedStdout) + c.Assert(s.Stderr(), testutil.EqualsWrapped, InterfacesDeprecationNotice) +} diff --git a/cmd/snap/cmd_keys.go b/cmd/snap/cmd_keys.go new file mode 100644 index 00000000..6c23a8db --- /dev/null +++ b/cmd/snap/cmd_keys.go @@ -0,0 +1,109 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016-2021 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "encoding/json" + "fmt" + + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/asserts/signtool" + "github.com/snapcore/snapd/i18n" +) + +type cmdKeys struct { + JSON bool `long:"json"` +} + +func init() { + cmd := addCommand("keys", + i18n.G("List cryptographic keys"), + i18n.G(` +The keys command lists cryptographic keys that can be used for signing +assertions. +`), + func() flags.Commander { + return &cmdKeys{} + }, map[string]string{ + // TRANSLATORS: This should not start with a lowercase letter. + "json": i18n.G("Output results in JSON format"), + }, nil) + cmd.hidden = true + cmd.completeHidden = true +} + +// Key represents a key that can be used for signing assertions. +type Key struct { + Name string `json:"name"` + Sha3_384 string `json:"sha3-384"` +} + +func outputJSON(keys []Key) error { + obj, err := json.Marshal(keys) + if err != nil { + return err + } + fmt.Fprintf(Stdout, "%s\n", obj) + return nil +} + +func outputText(keys []Key) error { + if len(keys) == 0 { + fmt.Fprintf(Stderr, "No keys registered, see `snapcraft create-key`\n") + return nil + } + + w := tabWriter() + defer w.Flush() + + fmt.Fprintln(w, i18n.G("Name\tSHA3-384")) + for _, key := range keys { + fmt.Fprintf(w, "%s\t%s\n", key.Name, key.Sha3_384) + } + return nil +} + +func (x *cmdKeys) Execute(args []string) error { + if len(args) > 0 { + return ErrExtraArgs + } + + keypairMgr, err := signtool.GetKeypairManager() + if err != nil { + return err + } + + kinfos, err := keypairMgr.List() + if err != nil { + return err + } + keys := make([]Key, len(kinfos)) + for i, kinfo := range kinfos { + keys[i].Name = kinfo.Name + keys[i].Sha3_384 = kinfo.ID + } + + if x.JSON { + return outputJSON(keys) + } + + return outputText(keys) +} diff --git a/cmd/snap/cmd_keys_test.go b/cmd/snap/cmd_keys_test.go new file mode 100644 index 00000000..356a6358 --- /dev/null +++ b/cmd/snap/cmd_keys_test.go @@ -0,0 +1,155 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "encoding/json" + "fmt" + "net/url" + "os" + "path/filepath" + "testing" + + . "gopkg.in/check.v1" + + snap "github.com/snapcore/snapd/cmd/snap" + "github.com/snapcore/snapd/store" +) + +type SnapKeysSuite struct { + BaseSnapSuite + + GnupgCmd string + tempdir string +} + +// FIXME: Ideally we would just use gpg2 and remove the gnupg2_test.go file. +// However currently there is LP: #1621839 which prevents us from switching to +// gpg2 fully. Once this is resolved we should switch. +var _ = Suite(&SnapKeysSuite{GnupgCmd: "/usr/bin/gpg"}) + +var fakePinentryData = []byte(`#!/bin/sh +set -e +echo "OK Pleased to meet you" +while true; do + read line + case $line in + BYE) + exit 0 + ;; + *) + echo "OK I agree to everything" + ;; +esac +done +`) + +func (s *SnapKeysSuite) SetUpTest(c *C) { + if testing.Short() && s.GnupgCmd == "/usr/bin/gpg2" { + c.Skip("gpg2 does not do short tests") + } + s.BaseSnapSuite.SetUpTest(c) + + s.tempdir = c.MkDir() + for _, fileName := range []string{"pubring.gpg", "secring.gpg", "trustdb.gpg"} { + data, err := os.ReadFile(filepath.Join("test-data", fileName)) + c.Assert(err, IsNil) + err = os.WriteFile(filepath.Join(s.tempdir, fileName), data, 0644) + c.Assert(err, IsNil) + } + fakePinentryFn := filepath.Join(s.tempdir, "pinentry-fake") + err := os.WriteFile(fakePinentryFn, fakePinentryData, 0755) + c.Assert(err, IsNil) + gpgAgentConfFn := filepath.Join(s.tempdir, "gpg-agent.conf") + err = os.WriteFile(gpgAgentConfFn, []byte(fmt.Sprintf(`pinentry-program %s`, fakePinentryFn)), 0644) + c.Assert(err, IsNil) + + os.Setenv("SNAP_GNUPG_HOME", s.tempdir) + os.Setenv("SNAP_GNUPG_CMD", s.GnupgCmd) + + // by default avoid talking to the real store + s.AddCleanup(snap.MockStoreNew(func(cfg *store.Config, stoCtx store.DeviceAndAuthContext) *store.Store { + if cfg == nil { + cfg = store.DefaultConfig() + } + serverURL, _ := url.Parse("http://nowhere.example.com") + cfg.AssertionsBaseURL = serverURL + return store.New(cfg, stoCtx) + })) +} + +func (s *SnapKeysSuite) TearDownTest(c *C) { + os.Unsetenv("SNAP_GNUPG_HOME") + os.Unsetenv("SNAP_GNUPG_CMD") + s.BaseSnapSuite.TearDownTest(c) +} + +func (s *SnapKeysSuite) TestKeys(c *C) { + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"keys"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + c.Check(s.Stdout(), Matches, `Name +SHA3-384 +default +g4Pks54W_US4pZuxhgG_RHNAf_UeZBBuZyGRLLmMj1Do3GkE_r_5A5BFjx24ZwVJ +another +DVQf1U4mIsuzlQqAebjjTPYtYJ-GEhJy0REuj3zvpQYTZ7EJj7adBxIXLJ7Vmk3L +`) + c.Check(s.Stderr(), Equals, "") +} + +func (s *SnapKeysSuite) TestKeysEmptyNoHeader(c *C) { + // simulate empty keys + err := os.RemoveAll(s.tempdir) + c.Assert(err, IsNil) + + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"keys"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + c.Check(s.Stdout(), Equals, "") + c.Check(s.Stderr(), Equals, "No keys registered, see `snapcraft create-key`\n") +} + +func (s *SnapKeysSuite) TestKeysJSON(c *C) { + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"keys", "--json"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + expectedResponse := []snap.Key{ + { + Name: "default", + Sha3_384: "g4Pks54W_US4pZuxhgG_RHNAf_UeZBBuZyGRLLmMj1Do3GkE_r_5A5BFjx24ZwVJ", + }, + { + Name: "another", + Sha3_384: "DVQf1U4mIsuzlQqAebjjTPYtYJ-GEhJy0REuj3zvpQYTZ7EJj7adBxIXLJ7Vmk3L", + }, + } + var obtainedResponse []snap.Key + json.Unmarshal(s.stdout.Bytes(), &obtainedResponse) + c.Check(obtainedResponse, DeepEquals, expectedResponse) + c.Check(s.Stderr(), Equals, "") +} + +func (s *SnapKeysSuite) TestKeysJSONEmpty(c *C) { + err := os.RemoveAll(os.Getenv("SNAP_GNUPG_HOME")) + c.Assert(err, IsNil) + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"keys", "--json"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + c.Check(s.Stdout(), Equals, "[]\n") + c.Check(s.Stderr(), Equals, "") +} diff --git a/cmd/snap/cmd_known.go b/cmd/snap/cmd_known.go new file mode 100644 index 00000000..f763134d --- /dev/null +++ b/cmd/snap/cmd_known.go @@ -0,0 +1,147 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "fmt" + "strings" + + "github.com/jessevdk/go-flags" + "golang.org/x/xerrors" + + "github.com/snapcore/snapd/asserts" + "github.com/snapcore/snapd/client" + "github.com/snapcore/snapd/i18n" + "github.com/snapcore/snapd/overlord/auth" + "github.com/snapcore/snapd/store" +) + +type cmdKnown struct { + clientMixin + KnownOptions struct { + // XXX: how to get a list of assert types for completion? + AssertTypeName assertTypeName `required:"true"` + HeaderFilters []string `required:"0"` + } `positional-args:"true" required:"true"` + + Remote bool `long:"remote"` + Direct bool `long:"direct"` +} + +var shortKnownHelp = i18n.G("Show known assertions of the provided type") +var longKnownHelp = i18n.G(` +The known command shows known assertions of the provided type. +If header=value pairs are provided after the assertion type, the assertions +shown must also have the specified headers matching the provided values. +`) + +func init() { + addCommand("known", shortKnownHelp, longKnownHelp, func() flags.Commander { + return &cmdKnown{} + }, map[string]string{ + // TRANSLATORS: This should not start with a lowercase letter. + "remote": i18n.G("Query the store for the assertion, via snapd if possible"), + // TRANSLATORS: This should not start with a lowercase letter. + "direct": i18n.G("Query the store for the assertion, without attempting to go via snapd"), + }, []argDesc{ + { + // TRANSLATORS: This needs to begin with < and end with > + name: i18n.G(""), + // TRANSLATORS: This should not start with a lowercase letter. + desc: i18n.G("Assertion type name"), + }, { + // TRANSLATORS: This needs to begin with < and end with > + name: i18n.G("
"), + // TRANSLATORS: This should not start with a lowercase letter. + desc: i18n.G("Constrain listing to those matching header=value"), + }, + }) +} + +var storeNew = store.New + +func downloadAssertion(typeName string, headers map[string]string) ([]asserts.Assertion, error) { + var user *auth.UserState + + // FIXME: set auth context + var storeCtx store.DeviceAndAuthContext + + at := asserts.Type(typeName) + if at == nil { + return nil, fmt.Errorf("cannot find assertion type %q", typeName) + } + primaryKeys, err := asserts.PrimaryKeyFromHeaders(at, headers) + if err != nil { + return nil, fmt.Errorf("cannot query remote assertion: %v", err) + } + + sto := storeNew(nil, storeCtx) + as, err := sto.Assertion(at, primaryKeys, user) + if err != nil { + return nil, err + } + + return []asserts.Assertion{as}, nil +} + +func (x *cmdKnown) Execute(args []string) error { + if len(args) > 0 { + return ErrExtraArgs + } + + // TODO: share this kind of parsing once it's clearer how often is used in snap + headers := map[string]string{} + for _, headerFilter := range x.KnownOptions.HeaderFilters { + parts := strings.SplitN(headerFilter, "=", 2) + if len(parts) != 2 { + return fmt.Errorf(i18n.G("invalid header filter: %q (want key=value)"), headerFilter) + } + headers[parts[0]] = parts[1] + } + + var assertions []asserts.Assertion + var err error + switch { + case x.Remote && !x.Direct: + // --remote will query snapd + assertions, err = x.client.Known(string(x.KnownOptions.AssertTypeName), headers, &client.KnownOptions{Remote: true}) + // if snapd is unavailable automatically fallback + var connErr client.ConnectionError + if xerrors.As(err, &connErr) { + assertions, err = downloadAssertion(string(x.KnownOptions.AssertTypeName), headers) + } + case x.Direct: + // --direct implies remote + assertions, err = downloadAssertion(string(x.KnownOptions.AssertTypeName), headers) + default: + // default is to look only local + assertions, err = x.client.Known(string(x.KnownOptions.AssertTypeName), headers, nil) + } + if err != nil { + return err + } + + enc := asserts.NewEncoder(Stdout) + for _, a := range assertions { + enc.Encode(a) + } + + return nil +} diff --git a/cmd/snap/cmd_known_test.go b/cmd/snap/cmd_known_test.go new file mode 100644 index 00000000..8204d1d5 --- /dev/null +++ b/cmd/snap/cmd_known_test.go @@ -0,0 +1,229 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + + "github.com/jessevdk/go-flags" + "gopkg.in/check.v1" + + "github.com/snapcore/snapd/client" + snap "github.com/snapcore/snapd/cmd/snap" + "github.com/snapcore/snapd/store" +) + +// acquire example data via: +// curl -H "accept: application/x.ubuntu.assertion" https://assertions.ubuntu.com/v1/assertions/model/16/canonical/pi2 +const mockModelAssertion = `type: model +authority-id: canonical +series: 16 +brand-id: canonical +model: pi99 +architecture: armhf +gadget: pi99 +kernel: pi99-kernel +timestamp: 2016-08-31T00:00:00.0Z +sign-key-sha3-384: 9tydnLa6MTJ-jaQTFUXEwHl1yRx7ZS4K5cyFDhYDcPzhS7uyEkDxdUjg9g08BtNn + +AcLorsomethingthatlooksvaguelylikeasignature== +` + +func (s *SnapSuite) TestKnownViaSnapd(c *check.C) { + n := 0 + expectedQuery := url.Values{ + "series": []string{"16"}, + "brand-id": []string{"canonical"}, + "model": []string{"pi99"}, + } + + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch n { + case 0: + c.Check(r.URL.Path, check.Equals, "/v2/assertions/model") + c.Check(r.URL.Query(), check.DeepEquals, expectedQuery) + w.Header().Set("X-Ubuntu-Assertions-Count", "1") + fmt.Fprint(w, mockModelAssertion) + default: + c.Fatalf("expected to get 1 requests, now on %d", n+1) + } + n++ + }) + + // first run "normal" + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"known", "model", "series=16", "brand-id=canonical", "model=pi99"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + c.Check(s.Stdout(), check.Equals, mockModelAssertion) + c.Check(s.Stderr(), check.Equals, "") + c.Check(n, check.Equals, 1) + + // then with "--remote" + n = 0 + s.stdout.Reset() + expectedQuery["remote"] = []string{"true"} + rest, err = snap.Parser(snap.Client()).ParseArgs([]string{"known", "--remote", "model", "series=16", "brand-id=canonical", "model=pi99"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + c.Check(s.Stdout(), check.Equals, mockModelAssertion) + c.Check(s.Stderr(), check.Equals, "") + c.Check(n, check.Equals, 1) +} + +func (s *SnapSuite) TestKnownRemoteViaSnapd(c *check.C) { + n := 0 + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch n { + case 0: + c.Check(r.URL.Path, check.Equals, "/v2/assertions/model") + c.Check(r.URL.Query(), check.DeepEquals, url.Values{ + "series": []string{"16"}, + "brand-id": []string{"canonical"}, + "model": []string{"pi99"}, + "remote": []string{"true"}, + }) + w.Header().Set("X-Ubuntu-Assertions-Count", "1") + fmt.Fprint(w, mockModelAssertion) + default: + c.Fatalf("expected to get 1 requests, now on %d", n+1) + } + n++ + }) + + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"known", "--remote", "model", "series=16", "brand-id=canonical", "model=pi99"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + c.Check(s.Stdout(), check.Equals, mockModelAssertion) + c.Check(s.Stderr(), check.Equals, "") + c.Check(n, check.Equals, 1) +} + +func (s *SnapSuite) TestKnownRemoteDirect(c *check.C) { + var server *httptest.Server + + restorer := snap.MockStoreNew(func(cfg *store.Config, stoCtx store.DeviceAndAuthContext) *store.Store { + if cfg == nil { + cfg = store.DefaultConfig() + } + serverURL, _ := url.Parse(server.URL) + cfg.AssertionsBaseURL = serverURL + return store.New(cfg, stoCtx) + }) + defer restorer() + + n := 0 + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.URL.Path, check.Matches, ".*/assertions/.*") // basic check for request + switch n { + case 0: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/assertions/model/16/canonical/pi99") + fmt.Fprint(w, mockModelAssertion) + default: + c.Fatalf("expected to get 1 requests, now on %d", n+1) + } + + n++ + })) + + // first test "--remote --direct" + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"known", "--remote", "--direct", "model", "series=16", "brand-id=canonical", "model=pi99"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + c.Check(s.Stdout(), check.Equals, mockModelAssertion) + c.Check(s.Stderr(), check.Equals, "") + c.Check(n, check.Equals, 1) + + // "--direct" behave the same as "--remote --direct" + s.stdout.Reset() + n = 0 + rest, err = snap.Parser(snap.Client()).ParseArgs([]string{"known", "--direct", "model", "series=16", "brand-id=canonical", "model=pi99"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + c.Check(s.Stdout(), check.Equals, mockModelAssertion) + c.Check(s.Stderr(), check.Equals, "") + c.Check(n, check.Equals, 1) +} + +func (s *SnapSuite) TestKnownRemoteAutoFallback(c *check.C) { + var server *httptest.Server + + restorer := snap.MockStoreNew(func(cfg *store.Config, stoCtx store.DeviceAndAuthContext) *store.Store { + if cfg == nil { + cfg = store.DefaultConfig() + } + serverURL, _ := url.Parse(server.URL) + cfg.AssertionsBaseURL = serverURL + return store.New(cfg, stoCtx) + }) + defer restorer() + + n := 0 + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.URL.Path, check.Matches, ".*/assertions/.*") // basic check for request + switch n { + case 0: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/assertions/model/16/canonical/pi99") + fmt.Fprint(w, mockModelAssertion) + default: + c.Fatalf("expected to get 1 requests, now on %d", n+1) + } + n++ + })) + + cli := snap.Client() + cli.Hijack(func(*http.Request) (*http.Response, error) { + return nil, client.ConnectionError{Err: fmt.Errorf("no snapd")} + }) + + rest, err := snap.Parser(cli).ParseArgs([]string{"known", "--remote", "model", "series=16", "brand-id=canonical", "model=pi99"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + c.Check(s.Stdout(), check.Equals, mockModelAssertion) + c.Check(s.Stderr(), check.Equals, "") +} + +func (s *SnapSuite) TestKnownRemoteMissingPrimaryKey(c *check.C) { + _, err := snap.Parser(snap.Client()).ParseArgs([]string{"known", "--remote", "--direct", "model", "series=16", "brand-id=canonical"}) + c.Assert(err, check.ErrorMatches, `cannot query remote assertion: must provide primary key: model`) +} + +func (s *SnapSuite) TestAssertTypeNameCompletion(c *check.C) { + n := 0 + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch n { + case 0: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/assertions") + fmt.Fprintln(w, `{"type": "sync", "result": { "types": [ "account", "... more stuff ...", "validation" ] } }`) + default: + c.Fatalf("expected to get 1 requests, now on %d", n+1) + } + + n++ + }) + + c.Check(snap.AssertTypeNameCompletion("v"), check.DeepEquals, []flags.Completion{{Item: "validation"}}) + c.Check(n, check.Equals, 1) +} diff --git a/cmd/snap/cmd_list.go b/cmd/snap/cmd_list.go new file mode 100644 index 00000000..2914a771 --- /dev/null +++ b/cmd/snap/cmd_list.go @@ -0,0 +1,142 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "errors" + "fmt" + "sort" + "strings" + "text/tabwriter" + + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/client" + "github.com/snapcore/snapd/i18n" +) + +var shortListHelp = i18n.G("List installed snaps") +var longListHelp = i18n.G(` +The list command displays a summary of snaps installed in the current system. + +A green check mark (given color and unicode support) after a publisher name +indicates that the publisher has been verified. +`) + +type cmdList struct { + clientMixin + Positional struct { + Snaps []installedSnapName `positional-arg-name:""` + } `positional-args:"yes"` + + All bool `long:"all"` + colorMixin +} + +func init() { + addCommand("list", shortListHelp, longListHelp, func() flags.Commander { return &cmdList{} }, + colorDescs.also(map[string]string{ + // TRANSLATORS: This should not start with a lowercase letter. + "all": i18n.G("Show all revisions"), + }), nil) +} + +type snapsByName []*client.Snap + +func (s snapsByName) Len() int { return len(s) } +func (s snapsByName) Less(i, j int) bool { return s[i].Name < s[j].Name } +func (s snapsByName) Swap(i, j int) { s[i], s[j] = s[j], s[i] } + +var ErrNoMatchingSnaps = errors.New(i18n.G("no matching snaps installed")) + +// snapd will give us and we want +// "" (local snap) "-" +// latest/risk latest/risk +// track/risk track/risk +// track/risk/branch track/risk/… +// anything else SISO +func fmtChannel(ch string) string { + if ch == "" { + // "" -> "-" (local snap) + return "-" + } + if strings.Count(ch, "/") != 2 { + return ch + } + idx := strings.LastIndexByte(ch, '/') + return ch[:idx+1] + "…" +} + +func fmtVersion(v string) string { + if v == "" { + // most likely a broken snap, leave a placeholder + return "-" + } + return v +} + +func (x *cmdList) Execute(args []string) error { + if len(args) > 0 { + return ErrExtraArgs + } + + names := installedSnapNames(x.Positional.Snaps) + snaps, err := x.client.List(names, &client.ListOptions{All: x.All}) + if err != nil { + if err == client.ErrNoSnapsInstalled { + if len(names) == 0 { + fmt.Fprintln(Stderr, i18n.G("No snaps are installed yet. Try 'snap install hello-world'.")) + return nil + } else { + return ErrNoMatchingSnaps + } + } + return err + } else if len(snaps) == 0 { + return ErrNoMatchingSnaps + } + sort.Sort(snapsByName(snaps)) + + esc := x.getEscapes() + w := tabWriter() + + // TRANSLATORS: the %s is to insert a filler escape sequence (please keep it flush to the column header, with no extra spaces) + fmt.Fprintf(w, i18n.G("Name\tVersion\tRev\tTracking\tPublisher%s\tNotes\n"), fillerPublisher(esc)) + + for _, snap := range snaps { + // doing it this way because otherwise it's a sea of %s\t%s\t%s + line := []string{ + snap.Name, + fmtVersion(snap.Version), + snap.Revision.String(), + fmtChannel(snap.TrackingChannel), + shortPublisher(esc, snap.Publisher), + NotesFromLocal(snap).String(), + } + fmt.Fprintln(w, strings.Join(line, "\t")) + } + w.Flush() + + return nil +} + +func tabWriter() *tabwriter.Writer { + return tabwriter.NewWriter(Stdout, 5, 3, 2, ' ', 0) +} diff --git a/cmd/snap/cmd_list_test.go b/cmd/snap/cmd_list_test.go new file mode 100644 index 00000000..b711e375 --- /dev/null +++ b/cmd/snap/cmd_list_test.go @@ -0,0 +1,260 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "fmt" + "net/http" + + "gopkg.in/check.v1" + + snap "github.com/snapcore/snapd/cmd/snap" +) + +func (s *SnapSuite) TestListHelp(c *check.C) { + msg := `Usage: + snap.test list [list-OPTIONS] [...] + +The list command displays a summary of snaps installed in the current system. + +A green check mark (given color and unicode support) after a publisher name +indicates that the publisher has been verified. + +[list command options] + --all Show all revisions + --color=[auto|never|always] Use a little bit of color to highlight + some things. (default: auto) + --unicode=[auto|never|always] Use a little bit of Unicode to improve + legibility. (default: auto) +` + s.testSubCommandHelp(c, "list", msg) +} + +func (s *SnapSuite) TestList(c *check.C) { + n := 0 + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch n { + case 0: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/snaps") + c.Check(r.URL.RawQuery, check.Equals, "") + fmt.Fprintln(w, `{"type": "sync", "result": [ +{ + "name": "foo", + "status": "active", + "version": "4.2", + "developer": "bar", + "publisher": {"id": "bar-id", "username": "bar", "display-name": "Bar", "validation": "unproven"}, + "health": {"status": "blocked"}, + "revision": 17, + "tracking-channel": "potatoes" +}]}`) + default: + c.Fatalf("expected to get 1 requests, now on %d", n+1) + } + + n++ + }) + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"list"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + c.Check(s.Stdout(), check.Equals, ` +Name Version Rev Tracking Publisher Notes +foo 4.2 17 potatoes bar blocked +`[1:]) + c.Check(s.Stderr(), check.Equals, "") +} + +func (s *SnapSuite) TestListAll(c *check.C) { + n := 0 + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch n { + case 0: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/snaps") + c.Check(r.URL.RawQuery, check.Equals, "select=all") + fmt.Fprintln(w, `{"type": "sync", "result": [{"name": "foo", "status": "active", "version": "4.2", "developer": "bar", "publisher": {"id": "bar-id", "username": "bar", "display-name": "Bar", "validation": "unproven"}, "revision":17, "tracking-channel": "stable"}]}`) + default: + c.Fatalf("expected to get 1 requests, now on %d", n+1) + } + + n++ + }) + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"list", "--all"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + c.Check(s.Stdout(), check.Matches, `Name +Version +Rev +Tracking +Publisher +Notes +foo +4.2 +17 +stable +bar +- +`) + c.Check(s.Stderr(), check.Equals, "") +} + +func (s *SnapSuite) TestListEmpty(c *check.C) { + n := 0 + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch n { + case 0: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/snaps") + fmt.Fprintln(w, `{"type": "sync", "result": []}`) + default: + c.Fatalf("expected to get 1 requests, now on %d", n+1) + } + + n++ + }) + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"list"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + c.Check(s.Stdout(), check.Equals, "") + c.Check(s.Stderr(), check.Equals, "No snaps are installed yet. Try 'snap install hello-world'.\n") +} + +func (s *SnapSuite) TestListEmptyWithQuery(c *check.C) { + n := 0 + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch n { + case 0: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/snaps") + fmt.Fprintln(w, `{"type": "sync", "result": []}`) + default: + c.Fatalf("expected to get 1 requests, now on %d", n+1) + } + + n++ + }) + _, err := snap.Parser(snap.Client()).ParseArgs([]string{"list", "quux"}) + c.Assert(err, check.ErrorMatches, `no matching snaps installed`) +} + +func (s *SnapSuite) TestListWithNoMatchingQuery(c *check.C) { + n := 0 + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch n { + case 0: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/snaps") + c.Check(r.URL.Query().Get("snaps"), check.Equals, "quux") + fmt.Fprintln(w, `{"type": "sync", "result": []}`) + default: + c.Fatalf("expected to get 1 requests, now on %d", n+1) + } + + n++ + }) + _, err := snap.Parser(snap.Client()).ParseArgs([]string{"list", "quux"}) + c.Assert(err, check.ErrorMatches, "no matching snaps installed") +} + +func (s *SnapSuite) TestListWithQuery(c *check.C) { + n := 0 + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch n { + case 0: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/snaps") + c.Check(r.URL.Query().Get("snaps"), check.Equals, "foo") + fmt.Fprintln(w, `{"type": "sync", "result": [{"name": "foo", "status": "active", "version": "4.2", "developer": "bar", "publisher": {"id": "bar-id", "username": "bar", "display-name": "Bar", "validation": "unproven"}, "revision":17, "tracking-channel": "1.10/stable/fix1234"}]}`) + default: + c.Fatalf("expected to get 1 requests, now on %d", n+1) + } + + n++ + }) + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"list", "foo"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + c.Check(s.Stdout(), check.Matches, `Name +Version +Rev +Tracking +Publisher +Notes +foo +4.2 +17 +1\.10/stable/… +bar +- +`) + c.Check(s.Stderr(), check.Equals, "") + // ensure that the fake server api was actually hit + c.Check(n, check.Equals, 1) +} + +func (s *SnapSuite) TestListWithNotes(c *check.C) { + n := 0 + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch n { + case 0: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/snaps") + fmt.Fprintln(w, `{"type": "sync", "result": [ +{"name": "foo", "status": "active", "version": "4.2", "developer": "bar", "publisher": {"id": "bar-id", "username": "bar", "display-name": "Bar", "validation": "unproven"}, "revision":17, "trymode": true} +,{"name": "dm1", "status": "active", "version": "5", "revision":1, "devmode": true, "confinement": "devmode"} +,{"name": "dm2", "status": "active", "version": "5", "revision":1, "devmode": true, "confinement": "strict"} +,{"name": "cf1", "status": "active", "version": "6", "revision":2, "confinement": "devmode", "jailmode": true} +,{"name": "br1", "status": "active", "version": "", "revision":2, "publisher": {"id": "bar-id", "username": "bar", "display-name": "Bar", "validation": "unproven"}, "confinement": "strict", "broken": "snap is broken"} +,{"name": "dbr1", "status": "", "version": "", "revision":2, "publisher": {"id": "bar-id", "username": "bar", "display-name": "Bar", "validation": "unproven"}, "confinement": "strict", "broken": "snap is broken"} +]}`) + default: + c.Fatalf("expected to get 1 requests, now on %d", n+1) + } + + n++ + }) + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"list"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + c.Check(s.Stdout(), check.Matches, `(?ms)^Name +Version +Rev +Tracking +Publisher +Notes$`) + c.Check(s.Stdout(), check.Matches, `(?ms).*^foo +4.2 +17 +- +bar +try$`) + c.Check(s.Stdout(), check.Matches, `(?ms).*^dm1 +.* +devmode$`) + c.Check(s.Stdout(), check.Matches, `(?ms).*^dm2 +.* +devmode$`) + c.Check(s.Stdout(), check.Matches, `(?ms).*^cf1 +.* +jailmode$`) + c.Check(s.Stdout(), check.Matches, `(?ms).*^br1 +- +2 +- +bar +broken$`) + c.Check(s.Stdout(), check.Matches, `(?ms).*^dbr1 +- +2 +- +bar +disabled,broken$`) + c.Check(s.Stderr(), check.Equals, "") +} + +func (s *SnapSuite) TestFormatChannel(c *check.C) { + type tableT struct { + channel string + expected string + } + for _, t := range []tableT{ + {"", "-"}, + {"latest/stable", "latest/stable"}, + {"foo/stable", "foo/stable"}, + {"foo/edge", "foo/edge"}, + {"foo/stable/bar", "foo/stable/…"}, + {"foo/edge/bar", "foo/edge/…"}, + } { + c.Check(snap.FormatChannel(t.channel), check.Equals, t.expected, check.Commentf(t.channel)) + } + + // and some SISO tests just to check it doesn't panic nor return empty string + // (the former would break scripts) + for _, ch := range []string{ + "", + "\x00", + "/", + "//", + "///", + "////", + "a/", + "/b", + "a//b", + "/stable", + "/edge", + } { + c.Check(snap.FormatChannel(ch), check.Not(check.Equals), "", check.Commentf(ch)) + } +} diff --git a/cmd/snap/cmd_login.go b/cmd/snap/cmd_login.go new file mode 100644 index 00000000..03371fbd --- /dev/null +++ b/cmd/snap/cmd_login.go @@ -0,0 +1,135 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2014-2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "bufio" + "fmt" + "strings" + + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/client" + "github.com/snapcore/snapd/i18n" +) + +type cmdLogin struct { + clientMixin + Positional struct { + Email string + } `positional-args:"yes"` +} + +var shortLoginHelp = i18n.G("Authenticate to snapd and the store") + +var longLoginHelp = i18n.G(` +The login command authenticates the user to snapd and the snap store, and saves +credentials into the ~/.snap/auth.json file. Further communication with snapd +will then be made using those credentials. + +It's not necessary to log in to interact with snapd. Doing so, however, enables +interactions without sudo, as well as some some developer-oriented features as +detailed in the help for the find, install and refresh commands. + +An account can be set up at https://login.ubuntu.com +`) + +func init() { + addCommand("login", + shortLoginHelp, + longLoginHelp, + func() flags.Commander { + return &cmdLogin{} + }, nil, []argDesc{{ + // TRANSLATORS: This is a noun, and it needs to begin with < and end with > + name: i18n.G(""), + // TRANSLATORS: This should not start with a lowercase letter (unless it's "login.ubuntu.com") + desc: i18n.G("The login.ubuntu.com email to login as"), + }}) +} + +func requestLoginWith2faRetry(cli *client.Client, email, password string) error { + var otp []byte + var err error + + var msgs = [3]string{ + i18n.G("Two-factor code: "), + i18n.G("Bad code. Try again: "), + i18n.G("Wrong again. Once more: "), + } + + reader := bufio.NewReader(nil) + + for i := 0; ; i++ { + // first try is without otp + _, err = cli.Login(email, password, string(otp)) + if i >= len(msgs) || !client.IsTwoFactorError(err) { + return err + } + + reader.Reset(Stdin) + fmt.Fprint(Stdout, msgs[i]) + // the browser shows it as well (and Sergio wants to see it ;) + otp, _, err = reader.ReadLine() + if err != nil { + return err + } + } +} + +func requestLogin(cli *client.Client, email string) error { + fmt.Fprintf(Stdout, i18n.G("Password of %q: "), email) + password, err := ReadPassword(0) + fmt.Fprint(Stdout, "\n") + if err != nil { + return err + } + + // strings.TrimSpace needed because we get \r from the pty in the tests + return requestLoginWith2faRetry(cli, email, strings.TrimSpace(string(password))) +} + +func (x *cmdLogin) Execute(args []string) error { + if len(args) > 0 { + return ErrExtraArgs + } + + //TRANSLATORS: after the "... at" follows a URL in the next line + fmt.Fprint(Stdout, i18n.G("Personal information is handled as per our privacy notice at\n")) + fmt.Fprint(Stdout, "https://www.ubuntu.com/legal/dataprivacy/snap-store\n\n") + + email := x.Positional.Email + if email == "" { + fmt.Fprint(Stdout, i18n.G("Email address: ")) + in, _, err := bufio.NewReader(Stdin).ReadLine() + if err != nil { + return err + } + email = string(in) + } + + err := requestLogin(x.client, email) + if err != nil { + return err + } + fmt.Fprintln(Stdout, i18n.G("Login successful")) + + return nil +} diff --git a/cmd/snap/cmd_login_test.go b/cmd/snap/cmd_login_test.go new file mode 100644 index 00000000..f3f3e580 --- /dev/null +++ b/cmd/snap/cmd_login_test.go @@ -0,0 +1,93 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "fmt" + "io" + "net/http" + + . "gopkg.in/check.v1" + + snap "github.com/snapcore/snapd/cmd/snap" +) + +var mockLoginRsp = `{"type": "sync", "result": {"id":42, "username": "foo", "email": "foo@example.com", "macaroon": "yummy", "discarages":"plenty"}}` + +func makeLoginTestServer(c *C, n *int) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + switch *n { + case 0: + c.Check(r.URL.Path, Equals, "/v2/login") + c.Check(r.Method, Equals, "POST") + postData, err := io.ReadAll(r.Body) + c.Assert(err, IsNil) + c.Check(string(postData), Equals, `{"email":"foo@example.com","password":"some-password"}`+"\n") + fmt.Fprintln(w, mockLoginRsp) + default: + c.Fatalf("unexpected path %q", r.URL.Path) + } + *n++ + } +} + +func (s *SnapSuite) TestLoginSimple(c *C) { + n := 0 + s.RedirectClientToTestServer(makeLoginTestServer(c, &n)) + + // send the password + s.password = "some-password\n" + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"login", "foo@example.com"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + c.Check(s.Stdout(), Equals, `Personal information is handled as per our privacy notice at +https://www.ubuntu.com/legal/dataprivacy/snap-store + +Password of "foo@example.com": +Login successful +`) + c.Check(s.Stderr(), Equals, "") + c.Check(n, Equals, 1) +} + +func (s *SnapSuite) TestLoginAskEmail(c *C) { + n := 0 + s.RedirectClientToTestServer(makeLoginTestServer(c, &n)) + + // send the email + fmt.Fprint(s.stdin, "foo@example.com\n") + // send the password + s.password = "some-password" + + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"login"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + // test slightly ugly, on a real system STDOUT will be: + // Email address: foo@example.com\n + // because the input to stdin is echoed + c.Check(s.Stdout(), Equals, `Personal information is handled as per our privacy notice at +https://www.ubuntu.com/legal/dataprivacy/snap-store + +Email address: Password of "foo@example.com": +Login successful +`) + c.Check(s.Stderr(), Equals, "") + c.Check(n, Equals, 1) +} diff --git a/cmd/snap/cmd_logout.go b/cmd/snap/cmd_logout.go new file mode 100644 index 00000000..05f80faf --- /dev/null +++ b/cmd/snap/cmd_logout.go @@ -0,0 +1,53 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2015-2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/i18n" +) + +type cmdLogout struct { + clientMixin +} + +var shortLogoutHelp = i18n.G("Log out of snapd and the store") + +var longLogoutHelp = i18n.G(` +The logout command logs the current user out of snapd and the store. +`) + +func init() { + addCommand("logout", + shortLogoutHelp, + longLogoutHelp, + func() flags.Commander { + return &cmdLogout{} + }, nil, nil) +} + +func (cmd *cmdLogout) Execute(args []string) error { + if len(args) > 0 { + return ErrExtraArgs + } + + return cmd.client.Logout() +} diff --git a/cmd/snap/cmd_managed.go b/cmd/snap/cmd_managed.go new file mode 100644 index 00000000..a814c343 --- /dev/null +++ b/cmd/snap/cmd_managed.go @@ -0,0 +1,57 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "fmt" + + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/i18n" +) + +var shortIsManagedHelp = i18n.G("Print whether the system is managed") +var longIsManagedHelp = i18n.G(` +The managed command will print true or false informing whether +snapd has registered users. +`) + +type cmdIsManaged struct { + clientMixin +} + +func init() { + cmd := addCommand("managed", shortIsManagedHelp, longIsManagedHelp, func() flags.Commander { return &cmdIsManaged{} }, nil, nil) + cmd.hidden = true +} + +func (cmd cmdIsManaged) Execute(args []string) error { + if len(args) > 0 { + return ErrExtraArgs + } + + sysinfo, err := cmd.client.SysInfo() + if err != nil { + return err + } + + fmt.Fprintf(Stdout, "%v\n", sysinfo.Managed) + return nil +} diff --git a/cmd/snap/cmd_managed_test.go b/cmd/snap/cmd_managed_test.go new file mode 100644 index 00000000..140375f1 --- /dev/null +++ b/cmd/snap/cmd_managed_test.go @@ -0,0 +1,46 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "fmt" + "net/http" + + . "gopkg.in/check.v1" + + snap "github.com/snapcore/snapd/cmd/snap" +) + +func (s *SnapSuite) TestManaged(c *C) { + for _, managed := range []bool{true, false} { + s.stdout.Truncate(0) + + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + c.Check(r.Method, Equals, "GET") + c.Check(r.URL.Path, Equals, "/v2/system-info") + + fmt.Fprintf(w, `{"type":"sync", "status-code": 200, "result": {"managed":%v}}`, managed) + }) + + _, err := snap.Parser(snap.Client()).ParseArgs([]string{"managed"}) + c.Assert(err, IsNil) + c.Check(s.Stdout(), Equals, fmt.Sprintf("%v\n", managed)) + } +} diff --git a/cmd/snap/cmd_model.go b/cmd/snap/cmd_model.go new file mode 100644 index 00000000..cbc6a88d --- /dev/null +++ b/cmd/snap/cmd_model.go @@ -0,0 +1,175 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "errors" + "fmt" + + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/client" + "github.com/snapcore/snapd/client/clientutil" + "github.com/snapcore/snapd/i18n" +) + +var ( + shortModelHelp = i18n.G("Get the active model for this device") + longModelHelp = i18n.G(` +The model command returns the active model assertion information for this +device. + +By default, only the essential model identification information is +included in the output, but this can be expanded to include all of an +assertion's non-meta headers. + +The verbose output is presented in a structured, yaml-like format. + +Similarly, the active serial assertion can be used for the output instead of the +model assertion. +`) + + errNoMainAssertion = errors.New(i18n.G("device not ready yet (no assertions found)")) + errNoSerial = errors.New(i18n.G("device not registered yet (no serial assertion found)")) + errNoVerboseAssertion = errors.New(i18n.G("cannot use --verbose with --assertion")) +) + +// cmdModelFormatter implements the interface required by clientutil.Print* +// functions, as it formats the output it requires some extra information from +// environment its called from. +type cmdModelFormatter struct { + client *client.Client + esc *escapes +} + +func (mf cmdModelFormatter) GetEscapedDash() string { + return mf.esc.dash +} + +func (mf cmdModelFormatter) LongPublisher(storeAccountID string) string { + storeAccount, err := mf.client.StoreAccount(storeAccountID) + if err != nil { + return "" + } + // use the longPublisher helper to format the brand store account + // like we do in `snap info` + return longPublisher(mf.esc, storeAccount) +} + +type cmdModel struct { + clientMixin + timeMixin + colorMixin + + Serial bool `long:"serial"` + Verbose bool `long:"verbose"` + Assertion bool `long:"assertion"` +} + +func init() { + addCommand("model", + shortModelHelp, + longModelHelp, + func() flags.Commander { + return &cmdModel{} + }, colorDescs.also(timeDescs).also(map[string]string{ + "assertion": i18n.G("Print the raw assertion."), + "verbose": i18n.G("Print all specific assertion fields."), + "serial": i18n.G( + "Print the serial assertion instead of the model assertion."), + }), + []argDesc{}, + ) +} + +func (x *cmdModel) Execute(args []string) error { + if x.Verbose && x.Assertion { + // can't do a verbose mode for the assertion + return errNoVerboseAssertion + } + + serialAssertion, serialErr := x.client.CurrentSerialAssertion() + modelAssertion, modelErr := x.client.CurrentModelAssertion() + + // if we didn't get a model assertion bail early + if modelErr != nil { + if client.IsAssertionNotFoundError(modelErr) { + // device is not registered yet - use specific error message + return errNoMainAssertion + } + return modelErr + } + + // if the serial assertion error is anything other than not found, also + // bail early + // the serial assertion not being found may not be fatal + if serialErr != nil && !client.IsAssertionNotFoundError(serialErr) { + return serialErr + } + + if x.Assertion { + // if we are using the serial assertion and we specifically didn't find the + // serial assertion, bail with specific error + if x.Serial && client.IsAssertionNotFoundError(serialErr) { + return errNoMainAssertion + } + } + + termWidth, _ := termSize() + termWidth -= 3 + if termWidth > 100 { + // any wider than this and it gets hard to read + termWidth = 100 + } + + w := tabWriter() + + if x.Serial && client.IsAssertionNotFoundError(serialErr) { + // for serial assertion, the primary keys are output (model and + // brand-id), but if we didn't find the serial assertion then we still + // output the brand-id and model from the model assertion, but also + // return a devNotReady error + fmt.Fprintf(w, "brand-id:\t%s\n", modelAssertion.HeaderString("brand-id")) + fmt.Fprintf(w, "model:\t%s\n", modelAssertion.HeaderString("model")) + w.Flush() + return errNoSerial + } + + modelFormatter := cmdModelFormatter{ + esc: x.getEscapes(), + client: x.client, + } + opts := clientutil.PrintModelAssertionOptions{ + TermWidth: termWidth, + AbsTime: x.AbsTime, + Verbose: x.Verbose, + Assertion: x.Assertion, + } + if x.Serial { + if err := clientutil.PrintSerialAssertionYAML(w, *serialAssertion, modelFormatter, opts); err != nil { + return err + } + } else { + if err := clientutil.PrintModelAssertion(w, *modelAssertion, serialAssertion, modelFormatter, opts); err != nil { + return err + } + } + return w.Flush() +} diff --git a/cmd/snap/cmd_model_test.go b/cmd/snap/cmd_model_test.go new file mode 100644 index 00000000..03d1312c --- /dev/null +++ b/cmd/snap/cmd_model_test.go @@ -0,0 +1,647 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2019 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "fmt" + "net/http" + + "gopkg.in/check.v1" + + snap "github.com/snapcore/snapd/cmd/snap" +) + +const happyModelAssertionResponse = `type: model +authority-id: mememe +series: 16 +brand-id: mememe +model: test-model +architecture: amd64 +base: core18 +gadget: pc=18 +kernel: pc-kernel=18 +store: mememestore +system-user-authority: + - youyouyou + - mememe +required-snaps: + - core + - hello-world +timestamp: 2017-07-27T00:00:00.0Z +sign-key-sha3-384: 8B3Wmemeu3H6i4dEV4Q85Q4gIUCHIBCNMHq49e085QeLGHi7v27l3Cqmemer4__t + +AcLBcwQAAQoAHRYhBMbX+t6MbKGH5C3nnLZW7+q0g6ELBQJdTdwTAAoJELZW7+q0g6ELEvgQAI3j +jXTqR6kKOqvw94pArwdMDUaZ++tebASAZgso8ejrW2DQGWSc0Q7SQICIR8bvHxqS1GtupQswOzwS +U8hjDTv7WEchH1jylyTj/1W1GernmitTKycecRlEkSOE+EpuqBFgTtj6PdA1Fj3CiCRi1rLMhgF2 +luCOitBLaP+E8P3fuATsLqqDLYzt1VY4Y14MU75hMn+CxAQdnOZTI+NzGMasPsldmOYCPNaN/b3N +6/fDLU47RtNlMJ3K0Tz8kj0bqRbegKlD0RdNbAgo9iZwNmrr5E9WCu9f/0rUor/NIxO77H2ExIll +zhmsZ7E6qlxvAgBmzKgAXrn68gGrBkIb0eXKiCaKy/i2ApvjVZ9HkOzA6Ldd+SwNJv/iA8rdiMsq +p2BfKV5f3ju5b6+WktHxAakJ8iqQmj9Yh7piHjsOAUf1PEJd2s2nqQ+pEEn1F0B23gVCY/Fa9YRQ +iKtWVeL3rBw4dSAaK9rpTMqlNcr+yrdXfTK5YzkCC6RU4yzc5MW0hKeseeSiEDSaRYxvftjFfVNa +ZaVXKg8Lu+cHtCJDeYXEkPIDQzXswdBO1M8Mb9D0mYxQwHxwvsWv1DByB+Otq08EYgPh4kyHo7ag +85yK2e/NQ/fxSwQJMhBF74jM1z9arq6RMiE/KOleFAOraKn2hcROKnEeinABW+sOn6vNuMVv +` + +const happyUC20ModelAssertionResponse = `type: model +authority-id: testrootorg +series: 16 +brand-id: testrootorg +model: test-snapd-core-20-amd64 +architecture: amd64 +base: core20 +storage-safety: prefer-encrypted +grade: dangerous +snaps: + - + default-channel: 20/edge + id: UqFziVZDHLSyO3TqSWgNBoAdHbLI4dAH + name: pc + type: gadget + - + default-channel: 20/edge + id: pYVQrBcKmBa0mZ4CCN7ExT6jH8rY1hza + name: pc-kernel + type: kernel + - + name: app-snap + default-channel: foo + presence: optional + modes: + - recover + - run + - + default-channel: latest/stable + id: DLqre5XGLbDqg9jPtiAhRRjDuPVa5X1q + name: core20 + type: base + - + default-channel: latest/stable + id: PMrrV4ml8uWuEUDBT8dSGnKUYbevVhc4 + name: snapd + type: snapd +timestamp: 2018-09-11T22:00:00+00:00 +sign-key-sha3-384: hIedp1AvrWlcDI4uS_qjoFLzjKl5enu4G2FYJpgB3Pj-tUzGlTQBxMBsBmi-tnJR + +AcLBUgQAAQoABgUCX06qogAAv10QAFaqQ0NDDvIB7LqM0xNIz+5Y6PB5wJaRk0HqVsg2LlNgS0PQ +uJf0uFMV4GjQMraL7ZYv9BGyUoA+cz8Nbiz85m1g2ADt0ugqR/x2bAojii9lbFLmWpDMJcZhrtB1 +3k32lEUwqTMvzYTGiZ6TVug0KYbdmf2+5IGxsayAS3EwdrfbuGRHZOv6XGV7bmm1GEwCRAFvgHCk +BHKoLZ+rfbNclF4l6G+biWJTdyc5jCMpMQ6X/INnx2hXaMRf9Jfrpl6s2bGCfsxW6HVf7AWZ8qHK +jtWPQqJ6NFu2Kw1lYIA202ReK8DC3gfAlOeNzUG5dTPor3KwAoDJJI8ZaQypOazEhO9SHERIutbP +eqPxPmEoB2+E0/o0+g0o5jK4qww3Yd7b8FTDkqm2xfuuldWAiAA4x6ZOQb2So9OLT6ovqHnD3D2r +pLW/lhqwfKp3xzIVUrLi0sjGOVXu5xFDDRyFICZ6kwC7JynRGfHoa5E2y7rv8ehnOZQJ+esz9sgY +lCJcyJ8vhabDlVHg0msSeNKMVBwhQnOSakEwlcfVyaSnapArkF+OCAMl8cuGpMTKO7vJLIJo2c2P +jcVE0ftsTGs9eBi2HmdDhu3e3fmxHt9VcC4uRSOnYNVcJnMh0yVmG8RGS/Dqcz04II7llww6JJYG +KKjQ3RU/TduXa8VJsoWiRRUYAv3H +` + +const happyModelWithDisplayNameAssertionResponse = `type: model +authority-id: mememe +series: 16 +brand-id: mememe +model: test-model +architecture: amd64 +display-name: Model Name +base: core18 +gadget: pc=18 +kernel: pc-kernel=18 +store: mememestore +system-user-authority: + - youyouyou + - mememe +required-snaps: + - core + - hello-world +timestamp: 2017-07-27T00:00:00.0Z +sign-key-sha3-384: 8B3Wmemeu3H6i4dEV4Q85Q4gIUCHIBCNMHq49e085QeLGHi7v27l3Cqmemer4__t + +AcLBcwQAAQoAHRYhBMbX+t6MbKGH5C3nnLZW7+q0g6ELBQJdTdwTAAoJELZW7+q0g6ELEvgQAI3j +jXTqR6kKOqvw94pArwdMDUaZ++tebASAZgso8ejrW2DQGWSc0Q7SQICIR8bvHxqS1GtupQswOzwS +U8hjDTv7WEchH1jylyTj/1W1GernmitTKycecRlEkSOE+EpuqBFgTtj6PdA1Fj3CiCRi1rLMhgF2 +luCOitBLaP+E8P3fuATsLqqDLYzt1VY4Y14MU75hMn+CxAQdnOZTI+NzGMasPsldmOYCPNaN/b3N +6/fDLU47RtNlMJ3K0Tz8kj0bqRbegKlD0RdNbAgo9iZwNmrr5E9WCu9f/0rUor/NIxO77H2ExIll +zhmsZ7E6qlxvAgBmzKgAXrn68gGrBkIb0eXKiCaKy/i2ApvjVZ9HkOzA6Ldd+SwNJv/iA8rdiMsq +p2BfKV5f3ju5b6+WktHxAakJ8iqQmj9Yh7piHjsOAUf1PEJd2s2nqQ+pEEn1F0B23gVCY/Fa9YRQ +iKtWVeL3rBw4dSAaK9rpTMqlNcr+yrdXfTK5YzkCC6RU4yzc5MW0hKeseeSiEDSaRYxvftjFfVNa +ZaVXKg8Lu+cHtCJDeYXEkPIDQzXswdBO1M8Mb9D0mYxQwHxwvsWv1DByB+Otq08EYgPh4kyHo7ag +85yK2e/NQ/fxSwQJMhBF74jM1z9arq6RMiE/KOleFAOraKn2hcROKnEeinABW+sOn6vNuMVv +` + +const happyAccountAssertionResponse = `type: account +authority-id: canonical +account-id: mememe +display-name: MeMeMe +timestamp: 2016-04-01T00:00:00.0Z +username: meuser +validation: certified +sign-key-sha3-384: -CvQKAwRQ5h3Ffn10FILJoEZUXOv6km9FwA80-Rcj-f-6jadQ89VRswHNiEB9Lxk + +AcLDXAQAAQoABgUCV7UYzwAKCRDUpVvql9g3IK7uH/4udqNOurx5WYVknzXdwekp0ovHCQJ0iBPw +TSFxEVr9faZSzb7eqJ1WicHsShf97PYS3ClRYAiluFsjRA8Y03kkSVJHjC+sIwGFubsnkmgflt6D +WEmYIl0UBmeaEDS8uY4Xvp9NsLTzNEj2kvzy/52gKaTc1ZSl5RDL9ppMav+0V9iBYpiDPBWH2rJ+ +aDSD8Rkyygm0UscfAKyDKH4lrvZ0WkYyi1YVNPrjQ/AtBySh6Q4iJ3LifzKa9woIyAuJET/4/FPY +oirqHAfuvNod36yNQIyNqEc20AvTvZNH0PSsg4rq3DLjIPzv5KbJO9lhsasNJK1OdL6x8Yqrdsbk +ldZp4qkzfjV7VOMQKaadfcZPRaVVeJWOBnBiaukzkhoNlQi1sdCdkBB/AJHZF8QXw6c7vPDcfnCV +1lW7ddQ2p8IsJbT6LzpJu3GW/P4xhNgCjtCJ1AJm9a9RqLwQYgdLZwwDa9iCRtqTbRXBlfy3apps +1VjbQ3h5iCd0hNfwDBnGVm1rhLKHCD1DUdNE43oN2ZlE7XGyh0HFV6vKlpqoW3eoXCIxWu+HBY96 ++LSl/jQgCkb0nxYyzEYK4Reb31D0mYw1Nji5W+MIF5E09+DYZoOT0UvR05YMwMEOeSdI/hLWg/5P +k+GDK+/KopMmpd4D1+jjtF7ZvqDpmAV98jJGB2F88RyVb4gcjmFFyTi4Kv6vzz/oLpbm0qrizC0W +HLGDN/ymGA5sHzEgEx7U540vz/q9VX60FKqL2YZr/DcyY9GKX5kCG4sNqIIHbcJneZ4frM99oVDu +7Jv+DIx/Di6D1ULXol2XjxbbJLKHFtHksR97ceaFvcZwTogC61IYUBJCvvMoqdXAWMhEXCr0QfQ5 +Xbi31XW2d4/lF/zWlAkRnGTzufIXFni7+nEuOK0SQEzO3/WaRedK1SGOOtTDjB8/3OJeW96AUYK5 +oTIynkYkEyHWMNCXALg+WQW6L4/YO7aUjZ97zOWIugd7Xy63aT3r/EHafqaY2nacOhLfkeKZ830b +o/ezjoZQAxbh6ce7JnXRgE9ELxjdAhBTpGjmmmN2sYrJ7zP9bOgly0BnEPXGSQfFA+NNNw1FADx1 +MUY8q9DBjmVtgqY+1KGTV5X8KvQCBMODZIf/XJPHdCRAHxMd8COypcwgL2vDIIXpOFbi1J/B0GF+ +eklxk9wzBA8AecBMCwCzIRHDNpD1oa2we38bVFrOug6e/VId1k1jYFJjiLyLCDmV8IMYwEllHSXp +LQAdm3xZ7t4WnxYC8YSCk9mXf3CZg59SpmnV5Q5Z6A5Pl7Nc3sj7hcsMBZEsOMPzNC9dPsBnZvjs +WpPUffJzEdhHBFhvYMuD4Vqj6ejUv9l3oTrjQWVC` + +// note: this serial assertion was generated by adding print statements to the +// test in api_model_test.go that generate a fake serial assertion +const happySerialAssertionResponse = `type: serial +authority-id: my-brand +brand-id: my-brand +model: my-old-model +serial: serialserial +device-key: + AcZrBFaFwYABAvCgEOrrLA6FKcreHxCcOoTgBUZ+IRG7Nb8tzmEAklaQPGpv7skapUjwD1luE2go + mTcoTssVHrfLpBoSDV1aBs44rg3NK40ZKPJP7d2zkds1GxUo1Ea5vfet3SJ4h3aRABEBAAE= +device-key-sha3-384: iqLo9doLzK8De9925UrdUyuvPbBad72OTWVE9YJXqd6nz9dKvwJ_lHP5bVxrl3VO +timestamp: 2019-08-26T16:34:21-05:00 +sign-key-sha3-384: anCEGC2NYq7DzDEi6y7OafQCVeVLS90XlLt9PNjrRl9sim5rmRHDDNFNO7ODcWQW + +AcJwBAABCgAGBQJdZFBdAADCLALwR6Sy24wm9PffwbvUhOEXneyY3BnxKC0+NgdHu1gU8go9vEP1 +i+Flh5uoS70+MBIO+nmF8T+9JWIx2QWFDDxvcuFosnIhvUajCEQohauys5FMz/H/WvB0vrbTBpvK +eg== +` + +const happySerialUC20AssertionResponse = `type: serial +authority-id: testrootorg +brand-id: testrootorg +model: test-snapd-core-20-amd64 +serial: 7777 +device-key: + AcbBTQRWhcGAARAAuKf9n7WvZDI7u3NzMkD8WN+dxCYrb0UE9XIaHcbrj0i2zJpxCtUtpzoEo7Uk + Cvxuhr2uBpzAa8fScwzOd77MGHIZQDpS7sFSkhYsSSN0m4sy8vRevsj0roN31fugCjRnhtLTkgxo + KSoAsK87vYnC+m5V5AHaRER7q1KgpUoVD7eLOJZyrd/tWecsLL9OK87yAQHdF/cVlQupOP6OU3fK + DllER6V2TD4jADK2Gyj2lDhy3F0+rE0a+zsGpmQQBorvzbozUHgBE3z/XjTTMrHYP4m+4V5HeWdn + rHt/x1LZ8wMTCMT1eeruclC82UPRgF0zWI+P7WgBqogJpCbfadhAj1zvKW+5vJ385n0BU7PoAZtA + KddBbsmEnfK/gWIxgFemIrYcYGhIBxYY6iNcygTYRFo4R9xm3bELHLG+viHggih4Lrjnb4sLHOdC + h3C4/45bY+6hSno8GQGlp4kYQQM8mrF9st51jIM6oyB84NtoySLYYE1wMeGNzDHSuI+1IiRmaTgy + Q2ImXTuqOhclhNA1sOi3R4H+oOBxe6GmoM5ATBPBqJeqUEvK8GpSRCig0QH4qMNF/abNKwvKhGMZ + LqtpFp5LNx7xYuAwoVkcq0nxQTsXctl3gJqY+lRx7mIeoXLZPKZyJees+5v96oa9lMdNX3f5UUpX + zq0cNhdgHrXZfcsAEQEAAQ== +device-key-sha3-384: CZeO_5nJm_Rg0izosNfcQRoQj9nFtAmK2Y_tz4YjlKlvS93b_9gTDHuby5HHwi7d +timestamp: 2020-09-03T14:42:47-05:00 +sign-key-sha3-384: hIedp1AvrWlcDI4uS_qjoFLzjKl5enu4G2FYJpgB3Pj-tUzGlTQBxMBsBmi-tnJR + +AcLBUgQAAQoABgUCX1FHNwAAqFoQABFiyzipoTYAuYN0Wd7cXuPPD7z+z+E+LoZZ+j4vUKqvnGX8 +tksb2nEEOQhjSvVof5pPOswWgq8Nj52dtYA20R5Zgfy0MZHHcCCfgxaRj6EiFyrG5h9l5wWMnzdb +pXo9SJ3hxw6lKdj3n9RAAY0mACvw6f/trcyLeSxQ7EBm6X9c4ohJSjlHkKj0TlKkNTrFflko5aQH +uJUk/YgsvMTZUHbgj6QKHlODUH8iRvOHxzn/Y9BlnzBsb/SyzvNTPeQyzFtd9QkESI2sWghviys2 +fGeEZPeXU6xts6Ht+xhr3mj5npZwkkL/6YxSzm9owQ0zGrfaFTswN+xoDKZ5498qRtSY3mCK/5xx +kvWpOTHHhfvuS3GGyvRZOih7IAffDEwQsUNh8V9IjQNNTIkCYTPZz4WBM42mI8UgeDsnDImmcoc0 +GlqBeCxUigszJlEdUAHQklwW7Sgp13mceR3zB7BHgp4Sk7n0RyPuTQUA94ys6SeesK5YphwmhVed +V02lkdeqRbGt3yZ/T5Zg8CIUIM0RKDSqoHgvoCMZh98dRGv6LPRj/P0RSWmjYWotjdK+lXK1fySM +RXMNJIInZoC0x8qEwGLXVl5V3z8motLG71ie7PQ677W0dE9XM5LRnZHEKXP41jfaOO9vu12TtBsh +pe/pnYDfIzU6OyOsdmkGWaWD+nbD +` + +const noModelAssertionYetResponse = ` +{ + "type": "error", + "status-code": 404, + "status": "Not Found", + "result": { + "message": "no model assertion yet", + "kind": "assertion-not-found", + "value": "model" + } +}` + +const noSerialAssertionYetResponse = ` +{ + "type": "error", + "status-code": 404, + "status": "Not Found", + "result": { + "message": "no serial assertion yet", + "kind": "assertion-not-found", + "value": "serial" + } +}` + +// helper for constructing different types of responses to the client +type checkResponder func(c *check.C, w http.ResponseWriter, r *http.Request) + +func simpleHappyResponder(body string) checkResponder { + return func(c *check.C, w http.ResponseWriter, r *http.Request) { + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.RawQuery, check.Equals, "") + fmt.Fprintln(w, body) + } +} + +func simpleUnhappyResponder(errBody string) checkResponder { + return func(c *check.C, w http.ResponseWriter, r *http.Request) { + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.RawQuery, check.Equals, "") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(404) + fmt.Fprintln(w, errBody) + } +} + +func simpleAssertionAccountResponder(body string) checkResponder { + return func(c *check.C, w http.ResponseWriter, r *http.Request) { + c.Check(r.Method, check.Equals, "GET") + w.Header().Set("X-Ubuntu-Assertions-Count", "1") + fmt.Fprintln(w, body) + } +} + +func makeHappyTestServerHandler(c *check.C, modelResp, serialResp, accountResp checkResponder) func(w http.ResponseWriter, r *http.Request) { + var nModelSerial, nModel, nKnown int + return func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v2/model": + switch nModel { + case 0: + modelResp(c, w, r) + default: + c.Fatalf("expected to get 1 request for /v2/model, now on %d", nModel+1) + } + nModel++ + case "/v2/model/serial": + switch nModelSerial { + case 0: + serialResp(c, w, r) + default: + c.Fatalf("expected to get 1 request for /v2/model, now on %d", nModelSerial+1) + } + nModelSerial++ + case "/v2/assertions/account": + switch nKnown { + case 0: + accountResp(c, w, r) + default: + c.Fatalf("expected to get 1 request for /v2/model, now on %d", nKnown+1) + } + nKnown++ + default: + c.Fatalf("unexpected request to %s", r.URL.Path) + } + } +} + +func (s *SnapSuite) TestNoModelYet(c *check.C) { + s.RedirectClientToTestServer( + makeHappyTestServerHandler( + c, + simpleUnhappyResponder(noModelAssertionYetResponse), + simpleUnhappyResponder(noSerialAssertionYetResponse), + simpleAssertionAccountResponder(happyAccountAssertionResponse), + )) + _, err := snap.Parser(snap.Client()).ParseArgs([]string{"model"}) + c.Assert(err, check.ErrorMatches, `device not ready yet \(no assertions found\)`) +} + +func (s *SnapSuite) TestNoSerialYet(c *check.C) { + s.RedirectClientToTestServer( + makeHappyTestServerHandler( + c, + simpleHappyResponder(happyModelAssertionResponse), + simpleUnhappyResponder(noSerialAssertionYetResponse), + simpleAssertionAccountResponder(happyAccountAssertionResponse), + )) + _, err := snap.Parser(snap.Client()).ParseArgs([]string{"model", "--serial"}) + c.Assert(err, check.ErrorMatches, `device not registered yet \(no serial assertion found\)`) + c.Check(s.Stderr(), check.Equals, "") + c.Check(s.Stdout(), check.Equals, ` +brand-id: mememe +model: test-model +`[1:]) +} + +func (s *SnapSuite) TestModel(c *check.C) { + + for _, tt := range []struct { + comment string + modelF checkResponder + serialF checkResponder + outText string + }{ + { + comment: "normal serial and model asserts", + modelF: simpleHappyResponder(happyModelAssertionResponse), + serialF: simpleHappyResponder(happySerialAssertionResponse), + outText: ` +brand MeMeMe (meuser**) +model test-model +serial serialserial +`[1:], + }, + { + comment: "normal uc20 serial and model asserts", + modelF: simpleHappyResponder(happyUC20ModelAssertionResponse), + serialF: simpleHappyResponder(happySerialUC20AssertionResponse), + outText: ` +brand MeMeMe (meuser**) +model test-snapd-core-20-amd64 +grade dangerous +storage-safety prefer-encrypted +serial 7777 +`[1:], + }, + { + comment: "model assert has display-name", + modelF: simpleHappyResponder(happyModelWithDisplayNameAssertionResponse), + serialF: simpleHappyResponder(happySerialAssertionResponse), + outText: ` +brand MeMeMe (meuser**) +model Model Name (test-model) +serial serialserial +`[1:], + }, + { + comment: "missing serial assert", + modelF: simpleHappyResponder(happyModelAssertionResponse), + serialF: simpleUnhappyResponder(noSerialAssertionYetResponse), + outText: ` +brand MeMeMe (meuser**) +model test-model +serial - (device not registered yet) +`[1:], + }, + } { + s.RedirectClientToTestServer( + makeHappyTestServerHandler( + c, + tt.modelF, + tt.serialF, + simpleAssertionAccountResponder(happyAccountAssertionResponse), + )) + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"model"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + c.Check(s.Stdout(), check.Equals, tt.outText, check.Commentf("\n%s\n", tt.outText)) + c.Check(s.Stderr(), check.Equals, "") + s.ResetStdStreams() + } +} + +func (s *SnapSuite) TestModelVerbose(c *check.C) { + s.RedirectClientToTestServer( + makeHappyTestServerHandler( + c, + simpleHappyResponder(happyModelAssertionResponse), + simpleHappyResponder(happySerialAssertionResponse), + simpleAssertionAccountResponder(happyAccountAssertionResponse), + )) + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"model", "--verbose", "--abs-time"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + c.Check(s.Stdout(), check.Equals, ` +brand-id: mememe +model: test-model +serial: serialserial +architecture: amd64 +base: core18 +gadget: pc=18 +kernel: pc-kernel=18 +store: mememestore +system-user-authority: + - youyouyou + - mememe +timestamp: 2017-07-27T00:00:00Z +required-snaps: + - core + - hello-world +`[1:]) + c.Check(s.Stderr(), check.Equals, "") +} + +func (s *SnapSuite) TestModelVerboseUC20(c *check.C) { + s.RedirectClientToTestServer( + makeHappyTestServerHandler( + c, + simpleHappyResponder(happyUC20ModelAssertionResponse), + simpleHappyResponder(happySerialAssertionResponse), + simpleAssertionAccountResponder(happyAccountAssertionResponse), + )) + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"model", "--verbose", "--abs-time"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + c.Check(s.Stdout(), check.Equals, ` +brand-id: testrootorg +model: test-snapd-core-20-amd64 +grade: dangerous +storage-safety: prefer-encrypted +serial: serialserial +architecture: amd64 +base: core20 +timestamp: 2018-09-11T22:00:00Z +snaps: + - name: pc + id: UqFziVZDHLSyO3TqSWgNBoAdHbLI4dAH + type: gadget + default-channel: 20/edge + - name: pc-kernel + id: pYVQrBcKmBa0mZ4CCN7ExT6jH8rY1hza + type: kernel + default-channel: 20/edge + - name: app-snap + default-channel: foo + presence: optional + modes: [recover, run] + - name: core20 + id: DLqre5XGLbDqg9jPtiAhRRjDuPVa5X1q + type: base + default-channel: latest/stable + - name: snapd + id: PMrrV4ml8uWuEUDBT8dSGnKUYbevVhc4 + type: snapd + default-channel: latest/stable +`[1:]) + c.Check(s.Stderr(), check.Equals, "") +} + +func (s *SnapSuite) TestModelVerboseDisplayName(c *check.C) { + s.RedirectClientToTestServer( + makeHappyTestServerHandler( + c, + simpleHappyResponder(happyModelWithDisplayNameAssertionResponse), + simpleHappyResponder(happySerialAssertionResponse), + simpleAssertionAccountResponder(happyAccountAssertionResponse), + )) + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"model", "--verbose", "--abs-time"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + c.Check(s.Stdout(), check.Equals, ` +brand-id: mememe +model: test-model +serial: serialserial +architecture: amd64 +base: core18 +display-name: Model Name +gadget: pc=18 +kernel: pc-kernel=18 +store: mememestore +system-user-authority: + - youyouyou + - mememe +timestamp: 2017-07-27T00:00:00Z +required-snaps: + - core + - hello-world +`[1:]) + c.Check(s.Stderr(), check.Equals, "") +} + +func (s *SnapSuite) TestModelVerboseNoSerialYet(c *check.C) { + s.RedirectClientToTestServer( + makeHappyTestServerHandler( + c, + simpleHappyResponder(happyModelAssertionResponse), + simpleUnhappyResponder(noSerialAssertionYetResponse), + simpleAssertionAccountResponder(happyAccountAssertionResponse), + )) + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"model", "--verbose", "--abs-time"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + c.Check(s.Stdout(), check.Equals, ` +brand-id: mememe +model: test-model +serial: -- (device not registered yet) +architecture: amd64 +base: core18 +gadget: pc=18 +kernel: pc-kernel=18 +store: mememestore +system-user-authority: + - youyouyou + - mememe +timestamp: 2017-07-27T00:00:00Z +required-snaps: + - core + - hello-world +`[1:]) + c.Check(s.Stderr(), check.Equals, "") +} + +func (s *SnapSuite) TestModelAssertion(c *check.C) { + s.RedirectClientToTestServer( + makeHappyTestServerHandler( + c, + simpleHappyResponder(happyModelAssertionResponse), + simpleHappyResponder(happySerialAssertionResponse), + simpleAssertionAccountResponder(happyAccountAssertionResponse), + )) + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"model", "--assertion"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + c.Check(s.Stdout(), check.Equals, happyModelAssertionResponse) + c.Check(s.Stderr(), check.Equals, "") +} + +func (s *SnapSuite) TestModelAssertionVerbose(c *check.C) { + // check that no calls to the server happen + s.RedirectClientToTestServer( + func(w http.ResponseWriter, r *http.Request) { + c.Fatalf("unexpected request to %s", r.URL.Path) + }, + ) + _, err := snap.Parser(snap.Client()).ParseArgs([]string{"model", "--assertion", "--verbose"}) + c.Assert(err, check.ErrorMatches, "cannot use --verbose with --assertion") + c.Check(s.Stdout(), check.Equals, "") + c.Check(s.Stderr(), check.Equals, "") +} + +func (s *SnapSuite) TestSerial(c *check.C) { + s.RedirectClientToTestServer( + makeHappyTestServerHandler( + c, + simpleHappyResponder(happyModelAssertionResponse), + simpleHappyResponder(happySerialAssertionResponse), + simpleAssertionAccountResponder(happyAccountAssertionResponse), + )) + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"model", "--serial"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + c.Check(s.Stdout(), check.Equals, ` +brand-id: my-brand +model: my-old-model +serial: serialserial +`[1:]) + c.Check(s.Stderr(), check.Equals, "") +} + +func (s *SnapSuite) TestSerialVerbose(c *check.C) { + s.RedirectClientToTestServer( + makeHappyTestServerHandler( + c, + simpleHappyResponder(happyModelAssertionResponse), + simpleHappyResponder(happySerialAssertionResponse), + simpleAssertionAccountResponder(happyAccountAssertionResponse), + )) + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"model", "--serial", "--verbose", "--abs-time"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + c.Check(s.Stdout(), check.Equals, ` +brand-id: my-brand +model: my-old-model +serial: serialserial +timestamp: 2019-08-26T16:34:21-05:00 +device-key-sha3-384: | + iqLo9doLzK8De9925UrdUyuvPbBad72OTWVE9YJXqd6nz9dKvwJ_lHP5bVxrl3VO +device-key: | + AcZrBFaFwYABAvCgEOrrLA6FKcreHxCcOoTgBUZ+IRG7Nb8tzmEAklaQPGpv7skapUjwD1luE2g + omTcoTssVHrfLpBoSDV1aBs44rg3NK40ZKPJP7d2zkds1GxUo1Ea5vfet3SJ4h3aRABEBAAE= +`[1:]) + c.Check(s.Stderr(), check.Equals, "") +} + +func (s *SnapSuite) TestSerialAssertion(c *check.C) { + s.RedirectClientToTestServer( + makeHappyTestServerHandler( + c, + simpleHappyResponder(happyModelAssertionResponse), + simpleHappyResponder(happySerialAssertionResponse), + simpleAssertionAccountResponder(happyAccountAssertionResponse), + )) + rest, err := snap.Parser(snap.Client()).ParseArgs([]string{"model", "--serial", "--assertion"}) + c.Assert(err, check.IsNil) + c.Assert(rest, check.DeepEquals, []string{}) + c.Check(s.Stdout(), check.Equals, happySerialAssertionResponse) + c.Check(s.Stderr(), check.Equals, "") +} + +func (s *SnapSuite) TestSerialAssertionSerialAssertionMissing(c *check.C) { + s.RedirectClientToTestServer( + makeHappyTestServerHandler( + c, + simpleHappyResponder(happyModelAssertionResponse), + simpleUnhappyResponder(noSerialAssertionYetResponse), + simpleAssertionAccountResponder(happyAccountAssertionResponse), + )) + _, err := snap.Parser(snap.Client()).ParseArgs([]string{"model", "--serial", "--assertion"}) + c.Assert(err, check.ErrorMatches, `device not ready yet \(no assertions found\)`) + c.Assert(s.Stdout(), check.Equals, "") + c.Assert(s.Stderr(), check.Equals, "") +} diff --git a/cmd/snap/cmd_pack.go b/cmd/snap/cmd_pack.go new file mode 100644 index 00000000..74330955 --- /dev/null +++ b/cmd/snap/cmd_pack.go @@ -0,0 +1,133 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016-2023 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "fmt" + "path/filepath" + + "github.com/jessevdk/go-flags" + "golang.org/x/xerrors" + + "github.com/snapcore/snapd/i18n" + + // for SanitizePlugsSlots + "github.com/snapcore/snapd/interfaces/builtin" + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/snap/pack" +) + +type packCmd struct { + CheckSkeleton bool `long:"check-skeleton"` + AppendVerity bool `long:"append-integrity-data" hidden:"yes"` + Filename string `long:"filename"` + Compression string `long:"compression"` + Positional struct { + SnapDir string `positional-arg-name:""` + TargetDir string `positional-arg-name:""` + } `positional-args:"yes"` +} + +var shortPackHelp = i18n.G("Pack the given directory as a snap") +var longPackHelp = i18n.G(` +The pack command packs the given snap-dir as a snap and writes the result to +target-dir. If target-dir is omitted, the result is written to current +directory. If both source-dir and target-dir are omitted, the pack command packs +the current directory. + +The default file name for a snap can be derived entirely from its snap.yaml, but +in some situations it's simpler for a script to feed the filename in. In those +cases, --filename can be given to override the default. If this filename is +not absolute it will be taken as relative to target-dir. + +When used with --check-skeleton, pack only checks whether snap-dir contains +valid snap metadata and raises an error otherwise. Application commands listed +in snap metadata file, but appearing with incorrect permission bits result in an +error. Commands that are missing from snap-dir are listed in diagnostic +messages.`, + +/* +When used with --append-integrity-data, pack will append dm-verity data at the end +of the snap to be used with snapd's snap integrity verification mechanism. +*/ +) + +func init() { + cmd := addCommand("pack", + shortPackHelp, + longPackHelp, + func() flags.Commander { + return &packCmd{} + }, map[string]string{ + // TRANSLATORS: This should not start with a lowercase letter. + "check-skeleton": i18n.G("Validate snap-dir metadata only"), + // TRANSLATORS: This should not start with a lowercase letter. + "filename": i18n.G("Output to this filename"), + // TRANSLATORS: This should not start with a lowercase letter. + "compression": i18n.G("Compression to use (e.g. xz or lzo)"), + // TRANSLATORS: This should not start with a lowercase letter. + "append-integrity-data": i18n.G("Generate and append dm-verity data"), + }, nil) + cmd.extra = func(cmd *flags.Command) { + // TRANSLATORS: this describes the default filename for a snap, e.g. core_16-2.35.2_amd64.snap + cmd.FindOptionByLongName("filename").DefaultMask = i18n.G("__.snap") + } +} + +func (x *packCmd) Execute([]string) error { + // plug/slot sanitization is disabled (no-op) by default at the package level for "snap" command, + // for "snap pack" however we want real validation. + snap.SanitizePlugsSlots = builtin.SanitizePlugsSlots + + if x.Positional.TargetDir != "" && x.Filename != "" && filepath.IsAbs(x.Filename) { + return fmt.Errorf(i18n.G("you can't specify an absolute filename while also specifying target dir.")) + } + + if x.Positional.SnapDir == "" { + x.Positional.SnapDir = "." + } + if x.Positional.TargetDir == "" { + x.Positional.TargetDir = "." + } + + if x.CheckSkeleton { + err := pack.CheckSkeleton(Stderr, x.Positional.SnapDir) + if err == snap.ErrMissingPaths { + return nil + } + return err + } + + snapPath, err := pack.Pack(x.Positional.SnapDir, &pack.Options{ + TargetDir: x.Positional.TargetDir, + SnapName: x.Filename, + Compression: x.Compression, + Integrity: x.AppendVerity, + }) + if err != nil { + // TRANSLATORS: the %q is the snap-dir (the first positional + // argument to the command); the %v is an error + return xerrors.Errorf(i18n.G("cannot pack %q: %w"), x.Positional.SnapDir, err) + + } + // TRANSLATORS: %s is the path to the built snap file + fmt.Fprintf(Stdout, i18n.G("built: %s\n"), snapPath) + return nil +} diff --git a/cmd/snap/cmd_pack_test.go b/cmd/snap/cmd_pack_test.go new file mode 100644 index 00000000..74a1da97 --- /dev/null +++ b/cmd/snap/cmd_pack_test.go @@ -0,0 +1,231 @@ +package main_test + +import ( + "fmt" + "os" + "path" + "path/filepath" + + "gopkg.in/check.v1" + + snaprun "github.com/snapcore/snapd/cmd/snap" + "github.com/snapcore/snapd/logger" + "github.com/snapcore/snapd/testutil" +) + +const packSnapYaml = `name: hello +version: 1.0.1 +apps: + app: + command: bin/hello +` + +func makeSnapDirForPack(c *check.C, snapYaml string) string { + tempdir := c.MkDir() + c.Assert(os.Chmod(tempdir, 0755), check.IsNil) + + // use meta/snap.yaml + metaDir := filepath.Join(tempdir, "meta") + err := os.Mkdir(metaDir, 0755) + c.Assert(err, check.IsNil) + err = os.WriteFile(filepath.Join(metaDir, "snap.yaml"), []byte(snapYaml), 0644) + c.Assert(err, check.IsNil) + + return tempdir +} + +func makeComponentDirForPack(c *check.C, compYaml string) string { + tempdir := c.MkDir() + c.Assert(os.Chmod(tempdir, 0755), check.IsNil) + + metaDir := filepath.Join(tempdir, "meta") + err := os.Mkdir(metaDir, 0755) + c.Assert(err, check.IsNil) + err = os.WriteFile(filepath.Join(metaDir, "component.yaml"), []byte(compYaml), 0644) + c.Assert(err, check.IsNil) + + return tempdir +} + +func (s *SnapSuite) TestPackCheckSkeletonNoAppFiles(c *check.C) { + _, r := logger.MockLogger() + defer r() + + snapDir := makeSnapDirForPack(c, packSnapYaml) + + // check-skeleton does not fail due to missing files + _, err := snaprun.Parser(snaprun.Client()).ParseArgs([]string{"pack", "--check-skeleton", snapDir}) + c.Assert(err, check.IsNil) +} + +func (s *SnapSuite) TestPackCheckSkeletonBadMeta(c *check.C) { + // no snap name + snapYaml := ` +version: foobar +apps: +` + snapDir := makeSnapDirForPack(c, snapYaml) + + _, err := snaprun.Parser(snaprun.Client()).ParseArgs([]string{"pack", "--check-skeleton", snapDir}) + c.Assert(err, check.ErrorMatches, `cannot validate snap "": snap name cannot be empty`) +} + +func (s *SnapSuite) TestPackCheckSkeletonConflictingCommonID(c *check.C) { + // conflicting common-id + snapYaml := `name: foo +version: foobar +apps: + foo: + common-id: org.foo.foo + bar: + common-id: org.foo.foo +` + snapDir := makeSnapDirForPack(c, snapYaml) + + _, err := snaprun.Parser(snaprun.Client()).ParseArgs([]string{"pack", "--check-skeleton", snapDir}) + c.Assert(err, check.ErrorMatches, `cannot validate snap "foo": application ("bar" common-id "org.foo.foo" must be unique, already used by application "foo"|"foo" common-id "org.foo.foo" must be unique, already used by application "bar")`) +} + +func (s *SnapSuite) TestPackCheckSkeletonWonkyInterfaces(c *check.C) { + snapYaml := ` +name: foo +version: 1.0.1 +slots: + kale: +` + snapDir := makeSnapDirForPack(c, snapYaml) + + _, err := snaprun.Parser(snaprun.Client()).ParseArgs([]string{"pack", "--check-skeleton", snapDir}) + c.Assert(err, check.IsNil) + c.Check(s.stderr.String(), check.Equals, "snap \"foo\" has bad plugs or slots: kale (unknown interface \"kale\")\n") +} + +func (s *SnapSuite) TestPackPacksFailsForMissingPaths(c *check.C) { + _, r := logger.MockLogger() + defer r() + + snapDir := makeSnapDirForPack(c, packSnapYaml) + + _, err := snaprun.Parser(snaprun.Client()).ParseArgs([]string{"pack", snapDir, snapDir}) + c.Assert(err, check.ErrorMatches, ".* snap is unusable due to missing files") +} + +func (s *SnapSuite) TestPackPacksASnap(c *check.C) { + snapDir := makeSnapDirForPack(c, packSnapYaml) + + const helloBinContent = `#!/bin/sh +printf "hello world" +` + // an example binary + binDir := filepath.Join(snapDir, "bin") + err := os.Mkdir(binDir, 0755) + c.Assert(err, check.IsNil) + err = os.WriteFile(filepath.Join(binDir, "hello"), []byte(helloBinContent), 0755) + c.Assert(err, check.IsNil) + + _, err = snaprun.Parser(snaprun.Client()).ParseArgs([]string{"pack", snapDir, snapDir}) + c.Assert(err, check.IsNil) + + matches, err := filepath.Glob(snapDir + "/hello*.snap") + c.Assert(err, check.IsNil) + c.Assert(matches, check.HasLen, 1) +} + +func (s *SnapSuite) TestPackPacksASnapWithCompressionHappy(c *check.C) { + snapDir := makeSnapDirForPack(c, "name: hello\nversion: 1.0") + + for _, comp := range []string{"xz", "lzo"} { + _, err := snaprun.Parser(snaprun.Client()).ParseArgs([]string{"pack", "--compression", comp, snapDir, snapDir}) + c.Assert(err, check.IsNil) + + matches, err := filepath.Glob(snapDir + "/hello*.snap") + c.Assert(err, check.IsNil) + c.Assert(matches, check.HasLen, 1) + err = os.Remove(matches[0]) + c.Assert(err, check.IsNil) + } +} + +func (s *SnapSuite) TestPackPacksASnapWithCompressionUnhappy(c *check.C) { + snapDir := makeSnapDirForPack(c, "name: hello\nversion: 1.0") + + for _, comp := range []string{"gzip", "zstd", "silly"} { + _, err := snaprun.Parser(snaprun.Client()).ParseArgs([]string{"pack", "--compression", comp, snapDir, snapDir}) + c.Assert(err, check.ErrorMatches, fmt.Sprintf(`cannot pack "/.*": cannot use compression %q`, comp)) + } +} + +func (s *SnapSuite) TestPackPacksASnapWithIntegrityHappy(c *check.C) { + snapDir := makeSnapDirForPack(c, "name: hello\nversion: 1.0") + + // mock the verity-setup command, what it does is make a copy of the snap + // and then returns pre-calculated output + vscmd := testutil.MockCommand(c, "veritysetup", fmt.Sprintf(` +case "$1" in + --version) + echo "veritysetup 2.2.6" + exit 0 + ;; + format) + cp %[1]s/hello_1.0_all.snap %[1]s/hello_1.0_all.snap.verity + echo "VERITY header information for %[1]s/hello_1.0_all.snap.verity" + echo "UUID: 8f6dcdd2-9426-49d8-9879-a5c87fc78c15" + echo "Hash type: 1" + echo "Data blocks: 1" + echo "Data block size: 4096" + echo "Hash block size: 4096" + echo "Hash algorithm: sha256" + echo "Salt: 06d01a87b298b6855b6a3a1b32450deba4550417cbec2bb21a38d6dda24a1b53" + echo "Root hash: 306398e250a950ea1cbfceda608ee4585f053323251b08b7ed3f004740e91ba5" + ;; +esac +`, snapDir)) + defer vscmd.Restore() + + _, err := snaprun.Parser(snaprun.Client()).ParseArgs([]string{"pack", "--append-integrity-data", snapDir, snapDir}) + c.Assert(err, check.IsNil) + + snapOriginal := path.Join(snapDir, "hello_1.0_all.snap") + snapVerity := snapOriginal + ".verity" + c.Assert(vscmd.Calls(), check.HasLen, 2) + c.Check(vscmd.Calls()[0], check.DeepEquals, []string{"veritysetup", "--version"}) + c.Check(vscmd.Calls()[1], check.DeepEquals, []string{"veritysetup", "format", snapOriginal, snapVerity}) + + matches, err := filepath.Glob(snapDir + "/hello*.snap") + c.Assert(err, check.IsNil) + c.Assert(matches, check.HasLen, 1) + err = os.Remove(matches[0]) + c.Assert(err, check.IsNil) +} + +func (s *SnapSuite) TestPackComponentHappy(c *check.C) { + const compYaml = `component: snap+comp +version: 12a +type: test +` + _, r := logger.MockLogger() + defer r() + + snapDir := makeComponentDirForPack(c, compYaml) + + // check-skeleton does not fail due to missing files + _, err := snaprun.Parser(snaprun.Client()).ParseArgs([]string{"pack", snapDir}) + c.Assert(err, check.IsNil) + err = os.Remove("snap+comp_12a.comp") + c.Assert(err, check.IsNil) +} + +func (s *SnapSuite) TestPackComponentBadName(c *check.C) { + const compYaml = `component: snapcomp +version: 12a +type: test +` + _, r := logger.MockLogger() + defer r() + + snapDir := makeComponentDirForPack(c, compYaml) + + // check-skeleton does not fail due to missing files + _, err := snaprun.Parser(snaprun.Client()).ParseArgs([]string{"pack", snapDir}) + c.Assert(err, check.ErrorMatches, `.*: cannot parse component.yaml: incorrect component name "snapcomp"`) +} diff --git a/cmd/snap/cmd_paths.go b/cmd/snap/cmd_paths.go new file mode 100644 index 00000000..9578bd0a --- /dev/null +++ b/cmd/snap/cmd_paths.go @@ -0,0 +1,62 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2018 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "fmt" + + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/i18n" +) + +var pathsHelp = i18n.G("Print system paths") +var longPathsHelp = i18n.G(` +The paths command prints the list of paths detected and used by snapd. +`) + +type cmdPaths struct{} + +func init() { + addDebugCommand("paths", pathsHelp, longPathsHelp, func() flags.Commander { + return &cmdPaths{} + }, nil, nil) +} + +func (cmd cmdPaths) Execute(args []string) error { + if len(args) > 0 { + return ErrExtraArgs + } + + // TODO: include paths reported by snap-confine + for _, p := range []struct { + name string + path string + }{ + {"SNAPD_MOUNT", dirs.SnapMountDir}, + {"SNAPD_BIN", dirs.SnapBinariesDir}, + {"SNAPD_LIBEXEC", dirs.DistroLibExecDir}, + } { + fmt.Fprintf(Stdout, "%s=%s\n", p.name, p.path) + } + + return nil +} diff --git a/cmd/snap/cmd_paths_test.go b/cmd/snap/cmd_paths_test.go new file mode 100644 index 00000000..0eb9c430 --- /dev/null +++ b/cmd/snap/cmd_paths_test.go @@ -0,0 +1,90 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2018 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + . "gopkg.in/check.v1" + + snap "github.com/snapcore/snapd/cmd/snap" + "github.com/snapcore/snapd/dirs" + "github.com/snapcore/snapd/release" +) + +func (s *SnapSuite) TestPathsUbuntu(c *C) { + restore := release.MockReleaseInfo(&release.OS{ID: "ubuntu"}) + defer restore() + defer dirs.SetRootDir("/") + + dirs.SetRootDir("/") + _, err := snap.Parser(snap.Client()).ParseArgs([]string{"debug", "paths"}) + c.Assert(err, IsNil) + c.Assert(s.Stdout(), Equals, ""+ + "SNAPD_MOUNT=/snap\n"+ + "SNAPD_BIN=/snap/bin\n"+ + "SNAPD_LIBEXEC=/usr/lib/snapd\n") + c.Assert(s.Stderr(), Equals, "") +} + +func (s *SnapSuite) TestPathsFedora(c *C) { + restore := release.MockReleaseInfo(&release.OS{ID: "fedora"}) + defer restore() + defer dirs.SetRootDir("/") + + dirs.SetRootDir("/") + _, err := snap.Parser(snap.Client()).ParseArgs([]string{"debug", "paths"}) + c.Assert(err, IsNil) + c.Assert(s.Stdout(), Equals, ""+ + "SNAPD_MOUNT=/var/lib/snapd/snap\n"+ + "SNAPD_BIN=/var/lib/snapd/snap/bin\n"+ + "SNAPD_LIBEXEC=/usr/libexec/snapd\n") + c.Assert(s.Stderr(), Equals, "") +} + +func (s *SnapSuite) TestPathsArch(c *C) { + defer dirs.SetRootDir("/") + + // old /etc/os-release contents + restore := release.MockReleaseInfo(&release.OS{ID: "arch", IDLike: []string{"archlinux"}}) + defer restore() + + dirs.SetRootDir("/") + _, err := snap.Parser(snap.Client()).ParseArgs([]string{"debug", "paths"}) + c.Assert(err, IsNil) + c.Assert(s.Stdout(), Equals, ""+ + "SNAPD_MOUNT=/var/lib/snapd/snap\n"+ + "SNAPD_BIN=/var/lib/snapd/snap/bin\n"+ + "SNAPD_LIBEXEC=/usr/lib/snapd\n") + c.Assert(s.Stderr(), Equals, "") + + s.ResetStdStreams() + + // new contents, as set by filesystem-2018.12-1 + restore = release.MockReleaseInfo(&release.OS{ID: "archlinux"}) + defer restore() + + dirs.SetRootDir("/") + _, err = snap.Parser(snap.Client()).ParseArgs([]string{"debug", "paths"}) + c.Assert(err, IsNil) + c.Assert(s.Stdout(), Equals, ""+ + "SNAPD_MOUNT=/var/lib/snapd/snap\n"+ + "SNAPD_BIN=/var/lib/snapd/snap/bin\n"+ + "SNAPD_LIBEXEC=/usr/lib/snapd\n") + c.Assert(s.Stderr(), Equals, "") +} diff --git a/cmd/snap/cmd_prefer.go b/cmd/snap/cmd_prefer.go new file mode 100644 index 00000000..1032cd39 --- /dev/null +++ b/cmd/snap/cmd_prefer.go @@ -0,0 +1,68 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/i18n" +) + +type cmdPrefer struct { + waitMixin + Positionals struct { + Snap installedSnapName `required:"yes"` + } `positional-args:"true"` +} + +var shortPreferHelp = i18n.G("Enable aliases from a snap, disabling any conflicting aliases") +var longPreferHelp = i18n.G(` +The prefer command enables all aliases of the given snap in preference +to conflicting aliases of other snaps whose aliases will be disabled +(or removed, for manual ones). +`) + +func init() { + addCommand("prefer", shortPreferHelp, longPreferHelp, func() flags.Commander { + return &cmdPrefer{} + }, waitDescs, []argDesc{ + {name: ""}, + }) +} + +func (x *cmdPrefer) Execute(args []string) error { + if len(args) > 0 { + return ErrExtraArgs + } + + id, err := x.client.Prefer(string(x.Positionals.Snap)) + if err != nil { + return err + } + chg, err := x.wait(id) + if err != nil { + if err == noWait { + return nil + } + return err + } + + return showAliasChanges(chg) +} diff --git a/cmd/snap/cmd_prefer_test.go b/cmd/snap/cmd_prefer_test.go new file mode 100644 index 00000000..5091609b --- /dev/null +++ b/cmd/snap/cmd_prefer_test.go @@ -0,0 +1,72 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2016 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "fmt" + "net/http" + + . "gopkg.in/check.v1" + + . "github.com/snapcore/snapd/cmd/snap" +) + +func (s *SnapSuite) TestPreferHelp(c *C) { + msg := `Usage: + snap.test prefer [prefer-OPTIONS] + +The prefer command enables all aliases of the given snap in preference +to conflicting aliases of other snaps whose aliases will be disabled +(or removed, for manual ones). + +[prefer command options] + --no-wait Do not wait for the operation to finish but just print the + change id. +` + s.testSubCommandHelp(c, "prefer", msg) +} + +func (s *SnapSuite) TestPrefer(c *C) { + s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v2/aliases": + c.Check(r.Method, Equals, "POST") + c.Check(DecodedRequestBody(c, r), DeepEquals, map[string]interface{}{ + "action": "prefer", + "snap": "some-snap", + }) + w.WriteHeader(202) + fmt.Fprintln(w, `{"type":"async", "status-code": 202, "change": "zzz"}`) + case "/v2/changes/zzz": + c.Check(r.Method, Equals, "GET") + fmt.Fprintln(w, `{"type":"sync", "result":{"ready": true, "status": "Done", "data": {"aliases-added": [{"alias": "alias1", "snap": "some-snap", "app": "cmd1"}]}}}`) + default: + c.Fatalf("unexpected path %q", r.URL.Path) + } + }) + rest, err := Parser(Client()).ParseArgs([]string{"prefer", "some-snap"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + c.Assert(s.Stdout(), Equals, ""+ + "Added:\n"+ + " - some-snap.cmd1 as alias1\n", + ) + c.Assert(s.Stderr(), Equals, "") +} diff --git a/cmd/snap/cmd_prepare_image.go b/cmd/snap/cmd_prepare_image.go new file mode 100644 index 00000000..6ad2e39e --- /dev/null +++ b/cmd/snap/cmd_prepare_image.go @@ -0,0 +1,201 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2014-2023 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/i18n" + "github.com/snapcore/snapd/image" + "github.com/snapcore/snapd/interfaces/builtin" + "github.com/snapcore/snapd/seed/seedwriter" + "github.com/snapcore/snapd/snap" +) + +type cmdPrepareImage struct { + Classic bool `long:"classic"` + Preseed bool `long:"preseed"` + PreseedSignKey string `long:"preseed-sign-key"` + // optional path to AppArmor kernel features directory + AppArmorKernelFeaturesDir string `long:"apparmor-features-dir"` + // optional sysfs overlay + SysfsOverlay string `long:"sysfs-overlay"` + Architecture string `long:"arch"` + + Positional struct { + ModelAssertionFn string + TargetDir string + } `positional-args:"yes" required:"yes"` + + Channel string `long:"channel"` + + Customize string `long:"customize" hidden:"yes"` + + // TODO: introduce SnapWithChannel? + Snaps []string `long:"snap" value-name:"[=]"` + ExtraSnaps []string `long:"extra-snaps" hidden:"yes"` // DEPRECATED + RevisionsFile string `long:"revisions"` + WriteRevisionsFile string `long:"write-revisions" optional:"true" optional-value:"./seed.manifest"` +} + +func init() { + addCommand("prepare-image", + i18n.G("Prepare a device image"), + i18n.G(` +The prepare-image command performs some of the steps necessary for +creating device images. + +For core images it is not invoked directly but usually via +ubuntu-image. + +For preparing classic images it supports a --classic mode`), + func() flags.Commander { return &cmdPrepareImage{} }, + map[string]string{ + // TRANSLATORS: This should not start with a lowercase letter. + "classic": i18n.G("Enable classic mode to prepare a classic model image"), + // TRANSLATORS: This should not start with a lowercase letter. + "preseed": i18n.G("Preseed (UC20+ only)"), + // TRANSLATORS: This should not start with a lowercase letter. + "preseed-sign-key": i18n.G("Name of the key to use to sign preseed assertion, otherwise use the default key"), + // TRANSLATORS: This should not start with a lowercase letter. + "sysfs-overlay": i18n.G("Optional sysfs overlay to be used when running preseeding steps"), + // TRANSLATORS: This should not start with a lowercase letter. + "apparmor-features-dir": i18n.G("Optional path to apparmor kernel features directory (UC20+ only)"), + // TRANSLATORS: This should not start with a lowercase letter. + "arch": i18n.G("Specify an architecture for snaps for --classic when the model does not"), + // TRANSLATORS: This should not start with a lowercase letter. + "snap": i18n.G("Include the given snap from the store or a local file and/or specify the channel to track for the given snap"), + // TRANSLATORS: This should not start with a lowercase letter. + "extra-snaps": i18n.G("Extra snaps to be installed (DEPRECATED)"), + // TRANSLATORS: This should not start with a lowercase letter. + "revisions": i18n.G("Specify a seeds.manifest file referencing the exact revisions of the provided snaps which should be installed"), + // TRANSLATORS: This should not start with a lowercase letter. + "write-revisions": i18n.G("Writes a manifest file containing references to the exact snap revisions used for the image. A path for the manifest is optional."), + // TRANSLATORS: This should not start with a lowercase letter. + "channel": i18n.G("The channel to use"), + // TRANSLATORS: This should not start with a lowercase letter. + "customize": i18n.G("Image customizations specified as JSON file."), + }, []argDesc{ + { + // TRANSLATORS: This needs to begin with < and end with > + name: i18n.G(""), + // TRANSLATORS: This should not start with a lowercase letter. + desc: i18n.G("The model assertion name"), + }, { + // TRANSLATORS: This needs to begin with < and end with > + name: i18n.G(""), + // TRANSLATORS: This should not start with a lowercase letter. + desc: i18n.G("The target directory"), + }, + }) +} + +var imagePrepare = image.Prepare +var seedwriterReadManifest = seedwriter.ReadManifest + +func (x *cmdPrepareImage) Execute(args []string) error { + // plug/slot sanitization is disabled (no-op) by default at the package + // level for "snap" command, for seed/seedwriter used by image however + // we want real validation. + snap.SanitizePlugsSlots = builtin.SanitizePlugsSlots + + opts := &image.Options{ + Snaps: x.ExtraSnaps, + ModelFile: x.Positional.ModelAssertionFn, + Channel: x.Channel, + Architecture: x.Architecture, + SeedManifestPath: x.WriteRevisionsFile, + } + + if x.RevisionsFile != "" { + seedManifest, err := seedwriterReadManifest(x.RevisionsFile) + if err != nil { + return err + } + opts.SeedManifest = seedManifest + } + + if x.Customize != "" { + custo, err := readImageCustomizations(x.Customize) + if err != nil { + return err + } + opts.Customizations = *custo + } + + snaps := make([]string, 0, len(x.Snaps)+len(x.ExtraSnaps)) + snapChannels := make(map[string]string) + for _, snapWChannel := range x.Snaps { + snapAndChannel := strings.SplitN(snapWChannel, "=", 2) + snaps = append(snaps, snapAndChannel[0]) + if len(snapAndChannel) == 2 { + snapChannels[snapAndChannel[0]] = snapAndChannel[1] + } + } + + snaps = append(snaps, x.ExtraSnaps...) + + if len(snaps) != 0 { + opts.Snaps = snaps + } + if len(snapChannels) != 0 { + opts.SnapChannels = snapChannels + } + + // store-wide cohort key via env, see image/options.go + opts.WideCohortKey = os.Getenv("UBUNTU_STORE_COHORT_KEY") + + opts.PrepareDir = x.Positional.TargetDir + opts.Classic = x.Classic + + if x.PreseedSignKey != "" && !x.Preseed { + return fmt.Errorf("--preseed-sign-key cannot be used without --preseed") + } + + if x.SysfsOverlay != "" && !x.Preseed { + return fmt.Errorf("--sysfs-overlay cannot be used without --preseed") + } + + opts.Preseed = x.Preseed + opts.PreseedSignKey = x.PreseedSignKey + opts.AppArmorKernelFeaturesDir = x.AppArmorKernelFeaturesDir + opts.SysfsOverlay = x.SysfsOverlay + + return imagePrepare(opts) +} + +func readImageCustomizations(customizationsFile string) (*image.Customizations, error) { + f, err := os.Open(customizationsFile) + if err != nil { + return nil, fmt.Errorf("cannot read image customizations: %v", err) + } + defer f.Close() + dec := json.NewDecoder(f) + var custo image.Customizations + if err := dec.Decode(&custo); err != nil { + return nil, fmt.Errorf("cannot parse customizations %q: %v", customizationsFile, err) + } + return &custo, nil +} diff --git a/cmd/snap/cmd_prepare_image_test.go b/cmd/snap/cmd_prepare_image_test.go new file mode 100644 index 00000000..7bbf0d6d --- /dev/null +++ b/cmd/snap/cmd_prepare_image_test.go @@ -0,0 +1,263 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2021 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "os" + "path/filepath" + + . "gopkg.in/check.v1" + + cmdsnap "github.com/snapcore/snapd/cmd/snap" + "github.com/snapcore/snapd/image" + "github.com/snapcore/snapd/seed/seedwriter" + "github.com/snapcore/snapd/snap" +) + +type SnapPrepareImageSuite struct { + BaseSnapSuite +} + +var _ = Suite(&SnapPrepareImageSuite{}) + +func (s *SnapPrepareImageSuite) TestPrepareImageCore(c *C) { + var opts *image.Options + prep := func(o *image.Options) error { + opts = o + return nil + } + r := cmdsnap.MockImagePrepare(prep) + defer r() + + rest, err := cmdsnap.Parser(cmdsnap.Client()).ParseArgs([]string{"prepare-image", "model", "prepare-dir"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + + c.Check(opts, DeepEquals, &image.Options{ + ModelFile: "model", + PrepareDir: "prepare-dir", + }) +} + +func (s *SnapPrepareImageSuite) TestPrepareImageClassic(c *C) { + var opts *image.Options + prep := func(o *image.Options) error { + opts = o + return nil + } + r := cmdsnap.MockImagePrepare(prep) + defer r() + + rest, err := cmdsnap.Parser(cmdsnap.Client()).ParseArgs([]string{"prepare-image", "--classic", "model", "prepare-dir"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + + c.Check(opts, DeepEquals, &image.Options{ + Classic: true, + ModelFile: "model", + PrepareDir: "prepare-dir", + }) +} + +func (s *SnapPrepareImageSuite) TestPrepareImageClassicArch(c *C) { + var opts *image.Options + prep := func(o *image.Options) error { + opts = o + return nil + } + r := cmdsnap.MockImagePrepare(prep) + defer r() + + rest, err := cmdsnap.Parser(cmdsnap.Client()).ParseArgs([]string{"prepare-image", "--classic", "--arch", "i386", "model", "prepare-dir"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + + c.Check(opts, DeepEquals, &image.Options{ + Classic: true, + Architecture: "i386", + ModelFile: "model", + PrepareDir: "prepare-dir", + }) +} + +func (s *SnapPrepareImageSuite) TestPrepareImageClassicWideCohort(c *C) { + var opts *image.Options + prep := func(o *image.Options) error { + opts = o + return nil + } + r := cmdsnap.MockImagePrepare(prep) + defer r() + + os.Setenv("UBUNTU_STORE_COHORT_KEY", "is-six-centuries") + + rest, err := cmdsnap.Parser(cmdsnap.Client()).ParseArgs([]string{"prepare-image", "--classic", "model", "prepare-dir"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + + c.Check(opts, DeepEquals, &image.Options{ + Classic: true, + WideCohortKey: "is-six-centuries", + ModelFile: "model", + PrepareDir: "prepare-dir", + }) + + os.Unsetenv("UBUNTU_STORE_COHORT_KEY") +} + +func (s *SnapPrepareImageSuite) TestPrepareImageExtraSnaps(c *C) { + var opts *image.Options + prep := func(o *image.Options) error { + opts = o + return nil + } + r := cmdsnap.MockImagePrepare(prep) + defer r() + + rest, err := cmdsnap.Parser(cmdsnap.Client()).ParseArgs([]string{"prepare-image", "model", "prepare-dir", "--channel", "candidate", "--snap", "foo", "--snap", "bar=t/edge", "--snap", "local.snap", "--extra-snaps", "local2.snap", "--extra-snaps", "store-snap"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + + c.Check(opts, DeepEquals, &image.Options{ + ModelFile: "model", + Channel: "candidate", + PrepareDir: "prepare-dir", + Snaps: []string{"foo", "bar", "local.snap", "local2.snap", "store-snap"}, + SnapChannels: map[string]string{"bar": "t/edge"}, + }) +} + +func (s *SnapPrepareImageSuite) TestPrepareImageCustomize(c *C) { + var opts *image.Options + prep := func(o *image.Options) error { + opts = o + return nil + } + r := cmdsnap.MockImagePrepare(prep) + defer r() + + tmpdir := c.MkDir() + customizeFile := filepath.Join(tmpdir, "custo.json") + err := os.WriteFile(customizeFile, []byte(`{ + "console-conf": "disabled", + "cloud-init-user-data": "cloud-init-user-data" +}`), 0644) + c.Assert(err, IsNil) + + rest, err := cmdsnap.Parser(cmdsnap.Client()).ParseArgs([]string{"prepare-image", "model", "prepare-dir", "--customize", customizeFile}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + + c.Check(opts, DeepEquals, &image.Options{ + ModelFile: "model", + PrepareDir: "prepare-dir", + Customizations: image.Customizations{ + ConsoleConf: "disabled", + CloudInitUserData: "cloud-init-user-data", + }, + }) +} + +func (s *SnapPrepareImageSuite) TestReadSeedManifest(c *C) { + var opts *image.Options + prep := func(o *image.Options) error { + opts = o + return nil + } + r := cmdsnap.MockImagePrepare(prep) + defer r() + + var readManifestCalls int + r = cmdsnap.MockSeedWriterReadManifest(func(manifestFile string) (*seedwriter.Manifest, error) { + readManifestCalls++ + c.Check(manifestFile, Equals, "seed.manifest") + return seedwriter.MockManifest(map[string]*seedwriter.ManifestSnapRevision{"snapd": {SnapName: "snapd", Revision: snap.R(100)}}, nil, nil, nil), nil + }) + defer r() + + rest, err := cmdsnap.Parser(cmdsnap.Client()).ParseArgs([]string{"prepare-image", "model", "prepare-dir", "--revisions", "seed.manifest"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + + c.Check(readManifestCalls, Equals, 1) + c.Check(opts, DeepEquals, &image.Options{ + ModelFile: "model", + PrepareDir: "prepare-dir", + SeedManifest: seedwriter.MockManifest(map[string]*seedwriter.ManifestSnapRevision{"snapd": {SnapName: "snapd", Revision: snap.R(100)}}, nil, nil, nil), + }) +} + +func (s *SnapPrepareImageSuite) TestPrepareImagePreseedArgError(c *C) { + _, err := cmdsnap.Parser(cmdsnap.Client()).ParseArgs([]string{"prepare-image", "--preseed-sign-key", "key", "model", "prepare-dir"}) + c.Assert(err, ErrorMatches, `--preseed-sign-key cannot be used without --preseed`) +} + +func (s *SnapPrepareImageSuite) TestPrepareImagePreseed(c *C) { + var opts *image.Options + prep := func(o *image.Options) error { + opts = o + return nil + } + r := cmdsnap.MockImagePrepare(prep) + defer r() + + rest, err := cmdsnap.Parser(cmdsnap.Client()).ParseArgs([]string{"prepare-image", "--preseed", "--preseed-sign-key", "key", "--apparmor-features-dir", "aafeatures-dir", "--sysfs-overlay", "sys-overlay", "model", "prepare-dir"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + + c.Check(opts, DeepEquals, &image.Options{ + ModelFile: "model", + PrepareDir: "prepare-dir", + Preseed: true, + PreseedSignKey: "key", + SysfsOverlay: "sys-overlay", + AppArmorKernelFeaturesDir: "aafeatures-dir", + }) +} + +func (s *SnapPrepareImageSuite) TestPrepareImageWriteRevisions(c *C) { + var opts *image.Options + prep := func(o *image.Options) error { + opts = o + return nil + } + r := cmdsnap.MockImagePrepare(prep) + defer r() + + rest, err := cmdsnap.Parser(cmdsnap.Client()).ParseArgs([]string{"prepare-image", "model", "prepare-dir", "--write-revisions"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + + c.Check(opts, DeepEquals, &image.Options{ + ModelFile: "model", + PrepareDir: "prepare-dir", + SeedManifestPath: "./seed.manifest", + }) + + rest, err = cmdsnap.Parser(cmdsnap.Client()).ParseArgs([]string{"prepare-image", "model", "prepare-dir", "--write-revisions=/tmp/seed.manifest"}) + c.Assert(err, IsNil) + c.Assert(rest, DeepEquals, []string{}) + + c.Check(opts, DeepEquals, &image.Options{ + ModelFile: "model", + PrepareDir: "prepare-dir", + SeedManifestPath: "/tmp/seed.manifest", + }) +} diff --git a/cmd/snap/cmd_quota.go b/cmd/snap/cmd_quota.go new file mode 100644 index 00000000..6d7051af --- /dev/null +++ b/cmd/snap/cmd_quota.go @@ -0,0 +1,640 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2021 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "fmt" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/client" + "github.com/snapcore/snapd/gadget/quantity" + "github.com/snapcore/snapd/i18n" + "github.com/snapcore/snapd/strutil" +) + +var shortQuotaHelp = i18n.G("Show quota group for a set of snaps") +var longQuotaHelp = i18n.G(` +The quota command shows information about a quota group, including the set of +snaps and any sub-groups it contains, as well as its resource constraints and +the current usage of those constrained resources. +`) + +var shortQuotasHelp = i18n.G("Show quota groups") +var longQuotasHelp = i18n.G(` +The quotas command shows all quota groups. +`) + +var shortRemoveQuotaHelp = i18n.G("Remove quota group") +var longRemoveQuotaHelp = i18n.G(` +The remove-quota command removes the given quota group. + +Currently, only quota groups with no sub-groups can be removed. In order to +remove a quota group with sub-groups, the sub-groups must first be removed until +there are no sub-groups for the group, then the group itself can be removed. +`) + +var shortSetQuotaHelp = i18n.G(`Create or update a quota group.`) +var longSetQuotaHelp = i18n.G(` +The set-quota command updates or creates a quota group with the specified set of +snaps. + +A quota group sets resource limits on the set of snaps or snap services it contains. +Snaps can be at most in one quota group but quota groups can be nested. Nested quota +groups are subject to the restriction that the total sum of each existing quota +in sub-groups cannot exceed that of the parent group the nested groups are part of. + +All provided snaps are appended to the group; to remove a snap from a +quota group, the entire group must be removed with remove-quota and recreated +without the snap. To remove a sub-group from the quota group, the +sub-group must be removed directly with the remove-quota command. + +To set limits on individual services, one or more services can be placed into a +sub-group. The respective snap for each service must belong to the sub-group's +parent group. These sub-groups will have the same limitations as nested groups +which means their combined resource usage cannot exceed the resource limits set +for the parent group. Sub-groups which contain services cannot have their own +journal quotas set, and instead automatically inherit any journal quota their +parent quota group may have. + +The memory limit for a quota group can be increased but not decreased. To +decrease the memory limit for a quota group, the entire group must be removed +with the remove-quota command and recreated with a lower limit. Increasing the +memory limit for a quota group does not restart any services associated with +snaps in the quota group. + +The CPU limit for a quota group can be both increased and decreased after being +set on a quota group. The CPU limit can be specified as a single percentage which +means that the quota group is allowed an overall percentage of the CPU resources. Setting +it to 50% means that the quota group is allowed to use up to 50% of all CPU cores +in the allowed CPU set. Setting the percentage to 2x100% means that the quota group +is allowed up to 100% on two cpu cores. + +The CPU set limit for a quota group can be modified to include new cpus, or to remove +existing cpus from the quota already set. + +The threads limit for a quota group can be increased but not decreased. To +decrease the threads limit for a quota group, the entire group must be removed +with the remove-quota command and recreated with a lower limit. + +The journal limits can be increased and decreased after being set on a group. +Setting a journal limit will cause the snaps in the group to be put into the same +journal namespace. This will affect the behaviour of the log command. + +New quotas can be set on existing quota groups, but existing quotas cannot be removed +from a quota group, without removing and recreating the entire group. + +Adding new snaps to a quota group will result in all non-disabled services in +that snap being restarted. + +An existing sub group cannot be moved from one parent to another. +`) + +func init() { + addCommand("set-quota", shortSetQuotaHelp, longSetQuotaHelp, + func() flags.Commander { return &cmdSetQuota{} }, + waitDescs.also(map[string]string{ + "memory": i18n.G("Memory quota"), + "cpu": i18n.G("CPU quota"), + "cpu-set": i18n.G("CPU set quota"), + "threads": i18n.G("Threads quota"), + "journal-size": i18n.G("Journal size quota"), + "journal-rate-limit": i18n.G("Journal rate limit as /"), + "parent": i18n.G("Parent quota group"), + }), nil) + addCommand("quota", shortQuotaHelp, longQuotaHelp, func() flags.Commander { return &cmdQuota{} }, nil, nil) + addCommand("quotas", shortQuotasHelp, longQuotasHelp, func() flags.Commander { return &cmdQuotas{} }, nil, nil) + addCommand("remove-quota", shortRemoveQuotaHelp, longRemoveQuotaHelp, func() flags.Commander { return &cmdRemoveQuota{} }, nil, nil) +} + +type cmdSetQuota struct { + waitMixin + + MemoryMax string `long:"memory" optional:"true"` + CPUMax string `long:"cpu" optional:"true"` + CPUSet string `long:"cpu-set" optional:"true"` + ThreadsMax string `long:"threads" optional:"true"` + JournalSizeMax string `long:"journal-size" optional:"true"` + JournalRateLimit string `long:"journal-rate-limit" optional:"true"` + Parent string `long:"parent" optional:"true"` + Positional struct { + GroupName string `positional-arg-name:"" required:"true"` + Snaps []serviceName `positional-arg-name:"" optional:"true"` + } `positional-args:"yes"` +} + +// example cpu quota string: "2x50%", "90%" +var cpuValueMatcher = regexp.MustCompile(`([0-9]+x)?([0-9]+)%`) + +func parseCpuQuota(cpuMax string) (count int, percentage int, err error) { + parseError := func(input string) error { + return fmt.Errorf("cannot parse cpu quota string %q", input) + } + + match := cpuValueMatcher.FindStringSubmatch(cpuMax) + if match == nil { + return 0, 0, parseError(cpuMax) + } + + // Detect whether format was NxM% or M% + if len(match[1]) > 0 { + // Assume format was NxM% + count, err = strconv.Atoi(match[1][:len(match[1])-1]) + if err != nil || count == 0 { + return 0, 0, parseError(cpuMax) + } + } + + percentage, err = strconv.Atoi(match[2]) + if err != nil || percentage == 0 { + return 0, 0, parseError(cpuMax) + } + return count, percentage, nil +} + +func parseJournalRateQuota(journalRateLimit string) (count int, period time.Duration, err error) { + // the rate limit is a string of the form N/P, where N is the number of + // messages and P is the period as a time string (e.g 5s) + parts := strings.Split(journalRateLimit, "/") + if len(parts) != 2 { + return 0, 0, fmt.Errorf("rate limit must be of the form /") + } + + count, err = strconv.Atoi(parts[0]) + if err != nil { + return 0, 0, fmt.Errorf("cannot parse message count: %v", err) + } + + period, err = time.ParseDuration(parts[1]) + if err != nil { + return 0, 0, fmt.Errorf("cannot parse period: %v", err) + } + return count, period, nil +} + +func (x *cmdSetQuota) parseQuotas() (*client.QuotaValues, error) { + var quotaValues client.QuotaValues + + if x.MemoryMax != "" { + value, err := strutil.ParseByteSize(x.MemoryMax) + if err != nil { + return nil, err + } + quotaValues.Memory = quantity.Size(value) + } + + if x.CPUMax != "" { + countValue, percentageValue, err := parseCpuQuota(x.CPUMax) + if err != nil { + return nil, err + } + if percentageValue > 100 || percentageValue <= 0 { + return nil, fmt.Errorf("cannot use value %v: cpu quota percentage must be between 1 and 100", percentageValue) + } + + quotaValues.CPU = &client.QuotaCPUValues{ + Count: countValue, + Percentage: percentageValue, + } + } + + if x.CPUSet != "" { + var cpus []int + cpuTokens := strutil.CommaSeparatedList(x.CPUSet) + for _, cpuToken := range cpuTokens { + cpu, err := strconv.ParseUint(cpuToken, 10, 32) + if err != nil { + return nil, fmt.Errorf("cannot parse CPU set value %q", cpuToken) + } + cpus = append(cpus, int(cpu)) + } + + quotaValues.CPUSet = &client.QuotaCPUSetValues{ + CPUs: cpus, + } + } + + if x.ThreadsMax != "" { + value, err := strconv.ParseUint(x.ThreadsMax, 10, 32) + if err != nil { + return nil, fmt.Errorf("cannot use threads value %q", x.ThreadsMax) + } + quotaValues.Threads = int(value) + } + + if x.JournalSizeMax != "" || x.JournalRateLimit != "" { + quotaValues.Journal = &client.QuotaJournalValues{} + if x.JournalSizeMax != "" { + value, err := strutil.ParseByteSize(x.JournalSizeMax) + if err != nil { + return nil, fmt.Errorf("cannot parse journal size %q: %v", x.JournalSizeMax, err) + } + quotaValues.Journal.Size = quantity.Size(value) + } + + if x.JournalRateLimit != "" { + count, period, err := parseJournalRateQuota(x.JournalRateLimit) + if err != nil { + return nil, fmt.Errorf("cannot parse journal rate limit %q: %v", x.JournalRateLimit, err) + } + quotaValues.Journal.QuotaJournalRate = &client.QuotaJournalRate{ + RateCount: count, + RatePeriod: period, + } + } + } + + return "aValues, nil +} + +func (x *cmdSetQuota) hasQuotaSet() bool { + return x.MemoryMax != "" || x.CPUMax != "" || x.CPUSet != "" || + x.ThreadsMax != "" || x.JournalSizeMax != "" || x.JournalRateLimit != "" +} + +func (x *cmdSetQuota) splitSnapsAndServices() (snaps []string, services []string) { + names := serviceNames(x.Positional.Snaps) + for _, name := range names { + if strings.Contains(name, ".") { + services = append(services, name) + } else { + snaps = append(snaps, name) + } + } + return snaps, services +} + +func (x *cmdSetQuota) Execute(args []string) (err error) { + quotaProvided := x.hasQuotaSet() + snaps, services := x.splitSnapsAndServices() + + // figure out if the group exists or not to make error messages more useful + groupExists := false + if _, err = x.client.GetQuotaGroup(x.Positional.GroupName); err == nil { + groupExists = true + } + + var chgID string + + switch { + case !quotaProvided && x.Parent == "" && len(x.Positional.Snaps) == 0: + // no snaps or services were specified, no memory limit was specified, and no parent + // was specified, so just the group name was provided - this is not + // supported since there is nothing to change/create + + if groupExists { + return fmt.Errorf("no options set to change quota group") + } + return fmt.Errorf("cannot create quota group without any limit") + + case !quotaProvided && x.Parent != "" && len(x.Positional.Snaps) == 0: + // this is either trying to create a new group with a parent and forgot + // to specify the limits for the new group, or the user is trying + // to re-parent a group, i.e. move it from the current parent to a + // different one, which is currently unsupported + + if groupExists { + // TODO: or this could be setting the parent to the existing parent, + // which is effectively no change or update but maybe we allow since + // it's a noop? + return fmt.Errorf("cannot move a quota group to a new parent") + } + return fmt.Errorf("cannot create quota group without any limits") + + case quotaProvided: + // we have a limits to set for this group, so specify that along + // with whatever snaps may have been provided and whatever parent may + // have been specified + quotaValues, err := x.parseQuotas() + if err != nil { + return err + } + + // note that the group could currently exist with a parent, and we could + // be specifying x.Parent as "" here - in the future that may mean to + // orphan a sub-group to no longer have a parent, but currently it just + // means leave the group with whatever parent it has, or if it doesn't + // currently exist, create the group without a parent group + chgID, err = x.client.EnsureQuota(x.Positional.GroupName, &client.EnsureQuotaOptions{ + Parent: x.Parent, + Snaps: snaps, + Services: services, + Constraints: quotaValues, + }) + if err != nil { + return err + } + case len(x.Positional.Snaps) != 0: + // there are snaps or services specified for this group but no limits, so the + // group must already exist and we must be adding the specified snaps or services to + // the group + + // TODO: this case may someday also imply overwriting the current set of + // snaps or services with whatever was specified with some option, but we don't + // currently support that, so currently all snaps or services specified here are + // just added to the group + chgID, err = x.client.EnsureQuota(x.Positional.GroupName, &client.EnsureQuotaOptions{ + Parent: x.Parent, + Snaps: snaps, + Services: services, + }) + if err != nil { + return err + } + default: + // should be logically impossible to reach here + panic("impossible set of options") + } + + if _, err := x.wait(chgID); err != nil { + if err == noWait { + return nil + } + return err + } + + return nil +} + +type cmdQuota struct { + clientMixin + + Positional struct { + GroupName string `positional-arg-name:"" required:"true"` + } `positional-args:"yes"` +} + +func (x *cmdQuota) Execute(args []string) (err error) { + if len(args) != 0 { + return fmt.Errorf("too many arguments provided") + } + + group, err := x.client.GetQuotaGroup(x.Positional.GroupName) + if err != nil { + return err + } + + w := tabWriter() + defer w.Flush() + + fmt.Fprintf(w, "name:\t%s\n", group.GroupName) + if group.Parent != "" { + fmt.Fprintf(w, "parent:\t%s\n", group.Parent) + } + + // Constraints should always be non-nil, since a quota group always needs to + // have at least one limit set + if group.Constraints == nil { + return fmt.Errorf("internal error: constraints is missing from daemon response") + } + + fmt.Fprintf(w, "constraints:\n") + + if group.Constraints.Memory != 0 { + val := strings.TrimSpace(fmtSize(int64(group.Constraints.Memory))) + fmt.Fprintf(w, " memory:\t%s\n", val) + } + if group.Constraints.CPU != nil { + fmt.Fprintf(w, " cpu-count:\t%d\n", group.Constraints.CPU.Count) + fmt.Fprintf(w, " cpu-percentage:\t%d\n", group.Constraints.CPU.Percentage) + } + if group.Constraints.CPUSet != nil && len(group.Constraints.CPUSet.CPUs) > 0 { + cpus := strutil.IntsToCommaSeparated(group.Constraints.CPUSet.CPUs) + fmt.Fprintf(w, " cpu-set:\t%s\n", cpus) + } + if group.Constraints.Threads != 0 { + fmt.Fprintf(w, " threads:\t%d\n", group.Constraints.Threads) + } + if group.Constraints.Journal != nil { + if group.Constraints.Journal.Size != 0 { + val := strings.TrimSpace(fmtSize(int64(group.Constraints.Journal.Size))) + fmt.Fprintf(w, " journal-size:\t%s\n", val) + } + if group.Constraints.Journal.QuotaJournalRate != nil { + fmt.Fprintf(w, " journal-rate:\t%d/%s\n", + group.Constraints.Journal.RateCount, + group.Constraints.Journal.RatePeriod) + } + } + + memoryUsage := "0B" + currentThreads := 0 + if group.Current != nil { + memoryUsage = strings.TrimSpace(fmtSize(int64(group.Current.Memory))) + currentThreads = group.Current.Threads + } + + fmt.Fprintf(w, "current:\n") + if group.Constraints.Memory != 0 { + fmt.Fprintf(w, " memory:\t%s\n", memoryUsage) + } + if group.Constraints.Threads != 0 { + fmt.Fprintf(w, " threads:\t%d\n", currentThreads) + } + + if len(group.Subgroups) > 0 { + fmt.Fprint(w, "subgroups:\n") + for _, name := range group.Subgroups { + fmt.Fprintf(w, " - %s\n", name) + } + } + if len(group.Snaps) > 0 { + fmt.Fprint(w, "snaps:\n") + for _, snapName := range group.Snaps { + fmt.Fprintf(w, " - %s\n", snapName) + } + } + if len(group.Services) > 0 { + fmt.Fprint(w, "services:\n") + for _, name := range group.Services { + fmt.Fprintf(w, " - %s\n", name) + } + } + + return nil +} + +type cmdRemoveQuota struct { + waitMixin + + Positional struct { + GroupName string `positional-arg-name:"" required:"true"` + } `positional-args:"yes"` +} + +func (x *cmdRemoveQuota) Execute(args []string) (err error) { + chgID, err := x.client.RemoveQuotaGroup(x.Positional.GroupName) + if err != nil { + return err + } + + if _, err := x.wait(chgID); err != nil { + if err == noWait { + return nil + } + return err + } + + return nil +} + +type cmdQuotas struct { + clientMixin +} + +func (x *cmdQuotas) Execute(args []string) (err error) { + res, err := x.client.Quotas() + if err != nil { + return err + } + if len(res) == 0 { + fmt.Fprintln(Stdout, i18n.G("No quota groups defined.")) + return nil + } + + w := tabWriter() + fmt.Fprintf(w, "Quota\tParent\tConstraints\tCurrent\n") + err = processQuotaGroupsTree(res, func(q *client.QuotaGroupResult) error { + if q.Constraints == nil { + return fmt.Errorf("internal error: constraints is missing from daemon response") + } + + var grpConstraints []string + + // format memory constraint as memory=N + if q.Constraints.Memory != 0 { + grpConstraints = append(grpConstraints, "memory="+strings.TrimSpace(fmtSize(int64(q.Constraints.Memory)))) + } + + // format cpu constraint as cpu=NxM%,cpu-set=x,y,z + if q.Constraints.CPU != nil { + if q.Constraints.CPU.Count != 0 { + grpConstraints = append(grpConstraints, fmt.Sprintf("cpu=%dx%d%%", q.Constraints.CPU.Count, q.Constraints.CPU.Percentage)) + } else { + grpConstraints = append(grpConstraints, fmt.Sprintf("cpu=%d%%", q.Constraints.CPU.Percentage)) + } + } + + if q.Constraints.CPUSet != nil && len(q.Constraints.CPUSet.CPUs) > 0 { + cpus := strutil.IntsToCommaSeparated(q.Constraints.CPUSet.CPUs) + grpConstraints = append(grpConstraints, "cpu-set="+cpus) + } + + // format threads constraint as threads=N + if q.Constraints.Threads != 0 { + grpConstraints = append(grpConstraints, "threads="+strconv.Itoa(q.Constraints.Threads)) + } + + // format journal constraint as journal-size=xMB,journal-rate=x/y + if q.Constraints.Journal != nil { + if q.Constraints.Journal.Size != 0 { + grpConstraints = append(grpConstraints, "journal-size="+strings.TrimSpace(fmtSize(int64(q.Constraints.Journal.Size)))) + } + + if q.Constraints.Journal.QuotaJournalRate != nil { + grpConstraints = append(grpConstraints, + fmt.Sprintf("journal-rate=%d/%s", + q.Constraints.Journal.RateCount, q.Constraints.Journal.RatePeriod)) + } + } + + // format current resource values as memory=N,threads=N + var grpCurrent []string + if q.Current != nil { + if q.Constraints.Memory != 0 && q.Current.Memory != 0 { + grpCurrent = append(grpCurrent, "memory="+strings.TrimSpace(fmtSize(int64(q.Current.Memory)))) + } + if q.Constraints.Threads != 0 && q.Current.Threads != 0 { + grpCurrent = append(grpCurrent, "threads="+fmt.Sprintf("%d", q.Current.Threads)) + } + } + + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", q.GroupName, q.Parent, strings.Join(grpConstraints, ","), strings.Join(grpCurrent, ",")) + + return nil + }) + if err != nil { + return err + } + w.Flush() + return nil +} + +type quotaGroup struct { + res *client.QuotaGroupResult + subGroups []*quotaGroup +} + +type byQuotaName []*quotaGroup + +func (q byQuotaName) Len() int { return len(q) } +func (q byQuotaName) Swap(i, j int) { q[i], q[j] = q[j], q[i] } +func (q byQuotaName) Less(i, j int) bool { return q[i].res.GroupName < q[j].res.GroupName } + +// processQuotaGroupsTree recreates the hierarchy of quotas and then visits it +// recursively following the hierarchy first, then naming order. +func processQuotaGroupsTree(quotas []*client.QuotaGroupResult, handleGroup func(q *client.QuotaGroupResult) error) error { + var roots []*quotaGroup + groupLookup := make(map[string]*quotaGroup, len(quotas)) + + for _, q := range quotas { + grp := "aGroup{res: q} + groupLookup[q.GroupName] = grp + + if q.Parent == "" { + roots = append(roots, grp) + } + } + + sort.Sort(byQuotaName(roots)) + + // populate sub-groups + for _, g := range groupLookup { + sort.Strings(g.res.Subgroups) + for _, subgrpName := range g.res.Subgroups { + subGroup, ok := groupLookup[subgrpName] + if !ok { + return fmt.Errorf("internal error: inconsistent groups received, unknown subgroup %q", subgrpName) + } + g.subGroups = append(g.subGroups, subGroup) + } + } + + var processGroups func(groups []*quotaGroup) error + processGroups = func(groups []*quotaGroup) error { + for _, g := range groups { + if err := handleGroup(g.res); err != nil { + return err + } + if len(g.subGroups) > 0 { + if err := processGroups(g.subGroups); err != nil { + return err + } + } + } + return nil + } + return processGroups(roots) +} diff --git a/cmd/snap/cmd_quota_test.go b/cmd/snap/cmd_quota_test.go new file mode 100644 index 00000000..34703309 --- /dev/null +++ b/cmd/snap/cmd_quota_test.go @@ -0,0 +1,776 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2021 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main_test + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "gopkg.in/check.v1" + + main "github.com/snapcore/snapd/cmd/snap" + "github.com/snapcore/snapd/jsonutil" +) + +type quotaSuite struct { + BaseSnapSuite + quotaGetGroupHandlerCalls int + quotaGetGroupsHandlerCalls int + quotaPostHandlerCalls int +} + +var _ = check.Suite("aSuite{}) + +func (s *quotaSuite) SetUpTest(c *check.C) { + s.BaseSnapSuite.SetUpTest(c) + s.quotaGetGroupHandlerCalls = 0 + s.quotaGetGroupsHandlerCalls = 0 + s.quotaPostHandlerCalls = 0 +} + +func (s *quotaSuite) makeFakeGetQuotaGroupNotFoundHandler(c *check.C, group string) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + s.quotaGetGroupHandlerCalls++ + c.Check(r.URL.Path, check.Equals, "/v2/quotas/"+group) + c.Check(r.Method, check.Equals, "GET") + w.WriteHeader(404) + fmt.Fprintln(w, `{ + "result": { + "message": "not found" + }, + "status": "Not Found", + "status-code": 404, + "type": "error" + }`) + } + +} + +func (s *quotaSuite) makeFakeGetQuotaGroupHandler(c *check.C, body string) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + s.quotaGetGroupHandlerCalls++ + c.Check(r.URL.Path, check.Equals, "/v2/quotas/foo") + c.Check(r.Method, check.Equals, "GET") + w.WriteHeader(200) + fmt.Fprintln(w, body) + } +} + +func (s *quotaSuite) makeFakeGetQuotaGroupsHandler(c *check.C, body string) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + s.quotaGetGroupsHandlerCalls++ + c.Check(r.URL.Path, check.Equals, "/v2/quotas") + c.Check(r.Method, check.Equals, "GET") + w.WriteHeader(200) + fmt.Fprintln(w, body) + } +} + +func dispatchFakeHandlers(c *check.C, routes map[string]http.HandlerFunc) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + if router, ok := routes[r.URL.Path]; ok { + router(w, r) + return + } + c.Errorf("unexpected call to %s", r.URL.Path) + } +} + +type fakeQuotaGroupPostHandlerOpts struct { + action string + body string + groupName string + parentName string + snaps []string + services []string + maxMemory int64 + maxThreads int + cpuCount int + cpuPercentage int + cpuSet []int +} + +type quotasEnsureBodyConstraintsCPU struct { + Count int `json:"count,omitempty"` + Percentage int `json:"percentage,omitempty"` +} + +type quotasEnsureBodyConstraintsCPUSet struct { + CPUs []int `json:"cpus,omitempty"` +} + +type quotasEnsureBodyConstraints struct { + Memory int64 `json:"memory,omitempty"` + Threads int `json:"threads,omitempty"` + CPU quotasEnsureBodyConstraintsCPU `json:"cpu,omitempty"` + CPUSet quotasEnsureBodyConstraintsCPUSet `json:"cpu-set,omitempty"` +} + +type quotasEnsureBody struct { + Action string `json:"action"` + GroupName string `json:"group-name,omitempty"` + ParentName string `json:"parent,omitempty"` + Snaps []string `json:"snaps,omitempty"` + Services []string `json:"services,omitempty"` + Constraints quotasEnsureBodyConstraints `json:"constraints,omitempty"` +} + +func (s *quotaSuite) makeFakeQuotaPostHandler(c *check.C, opts fakeQuotaGroupPostHandlerOpts) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + s.quotaPostHandlerCalls++ + c.Check(r.URL.Path, check.Equals, "/v2/quotas") + c.Check(r.Method, check.Equals, "POST") + + buf, err := io.ReadAll(r.Body) + c.Assert(err, check.IsNil) + + switch opts.action { + case "remove": + c.Check(string(buf), check.Equals, fmt.Sprintf(`{"action":"remove","group-name":%q}`+"\n", opts.groupName)) + case "ensure": + exp := quotasEnsureBody{ + Action: "ensure", + GroupName: opts.groupName, + ParentName: opts.parentName, + Snaps: opts.snaps, + Services: opts.services, + Constraints: quotasEnsureBodyConstraints{}, + } + if opts.maxMemory != 0 { + exp.Constraints.Memory = opts.maxMemory + } + if opts.maxThreads != 0 { + exp.Constraints.Threads = opts.maxThreads + } + if opts.cpuCount != 0 { + exp.Constraints.CPU.Count = opts.cpuCount + } + if opts.cpuPercentage != 0 { + exp.Constraints.CPU.Percentage = opts.cpuPercentage + } + if len(opts.cpuSet) != 0 { + exp.Constraints.CPUSet.CPUs = opts.cpuSet + } + + postJSON := quotasEnsureBody{} + err := jsonutil.DecodeWithNumber(bytes.NewReader(buf), &postJSON) + c.Assert(err, check.IsNil) + c.Assert(postJSON, check.DeepEquals, exp) + default: + c.Fatalf("unexpected action %q", opts.action) + } + w.WriteHeader(202) + fmt.Fprintln(w, opts.body) + } +} + +func makeChangesHandler(c *check.C) func(w http.ResponseWriter, r *http.Request) { + n := 0 + return func(w http.ResponseWriter, r *http.Request) { + n++ + switch n { + case 1: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/changes/42") + fmt.Fprintln(w, `{"type": "sync", "result": {"status": "Doing"}}`) + case 2: + c.Check(r.Method, check.Equals, "GET") + c.Check(r.URL.Path, check.Equals, "/v2/changes/42") + fmt.Fprintln(w, `{"type": "sync", "result": {"ready": true, "status": "Done"}}`) + default: + c.Fatalf("expected to get 2 requests, now on %d", n+1) + } + } +} + +func (s *quotaSuite) TestParseQuotas(c *check.C) { + for _, testData := range []struct { + maxMemory string + cpuMax string + cpuSet string + threadsMax string + journalSizeMax string + journalRateLimit string + + // Use the JSON representation of the quota, as it's easier to handle in the test data + quotas string + err string + }{ + {maxMemory: "12KB", quotas: `{"memory":12000}`}, + {cpuMax: "12x40%", quotas: `{"cpu":{"count":12,"percentage":40}}`}, + {cpuMax: "40%", quotas: `{"cpu":{"percentage":40}}`}, + {cpuSet: "1,3", quotas: `{"cpu-set":{"cpus":[1,3]}}`}, + {threadsMax: "2", quotas: `{"threads":2}`}, + {journalSizeMax: "16MB", quotas: `{"journal":{"size":16000000}}`}, + {journalRateLimit: "10/15s", quotas: `{"journal":{"rate-count":10,"rate-period":15000000000}}`}, + {journalRateLimit: "1500/15ms", quotas: `{"journal":{"rate-count":1500,"rate-period":15000000}}`}, + {journalRateLimit: "1/15us", quotas: `{"journal":{"rate-count":1,"rate-period":15000}}`}, + {journalRateLimit: "0/0s", quotas: `{"journal":{"rate-count":0,"rate-period":0}}`}, + + // Error cases + {cpuMax: "ASD", err: `cannot parse cpu quota string "ASD"`}, + {cpuMax: "0x100%", err: `cannot parse cpu quota string "0x100%"`}, + {cpuMax: "2x0%", err: `cannot parse cpu quota string "2x0%"`}, + {cpuMax: "200", err: `cannot parse cpu quota string "200"`}, + {cpuMax: "20D", err: `cannot parse cpu quota string "20D"`}, + {cpuMax: "2x101%", err: `cannot use value 101: cpu quota percentage must be between 1 and 100`}, + {cpuSet: "x", err: `cannot parse CPU set value "x"`}, + {cpuSet: "1:2", err: `cannot parse CPU set value "1:2"`}, + {cpuSet: "0,-2", err: `cannot parse CPU set value "-2"`}, + {threadsMax: "xxx", err: `cannot use threads value "xxx"`}, + {threadsMax: "-3", err: `cannot use threads value "-3"`}, + {journalRateLimit: "0", err: `cannot parse journal rate limit "0": rate limit must be of the form /`}, + {journalRateLimit: "x/5m", err: `cannot parse journal rate limit "x/5m": cannot parse message count: strconv.Atoi: parsing "x": invalid syntax`}, + {journalRateLimit: "1/wow", err: `cannot parse journal rate limit "1/wow": cannot parse period: time: invalid duration ["]?wow["]?`}, + } { + quotas, err := main.ParseQuotaValues(testData.maxMemory, testData.cpuMax, + testData.cpuSet, testData.threadsMax, testData.journalSizeMax, testData.journalRateLimit) + testLabel := check.Commentf("%v", testData) + if testData.err == "" { + c.Check(err, check.IsNil, testLabel) + var jsonQuota bytes.Buffer + err := json.NewEncoder(&jsonQuota).Encode(quotas) + c.Assert(err, check.IsNil, testLabel) + c.Check(strings.TrimSpace(jsonQuota.String()), check.Equals, testData.quotas, testLabel) + } else { + c.Check(err, check.ErrorMatches, testData.err, testLabel) + } + } +} + +func (s *quotaSuite) TestSetQuotaInvalidArgs(c *check.C) { + const json = `{ + "type": "sync", + "status-code": 200, + "result": {} + }` + s.RedirectClientToTestServer(s.makeFakeGetQuotaGroupHandler(c, json)) + + for _, args := range []struct { + args []string + err string + }{ + {[]string{"set-quota"}, "the required argument `` was not provided"}, + {[]string{"set-quota", "--memory=99B"}, "the required argument `` was not provided"}, + {[]string{"set-quota", "--memory=99", "foo"}, `cannot parse "99": need a number with a unit as input`}, + {[]string{"set-quota", "--memory=888X", "foo"}, `cannot parse "888X\": try 'kB' or 'MB'`}, + {[]string{"set-quota", "--cpu=0", "foo"}, `cannot parse cpu quota string "0"`}, + // remove-quota command + {[]string{"remove-quota"}, "the required argument `` was not provided"}, + } { + s.stdout.Reset() + s.stderr.Reset() + + _, err := main.Parser(main.Client()).ParseArgs(args.args) + c.Check(err, check.ErrorMatches, args.err, check.Commentf("%q", args.args)) + } +} + +func (s *quotaSuite) TestSetQuotaCpuHappy(c *check.C) { + const postJSON = `{"type": "async", "status-code": 202,"change":"42", "result": []}` + fakeHandlerOpts := fakeQuotaGroupPostHandlerOpts{ + action: "ensure", + body: postJSON, + groupName: "foo", + cpuCount: 2, + cpuPercentage: 50, + } + // this data is not tested against, but it should still be valid + const getJson = `{ + "type": "sync", + "status-code": 200, + "result": { + "group-name":"foo", + "constraints": { "cpu":{"count":2, "percentage":50} }, + } + }` + routes := map[string]http.HandlerFunc{ + "/v2/quotas": s.makeFakeQuotaPostHandler( + c, + fakeHandlerOpts, + ), + "/v2/quotas/foo": s.makeFakeGetQuotaGroupHandler(c, getJson), + "/v2/changes/42": makeChangesHandler(c), + } + s.RedirectClientToTestServer(dispatchFakeHandlers(c, routes)) + + // ensure that --cpu still works with cgroup version 1 + _, err := main.Parser(main.Client()).ParseArgs([]string{"set-quota", "--cpu=2x50%", "foo"}) + c.Check(err, check.IsNil) + c.Check(s.quotaGetGroupHandlerCalls, check.Equals, 1) + c.Check(s.quotaPostHandlerCalls, check.Equals, 1) +} + +func (s *quotaSuite) TestSetQuotaSnapServices(c *check.C) { + const postJSON = `{"type": "async", "status-code": 202,"change":"42", "result": []}` + fakeHandlerOpts := fakeQuotaGroupPostHandlerOpts{ + action: "ensure", + body: postJSON, + groupName: "foo", + snaps: []string{"my-snap"}, + services: []string{"snap.svc1", "snap.svc2"}, + cpuCount: 2, + cpuPercentage: 50, + } + // this data is not tested against, but it should still be valid + const getJson = `{ + "type": "sync", + "status-code": 200, + "result": { + "group-name":"foo", + "constraints": { "cpu":{"count":2, "percentage":50} }, + } + }` + routes := map[string]http.HandlerFunc{ + "/v2/quotas": s.makeFakeQuotaPostHandler( + c, + fakeHandlerOpts, + ), + "/v2/quotas/foo": s.makeFakeGetQuotaGroupHandler(c, getJson), + "/v2/changes/42": makeChangesHandler(c), + } + s.RedirectClientToTestServer(dispatchFakeHandlers(c, routes)) + + // ensure we correctly parse the snap.service format and send it to the daemon. + _, err := main.Parser(main.Client()).ParseArgs([]string{"set-quota", "--cpu=2x50%", "foo", "my-snap", "snap.svc1", "snap.svc2"}) + c.Check(err, check.IsNil) + c.Check(s.quotaGetGroupHandlerCalls, check.Equals, 1) + c.Check(s.quotaPostHandlerCalls, check.Equals, 1) +} + +func (s *quotaSuite) TestGetQuotaGroup(c *check.C) { + restore := main.MockIsStdinTTY(true) + defer restore() + + const json = `{ + "type": "sync", + "status-code": 200, + "result": { + "group-name":"foo", + "parent":"bar", + "subgroups":["subgrp1"], + "snaps":["snap-a","snap-b"], + "services":["snap-a.svc1", "snap-b.svc2"], + "constraints": { "memory": 1000 }, + "current": { "memory": 900 } + } + }` + + s.RedirectClientToTestServer(s.makeFakeGetQuotaGroupHandler(c, json)) + + rest, err := main.Parser(main.Client()).ParseArgs([]string{"quota", "foo"}) + c.Assert(err, check.IsNil) + c.Check(rest, check.HasLen, 0) + c.Check(s.Stderr(), check.Equals, "") + c.Check(s.Stdout(), check.Equals, ` +name: foo +parent: bar +constraints: + memory: 1000B +current: + memory: 900B +subgroups: + - subgrp1 +snaps: + - snap-a + - snap-b +services: + - snap-a.svc1 + - snap-b.svc2 +`[1:]) + c.Check(s.quotaGetGroupHandlerCalls, check.Equals, 1) + c.Check(s.quotaPostHandlerCalls, check.Equals, 0) +} + +func (s *quotaSuite) TestGetMemoryQuotaGroupSimple(c *check.C) { + const jsonTemplate = `{ + "type": "sync", + "status-code": 200, + "result": { + "group-name": "foo", + "constraints": {"memory": 1000}, + "current": {"memory": %d} + } + }` + + s.RedirectClientToTestServer(s.makeFakeGetQuotaGroupHandler(c, fmt.Sprintf(jsonTemplate, 0))) + + outputTemplate := ` +name: foo +constraints: + memory: 1000B +current: + memory: %dB +`[1:] + + rest, err := main.Parser(main.Client()).ParseArgs([]string{"quota", "foo"}) + c.Assert(err, check.IsNil) + c.Check(rest, check.HasLen, 0) + c.Check(s.Stderr(), check.Equals, "") + c.Check(s.Stdout(), check.Equals, fmt.Sprintf(outputTemplate, 0)) + c.Check(s.quotaGetGroupHandlerCalls, check.Equals, 1) + c.Check(s.quotaPostHandlerCalls, check.Equals, 0) + + s.stdout.Reset() + s.stderr.Reset() + + s.RedirectClientToTestServer(s.makeFakeGetQuotaGroupHandler(c, fmt.Sprintf(jsonTemplate, 500))) + + rest, err = main.Parser(main.Client()).ParseArgs([]string{"quota", "foo"}) + c.Assert(err, check.IsNil) + c.Check(rest, check.HasLen, 0) + c.Check(s.Stderr(), check.Equals, "") + c.Check(s.Stdout(), check.Equals, fmt.Sprintf(outputTemplate, 500)) + c.Check(s.quotaGetGroupHandlerCalls, check.Equals, 2) +} + +func (s *quotaSuite) TestGetCpuQuotaGroupSimple(c *check.C) { + const jsonTemplate = `{ + "type": "sync", + "status-code": 200, + "result": { + "group-name": "foo", + "constraints": {"cpu":{"count":1,"percentage":50},"cpu-set":{"cpus":[0,1]},"threads":32}, + "current": {"threads": %d} + } + }` + + s.RedirectClientToTestServer(s.makeFakeGetQuotaGroupHandler(c, fmt.Sprintf(jsonTemplate, 16))) + + outputTemplate := ` +name: foo +constraints: + cpu-count: 1 + cpu-percentage: 50 + cpu-set: 0,1 + threads: 32 +current: + threads: %d +`[1:] + + rest, err := main.Parser(main.Client()).ParseArgs([]string{"quota", "foo"}) + c.Assert(err, check.IsNil) + c.Check(rest, check.HasLen, 0) + c.Check(s.Stderr(), check.Equals, "") + c.Check(s.Stdout(), check.Equals, fmt.Sprintf(outputTemplate, 16)) + c.Check(s.quotaGetGroupHandlerCalls, check.Equals, 1) + + s.stdout.Reset() + s.stderr.Reset() + + s.RedirectClientToTestServer(s.makeFakeGetQuotaGroupHandler(c, fmt.Sprintf(jsonTemplate, 500))) + + rest, err = main.Parser(main.Client()).ParseArgs([]string{"quota", "foo"}) + c.Assert(err, check.IsNil) + c.Check(rest, check.HasLen, 0) + c.Check(s.Stderr(), check.Equals, "") + c.Check(s.Stdout(), check.Equals, fmt.Sprintf(outputTemplate, 500)) + c.Check(s.quotaGetGroupHandlerCalls, check.Equals, 2) +} + +func (s *quotaSuite) TestJournalQuotaGroupSimple(c *check.C) { + const jsonTemplate = `{ + "type": "sync", + "status-code": 200, + "result": { + "group-name": "foo", + "constraints": {"journal":{"size":1048576,"rate-count":50,"rate-period":60000000000}} + } + }` + + s.RedirectClientToTestServer(s.makeFakeGetQuotaGroupHandler(c, jsonTemplate)) + + outputTemplate := ` +name: foo +constraints: + journal-size: 1.05MB + journal-rate: 50/1m0s +current: +`[1:] + + rest, err := main.Parser(main.Client()).ParseArgs([]string{"quota", "foo"}) + c.Assert(err, check.IsNil) + c.Check(rest, check.HasLen, 0) + c.Check(s.Stderr(), check.Equals, "") + c.Check(s.Stdout(), check.Equals, outputTemplate) + c.Check(s.quotaGetGroupHandlerCalls, check.Equals, 1) +} + +func (s *quotaSuite) TestSetQuotaGroupCreateNew(c *check.C) { + const postJSON = `{"type": "async", "status-code": 202,"change":"42", "result": []}` + fakeHandlerOpts := fakeQuotaGroupPostHandlerOpts{ + action: "ensure", + body: postJSON, + groupName: "foo", + parentName: "bar", + snaps: []string{"snap-a"}, + maxMemory: 999, + } + + routes := map[string]http.HandlerFunc{ + "/v2/quotas": s.makeFakeQuotaPostHandler( + c, + fakeHandlerOpts, + ), + // the foo quota group is not found since it doesn't exist yet + "/v2/quotas/foo": s.makeFakeGetQuotaGroupNotFoundHandler(c, "foo"), + + "/v2/changes/42": makeChangesHandler(c), + } + + s.RedirectClientToTestServer(dispatchFakeHandlers(c, routes)) + + rest, err := main.Parser(main.Client()).ParseArgs([]string{"set-quota", "foo", "--memory=999B", "--parent=bar", "snap-a"}) + c.Assert(err, check.IsNil) + c.Check(rest, check.HasLen, 0) + c.Check(s.Stderr(), check.Equals, "") + c.Check(s.Stdout(), check.Equals, "") + c.Check(s.quotaGetGroupHandlerCalls, check.Equals, 1) + c.Check(s.quotaPostHandlerCalls, check.Equals, 1) +} + +func (s *quotaSuite) TestSetQuotaGroupUpdateExistingUnhappy(c *check.C) { + const exists = true + s.testSetQuotaGroupUpdateExistingUnhappy(c, "no options set to change quota group", exists) +} + +func (s *quotaSuite) TestSetQuotaGroupCreateNewUnhappy(c *check.C) { + const exists = false + s.testSetQuotaGroupUpdateExistingUnhappy(c, "cannot create quota group without any limit", exists) +} + +func (s *quotaSuite) TestSetQuotaGroupCreateNewUnhappyWithParent(c *check.C) { + const exists = false + s.testSetQuotaGroupUpdateExistingUnhappy(c, "cannot create quota group without any limits", exists, "--parent=bar") +} + +func (s *quotaSuite) TestSetQuotaGroupUpdateExistingUnhappyWithParent(c *check.C) { + const exists = true + s.testSetQuotaGroupUpdateExistingUnhappy(c, "cannot move a quota group to a new parent", exists, "--parent=bar") +} + +func (s *quotaSuite) testSetQuotaGroupUpdateExistingUnhappy(c *check.C, errPattern string, exists bool, args ...string) { + if exists { + // existing group has 1000 memory limit + const getJson = `{ + "type": "sync", + "status-code": 200, + "result": { + "group-name":"foo", + "current": { + "memory": 500 + }, + "constraints": { + "memory": 1000 + } + } + }` + + s.RedirectClientToTestServer(s.makeFakeGetQuotaGroupHandler(c, getJson)) + } else { + s.RedirectClientToTestServer(s.makeFakeGetQuotaGroupNotFoundHandler(c, "foo")) + } + + cmdArgs := append([]string{"set-quota", "foo"}, args...) + _, err := main.Parser(main.Client()).ParseArgs(cmdArgs) + c.Assert(err, check.ErrorMatches, errPattern) + c.Check(s.Stdout(), check.Equals, "") + c.Check(s.quotaGetGroupHandlerCalls, check.Equals, 1) +} + +func (s *quotaSuite) TestSetQuotaGroupUpdateExisting(c *check.C) { + const postJSON = `{"type": "async", "status-code": 202,"change":"42", "result": []}` + fakeHandlerOpts := fakeQuotaGroupPostHandlerOpts{ + action: "ensure", + body: postJSON, + groupName: "foo", + maxMemory: 2000, + } + + const getJsonTemplate = `{ + "type": "sync", + "status-code": 200, + "result": { + "group-name":"foo", + "constraints": { "memory": %d }, + "current": { "memory": 500 } + } + }` + + routes := map[string]http.HandlerFunc{ + "/v2/quotas": s.makeFakeQuotaPostHandler( + c, + fakeHandlerOpts, + ), + "/v2/quotas/foo": s.makeFakeGetQuotaGroupHandler(c, fmt.Sprintf(getJsonTemplate, 1000)), + "/v2/changes/42": makeChangesHandler(c), + } + + s.RedirectClientToTestServer(dispatchFakeHandlers(c, routes)) + + // increase the memory limit to 2000 + rest, err := main.Parser(main.Client()).ParseArgs([]string{"set-quota", "foo", "--memory=2000B"}) + c.Assert(err, check.IsNil) + c.Check(rest, check.HasLen, 0) + c.Check(s.Stderr(), check.Equals, "") + c.Check(s.Stdout(), check.Equals, "") + c.Check(s.quotaGetGroupHandlerCalls, check.Equals, 1) + c.Check(s.quotaPostHandlerCalls, check.Equals, 1) + + s.stdout.Reset() + s.stderr.Reset() + + fakeHandlerOpts2 := fakeQuotaGroupPostHandlerOpts{ + action: "ensure", + body: postJSON, + groupName: "foo", + snaps: []string{"some-snap"}, + } + + routes = map[string]http.HandlerFunc{ + "/v2/quotas": s.makeFakeQuotaPostHandler( + c, + fakeHandlerOpts2, + ), + // the group was updated to have a 2000 memory limit now + "/v2/quotas/foo": s.makeFakeGetQuotaGroupHandler(c, fmt.Sprintf(getJsonTemplate, 2000)), + + "/v2/changes/42": makeChangesHandler(c), + } + + s.RedirectClientToTestServer(dispatchFakeHandlers(c, routes)) + + // add a snap to the group + rest, err = main.Parser(main.Client()).ParseArgs([]string{"set-quota", "foo", "some-snap"}) + c.Assert(err, check.IsNil) + c.Check(rest, check.HasLen, 0) + c.Check(s.Stderr(), check.Equals, "") + c.Check(s.Stdout(), check.Equals, "") + c.Check(s.quotaGetGroupHandlerCalls, check.Equals, 2) + c.Check(s.quotaPostHandlerCalls, check.Equals, 2) +} + +func (s *quotaSuite) TestRemoveQuotaGroup(c *check.C) { + const json = `{"type": "async", "status-code": 202,"change": "42"}` + fakeHandlerOpts := fakeQuotaGroupPostHandlerOpts{ + action: "remove", + body: json, + groupName: "foo", + } + + routes := map[string]http.HandlerFunc{ + "/v2/quotas": s.makeFakeQuotaPostHandler(c, fakeHandlerOpts), + + "/v2/changes/42": makeChangesHandler(c), + } + + s.RedirectClientToTestServer(dispatchFakeHandlers(c, routes)) + + rest, err := main.Parser(main.Client()).ParseArgs([]string{"remove-quota", "foo"}) + c.Assert(err, check.IsNil) + c.Check(rest, check.HasLen, 0) + c.Check(s.Stderr(), check.Equals, "") + c.Check(s.Stdout(), check.Equals, "") + c.Check(s.quotaPostHandlerCalls, check.Equals, 1) +} + +func (s *quotaSuite) TestGetAllQuotaGroups(c *check.C) { + restore := main.MockIsStdinTTY(true) + defer restore() + + s.RedirectClientToTestServer(s.makeFakeGetQuotaGroupsHandler(c, + `{"type": "sync", "status-code": 200, "result": [ + {"group-name":"aaa","subgroups":["ccc","ddd","fff"],"parent":"zzz","constraints":{"memory":1000}}, + {"group-name":"ddd","parent":"aaa","constraints":{"memory":400}}, + {"group-name":"ggg","constraints":{"memory":1000,"threads":100},"current":{"memory":3000}}, + {"group-name":"hhh","constraints":{"threads":100},"current":{"memory":2000}}, + {"group-name":"bbb","parent":"zzz","constraints":{"memory":1000},"current":{"memory":400}}, + {"group-name":"yyyyyyy","constraints":{"memory":1000}}, + {"group-name":"zzz","subgroups":["bbb","aaa"],"constraints":{"memory":5000}}, + {"group-name":"ccc","parent":"aaa","constraints":{"memory":400}}, + {"group-name":"fff","parent":"aaa","constraints":{"memory":1000},"current":{"memory":0}}, + {"group-name":"xxx","constraints":{"memory":9900},"current":{"memory":10000}}, + {"group-name":"cp0","constraints":{"memory":9900, "cpu":{"percentage":90}},"current":{"memory":10000}}, + {"group-name":"cp1","subgroups":["cps0","js0","js1"],"constraints":{"cpu":{"count":2, "percentage":90}}}, + {"group-name":"cps0","parent":"cp1","constraints":{"cpu":{"percentage":40}}}, + {"group-name":"cp2","subgroups":["cps1"],"constraints":{"cpu":{"count":2,"percentage":100},"cpu-set":{"cpus":[0,1]}}}, + {"group-name":"cps1","parent":"cp2","constraints":{"memory":9900,"cpu":{"percentage":50},"cpu-set":{"cpus":[1]}},"current":{"memory":10000}}, + {"group-name":"js0","parent":"cp1","constraints":{"journal":{"size":1048576,"rate-count":50,"rate-period":60000000000}}}, + {"group-name":"js1","parent":"cp1","constraints":{"journal":{"rate-count":0,"rate-period":0}}} + ]}`)) + + rest, err := main.Parser(main.Client()).ParseArgs([]string{"quotas"}) + c.Assert(err, check.IsNil) + c.Check(rest, check.HasLen, 0) + c.Check(s.Stderr(), check.Equals, "") + c.Check(s.Stdout(), check.Equals, ` +Quota Parent Constraints Current +cp0 memory=9.9kB,cpu=90% memory=10.0kB +cp1 cpu=2x90% +cps0 cp1 cpu=40% +js0 cp1 journal-size=1.05MB,journal-rate=50/1m0s +js1 cp1 journal-rate=0/0s +cp2 cpu=2x100%,cpu-set=0,1 +cps1 cp2 memory=9.9kB,cpu=50%,cpu-set=1 memory=10.0kB +ggg memory=1000B,threads=100 memory=3000B +hhh threads=100 +xxx memory=9.9kB memory=10.0kB +yyyyyyy memory=1000B +zzz memory=5000B +aaa zzz memory=1000B +ccc aaa memory=400B +ddd aaa memory=400B +fff aaa memory=1000B +bbb zzz memory=1000B memory=400B +`[1:]) + c.Check(s.quotaGetGroupsHandlerCalls, check.Equals, 1) +} + +func (s *quotaSuite) TestGetAllQuotaGroupsInconsistencyError(c *check.C) { + restore := main.MockIsStdinTTY(true) + defer restore() + + s.RedirectClientToTestServer(s.makeFakeGetQuotaGroupsHandler(c, + `{"type": "sync", "status-code": 200, "result": [ + {"group-name":"aaa","subgroups":["ccc"],"max-memory":1000}]}`)) + + _, err := main.Parser(main.Client()).ParseArgs([]string{"quotas"}) + c.Assert(err, check.ErrorMatches, `internal error: inconsistent groups received, unknown subgroup "ccc"`) + c.Check(s.quotaGetGroupsHandlerCalls, check.Equals, 1) +} + +func (s *quotaSuite) TestNoQuotaGroups(c *check.C) { + restore := main.MockIsStdinTTY(true) + defer restore() + + s.RedirectClientToTestServer(s.makeFakeGetQuotaGroupsHandler(c, + `{"type": "sync", "status-code": 200, "result": []}`)) + + rest, err := main.Parser(main.Client()).ParseArgs([]string{"quotas"}) + c.Assert(err, check.IsNil) + c.Check(rest, check.HasLen, 0) + c.Check(s.Stderr(), check.Equals, "") + c.Check(s.Stdout(), check.Equals, "No quota groups defined.\n") + c.Check(s.quotaGetGroupsHandlerCalls, check.Equals, 1) +} diff --git a/cmd/snap/cmd_reboot.go b/cmd/snap/cmd_reboot.go new file mode 100644 index 00000000..76e9cb24 --- /dev/null +++ b/cmd/snap/cmd_reboot.go @@ -0,0 +1,129 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2020 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package main + +import ( + "fmt" + + "github.com/jessevdk/go-flags" + + "github.com/snapcore/snapd/i18n" +) + +type cmdReboot struct { + clientMixin + Positional struct { + Label string + } `positional-args:"true"` + + RunMode bool `long:"run"` + InstallMode bool `long:"install"` + RecoverMode bool `long:"recover"` + FactoryResetMode bool `long:"factory-reset"` +} + +var shortRebootHelp = i18n.G("Reboot into selected system and mode") +var longRebootHelp = i18n.G(` +The reboot command reboots the system into a particular mode of the selected +recovery system. + +When called without a system label and without a mode it will just +trigger a regular reboot. + +When called without a label, the current system will be used for "run" mode. The +default recovery system will be used for "recover", "factory-reset" and +"install" modes. + +Note that the "run" mode is only available for the current system. +`) + +func init() { + addCommand("reboot", shortRebootHelp, longRebootHelp, func() flags.Commander { + return &cmdReboot{} + }, map[string]string{ + // TRANSLATORS: This should not start with a lowercase letter. + "run": i18n.G("Boot into run mode"), + // TRANSLATORS: This should not start with a lowercase letter. + "install": i18n.G("Boot into install mode"), + // TRANSLATORS: This should not start with a lowercase letter. + "recover": i18n.G("Boot into recover mode"), + // TRANSLATORS: This should not start with a lowercase letter. + "factory-reset": i18n.G("Boot into factory-reset mode"), + }, []argDesc{ + { + // TRANSLATORS: This needs to begin with < and end with > + name: i18n.G("